多实例与重建
分别编辑两个实例,再重建示例检查资源清理。
SoEditor 1.2.1
为网站 CMS 而生
让内容编辑回归简单
一篇文章、一张产品介绍,或下一次发布。用熟悉的工具,把内容写好。
WYSIWYGHTML SourceCMS
点击后加载 · 内容仅保留在当前页面
如何验证
分别编辑两个实例,再重建示例检查资源清理。 “读取 HTML”显示规范内容,“重建示例”销毁并重新挂载实例。上传与保存不发送编辑内容,上传成功返回内置图片。
完整接入代码
下载完整示例源码。解压后执行 pnpm install、pnpm 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.1ts
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 丢失,检查是否部署了全部构建资源。