Mock upload and picker
Choose the sample image or select a local file for a mock upload. Enable failure and retry.
Make room for your content
An article, a product story, or your next announcement. Familiar tools for the work you do every day.
Loads on demand · Content stays in this page
What to verify
Choose the sample image or select a local file for a mock upload. Enable failure and retry. 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.
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.1import { createClassicEditor } from 'soeditor-release/cms/optional';
import { cmsRuntimePreset } from '@soeditor/presets/cms-runtime';
import { FileManagerPlugin, UploadPlugin } from '@soeditor/file-manager';
import {
fileManagerServiceToken,
uploadServiceToken,
} from '@soeditor/file-manager';
import { SoFinderAdapter } from '@soeditor/adapter-sofinder';
import {
options,
instance,
button,
delay,
label,
type DemoContext,
} from './shared.js';
export async function mount(ctx: DemoContext) {
const editor = await createClassicEditor(ctx.host, {
...options(ctx),
plugins: [...cmsRuntimePreset.plugins, FileManagerPlugin, UploadPlugin],
});
let failNext = false;
const asset = {
url: new URL('/sample-image.svg', location.origin).href,
name: 'Sample',
alt: label(ctx, '蓝色山峦', 'Blue hills'),
mime: 'image/svg+xml',
};
editor.editor.services.register(
fileManagerServiceToken,
new SoFinderAdapter({
// Replace this callback with the host's actual SoFinder picker.
pick: async () => ({ ...asset, mimeType: asset.mime }),
}),
);
editor.editor.services.register(uploadServiceToken, {
create() {
const controller = new AbortController();
return {
cancel: () => controller.abort(),
subscribe: () => () => undefined,
result: delay(controller.signal).then(() => {
if (failNext) {
failNext = false;
throw new Error(
label(
ctx,
'模拟上传失败,请重试',
'Mock upload failed. Retry.',
),
);
}
return asset;
}),
};
},
});
button(ctx, '选择示例图片', 'Choose sample image', async () => {
editor.focus();
await editor.editor.execute('media.browse');
});
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.setAttribute(
'aria-label',
label(ctx, '模拟上传图片', 'Mock image upload'),
);
input.addEventListener(
'change',
() => {
const file = input.files?.[0];
if (!file) return;
editor.focus();
void Promise.resolve(
editor.editor.execute('image.upload', {
file,
name: file.name,
type: file.type,
}),
)
.then(() => {
ctx.status.textContent = label(
ctx,
'模拟上传完成,使用内置图片',
'Mock upload complete; using the bundled image',
);
})
.catch((error: unknown) => {
ctx.status.textContent =
error instanceof Error
? error.message
: 'Upload failed';
});
input.value = '';
},
{ signal: ctx.signal },
);
ctx.controls.append(input);
button(ctx, '下次上传失败', 'Fail next upload', () => {
failNext = true;
ctx.status.textContent = label(
ctx,
'下次上传将模拟失败',
'The next upload will fail',
);
});
return instance(editor);
}Shared context and lifecycle
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
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
<!doctype html>
<html lang="en" data-example="assets">
<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>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.
Our best companion: SoFinder
SoFinder is our recommended asset management companion. See the integration guide and example for image, file and video URL workflows, and how the simulated picker differs from a real backend.