跳至正文

文档对应 SoEditor 1.3.0

视频与文章预览示例

SoEditor 1.3.0
为网站 CMS 而生

让内容编辑回归简单

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

WYSIWYGHTML SourceCMS

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

体验步骤

  1. 查看初始视频卡片:编辑区不会下载或播放 WebM。
  2. 双击卡片或按 Enter 修改标题与尺寸;也可点击“编辑视频”插入新视频。填写本站同源地址 /demo-video.webm
  3. 点击“读取 HTML”检查 video 标签;切换 Source 后能看到相同内容,卡片 UI 不应进入保存数据。
  4. 点击“预览整篇文章”,在弹出的预览窗口点击播放。示例视频无音轨,仅用于验证播放。
  5. 关闭预览,点击“重建示例”检查生命周期。

下载与完整代码

下载发布版示例源码,解压执行 pnpm installpnpm 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>

视频接入说明

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