视频与文章预览示例
SoEditor 1.3.0
为网站 CMS 而生
让内容编辑回归简单
一篇文章、一张产品介绍,或下一次发布。用熟悉的工具,把内容写好。
WYSIWYGHTML SourceCMS
点击后加载 · 内容仅保留在当前页面
体验步骤
- 查看初始视频卡片:编辑区不会下载或播放 WebM。
- 双击卡片或按 Enter 修改标题与尺寸;也可点击“编辑视频”插入新视频。填写本站同源地址
/demo-video.webm。 - 点击“读取 HTML”检查 video 标签;切换 Source 后能看到相同内容,卡片 UI 不应进入保存数据。
- 点击“预览整篇文章”,在弹出的预览窗口点击播放。示例视频无音轨,仅用于验证播放。
- 关闭预览,点击“重建示例”检查生命周期。
下载与完整代码
下载发布版示例源码,解压执行 pnpm install、pnpm dev,打开 video.html。下载包含同源 public/demo-video.webm,不会上传编辑内容。
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>