@thomasfosterau/prosekit-svelte 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Foster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,256 @@
1
- # Temporary Holding Version
1
+ # `@thomasfosterau/prosekit-svelte`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Svelte 5 rich-text editing on [ProseKit](https://prosekit.dev): `Editor`, `BlockEditor`, `InlineEditor`, a declarative `BlockToolbar` with an insert menu, and the link and image dialogs — all built on [bits-ui](https://bits-ui.com), all styled with daisyUI's semantic classes, every user-facing string a prop with an English default.
4
+
5
+ Requires Tailwind 4 + daisyUI in the consuming app (see [Peer requirements](#peer-requirements)). Accessible-name props end in `Label`. Every component spreads the native attributes you pass (`id`, `data-*`, `aria-*`, …) on the element it renders — `SlashMenu` on its `role="menu"` element, `LinkDialog` and `ImageDialog` on their `role="dialog"` element — with the dialogs' `role`, `aria-modal` and title wiring winning. `Editor`, `BlockEditor`, `InlineEditor`, `BlockToolbar` and `SlashMenu` own their accessible name through `label` / `menuLabel`, or `labelledBy` to name it by a visible heading: give `labelledBy` the heading's `id` (or space-separated `id`s) and the component renders `aria-labelledby`, which wins over the label. `label` / `menuLabel` (or its default) stays on as `aria-label`, the fallback name if no element has that `id`; an empty `labelledBy` counts as unset. `aria-label` and `aria-labelledby` are not among their props, and passed anyway (from untyped code) the component's own attributes override the first and drop the second. Every component merges the caller's `class` last.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ pnpm add @thomasfosterau/prosekit-svelte prosekit @prosekit/pm
11
+ ```
12
+
13
+ ### Peer requirements
14
+
15
+ | Package | Range | Why |
16
+ | --- | --- | --- |
17
+ | `svelte` | `^5.30.1` | A declared peer. The components are compiled by your build, not pre-compiled. |
18
+ | `prosekit` | `^0.22.2` | A declared peer. The editor runtime, and the two stylesheets `Editor.svelte` imports. |
19
+ | `@prosekit/pm` | `^0.1.19` | A declared peer. ProseMirror's model and view, at the version ProseKit expects. |
20
+ | `tailwindcss` | `^4.0.0` | Not a declared peer, but **required**: the components emit Tailwind classes and ship none. |
21
+ | `daisyui` | `^5.0.0` | Same — the class vocabulary is daisyUI's (`btn`, `menu`, `modal-box`, `input`, `bg-base-100`). |
22
+
23
+ Tailwind 4 has no `tailwind.config.js`, so wire it up from your CSS entry. The `@source` line is what makes Tailwind scan this package's `dist/` and emit the classes it asks for; without it the editor renders unstyled.
24
+
25
+ ```css
26
+ @import 'tailwindcss';
27
+ @source '../node_modules/@thomasfosterau/prosekit-svelte/dist';
28
+ @plugin "daisyui" {
29
+ themes:
30
+ light --default,
31
+ dark --prefersdark;
32
+ }
33
+ ```
34
+
35
+ ProseKit's own stylesheets arrive through `Editor.svelte`'s bare imports — you do not import them yourself.
36
+
37
+ `@thomasfosterau/prosekit-utilities` and `bits-ui` are ordinary dependencies of this package, at the same release line, and come along automatically when you install it — you do not add either yourself.
38
+
39
+ ## App integration
40
+
41
+ **Import by path, never re-export from a shared component barrel.** `Editor.svelte` imports `prosekit/basic/style.css` and `prosekit/basic/typography.css` at the top level, and through `createEditor` it pulls the ProseKit runtime behind them — the largest client chunk in the applications this package was extracted from. Whatever module imports this package carries all of that in its chunk, so a route that genuinely edits a document imports a component directly:
42
+
43
+ ```svelte
44
+ <!-- A route that genuinely edits a document -->
45
+ <script lang="ts">
46
+ import { BlockEditor } from '@thomasfosterau/prosekit-svelte';
47
+ </script>
48
+ ```
49
+
50
+ and a route that only sometimes edits one reaches it behind a dynamic `import()`:
51
+
52
+ ```svelte
53
+ {#await import('@thomasfosterau/prosekit-svelte') then { BlockEditor }}
54
+ <BlockEditor name="body" {defaultContent} />
55
+ {/await}
56
+ ```
57
+
58
+ Never re-export a component from an **application-wide** component barrel (`$lib/components/index.ts` or similar) and never wire the messages context from a root layout — both put the editor's CSS and runtime into every page's shared chunk. `"sideEffects": false` lets a bundler tree-shake the rest of this package's exports away from a route that only imports one component, but only if nothing else in the bundle reaches this package through a barrel that is itself always loaded.
59
+
60
+ ### The messages context
61
+
62
+ Every string is already a prop, so a single call site needs nothing here. `setEditorMessages(partial)` is for an application that mounts the editor from several places and would rather not thread twenty props through each: it changes the _defaults_ for one subtree, merged over the English ones, and a caller's own prop still wins.
63
+
64
+ Call it **per render site** — from the component that mounts the editor, for the reason above — never from a root layout.
65
+
66
+ ```svelte
67
+ <!-- PostBodyField.svelte -->
68
+ <script lang="ts">
69
+ import { BlockEditor, setEditorMessages } from '@thomasfosterau/prosekit-svelte';
70
+ import * as m from '$lib/paraglide/messages';
71
+
72
+ setEditorMessages({
73
+ toolbarLabel: m.editor_toolbar(),
74
+ bold: m.editor_bold(),
75
+ slashParagraph: m.editor_slash_paragraph(),
76
+ });
77
+ </script>
78
+
79
+ <BlockEditor name="body" label={m.post_body()} />
80
+ ```
81
+
82
+ With no provider mounted — Storybook, a test, an application that never wires it — every string is its English default. That is the documented normal case, not an error.
83
+
84
+ ### Forms
85
+
86
+ Given a `name`, `BlockEditor` and `InlineEditor` emit a hidden input carrying the current document as JSON, in the **application's** envelope shape (`{"type":"blockDocument",…}`), so the field posts with the form and decodes with the shape your document schema declares. `documentType` names the envelope; the components remap to and from ProseMirror's `doc` shape at the boundary in both directions.
87
+
88
+ A worked example: a form field that composes an app extension and an app toolbar item, and the server action that reads the field back.
89
+
90
+ ```svelte
91
+ <!-- BlockEditorField.svelte -->
92
+ <script lang="ts">
93
+ import { BlockEditor, createDefaultToolbarItems, type ToolbarItem } from '@thomasfosterau/prosekit-svelte';
94
+ import type { NodeJSON } from '@thomasfosterau/prosekit-utilities';
95
+ import { blockExtension } from '$lib/server/document-schema.ts';
96
+
97
+ interface Props {
98
+ defaultContent?: NodeJSON | undefined;
99
+ }
100
+ let { defaultContent }: Props = $props();
101
+
102
+ const myItem: ToolbarItem = {
103
+ id: 'sidenote',
104
+ label: 'Sidenote',
105
+ kind: 'toggle',
106
+ isActive: (editor) => editor.nodes['sidenote']?.isActive() ?? false,
107
+ run: ({ editor }) => void editor.commands['insertNode']?.({ type: 'sidenote' }),
108
+ };
109
+ </script>
110
+
111
+ <form method="post" action="?/save">
112
+ <BlockEditor
113
+ name="content"
114
+ extension={blockExtension}
115
+ {defaultContent}
116
+ items={[...createDefaultToolbarItems(), myItem]}
117
+ />
118
+ <button type="submit">Save</button>
119
+ </form>
120
+ ```
121
+
122
+ ```ts
123
+ // +page.server.ts — reading the hidden input's JSON back with the core's DocumentSchema.decode
124
+ import * as Effect from 'effect/Effect';
125
+ import { blockDocumentSchema } from '$lib/server/document-schema.ts';
126
+
127
+ export const actions = {
128
+ save: async ({ request }) => {
129
+ const form = await request.formData();
130
+ const raw: unknown = JSON.parse(String(form.get('content')));
131
+
132
+ // decode: (v: unknown) => Effect.Effect<DocumentJSON<'blockDocument'>, DocumentDecodeError>
133
+ const body = await Effect.runPromise(
134
+ blockDocumentSchema
135
+ .decode(raw)
136
+ .pipe(Effect.catchTag('DocumentDecodeError', (error) => Effect.die(error))),
137
+ );
138
+
139
+ // … persist `body`.
140
+ },
141
+ };
142
+ ```
143
+
144
+ `blockDocumentSchema` here is the `DocumentSchema` a server module builds with `@thomasfosterau/prosekit-utilities/prosekit`'s `defineDocumentSchema` — see that package's README for `decode`'s full signature and its `DocumentDecodeError` channel. Feeding the stored envelope straight into ProseKit instead of through this boundary throws `RangeError: Unknown node type`.
145
+
146
+ # Editors
147
+
148
+ | Component | Purpose | Props | Snippets | ARIA |
149
+ | --- | --- | --- | --- | --- |
150
+ | `Editor<E extends Extension>` | The bare ProseKit mount; everything else in this package is built on it | `extension: E`, `content?: NodeJSON` (bindable), `defaultContent?: NodeJSON`, `label?`, `labelledBy?` (a visible element's `id`; wins over `label`, which stays the fallback), `class?` + `HTMLAttributes<HTMLDivElement>` without `aria-label` / `aria-labelledby` (`label` / `labelledBy` own the name) | `children?` — rendered inside `<ProseKit>`, **before** the mount node, which is where a toolbar goes | wrapper `role="group"` only when named: `aria-labelledby` given `labelledBy`, `aria-label` given `label` (the name when `labelledBy` resolves to no element); the content is ProseMirror's `.ProseMirror[contenteditable]` node, whose keyboard model is the extension's |
151
+ | `BlockEditor` | `Editor` on the block-document grammar, with the default toolbar, the envelope remap and a hidden form input | `extension?` (default `defineBlockDocumentExtension()`, built lazily), `documentType? = 'blockDocument'`, `content?` (bindable), `defaultContent?` (either shape), `name?`, `items?: ReadonlyArray<ToolbarItem \| ToolbarSeparator>`, `slashCommands?: ReadonlyArray<SlashCommand>`, `toolbar? = true` (`false` renders none, a snippet replaces the default), `label?`, `labelledBy?` (both forwarded to `Editor`), `class?` + `HTMLAttributes<HTMLDivElement>` without `aria-label` / `aria-labelledby` (forwarded to `Editor`'s wrapper; `label` / `labelledBy` own the name) | `toolbar?` — replaces the default `BlockToolbar`; `children?` — rendered after it | as `Editor`. Feeding a stored (`blockDocument`-typed) document straight into ProseKit throws `RangeError: Unknown node type`; this component is the boundary that stops that |
152
+ | `InlineEditor` | One line of rich text: marks but no blocks | `extension?` (default `defineInlineDocumentExtension()` plus the Enter binding), `documentType? = 'inlineDocument'`, `content?` (bindable), `defaultContent?` (either shape), `name?`, `label?`, `labelledBy?` (both forwarded to `Editor`), `class?` + `HTMLAttributes<HTMLDivElement>` without `aria-label` / `aria-labelledby` (forwarded to `Editor`'s wrapper; `label` / `labelledBy` own the name) | `children?` — rendered inside the provider, before the mount node | as `Editor`; **Enter is a no-op** — an inline document's top node is `inline*`, so there is no second block for it to make. The binding lives in the default extension; supply your own `extension` and you own the single-line semantics too |
153
+
154
+ ```svelte
155
+ <script lang="ts">
156
+ import { BlockEditor } from '@thomasfosterau/prosekit-svelte';
157
+ let content = $state();
158
+ </script>
159
+
160
+ <BlockEditor label="Post body" bind:content />
161
+ ```
162
+
163
+ # Toolbar
164
+
165
+ | Component | Purpose | Props | Snippets | ARIA |
166
+ | --- | --- | --- | --- | --- |
167
+ | `BlockToolbar` | The default toolbar, rendered from a list of items | `items? = createDefaultToolbarItems(messages)`, `slashCommands? = createDefaultSlashCommands(messages)`, `label? = 'Editor toolbar'`, `labelledBy?` (a visible element's `id`; wins over `label`, which stays the fallback), `insertLabel? = 'Insert'`, `insertMenuLabel? = 'Insert block'`, `linkDialog?: Partial<LinkDialogLabels>` (forwarded to `LinkDialog`), `imageDialog?: Partial<ImageDialogLabels>` (forwarded to `ImageDialog`), `class?` + `HTMLAttributes<HTMLDivElement>` without `aria-label` / `aria-labelledby` (on the `role="toolbar"` element; `label` / `labelledBy` own the name) | — (must be rendered inside an `Editor`; it reads the editor from ProseKit's context) | bits-ui `Toolbar.Root`: `role="toolbar"` named by `labelledBy` or else `label`, one tab stop, arrow keys move and wrap, Home/End to the ends, disabled items skipped; `kind: 'toggle'` items carry `aria-pressed` from `isActive` on every editor update; separators are `role="separator"` + `aria-orientation="vertical"` and not a keyboard stop; after a run, focus returns to the document unless the run opened a dialog |
168
+ | `SlashMenu` | The insert menu, exported for a toolbar of your own | `commands: ReadonlyArray<SlashCommand>`, `open?` (bindable, default `false`), `label? = 'Insert'`, `menuLabel? = 'Insert block'`, `labelledBy?` (a visible element's `id`, naming the menu; wins over `menuLabel`, which stays the fallback), `dialogs?: ToolbarRunContext['dialogs']` (omitted, `dialogs.link()` / `dialogs.image()` do nothing), `class?` (on the daisyUI `menu` list inside the content) + `HTMLAttributes<HTMLDivElement>` without `dir`, `aria-label` or `aria-labelledby` (on the `role="menu"` content element, while open; `menuLabel` / `labelledBy` own the name, and an `id` becomes the trigger's `aria-controls`) | `trigger?({ props })` — your own opening control, spreading `props`; omitted, a daisyUI `btn` labelled `label` | bits-ui `DropdownMenu`: `aria-haspopup="menu"` + `aria-expanded` on the trigger, `role="menu"` on the content (named by `labelledBy` or else `menuLabel`), `role="menuitem"` per entry, arrow keys, typeahead, Escape closes and returns focus to the trigger — or to the document after a choice, unless the command opened a dialog |
169
+ | `LinkDialog` | The link prompt, as a modal | `open?` (bindable), `href? = ''` (re-read on every open), `title? = ''`, `onSubmit: (attrs: { href: string; title: string \| null }) => void` (required), `onRemove?` (omitted, no remove button), `titleLabel? = 'Link'`, `hrefLabel? = 'URL'`, `linkTitleLabel? = 'Title (optional)'`, `submitLabel? = 'Apply'`, `removeLabel? = 'Remove link'`, `cancelLabel? = 'Cancel'`, `closeLabel? = 'Close'`, `invalidHrefMessage? = 'Enter an http, https or mailto URL.'`, `class?` + `HTMLAttributes<HTMLDivElement>` without `title` (on the `role="dialog"` element, while open; `role`, `aria-modal` and `aria-labelledby` are bits-ui's, and an `aria-describedby` of yours is kept) | — | bits-ui `Dialog`: `role="dialog"`, `aria-modal="true"`, `aria-labelledby` wired to the title, focus trap, Escape, scroll lock, focus return; a URL that fails `isSafeHref` is refused in a `role="alert"` with `aria-invalid` + `aria-describedby` on the field; the fields are in a `<form>`, so Enter submits |
170
+ | `ImageDialog` | The image prompt, as a modal | `open?` (bindable), `src? = ''`, `onSubmit: (attrs: { src: string }) => void` (required), `titleLabel? = 'Image'`, `srcLabel? = 'Image URL'`, `submitLabel? = 'Insert'`, `cancelLabel? = 'Cancel'`, `closeLabel? = 'Close'`, `invalidSrcMessage? = 'Enter an http, https or mailto URL.'`, `class?` + `HTMLAttributes<HTMLDivElement>` (as `LinkDialog`'s, `title` included) | — | as `LinkDialog` |
171
+
172
+ `BlockToolbar` renders `LinkDialog` and `ImageDialog` itself and opens them through `ToolbarRunContext.dialogs`; the link dialog is seeded from the selection's own link mark, so its button edits an existing link rather than replacing it, and its remove button runs `removeLink`.
173
+
174
+ ## The toolbar's data model
175
+
176
+ `BlockToolbar` renders a list rather than a hard-coded row of buttons — an application appends its own item, reorders, drops one, or passes a list of its own making.
177
+
178
+ ```ts
179
+ /** The editor as an item sees it: any grammar, `commands` / `nodes` / `marks` read by name. */
180
+ type ToolbarEditor = Omit<Editor<any>, 'commands' | 'marks' | 'nodes'> & {
181
+ readonly commands: Record<string, CommandAction>;
182
+ readonly marks: Record<string, MarkAction>;
183
+ readonly nodes: Record<string, NodeAction>;
184
+ };
185
+
186
+ interface ToolbarRunContext {
187
+ readonly editor: ToolbarEditor;
188
+ readonly dialogs: { readonly link: () => void; readonly image: () => void };
189
+ }
190
+
191
+ interface ToolbarItem {
192
+ readonly id: string;
193
+ readonly label: string;
194
+ readonly kind?: ('toggle' | 'action') | undefined; // 'toggle' => aria-pressed from isActive
195
+ readonly isActive?: ((editor: ToolbarEditor) => boolean) | undefined;
196
+ readonly isEnabled?: ((editor: ToolbarEditor) => boolean) | undefined; // false => disabled button
197
+ readonly run: (context: ToolbarRunContext) => void;
198
+ }
199
+
200
+ interface ToolbarSeparator {
201
+ readonly id: string;
202
+ readonly separator: true;
203
+ }
204
+
205
+ interface SlashCommand {
206
+ readonly id: string;
207
+ readonly label: string;
208
+ readonly run: (context: ToolbarRunContext) => void;
209
+ }
210
+ ```
211
+
212
+ `ToolbarEditor` is not `Editor<any>` because it cannot be: ProseKit derives `commands`, `nodes` and `marks` from the extension's own typing, and the derivation collapses to `never` for `any`, so `editor.commands.toggleBold()` in an item of your own would not compile. A real `Editor<E>` is assignable to `ToolbarEditor`, and naming a command the grammar lacks gets `undefined` rather than a type error.
213
+
214
+ | Export | Signature | Notes |
215
+ | --- | --- | --- |
216
+ | `isToolbarSeparator` | `(entry: ToolbarItem \| ToolbarSeparator) => entry is ToolbarSeparator` | Narrows an `items` entry to the separator arm. |
217
+ | `createDefaultToolbarItems` | `(messages?: Partial<EditorMessages>) => ReadonlyArray<ToolbarItem \| ToolbarSeparator>` | Nineteen items and two separators, in order: **bold, italic, underline, strike, highlight, subscript, superscript, link** \| **paragraph, heading 2, quote, bullets, numbered, code block, rule, table (3×3), image** \| **undo, redo**. |
218
+ | `createDefaultSlashCommands` | `(messages?: Partial<EditorMessages>) => ReadonlyArray<SlashCommand>` | Ten entries, in order: **paragraph, heading 2, blockquote, bullet list, ordered list, todo list, code block, table, image, horizontal rule**. |
219
+
220
+ Every default `run` goes through the editor's own `commands`, `marks` and `nodes`, so the set works against any extension registering those node and mark types and quietly does nothing against one that does not — the default item set covers exactly `defineBlockDocumentExtension()`'s grammar; an application composing extra nodes adds their items itself. Extending is appending:
221
+
222
+ ```ts
223
+ import { createDefaultToolbarItems, type ToolbarItem } from '@thomasfosterau/prosekit-svelte';
224
+
225
+ const sidenote: ToolbarItem = {
226
+ id: 'sidenote',
227
+ label: 'Sidenote',
228
+ kind: 'toggle',
229
+ isActive: (editor) => editor.nodes['sidenote']?.isActive() ?? false,
230
+ run: ({ editor }) => void editor.commands['insertNode']?.({ type: 'sidenote' }),
231
+ };
232
+
233
+ const items = [...createDefaultToolbarItems(), { id: 'app', separator: true }, sidenote];
234
+ ```
235
+
236
+ # Messages
237
+
238
+ `EditorMessages` is one flat interface of plain strings, grouped here by what they name:
239
+
240
+ - **Component labels** — `toolbarLabel`, `insertLabel`, `insertMenuLabel`; `linkDialogTitle`, `linkHrefLabel`, `linkTitleLabel`, `linkSubmitLabel`, `linkRemoveLabel`, `linkCancelLabel`, `linkCloseLabel`, `linkInvalidHrefMessage`; `imageDialogTitle`, `imageSrcLabel`, `imageSubmitLabel`, `imageCancelLabel`, `imageCloseLabel`, `imageInvalidSrcMessage`.
241
+ - **Default toolbar item labels** — `bold`, `italic`, `underline`, `strike`, `highlight`, `subscript`, `superscript`, `link`, `paragraph`, `heading2`, `quote`, `bullets`, `numbered`, `codeBlock`, `rule`, `table`, `image`, `undo`, `redo`.
242
+ - **Default insert-menu labels** — `slashParagraph`, `slashHeading2`, `slashBlockquote`, `slashBulletList`, `slashOrderedList`, `slashTodoList`, `slashCodeBlock`, `slashTable`, `slashImage`, `slashHorizontalRule`.
243
+
244
+ | Export | Signature | Notes |
245
+ | --- | --- | --- |
246
+ | `EditorMessages` | One flat interface of plain `string`s | Every component label prop, plus one key per default item and insert-menu entry. |
247
+ | `defaultEditorMessages` | `EditorMessages` | The English copy every prop falls back to — the applications this package was extracted from shipped these exact item labels, abbreviations included (`P`, `H2`, `Sub`, `Sup`). The default toolbar draws no icons, so an item's label is both its visible text and its accessible name. |
248
+ | `setEditorMessages` | `(messages: Partial<EditorMessages>) => void` | Provides copy for this subtree, merged over the defaults. Per render site, never from a root layout — see [App integration](#app-integration). |
249
+ | `getEditorMessages` | `() => EditorMessages` | Reads it, or `defaultEditorMessages` when no provider is mounted. |
250
+
251
+ The context key is `Symbol.for('@thomasfosterau/prosekit-svelte/messages')` — a registry symbol, so two copies of the module in one bundle still agree.
252
+
253
+ ## Known limits
254
+
255
+ - ProseKit's stock `link` mark declares `href`, `target` and `rel` only — no `title`. `LinkDialog`'s `title` field reaches `addLink` and survives only for a grammar whose own `link` mark declares one; against the stock mark, ProseMirror's `computeAttrs` silently drops it.
256
+ - ProseKit's stock `image` node declares `src`, `width` and `height` only, which is why `ImageDialog` asks for one field, deliberately. The applications this package was extracted from also prompted for alt text, a title and a caption, and `computeAttrs` dropped all three — three prompts collecting text that went nowhere. An application whose image node declares more passes its own toolbar item.
@@ -0,0 +1,66 @@
1
+ <!-- @component An `Editor` on the block-document grammar with the default toolbar (`toolbar`, default `true`), the envelope remap (`documentType`, default `'blockDocument'`, ⇄ `doc`) and, given `name`, a hidden form input carrying the document JSON. -->
2
+
3
+ <!--
4
+ BlockEditor — an `Editor` on the block-document grammar, with the default
5
+ toolbar and a hidden form input.
6
+
7
+ Two things it adds over `Editor`.
8
+
9
+ The envelope remap. A stored document's root `type` is the application's
10
+ own name for the surface (`blockDocument` by default); a compiled
11
+ ProseMirror schema's top node is always `doc`. This component is that
12
+ boundary, so it remaps in both directions: `defaultContent` is rewritten to
13
+ `doc` on the way in, and the hidden input always carries the application
14
+ shape. Feeding the stored shape straight into ProseKit throws
15
+ `RangeError: Unknown node type: blockDocument` and takes the hosting page
16
+ down with it — the bug both applications this package was extracted from
17
+ hit (#459).
18
+
19
+ The hidden input. Given a `name`, the editor is a progressive-enhancement
20
+ form field: the JSON of the current document, in the application's shape,
21
+ posts with the form.
22
+
23
+ `toolbar` is one prop carrying both halves of the design: `true` (the
24
+ default) renders `BlockToolbar`, `false` renders none, and a snippet —
25
+ which is what `{#snippet toolbar()}` on the call site passes — replaces it.
26
+ -->
27
+ <script lang="ts">import { fromEditorDocument, toEditorDocument } from "@thomasfosterau/prosekit-utilities";
28
+ import { defineBlockDocumentExtension } from "@thomasfosterau/prosekit-utilities/prosekit";
29
+ import BlockToolbar from "../toolbar/BlockToolbar.svelte";
30
+ import EditorMount from "./Editor.svelte";
31
+ let { extension, documentType = "blockDocument", content = $bindable(), defaultContent, name, items, slashCommands, toolbar = true, label, labelledBy, class: className, children, ...rest } = $props();
32
+ // Built here rather than in the prop default so an application that passes
33
+ // its own extension never constructs the stock one, and so the grammar is
34
+ // captured once — `Editor` creates its editor once for the same reason.
35
+ // svelte-ignore state_referenced_locally
36
+ const resolvedExtension = extension ?? defineBlockDocumentExtension();
37
+ const toStored = $derived(fromEditorDocument(documentType));
38
+ const editorDefaultContent = $derived(defaultContent === undefined ? undefined : toEditorDocument(defaultContent));
39
+ const serialised = $derived.by(() => {
40
+ const current = content ?? defaultContent;
41
+ return current === undefined ? undefined : JSON.stringify(toStored(current));
42
+ });
43
+ </script>
44
+
45
+ <EditorMount
46
+ {...rest}
47
+ bind:content
48
+ extension={resolvedExtension}
49
+ defaultContent={editorDefaultContent}
50
+ {label}
51
+ {labelledBy}
52
+ class={className}
53
+ >
54
+ {#if toolbar === true}
55
+ <BlockToolbar {items} {slashCommands} />
56
+ {:else if typeof toolbar === 'function'}
57
+ {@render toolbar()}
58
+ {/if}
59
+ {#if children}
60
+ {@render children()}
61
+ {/if}
62
+ </EditorMount>
63
+
64
+ {#if name !== undefined && serialised !== undefined}
65
+ <input type="hidden" {name} value={serialised} />
66
+ {/if}
@@ -0,0 +1,42 @@
1
+ import { type NodeJSON } from '@thomasfosterau/prosekit-utilities';
2
+ import type { Extension } from 'prosekit/core';
3
+ import type { Snippet } from 'svelte';
4
+ import type { HTMLAttributes } from 'svelte/elements';
5
+ import type { SlashCommand, ToolbarItem, ToolbarSeparator } from '../toolbar/items.ts';
6
+ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'aria-label' | 'aria-labelledby'> {
7
+ /**
8
+ * The grammar. Omitted, `defineBlockDocumentExtension()` — built lazily,
9
+ * so an application passing its own never constructs the default.
10
+ */
11
+ extension?: Extension | undefined;
12
+ /** The envelope `type` of the stored document. */
13
+ documentType?: string | undefined;
14
+ /** Two-way bound current content, in editor (`doc`) shape. */
15
+ content?: NodeJSON | undefined;
16
+ /** Content the editor is created with, in either shape. */
17
+ defaultContent?: NodeJSON | undefined;
18
+ /** Form field name. Given one, a hidden input carries the document's JSON. */
19
+ name?: string | undefined;
20
+ /** The toolbar's items. Passed to the default `BlockToolbar`. */
21
+ items?: ReadonlyArray<ToolbarItem | ToolbarSeparator> | undefined;
22
+ /** The insert menu's entries. Passed to the default `BlockToolbar`. */
23
+ slashCommands?: ReadonlyArray<SlashCommand> | undefined;
24
+ /** `true` for the default toolbar, `false` for none, a snippet to replace it. */
25
+ toolbar?: boolean | Snippet<[]> | undefined;
26
+ /** Accessible name of the wrapper. */
27
+ label?: string | undefined;
28
+ /**
29
+ * The `id` (or space-separated `id`s) of the visible element that names
30
+ * the wrapper, rendered as `aria-labelledby`. Wins over `label`, which
31
+ * stays the fallback name if no element has the id.
32
+ */
33
+ labelledBy?: string | undefined;
34
+ /** Extra classes on the wrapper. */
35
+ class?: string | undefined;
36
+ /** Rendered after the toolbar, inside the editor's provider. */
37
+ children?: Snippet<[]> | undefined;
38
+ }
39
+ /** An `Editor` on the block-document grammar with the default toolbar (`toolbar`, default `true`), the envelope remap (`documentType`, default `'blockDocument'`, ⇄ `doc`) and, given `name`, a hidden form input carrying the document JSON. */
40
+ declare const BlockEditor: import("svelte").Component<Props, {}, "content">;
41
+ type BlockEditor = ReturnType<typeof BlockEditor>;
42
+ export default BlockEditor;
@@ -0,0 +1,60 @@
1
+ <!-- @component The ProseKit editor mount: `content` binds two-way, `defaultContent` seeds once, and `children` (a toolbar) renders inside the editor context. Importing it pulls in ProseKit's runtime and stylesheets. -->
2
+
3
+ <!--
4
+ Editor — the ProseKit mount, and this package's only side-effecting
5
+ imports.
6
+
7
+ `prosekit/basic/style.css` and `prosekit/basic/typography.css` are bare
8
+ imports in another package's module graph, so the `"sideEffects": false`
9
+ this package declares about its own emitted modules still holds. What they
10
+ do mean is that anything importing this component pulls ProseKit's
11
+ stylesheets — and, through `createEditor`, the ProseKit runtime — into its
12
+ chunk. Import it by path from a route that genuinely edits a document, or
13
+ through a dynamic `import()` from one that only sometimes does, and never
14
+ re-export it from a barrel an application loads on every page.
15
+
16
+ The wrapper is a `role="group"` only when it is named, by `label` or
17
+ `labelledBy`: a group with no accessible name is noise in the
18
+ accessibility tree, and the thing that
19
+ matters — the `contenteditable` textbox ProseMirror builds — is named by
20
+ the application either way. `children` renders inside `<ProseKit>`, after
21
+ the mount node, which is where a toolbar goes: it needs the editor context
22
+ the provider sets.
23
+ -->
24
+ <script lang="ts" generics="E extends import('prosekit/core').Extension">import "prosekit/basic/style.css";
25
+ import "prosekit/basic/typography.css";
26
+ import { createEditor } from "prosekit/core";
27
+ import { ProseKit, useDocChange } from "prosekit/svelte";
28
+ let { extension, content = $bindable(), defaultContent, label, labelledBy, class: className, children, ...rest } = $props();
29
+ // The editor is created once. `extension` and the initial content are
30
+ // captured by value on purpose — reacting to later prop changes would
31
+ // recreate the editor as the user types (see `defaultContent` above).
32
+ // svelte-ignore state_referenced_locally
33
+ const initialContent = defaultContent ?? content;
34
+ // svelte-ignore state_referenced_locally
35
+ const editor = createEditor({
36
+ extension,
37
+ // The library's `NodeJSON` is the deeply-readonly twin of ProseKit's; the
38
+ // value is the same object either way, and `createEditor` only reads it.
39
+ ...initialContent !== undefined && { defaultContent: initialContent }
40
+ });
41
+ // `useDocChange` returns its dispose function, which `$effect` re-uses as
42
+ // the cleanup — ProseKit's hooks are not Svelte-aware, so this is the
43
+ // wiring that unsubscribes the document listener on unmount.
44
+ $effect(() => useDocChange(() => content = editor.getDocJSON(), { editor }));
45
+ </script>
46
+
47
+ <div
48
+ {...rest}
49
+ role={label === undefined && !labelledBy ? undefined : 'group'}
50
+ aria-label={label}
51
+ aria-labelledby={labelledBy || undefined}
52
+ class={['flex flex-col gap-2', className]}
53
+ >
54
+ <ProseKit {editor}>
55
+ {#if children}
56
+ {@render children()}
57
+ {/if}
58
+ <div {@attach editor.mount} class="outline-solid p-4"></div>
59
+ </ProseKit>
60
+ </div>
@@ -0,0 +1,57 @@
1
+ import 'prosekit/basic/style.css';
2
+ import 'prosekit/basic/typography.css';
3
+ import type { NodeJSON } from '@thomasfosterau/prosekit-utilities';
4
+ import type { Snippet } from 'svelte';
5
+ import type { HTMLAttributes } from 'svelte/elements';
6
+ declare function $$render<E extends import('prosekit/core').Extension>(): {
7
+ props: Omit<HTMLAttributes<HTMLDivElement>, "children" | "aria-label" | "aria-labelledby"> & {
8
+ /** The grammar and behaviour this editor is built from. */
9
+ extension: E;
10
+ /** Two-way bound current content; reflects user edits. */
11
+ content?: NodeJSON | undefined;
12
+ /**
13
+ * Content the editor is created with. Decoupled from `content` so an
14
+ * update form can pre-populate without retriggering editor recreation as
15
+ * the user types.
16
+ */
17
+ defaultContent?: NodeJSON | undefined;
18
+ /**
19
+ * Accessible name of the wrapper, and the fallback name when `labelledBy`
20
+ * names no element. With neither this nor `labelledBy`, the wrapper has
21
+ * no role.
22
+ */
23
+ label?: string | undefined;
24
+ /**
25
+ * The `id` (or space-separated `id`s) of the visible element that names
26
+ * the wrapper, rendered as `aria-labelledby`. Wins over `label`, which
27
+ * stays the fallback name if no element has the id.
28
+ */
29
+ labelledBy?: string | undefined;
30
+ /** Extra classes on the wrapper. */
31
+ class?: string | undefined;
32
+ /** Rendered inside the provider, before the mount node — the toolbar's place. */
33
+ children?: Snippet<[]> | undefined;
34
+ };
35
+ exports: {};
36
+ bindings: "content";
37
+ slots: {};
38
+ events: {};
39
+ };
40
+ declare class __sveltets_Render<E extends import('prosekit/core').Extension> {
41
+ props(): ReturnType<typeof $$render<E>>['props'];
42
+ events(): ReturnType<typeof $$render<E>>['events'];
43
+ slots(): ReturnType<typeof $$render<E>>['slots'];
44
+ bindings(): "content";
45
+ exports(): {};
46
+ }
47
+ interface $$IsomorphicComponent {
48
+ new <E extends import('prosekit/core').Extension>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<E>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<E>['props']>, ReturnType<__sveltets_Render<E>['events']>, ReturnType<__sveltets_Render<E>['slots']>> & {
49
+ $$bindings?: ReturnType<__sveltets_Render<E>['bindings']>;
50
+ } & ReturnType<__sveltets_Render<E>['exports']>;
51
+ <E extends import('prosekit/core').Extension>(internal: unknown, props: ReturnType<__sveltets_Render<E>['props']> & {}): ReturnType<__sveltets_Render<E>['exports']>;
52
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
53
+ }
54
+ /** The ProseKit editor mount: `content` binds two-way, `defaultContent` seeds once, and `children` (a toolbar) renders inside the editor context. Importing it pulls in ProseKit's runtime and stylesheets. */
55
+ declare const Editor: $$IsomorphicComponent;
56
+ type Editor<E extends import('prosekit/core').Extension> = InstanceType<typeof Editor<E>>;
57
+ export default Editor;
@@ -0,0 +1,55 @@
1
+ <!-- @component A single-line `Editor` on the inline-document grammar (Enter is a no-op), with the same envelope remap and hidden form input as `BlockEditor`. -->
2
+
3
+ <!--
4
+ InlineEditor — an `Editor` on the inline-document grammar: one line of
5
+ rich text, marks but no blocks.
6
+
7
+ It carries the same envelope remap and hidden form input as `BlockEditor`
8
+ (the applications this package was extracted from had the remap on the
9
+ block editor only, and a separate `InlineEditorInput` wrapper for the
10
+ field; both are folded in here), and one thing of its own: **Enter is a
11
+ no-op**. An inline document's top node is `inline*`, so there is no second
12
+ block for Enter to make; without the binding the base keymap's Enter runs
13
+ against a document that cannot hold its result, and a field that looks like
14
+ a single-line input behaves like a textarea.
15
+
16
+ That binding lives in the DEFAULT extension. An application passing its own
17
+ `extension` owns the single-line semantics too — union
18
+ `defineKeymap({ Enter: () => true })` into it, or accept the base keymap's
19
+ behaviour.
20
+ -->
21
+ <script lang="ts">import { fromEditorDocument, toEditorDocument } from "@thomasfosterau/prosekit-utilities";
22
+ import { defineInlineDocumentExtension } from "@thomasfosterau/prosekit-utilities/prosekit";
23
+ import { defineKeymap, union } from "prosekit/core";
24
+ import EditorMount from "./Editor.svelte";
25
+ let { extension, documentType = "inlineDocument", content = $bindable(), defaultContent, name, label, labelledBy, class: className, children, ...rest } = $props();
26
+ // Returning `true` claims the key: ProseKit runs the most recently added
27
+ // keymap first, so this shadows the base keymap's Enter rather than racing
28
+ // it.
29
+ // svelte-ignore state_referenced_locally
30
+ const resolvedExtension = extension ?? union(defineInlineDocumentExtension(), defineKeymap({ Enter: () => true }));
31
+ const toStored = $derived(fromEditorDocument(documentType));
32
+ const editorDefaultContent = $derived(defaultContent === undefined ? undefined : toEditorDocument(defaultContent));
33
+ const serialised = $derived.by(() => {
34
+ const current = content ?? defaultContent;
35
+ return current === undefined ? undefined : JSON.stringify(toStored(current));
36
+ });
37
+ </script>
38
+
39
+ <EditorMount
40
+ {...rest}
41
+ bind:content
42
+ extension={resolvedExtension}
43
+ defaultContent={editorDefaultContent}
44
+ {label}
45
+ {labelledBy}
46
+ class={className}
47
+ >
48
+ {#if children}
49
+ {@render children()}
50
+ {/if}
51
+ </EditorMount>
52
+
53
+ {#if name !== undefined && serialised !== undefined}
54
+ <input type="hidden" {name} value={serialised} />
55
+ {/if}
@@ -0,0 +1,36 @@
1
+ import { type NodeJSON } from '@thomasfosterau/prosekit-utilities';
2
+ import { type Extension } from 'prosekit/core';
3
+ import type { Snippet } from 'svelte';
4
+ import type { HTMLAttributes } from 'svelte/elements';
5
+ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'aria-label' | 'aria-labelledby'> {
6
+ /**
7
+ * The grammar. Omitted, `defineInlineDocumentExtension()` plus the
8
+ * Enter binding — built lazily, so an application passing its own never
9
+ * constructs the default.
10
+ */
11
+ extension?: Extension | undefined;
12
+ /** The envelope `type` of the stored document. */
13
+ documentType?: string | undefined;
14
+ /** Two-way bound current content, in editor (`doc`) shape. */
15
+ content?: NodeJSON | undefined;
16
+ /** Content the editor is created with, in either shape. */
17
+ defaultContent?: NodeJSON | undefined;
18
+ /** Form field name. Given one, a hidden input carries the document's JSON. */
19
+ name?: string | undefined;
20
+ /** Accessible name of the wrapper. */
21
+ label?: string | undefined;
22
+ /**
23
+ * The `id` (or space-separated `id`s) of the visible element that names
24
+ * the wrapper, rendered as `aria-labelledby`. Wins over `label`, which
25
+ * stays the fallback name if no element has the id.
26
+ */
27
+ labelledBy?: string | undefined;
28
+ /** Extra classes on the wrapper. */
29
+ class?: string | undefined;
30
+ /** Rendered inside the editor's provider, after the mount node. */
31
+ children?: Snippet<[]> | undefined;
32
+ }
33
+ /** A single-line `Editor` on the inline-document grammar (Enter is a no-op), with the same envelope remap and hidden form input as `BlockEditor`. */
34
+ declare const InlineEditor: import("svelte").Component<Props, {}, "content">;
35
+ type InlineEditor = ReturnType<typeof InlineEditor>;
36
+ export default InlineEditor;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@thomasfosterau/prosekit-svelte` — Svelte 5 editor components on ProseKit.
3
+ *
4
+ * `Editor.svelte` carries this package's only side-effecting imports, the
5
+ * ProseKit basic stylesheets, and through `createEditor` it pulls the
6
+ * ProseKit runtime behind them. Import a component from here by path from a
7
+ * route that genuinely edits a document, or through a dynamic `import()`
8
+ * from one that only sometimes does; never re-export one from a barrel an
9
+ * application loads on every page.
10
+ *
11
+ * @module
12
+ */
13
+ export { default as BlockEditor } from './editor/BlockEditor.svelte';
14
+ export { default as Editor } from './editor/Editor.svelte';
15
+ export { default as InlineEditor } from './editor/InlineEditor.svelte';
16
+ export { defaultEditorMessages, getEditorMessages, setEditorMessages, type EditorMessages, } from './messages.svelte.ts';
17
+ export { default as BlockToolbar } from './toolbar/BlockToolbar.svelte';
18
+ export { default as ImageDialog } from './toolbar/ImageDialog.svelte';
19
+ export { createDefaultSlashCommands, createDefaultToolbarItems, isToolbarSeparator, type SlashCommand, type ToolbarEditor, type ToolbarItem, type ToolbarRunContext, type ToolbarSeparator, } from './toolbar/items.ts';
20
+ export { default as LinkDialog } from './toolbar/LinkDialog.svelte';
21
+ export { default as SlashMenu } from './toolbar/SlashMenu.svelte';