跳至正文

文档对应 SoEditor 1.2.1

多实例与重建

分别编辑两个实例,再重建示例检查资源清理。

SoEditor 1.2.1
为网站 CMS 而生

让内容编辑回归简单

一篇文章、一张产品介绍,或下一次发布。用熟悉的工具,把内容写好。

WYSIWYGHTML SourceCMS

点击后加载 · 内容仅保留在当前页面

如何验证

分别编辑两个实例,再重建示例检查资源清理。 “读取 HTML”显示规范内容,“重建示例”销毁并重新挂载实例。上传与保存不发送编辑内容,上传成功返回内置图片。

完整接入代码

下载完整示例源码。解压后执行 pnpm installpnpm dev,打开对应的 basic.html 等页面。

以下代码与演示共用。使用 Vite TypeScript 页面,保留相同文件结构;别名用于固定 npm 发布包。

sh
pnpm add soeditor-release@npm:@soeditor/editor@1.2.1 @soeditor/file-manager@1.2.1 @soeditor/adapter-sofinder@1.2.1 @soeditor/presets@1.2.1
ts
import { createClassicEditor } from 'soeditor-release/cms';
import { options, instance, type DemoContext } from './shared.js';
export async function mount(ctx: DemoContext) {
    const second = document.createElement('textarea');
    second.name = 'secondary';
    second.setAttribute(
        'aria-label',
        ctx.locale === 'zh-CN' ? '第二个编辑器' : 'Second editor',
    );
    second.value =
        ctx.locale === 'zh-CN'
            ? '<p>第二个独立编辑器</p>'
            : '<p>Second independent editor</p>';
    ctx.form.append(second);
    const first = await createClassicEditor(ctx.host, options(ctx));
    try {
        const other = await createClassicEditor(second, options(ctx));
        const owned = instance(first, other);
        return {
            ...owned,
            async destroy() {
                await owned.destroy();
                second.remove();
            },
        };
    } catch (error) {
        await first.destroy();
        second.remove();
        throw error;
    }
}
共享上下文与生命周期
ts
import type {
    ClassicEditor,
    CreateClassicEditorOptions,
} from 'soeditor-release/cms';
export type Locale = 'zh-CN' | 'en';
export interface DemoContext {
    locale: Locale;
    host: HTMLTextAreaElement;
    form: HTMLFormElement;
    controls: HTMLElement;
    output: HTMLElement;
    status: HTMLElement;
    signal: AbortSignal;
}
export interface DemoInstance {
    editors: ClassicEditor[];
    destroy(): Promise<void>;
}
export function options(ctx: DemoContext): CreateClassicEditorOptions {
    return {
        locale: ctx.locale,
        minHeight: 280,
        onChange: () => {
            ctx.status.textContent = label(
                ctx,
                '内容已修改',
                'Content changed',
            );
        },
    };
}
export function label(ctx: DemoContext, zh: string, en: string): string {
    return ctx.locale === 'zh-CN' ? zh : en;
}
export function button(
    ctx: DemoContext,
    zh: string,
    en: string,
    action: () => void | Promise<unknown>,
): HTMLButtonElement {
    const el = document.createElement('button');
    el.type = 'button';
    el.textContent = label(ctx, zh, en);
    el.addEventListener(
        'click',
        () => {
            el.disabled = true;
            Promise.resolve()
                .then(action)
                .catch((error: unknown) => {
                    ctx.status.textContent =
                        error instanceof Error
                            ? error.message
                            : label(ctx, '操作失败', 'Action failed');
                })
                .finally(() => {
                    el.disabled = false;
                });
        },
        { signal: ctx.signal },
    );
    ctx.controls.append(el);
    return el;
}
export function instance(...editors: ClassicEditor[]): DemoInstance {
    return {
        editors,
        async destroy() {
            await Promise.all(editors.map((editor) => editor.destroy()));
        },
    };
}
export function delay(signal: AbortSignal): Promise<void> {
    return new Promise((resolve, reject) => {
        if (signal.aborted) {
            reject(new DOMException('Aborted', 'AbortError'));
            return;
        }
        const abort = () => {
            clearTimeout(timer);
            reject(new DOMException('Aborted', 'AbortError'));
        };
        const timer = setTimeout(() => {
            signal.removeEventListener('abort', abort);
            resolve();
        }, 500);
        signal.addEventListener('abort', abort, { once: true });
    });
}
页面挂载与重建
ts
import 'soeditor-release/cms/styles.css';
import './style.css';
import {
    button,
    label,
    type DemoContext,
    type DemoInstance,
} from './shared.js';
const loaders = {
    basic: () => import('./basic.js'),
    form: () => import('./form.js'),
    source: () => import('./source.js'),
    assets: () => import('./assets.js'),
    save: () => import('./save.js'),
    multiple: () => import('./multiple.js'),
};
const name = document.documentElement.dataset.example ?? 'basic';
function isExample(value: string): value is keyof typeof loaders {
    return value in loaders;
}
if (!isExample(name)) throw new Error('Unknown example');
const loadExample = loaders[name];
const locale =
    new URLSearchParams(location.search).get('lang') === 'en' ? 'en' : 'zh-CN';
document.documentElement.lang = locale;
const form = document.createElement('form');
const host = document.createElement('textarea');
host.name = 'content';
host.setAttribute(
    'aria-label',
    locale === 'en' ? 'Article content' : '文章内容',
);
const initial =
    locale === 'en'
        ? '<h2>A place for your next story</h2><p>Edit this article with SoEditor.</p><table><tbody><tr><th>Feature</th><th>Use</th></tr><tr><td>HTML</td><td>CMS content</td></tr></tbody></table>'
        : '<h2>从这里开始你的下一篇文章</h2><p>使用 SoEditor 编辑文章内容。</p><table><tbody><tr><th>功能</th><th>用途</th></tr><tr><td>HTML</td><td>CMS 内容</td></tr></tbody></table>';
host.defaultValue = initial;
form.append(host);
const controls = document.createElement('div');
controls.className = 'controls';
const status = document.createElement('p');
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
const details = document.createElement('details');
const summary = document.createElement('summary');
summary.textContent = 'HTML';
const output = document.createElement('pre');
details.append(summary, output);
const note = document.createElement('p');
note.className = 'notice';
note.textContent =
    locale === 'en'
        ? 'Local demo. Upload and save are simulated; no content is sent to a server.'
        : '本地演示:上传和保存均为模拟,编辑内容不会发送到服务器。';
document.body.append(note, controls, status, form, details);
let owned: DemoInstance | undefined;
let disposed = false;
let controller = new AbortController();
let task: Promise<void> = Promise.resolve();
async function mount() {
    controller = new AbortController();
    const ctx: DemoContext = {
        locale,
        host,
        form,
        controls,
        output,
        status,
        signal: controller.signal,
    };
    status.textContent = label(ctx, '正在加载…', 'Loading…');
    const module = await loadExample();
    if (disposed) return;
    const mounted = await module.mount(ctx);
    owned = mounted;
    if (disposed) {
        await mounted.destroy();
        owned = undefined;
        return;
    }
    button(ctx, '读取 HTML', 'Read HTML', () => {
        output.textContent =
            owned?.editors.map((editor) => editor.getData()).join('\n\n') ?? '';
        details.open = true;
    });
    button(ctx, '重建示例', 'Recreate demo', () => {
        task = task.then(async () => {
            controller.abort();
            await owned?.destroy();
            owned = undefined;
            controls.replaceChildren();
            host.value = initial;
            output.textContent = '';
            await mount();
        });
        return task;
    });
    status.textContent = label(ctx, '就绪', 'Ready');
    document.body.dataset.ready = 'true';
}
window.addEventListener(
    'pagehide',
    () => {
        disposed = true;
        controller.abort();
        void owned?.destroy();
    },
    { once: true },
);
task = mount().catch((error: unknown) => {
    status.textContent =
        error instanceof Error ? error.message : 'Failed to initialize';
});
HTML / CSS
html
<!doctype html>
<html lang="en" data-example="multiple">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="robots" content="noindex, nofollow" />
        <title>SoEditor demo</title>
    </head>
    <body>
        <script type="module" src="./runner.ts"></script>
    </body>
</html>
css
html {
    color-scheme: light;
    font:
        15px/1.6 system-ui,
        sans-serif;
    color: #202b3d;
    background: #fff;
}
body {
    margin: 12px;
}
* {
    box-sizing: border-box;
}
.controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
}
.controls button,
.controls input {
    max-width: 100%;
    font: inherit;
}
.controls button {
    border: 1px solid #b9c5d8;
    border-radius: 6px;
    background: #f7f9fc;
    color: #203c68;
    padding: 6px 12px;
    cursor: pointer;
}
button:focus-visible,
input:focus-visible {
    outline: 3px solid #2459c4;
    outline-offset: 2px;
}
button:disabled {
    opacity: 0.6;
    cursor: wait;
}
.notice {
    color: #4b5870;
    font-size: 13px;
}
[role='status'] {
    min-height: 24px;
}
pre {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    background: #f5f7fa;
    padding: 12px;
}
textarea {
    width: 100%;
    min-height: 200px;
}
form {
    min-width: 0;
}

配置与排错

所有实例都在创建时传入 locale。结束体验会移除 iframe;重新开始使用初始内容。若样式或 Source 丢失,检查是否部署了全部构建资源。

API · 常见问题

面向网站 CMS 的 HTML 编辑器 · MIT