Skip to content

Documentation for SoEditor 1.2.1

WYSIWYG + Source

Switch to Source, side-by-side and stacked views, then return to WYSIWYG.

SoEditor 1.2.1
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

What to verify

Switch to Source, side-by-side and stacked views, then return to WYSIWYG. Read HTML displays canonical content. Recreate demo destroys and remounts the instances. Upload and save send no editing content; mock uploads return the bundled image.

Complete integration code

Download all example sources. Extract, run pnpm install and pnpm dev, then open basic.html or another example page.

The demo runs this exact code. Use a Vite TypeScript page with the same file structure; the alias pins the npm release.

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/optional';
import { options, instance, button, type DemoContext } from './shared.js';
export async function mount(ctx: DemoContext) {
    const editor = await createClassicEditor(ctx.host, {
        ...options(ctx),
        editingModes: ['wysiwyg', 'source'],
        initialEditingMode: 'wysiwyg',
    });
    button(ctx, '可视化', 'WYSIWYG', () => editor.setWorkspaceView('wysiwyg'));
    button(ctx, 'HTML 源码', 'HTML Source', () =>
        editor.setWorkspaceView('source'),
    );
    button(ctx, '左右分屏', 'Side by side', () =>
        editor.setWorkspaceView('wysiwyg-source-horizontal'),
    );
    button(ctx, '上下分屏', 'Stacked', () =>
        editor.setWorkspaceView('wysiwyg-source-vertical'),
    );
    button(ctx, '格式化 HTML', 'Format HTML', async () => {
        await editor.setWorkspaceView('source');
        await editor.editor.execute('document.format');
    });
    return instance(editor);
}
Shared context and 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 });
    });
}
Page mounting and recreation
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 / CSS
html
<!doctype html>
<html lang="en" data-example="source">
    <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;
}

Configuration and troubleshooting

Each instance receives locale at creation. Closing removes the iframe; starting again restores initial content. If styles or Source are missing, check that all build assets were deployed.

API · Troubleshooting

HTML editing for website CMS · MIT