Video and article preview example
SoEditor 1.3.0
BUILT FOR YOUR CMS
Make room for your content
An article, a product story, or your next announcement. Familiar tools for the work you do every day.
WYSIWYGHTML SourceCMS
Loads on demand · Content stays in this page
Try it
- Inspect the initial card. The authoring surface does not fetch or play the WebM file.
- Double-click the card or press Enter to change title and dimensions. “Edit video” can insert another video using
/demo-video.webm. - Select “Read HTML” to inspect the video element. Source shows the same content, without card UI in saved data.
- Select “Preview article”, then play the video in the popup. The sample has no audio track and is only for playback verification.
- Close preview and select “Recreate demo” to exercise lifecycle cleanup.
Download and complete code
Download the published examples, extract, run pnpm install and pnpm dev, then open video.html. The archive includes same-origin public/demo-video.webm. No article content is uploaded.
ts
import { createClassicEditor } from 'soeditor-release/cms/optional';
import { cmsRuntimePreset } from '@soeditor/presets/cms-runtime';
import { createCmsVideoPlugin } from 'soeditor-release/video';
import { button, instance, options, type DemoContext } from './shared.js';
export async function mount(ctx: DemoContext) {
const editor = await createClassicEditor(ctx.host, {
...options(ctx),
plugins: [
...cmsRuntimePreset.plugins,
createCmsVideoPlugin({ youtube: true, youtubeMetadata: false }),
],
editingModes: ['wysiwyg', 'source'],
preview: true,
toolbar: [
'undo',
'redo',
'|',
'bold',
'italic',
'|',
'cmsVideo',
'popupPreview',
],
data: `${ctx.host.value}<p>Video</p><video src="/demo-video.webm" title="Local sample" controls preload="none" style="width:100%;aspect-ratio:16/9"></video>`,
});
button(ctx, '编辑视频', 'Edit video', async () => {
await editor.editor.execute('cms.video.open');
});
button(ctx, '预览整篇文章', 'Preview article', () => {
editor.openPreview();
});
button(ctx, '查看源码', 'HTML Source', () =>
editor.setWorkspaceView('source'),
);
return instance(editor);
}Shared lifecycle / 共享生命周期
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 = {
video: () => import('./video.js'),
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
<!doctype html>
<html lang="en" data-example="video">
<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>