@quillmark/svelte

中文文档
0.30.0发布于 2026/11/02Apache-2.0TypeScriptES Module构建来源已验证

Editor, live-preview and source surfaces over a Quillmark WASM session.

原始使用文档
文档目录16 节
  1. Install
  2. Subpaths
  3. Open a session
  4. The quill resolves from the document
  5. What a document is trusted to be
  6. Preview
  7. Visual editor
  8. Driving it from outside
  9. Wording
  10. Fonts
  11. Recompiling
  12. The caret bridge
  13. Errors
  14. Canonical markdown
  15. Theming
  16. Development

@quillmark/svelte

Editor + live-preview components for Quillmark WASM consumers. A WYSIWYG VisualEditor, and a canvas Preview that paints the compiled document and round-trips clicks to the editor, over one @quillmark/wasm session. Vanilla-TS cores with thin Svelte 5 wrappers.

Install

npm install @quillmark/svelte

svelte@^5.33 and @quillmark/wasm are peer dependencies: the session's handles cross the package boundary, so the consumer supplies the one copy both sides mint them from.

Subpaths

Each subpath is its own module root; a bundler pulls only what the entry you import reaches.

ImportSurface
@quillmark/svelte/coreWhat the surfaces share: the DocPath/Place address vocabulary, the EditorError channel, init. No UI framework.
@quillmark/svelte/previewThe live preview: createPreview + <Preview>. Reaches nothing editor-side.
@quillmark/svelte/visualThe federated WYSIWYG: <VisualEditor>, the codec's createField prose leaf.
@quillmark/svelteRe-exports /core.
@quillmark/svelte/presetThe opt-in stylesheet for the page around a surface (THEMING.md).

Open a session

The consumer owns the session and the handles, and drives every edit; the surfaces are views over it. The handles come from the @quillmark/wasm peer, never re-exported here.

import { Engine } from '@quillmark/wasm';
import { init } from '@quillmark/svelte/core';

// Quill and Document are exported only through init().
const { Quill, Document } = await init();

const quill = Quill.fromTree(tree); // tree: Map<string, Uint8Array> of the quill dir

// A NEW document: seeded from the quill's schema.
const doc = quill.seedDocument();

// An EXISTING document: parsed back from what you stored.
const doc = Document.fromMarkdown(markdown); // canonical Quillmark markdown
const doc = Document.fromStored(json); // the versioned storage DTO (`doc.toStored()`)

const session = await new Engine().open(quill, doc);
// free() on teardown the handles you MINTED: this quill, this doc, this session.

<VisualEditor> needs no session, and the first open loads the render build, so a host that mounts the editor over doc and quill first shows it without waiting on that load. The session opens behind it: await engine.load(quill), then engine.open(quill, doc), so its first compile reads what was typed meanwhile (HOSTING.md).

The quill resolves from the document

A stored document carries none of its quill's bytes, only a reference: doc.quillRef is name@version, persisted in the markdown itself. Resolution is host code: read the ref, map it to a Quill, open. No surface resolves, so one resolution per document holds by construction.

const doc = Document.fromMarkdown(stored); // Document, from the gate above
const quill = await registry.getQuill(doc.quillRef); // your ref → Quill mapping
const session = await new Engine().open(quill, doc);

@quillmark/quiver is one such registry, and the quill it hands back is borrowed: it is cached per ref and shared with every caller, so freeing it strands the next one.

Opening a document that names a different quill is the same sequence, in order: resolve the new ref, swap the editor's props, free the replaced handles that were yours to free, then open the next session behind the editor. <VisualEditor> re-keys itself on the new doc (see below); <Preview> swaps by remount ({#key session}).

What a document is trusted to be

A Document may be one its reader did not write — an import, a shared file, a row from a multi-tenant store — and two properties hold over one regardless. Markdown becomes typed nodes and then DOM, never a markup string: this package has no {@html}, no innerHTML and no eval, so a document's text cannot become tags. And a link renders as one only for http, https, mailto, tel, ftp, or a value naming no scheme at all; every other scheme draws as an inert span, on the editable surface and in the tips card that paints $ext.editor.tips outside any contenteditable. That gate is the render's rather than the model's, so a refused href stays on the mark and round-trips: opening a document never edits it.

What stays yours is the text edge and the ref. The markdown is the host's at both ends, and doc.quillRef is the document's own — a stored file names the quill that opens it, so the ref → Quill mapping above is what bounds which templates it can reach.

Preview

createPreview supplies the layer the session omits: viewport, DOM, DPR, click mapping. It is a pure view: it never calls session.apply; you drive the edit and hand it the resulting ChangeSet.

import { createPreview } from '@quillmark/svelte/preview';

const preview = createPreview(session, {
    container: document.querySelector('#preview')!,
    onPick: (at) => {
        /* a click resolved to an address, with a caret where the compile has one */
    },
    onError: (err) => {
        /* a page paint the backend refused; the preview shows its error state either way */
    }
});

// after an edit lands on `doc`, from any source:
preview.refresh(session.update(doc)); // repaint dirtyPages ∩ visible

In Svelte, <Preview {session} onPick={…} /> exposes the same verbs (refresh, scrollToField, focusPosition, endFollow, setZoom) via bind:this.

<Preview> binds once, at mount: swapping session, margin, onPick, onError or strings in place changes nothing on screen and reports rebind-ignored through onError at dev severity, naming the prop. Swap by remounting ({#key session}); drive in-place edits through refresh(change). class and style are the exceptions, landing on the root element and staying live.

Visual editor

<VisualEditor> is a federated composition of many small editors over one document: each content leaf a ProseMirror prose surface, each scalar a form control, cards the editor's own. It commits directly to the doc handle.

<script lang="ts">
    import { VisualEditor } from '@quillmark/svelte/visual';
</script>

<VisualEditor
    {doc}
    {quill}
    onChange={(change) => {
        /* an edit LANDED; `change.source` is 'prose' | 'field' | 'structure' */
    }}
    onActiveLeafChange={(active) => {
        /* the active leaf: `active.field` (a DocPath), and `active.cardId` for its card */
    }}
    onCaretMove={(at) => {
        /* the caret moved to `at.field` (a DocPath) at `at.pos` (UTF-16) */
    }}
    onError={(err) => {
        /* a failure the editor recovered from; editing continues */
    }}
    diagnostics={external}
/>

Hand it a different doc and the editor re-keys itself: every leaf remounts against the new handle, and the id state, the commit-error map and the active address seed fresh. quill is not part of that key — the schema is re-read on every derive, so a quill swap re-projects on its own; swapping it without the doc reports rebind-ignored.

Driving it from outside

bind:this reaches the same verbs the card header calls, so a toolbar, command palette or shortcut needs no second path into the document. Every one reports through onChange exactly as the click does.

<script lang="ts">
    let editor: ReturnType<typeof VisualEditor> | undefined = $state();
    let activeCard = $state<string | undefined>();
</script>

<VisualEditor
    bind:this={editor}
    {doc}
    {quill}
    onActiveLeafChange={(a) => (activeCard = a.cardId)}
/>

<button onclick={() => editor?.insertCard('indorsement')}>Add indorsement</button>
<button onclick={() => activeCard && editor?.moveCard(activeCard, -1)}>Move up</button>
<button onclick={() => editor?.focusField('main.subject')}>Jump to subject</button>

insertCard hands back the new card's cardId; removeCard, moveCard and setKind take one. focusField reaches any mounted field: a prose leaf takes its view's focus, a form control the same handoff a click on its label takes, and either way the group holding it is revealed first. washField plays the same arrival wash over a field and nothing else, for a change the host wrote: focus, scroll and closed groups stay as they are, and a field in a closed group washes nothing. A card key or a path the surface does not hold is a no-op that reports target-unknown through onError at dev severity.

Wording

The package ships English and a seam to replace it. strings is keyed and partial: set what you have translations for, and the rest stay the package's. Several keys are accessible names rather than decoration (the card controls, the add trigger), so an untranslated surface reads the wrong language to a screen reader. DEFAULT_VISUAL_STRINGS is the English, exported so you can compose against it. <Preview> takes its own three-key strings for the states it shows when there is nothing to paint.

<VisualEditor
    {doc}
    {quill}
    strings={{
        cardDelete: 'Supprimer la carte',
        addCard: '+ Ajouter une carte'
    }}
    formatDiagnostic={(d) =>
        d.code === 'validation::type_mismatch' ? `${d.path} : type incorrect` : undefined}
/>

formatDiagnostic gets the whole Diagnostic and returns undefined to take its own message. Validation and edit diagnostics re-word from code, path and args; parse errors and render warnings carry their detail inside English text and stand as they are (VISUAL_EDITOR.md).

Fonts

A run of text can be set in a font family the host supplies, spelled <qm-span family="…"> in markdown. Build the engine with the font files (.ttf, .otf, .ttc, .otc) and hand the editor the faces it lists: the format popover then offers each family, and the editor registers the faces with the page so a run draws in the face the preview sets it in.

const carlito = new Uint8Array(await (await fetch('/fonts/Carlito-Regular.ttf')).arrayBuffer());
const engine = new Engine({ fonts: [carlito] });
<script lang="ts">
    let fonts = $state<FontFace[]>([]);
    engine.fontFaces(quill).then((faces) => (fonts = faces));
</script>

<VisualEditor {doc} {quill} {fonts} />

@quillmark/fonts is the library every Quillmark client builds its engine over, so a family a document names sets the same way in each; a host's own files go after it. fontFaces loads the render build, as open does, so mount the editor first and let the faces arrive behind it. A family the document already sets edits with no library at all, and the picker offers it so it can be cleared.

Recompiling

onChange is the signal to recompile, and it covers all three lanes: a prose keystroke, a scalar write, a card operation. onCaretMove is a selection signal, not a change signal: it fires on a bare arrow key, so a recompile hung off it recompiles on every one.

let timer: ReturnType<typeof setTimeout> | undefined;

onChange: (change) => {
    // a structure op happens once per gesture, so it applies at once;
    // prose and field edits arrive per keystroke, so they debounce.
    if (change.source === 'structure') recompileNow();
    else {
        clearTimeout(timer);
        timer = setTimeout(recompileNow, 120);
    }
};

function recompileNow() {
    timer = undefined;
    const change = session.update(doc);
    preview.refresh(change);
    source.refresh();
    diagnostics = [...session.warnings]; // → the editor's `diagnostics` prop
}

session.warnings is a getter on a handle Svelte does not track: pull it per apply, or the editor shows the previous compile's errors.

The schedule is host policy, so the package ships no scheduler. Obligations: an edit of your own (an import, an undo, a direct doc write) recompiles by the same calls, since nothing polls the document; a compile re-reads the diagnostics; and teardown clears the timer before freeing the handles.

The caret bridge

The bridge lives at the consumer layer and is opt-in; the editor is unaware of the preview, the preview unaware of the editor. Both hops are pass-throughs: the two surfaces already speak one address grammar, the canonical DocPath.

// preview → editor: a click resolved to an address lands in the field it names.
onPick: (at) => visualEditor.setCaret(at);

// editor → preview: a caret move scrolls the preview to follow it,
// and a focus into a leaf with no caret to report ends the follow.
onCaretMove: preview.focusPosition;
onActiveLeafChange: preview.endFollow;

Both editor→preview hops or neither. A control reports its focus and no caret — there is no offset a date field could name — so without the second the preview keeps following the leaf the focus left, and every recompile pulls the pane back to it. A prose leaf restarts the follow with its next caret. A host that reads the active leaf for its own chrome calls endFollow from its handler.

A pick carries a caret where the compile tracks the content under the point, and the field alone where it tracks only the placement — a scalar the plate prints without tracking. setCaret takes both: an absent pos reveals and focuses the field, which is the whole of what a click on plate-placed ink can mean. The address may name an array element (main.keywords[0]), which lands on that row.

A consumer holding an Addr of its own maps it with fieldPathForAddr (from @quillmark/svelte/core). The playground app's split-pane route is the full reference shell: one session, both bridge directions, the preview following edits, and diagnostics routed inline.

Errors

Every surface takes onError. It reports failures the surface recovered from: a commit the boundary refused, a card operation that threw, a page paint that failed, a serialize that threw. None of them stop editing. Wire nothing and each lands in the console.

onError: (err) => {
    // err.code: 'commit-refused' | 'paint-failed' | … (see EditorErrorCode)
    // err.severity: 'error' (runtime) | 'dev' (a contract violation)
    // err.cause: whatever was thrown, unwrapped
    if (err.severity === 'error') telemetry.capture(err);
};

This is not the diagnostics channel. A Diagnostic is about the document and draws on the field it belongs to; an EditorError is about the surface and draws nowhere. A refused scalar commit produces both.

Canonical markdown

doc.toMarkdown() returns the document's canonical Quillmark markdown, which re-parses to an equal Document. A read-only mirror is that call in a <pre>, re-run after each edit lands, so it needs no surface from this package:

el.textContent = doc.toMarkdown();

Theming

The surfaces carry the behavior against a neutral, overridable visual baseline: a set of --qm-* CSS custom properties you override on any ancestor. See THEMING.md.

Development

This package is one workspace of quillmark-js; the gates are the root's, and the settled architecture lives in prose/.

npm run build -w packages/svelte   # svelte-package → dist/
npm run test  -w packages/svelte   # Vitest
npm run check -w packages/svelte   # svelte-check
npm run dev                        # the playground, from the root

版本与发布记录

@quillmark/svelte 共发布 48 个版本,首次发布于 2026/08/07;过去 12 个月发布了 48 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.30.0latest2026/11/021 天前
0.29.32026/11/021 天前
0.29.22026/10/313 天前
0.29.12026/10/313 天前
0.29.02026/10/304 天前
0.28.02026/10/295 天前

关于 @quillmark/svelte 的常见问题

@quillmark/svelte 是什么?

编辑器,实时预览和源表面在Quillmark WASM会话。

如何安装 @quillmark/svelte?

在项目目录运行 npm install @quillmark/svelte,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:svelte(^5.33.0)、@quillmark/wasm(>=0.134.1-0 <0.135.0-0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@quillmark/svelte 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@quillmark/svelte 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@quillmark/svelte 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@quillmark/svelte 可以商用吗?

@quillmark/svelte 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@quillmark/svelte 还在维护吗?

最新版本 0.30.0 发布于 2026/11/02(1 天前);至今共发布 48 个版本,其中过去 12 个月发布了 48 个;首次发布于 2026/08/07。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。