@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 +21 -0
- package/README.md +255 -2
- package/dist/editor/BlockEditor.svelte +66 -0
- package/dist/editor/BlockEditor.svelte.d.ts +42 -0
- package/dist/editor/Editor.svelte +60 -0
- package/dist/editor/Editor.svelte.d.ts +57 -0
- package/dist/editor/InlineEditor.svelte +55 -0
- package/dist/editor/InlineEditor.svelte.d.ts +36 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +21 -0
- package/dist/messages.svelte.d.ts +162 -0
- package/dist/messages.svelte.js +114 -0
- package/dist/toolbar/BlockToolbar.svelte +162 -0
- package/dist/toolbar/BlockToolbar.svelte.d.ts +52 -0
- package/dist/toolbar/ImageDialog.svelte +86 -0
- package/dist/toolbar/ImageDialog.svelte.d.ts +31 -0
- package/dist/toolbar/LinkDialog.svelte +113 -0
- package/dist/toolbar/LinkDialog.svelte.d.ts +40 -0
- package/dist/toolbar/SlashMenu.svelte +112 -0
- package/dist/toolbar/SlashMenu.svelte.d.ts +37 -0
- package/dist/toolbar/items.d.ts +125 -0
- package/dist/toolbar/items.js +262 -0
- package/package.json +65 -3
package/dist/index.js
ADDED
|
@@ -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, } from "./messages.svelte.js";
|
|
17
|
+
export { default as BlockToolbar } from './toolbar/BlockToolbar.svelte';
|
|
18
|
+
export { default as ImageDialog } from './toolbar/ImageDialog.svelte';
|
|
19
|
+
export { createDefaultSlashCommands, createDefaultToolbarItems, isToolbarSeparator, } from "./toolbar/items.js";
|
|
20
|
+
export { default as LinkDialog } from './toolbar/LinkDialog.svelte';
|
|
21
|
+
export { default as SlashMenu } from './toolbar/SlashMenu.svelte';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional per-render-site copy override.
|
|
3
|
+
*
|
|
4
|
+
* Every string this package renders is already a prop with an English
|
|
5
|
+
* default, so an application can translate one call site by passing props.
|
|
6
|
+
* {@link setEditorMessages} is the other half: it changes those *defaults*
|
|
7
|
+
* for one subtree, so an application that mounts the editor from several
|
|
8
|
+
* places wires its catalogue once per place instead of threading twenty
|
|
9
|
+
* props through each.
|
|
10
|
+
*
|
|
11
|
+
* It is deliberately set **per render site** — from the component that
|
|
12
|
+
* mounts an editor — and never from a root layout. Wiring it high in the
|
|
13
|
+
* tree means importing this package's barrel there, and `Editor.svelte`'s
|
|
14
|
+
* bare `prosekit/basic/*.css` imports would then land in every page's shared
|
|
15
|
+
* chunk along with the ProseKit runtime they pull behind them. That is the
|
|
16
|
+
* one bundle boundary this package exists to hold (the app repos measured it
|
|
17
|
+
* and enforce it with bundle-boundary tests; PERF-BUNDLE-001).
|
|
18
|
+
*
|
|
19
|
+
* When no provider is mounted — Storybook, a test, an application that never
|
|
20
|
+
* wires it — {@link getEditorMessages} returns {@link defaultEditorMessages}.
|
|
21
|
+
* The missing-provider case is the documented normal case, not an error:
|
|
22
|
+
* the editor is silently un-translated, never broken.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Every user-facing string this package can render, as one flat record of
|
|
28
|
+
* plain strings.
|
|
29
|
+
*
|
|
30
|
+
* Flat and plain on purpose. Nested groups would have to be merged
|
|
31
|
+
* recursively for a partial override to work, and message *functions* (what
|
|
32
|
+
* the applications this package was extracted from used) force every call
|
|
33
|
+
* site to remember the parentheses. A string is what a Svelte prop wants.
|
|
34
|
+
*
|
|
35
|
+
* The first block is the component label props; the rest are the labels of
|
|
36
|
+
* the items {@link createDefaultToolbarItems} and
|
|
37
|
+
* {@link createDefaultSlashCommands} build, which are data rather than
|
|
38
|
+
* markup and so are the only way to translate them.
|
|
39
|
+
*/
|
|
40
|
+
export interface EditorMessages {
|
|
41
|
+
/** Accessible name of `BlockToolbar`'s `role="toolbar"`. */
|
|
42
|
+
readonly toolbarLabel: string;
|
|
43
|
+
/** Visible label and accessible name of the insert-menu trigger. */
|
|
44
|
+
readonly insertLabel: string;
|
|
45
|
+
/** Accessible name of the open insert menu. */
|
|
46
|
+
readonly insertMenuLabel: string;
|
|
47
|
+
/** `LinkDialog`'s heading — the dialog's accessible name. */
|
|
48
|
+
readonly linkDialogTitle: string;
|
|
49
|
+
/** Label of `LinkDialog`'s URL field. */
|
|
50
|
+
readonly linkHrefLabel: string;
|
|
51
|
+
/** Label of `LinkDialog`'s optional title field. */
|
|
52
|
+
readonly linkTitleLabel: string;
|
|
53
|
+
/** `LinkDialog`'s submit button. */
|
|
54
|
+
readonly linkSubmitLabel: string;
|
|
55
|
+
/** `LinkDialog`'s remove-the-link button. */
|
|
56
|
+
readonly linkRemoveLabel: string;
|
|
57
|
+
/** `LinkDialog`'s cancel button. */
|
|
58
|
+
readonly linkCancelLabel: string;
|
|
59
|
+
/** Accessible name of `LinkDialog`'s corner close button. */
|
|
60
|
+
readonly linkCloseLabel: string;
|
|
61
|
+
/** Shown in a `role="alert"` when the URL fails `isSafeHref`. */
|
|
62
|
+
readonly linkInvalidHrefMessage: string;
|
|
63
|
+
/** `ImageDialog`'s heading — the dialog's accessible name. */
|
|
64
|
+
readonly imageDialogTitle: string;
|
|
65
|
+
/** Label of `ImageDialog`'s source field. */
|
|
66
|
+
readonly imageSrcLabel: string;
|
|
67
|
+
/** `ImageDialog`'s submit button. */
|
|
68
|
+
readonly imageSubmitLabel: string;
|
|
69
|
+
/** `ImageDialog`'s cancel button. */
|
|
70
|
+
readonly imageCancelLabel: string;
|
|
71
|
+
/** Accessible name of `ImageDialog`'s corner close button. */
|
|
72
|
+
readonly imageCloseLabel: string;
|
|
73
|
+
/** Shown in a `role="alert"` when the source fails `isSafeHref`. */
|
|
74
|
+
readonly imageInvalidSrcMessage: string;
|
|
75
|
+
/** Default toolbar item: toggle the `bold` mark. */
|
|
76
|
+
readonly bold: string;
|
|
77
|
+
/** Default toolbar item: toggle the `italic` mark. */
|
|
78
|
+
readonly italic: string;
|
|
79
|
+
/** Default toolbar item: toggle the `underline` mark. */
|
|
80
|
+
readonly underline: string;
|
|
81
|
+
/** Default toolbar item: toggle the `strike` mark. */
|
|
82
|
+
readonly strike: string;
|
|
83
|
+
/** Default toolbar item: toggle the `highlight` mark. */
|
|
84
|
+
readonly highlight: string;
|
|
85
|
+
/** Default toolbar item: toggle the `subscript` mark. */
|
|
86
|
+
readonly subscript: string;
|
|
87
|
+
/** Default toolbar item: toggle the `superscript` mark. */
|
|
88
|
+
readonly superscript: string;
|
|
89
|
+
/** Default toolbar item: open `LinkDialog`. */
|
|
90
|
+
readonly link: string;
|
|
91
|
+
/** Default toolbar item: set the block to a paragraph. */
|
|
92
|
+
readonly paragraph: string;
|
|
93
|
+
/** Default toolbar item: toggle a level-2 heading. */
|
|
94
|
+
readonly heading2: string;
|
|
95
|
+
/** Default toolbar item: toggle a blockquote. */
|
|
96
|
+
readonly quote: string;
|
|
97
|
+
/** Default toolbar item: toggle a bullet list. */
|
|
98
|
+
readonly bullets: string;
|
|
99
|
+
/** Default toolbar item: toggle an ordered list. */
|
|
100
|
+
readonly numbered: string;
|
|
101
|
+
/** Default toolbar item: toggle a code block. */
|
|
102
|
+
readonly codeBlock: string;
|
|
103
|
+
/** Default toolbar item: insert a horizontal rule. */
|
|
104
|
+
readonly rule: string;
|
|
105
|
+
/** Default toolbar item: insert a 3×3 table. */
|
|
106
|
+
readonly table: string;
|
|
107
|
+
/** Default toolbar item: open `ImageDialog`. */
|
|
108
|
+
readonly image: string;
|
|
109
|
+
/** Default toolbar item: undo. */
|
|
110
|
+
readonly undo: string;
|
|
111
|
+
/** Default toolbar item: redo. */
|
|
112
|
+
readonly redo: string;
|
|
113
|
+
/** Default insert-menu command: set the block to a paragraph. */
|
|
114
|
+
readonly slashParagraph: string;
|
|
115
|
+
/** Default insert-menu command: toggle a level-2 heading. */
|
|
116
|
+
readonly slashHeading2: string;
|
|
117
|
+
/** Default insert-menu command: toggle a blockquote. */
|
|
118
|
+
readonly slashBlockquote: string;
|
|
119
|
+
/** Default insert-menu command: toggle a bullet list. */
|
|
120
|
+
readonly slashBulletList: string;
|
|
121
|
+
/** Default insert-menu command: toggle an ordered list. */
|
|
122
|
+
readonly slashOrderedList: string;
|
|
123
|
+
/** Default insert-menu command: toggle a task list. */
|
|
124
|
+
readonly slashTodoList: string;
|
|
125
|
+
/** Default insert-menu command: toggle a code block. */
|
|
126
|
+
readonly slashCodeBlock: string;
|
|
127
|
+
/** Default insert-menu command: insert a 3×3 table. */
|
|
128
|
+
readonly slashTable: string;
|
|
129
|
+
/** Default insert-menu command: open `ImageDialog`. */
|
|
130
|
+
readonly slashImage: string;
|
|
131
|
+
/** Default insert-menu command: insert a horizontal rule. */
|
|
132
|
+
readonly slashHorizontalRule: string;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The English copy every prop falls back to.
|
|
136
|
+
*
|
|
137
|
+
* The item labels are the ones the applications this package was extracted
|
|
138
|
+
* from shipped, abbreviations included (`P`, `H2`, `Sub`, `Sup`): the
|
|
139
|
+
* default toolbar draws no icons, so an item's label is both its visible
|
|
140
|
+
* text and its accessible name, and changing them would change what those
|
|
141
|
+
* applications' users read. An application that wants longer names passes
|
|
142
|
+
* its own `items`, or overrides these keys.
|
|
143
|
+
*/
|
|
144
|
+
export declare const defaultEditorMessages: EditorMessages;
|
|
145
|
+
/**
|
|
146
|
+
* Provides the copy for this subtree, merged over
|
|
147
|
+
* {@link defaultEditorMessages}.
|
|
148
|
+
*
|
|
149
|
+
* Call it during the initialisation of the component that mounts an editor —
|
|
150
|
+
* Svelte's `setContext` is only legal there — and pass only the keys being
|
|
151
|
+
* translated; everything else keeps its English default.
|
|
152
|
+
*/
|
|
153
|
+
export declare function setEditorMessages(messages: Partial<EditorMessages>): void;
|
|
154
|
+
/**
|
|
155
|
+
* Reads the copy for this subtree, or {@link defaultEditorMessages} when no
|
|
156
|
+
* provider is mounted.
|
|
157
|
+
*
|
|
158
|
+
* Call it once in a component's `<script>` and spend the result in the
|
|
159
|
+
* `$props()` defaults (`label = messages.toolbarLabel`), so a caller's own
|
|
160
|
+
* prop still wins over the context.
|
|
161
|
+
*/
|
|
162
|
+
export declare function getEditorMessages(): EditorMessages;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional per-render-site copy override.
|
|
3
|
+
*
|
|
4
|
+
* Every string this package renders is already a prop with an English
|
|
5
|
+
* default, so an application can translate one call site by passing props.
|
|
6
|
+
* {@link setEditorMessages} is the other half: it changes those *defaults*
|
|
7
|
+
* for one subtree, so an application that mounts the editor from several
|
|
8
|
+
* places wires its catalogue once per place instead of threading twenty
|
|
9
|
+
* props through each.
|
|
10
|
+
*
|
|
11
|
+
* It is deliberately set **per render site** — from the component that
|
|
12
|
+
* mounts an editor — and never from a root layout. Wiring it high in the
|
|
13
|
+
* tree means importing this package's barrel there, and `Editor.svelte`'s
|
|
14
|
+
* bare `prosekit/basic/*.css` imports would then land in every page's shared
|
|
15
|
+
* chunk along with the ProseKit runtime they pull behind them. That is the
|
|
16
|
+
* one bundle boundary this package exists to hold (the app repos measured it
|
|
17
|
+
* and enforce it with bundle-boundary tests; PERF-BUNDLE-001).
|
|
18
|
+
*
|
|
19
|
+
* When no provider is mounted — Storybook, a test, an application that never
|
|
20
|
+
* wires it — {@link getEditorMessages} returns {@link defaultEditorMessages}.
|
|
21
|
+
* The missing-provider case is the documented normal case, not an error:
|
|
22
|
+
* the editor is silently un-translated, never broken.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
import { getContext, setContext } from 'svelte';
|
|
27
|
+
/**
|
|
28
|
+
* The English copy every prop falls back to.
|
|
29
|
+
*
|
|
30
|
+
* The item labels are the ones the applications this package was extracted
|
|
31
|
+
* from shipped, abbreviations included (`P`, `H2`, `Sub`, `Sup`): the
|
|
32
|
+
* default toolbar draws no icons, so an item's label is both its visible
|
|
33
|
+
* text and its accessible name, and changing them would change what those
|
|
34
|
+
* applications' users read. An application that wants longer names passes
|
|
35
|
+
* its own `items`, or overrides these keys.
|
|
36
|
+
*/
|
|
37
|
+
export const defaultEditorMessages = {
|
|
38
|
+
toolbarLabel: 'Editor toolbar',
|
|
39
|
+
insertLabel: 'Insert',
|
|
40
|
+
insertMenuLabel: 'Insert block',
|
|
41
|
+
linkDialogTitle: 'Link',
|
|
42
|
+
linkHrefLabel: 'URL',
|
|
43
|
+
linkTitleLabel: 'Title (optional)',
|
|
44
|
+
linkSubmitLabel: 'Apply',
|
|
45
|
+
linkRemoveLabel: 'Remove link',
|
|
46
|
+
linkCancelLabel: 'Cancel',
|
|
47
|
+
linkCloseLabel: 'Close',
|
|
48
|
+
linkInvalidHrefMessage: 'Enter an http, https or mailto URL.',
|
|
49
|
+
imageDialogTitle: 'Image',
|
|
50
|
+
imageSrcLabel: 'Image URL',
|
|
51
|
+
imageSubmitLabel: 'Insert',
|
|
52
|
+
imageCancelLabel: 'Cancel',
|
|
53
|
+
imageCloseLabel: 'Close',
|
|
54
|
+
imageInvalidSrcMessage: 'Enter an http, https or mailto URL.',
|
|
55
|
+
bold: 'Bold',
|
|
56
|
+
italic: 'Italic',
|
|
57
|
+
underline: 'Underline',
|
|
58
|
+
strike: 'Strike',
|
|
59
|
+
highlight: 'Highlight',
|
|
60
|
+
subscript: 'Sub',
|
|
61
|
+
superscript: 'Sup',
|
|
62
|
+
link: 'Link',
|
|
63
|
+
paragraph: 'P',
|
|
64
|
+
heading2: 'H2',
|
|
65
|
+
quote: 'Quote',
|
|
66
|
+
bullets: 'Bullets',
|
|
67
|
+
numbered: 'Numbered',
|
|
68
|
+
codeBlock: 'Code',
|
|
69
|
+
rule: 'Rule',
|
|
70
|
+
table: 'Table',
|
|
71
|
+
image: 'Image',
|
|
72
|
+
undo: 'Undo',
|
|
73
|
+
redo: 'Redo',
|
|
74
|
+
slashParagraph: 'Paragraph',
|
|
75
|
+
slashHeading2: 'Heading 2',
|
|
76
|
+
slashBlockquote: 'Blockquote',
|
|
77
|
+
slashBulletList: 'Bullet List',
|
|
78
|
+
slashOrderedList: 'Ordered List',
|
|
79
|
+
slashTodoList: 'Todo List',
|
|
80
|
+
slashCodeBlock: 'Code Block',
|
|
81
|
+
slashTable: 'Table',
|
|
82
|
+
slashImage: 'Image',
|
|
83
|
+
slashHorizontalRule: 'Horizontal Rule',
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* The context key.
|
|
87
|
+
*
|
|
88
|
+
* `Symbol.for` rather than `Symbol()`: a registry symbol is the same value
|
|
89
|
+
* in two copies of this module, which is what keeps a duplicated bundle from
|
|
90
|
+
* silently falling back to English on one half of the tree.
|
|
91
|
+
*/
|
|
92
|
+
const MESSAGES_KEY = Symbol.for('@thomasfosterau/prosekit-svelte/messages');
|
|
93
|
+
/**
|
|
94
|
+
* Provides the copy for this subtree, merged over
|
|
95
|
+
* {@link defaultEditorMessages}.
|
|
96
|
+
*
|
|
97
|
+
* Call it during the initialisation of the component that mounts an editor —
|
|
98
|
+
* Svelte's `setContext` is only legal there — and pass only the keys being
|
|
99
|
+
* translated; everything else keeps its English default.
|
|
100
|
+
*/
|
|
101
|
+
export function setEditorMessages(messages) {
|
|
102
|
+
setContext(MESSAGES_KEY, { ...defaultEditorMessages, ...messages });
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Reads the copy for this subtree, or {@link defaultEditorMessages} when no
|
|
106
|
+
* provider is mounted.
|
|
107
|
+
*
|
|
108
|
+
* Call it once in a component's `<script>` and spend the result in the
|
|
109
|
+
* `$props()` defaults (`label = messages.toolbarLabel`), so a caller's own
|
|
110
|
+
* prop still wins over the context.
|
|
111
|
+
*/
|
|
112
|
+
export function getEditorMessages() {
|
|
113
|
+
return getContext(MESSAGES_KEY) ?? defaultEditorMessages;
|
|
114
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<!-- @component The editor toolbar rendered from `ToolbarItem`s and `SlashCommand`s on a bits-ui `Toolbar` (one tab stop, arrow keys, `aria-pressed` toggles). Render it inside an `Editor`. -->
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
BlockToolbar — the editor's toolbar, rendered from a list of items.
|
|
5
|
+
|
|
6
|
+
ARIA and keyboard contract: bits-ui's `Toolbar`. `role="toolbar"`, one tab
|
|
7
|
+
stop for the whole toolbar, arrow keys to move between items (wrapping),
|
|
8
|
+
Home and End to the ends, and disabled items skipped. The applications this
|
|
9
|
+
package was extracted from had `role="toolbar"` and an accessible name and
|
|
10
|
+
nothing else — every button was a tab stop, and the "active" state of a
|
|
11
|
+
toggle was a CSS class with no `aria-pressed` behind it. That is what the
|
|
12
|
+
browser suite pins here.
|
|
13
|
+
|
|
14
|
+
What is local: `aria-pressed` for `kind: 'toggle'` items, read from the
|
|
15
|
+
item's own `isActive` on every editor update (hence `useEditor({ update:
|
|
16
|
+
true })`); `disabled` from `isEnabled`; the separators; and pulling focus
|
|
17
|
+
back into the document after a command runs, so typing continues where it
|
|
18
|
+
left off. A run that opens a dialog says so through `dialogs`, and the
|
|
19
|
+
refocus is skipped — otherwise it would fire before the dialog's focus trap
|
|
20
|
+
and steal the focus straight back.
|
|
21
|
+
|
|
22
|
+
The dialogs are rendered here rather than by each item because an item is a
|
|
23
|
+
plain value with no markup of its own; `ToolbarRunContext.dialogs` is the
|
|
24
|
+
seam. The link dialog is seeded from the selection's own link mark, so the
|
|
25
|
+
button edits an existing link instead of silently replacing it.
|
|
26
|
+
|
|
27
|
+
Every class is a literal: Tailwind only generates what it can read in
|
|
28
|
+
source, so `btn-${something}` would emit nothing at build time.
|
|
29
|
+
-->
|
|
30
|
+
<script lang="ts">import { Toolbar } from "bits-ui";
|
|
31
|
+
import { getMarkRange } from "prosekit/core";
|
|
32
|
+
import { useEditor } from "prosekit/svelte";
|
|
33
|
+
import { getEditorMessages } from "../messages.svelte.js";
|
|
34
|
+
import ImageDialog from "./ImageDialog.svelte";
|
|
35
|
+
import { createDefaultSlashCommands, createDefaultToolbarItems, isToolbarSeparator } from "./items.js";
|
|
36
|
+
import LinkDialog from "./LinkDialog.svelte";
|
|
37
|
+
import SlashMenu from "./SlashMenu.svelte";
|
|
38
|
+
const messages = getEditorMessages();
|
|
39
|
+
let { items = createDefaultToolbarItems(messages), slashCommands = createDefaultSlashCommands(messages), label = messages.toolbarLabel, labelledBy, insertLabel = messages.insertLabel, insertMenuLabel = messages.insertMenuLabel, linkDialog, imageDialog, class: className, id, ...rest } = $props();
|
|
40
|
+
const editorStore = useEditor({ update: true });
|
|
41
|
+
const BUTTON_CLASS = "btn btn-sm btn-ghost border-base-300 border";
|
|
42
|
+
const PRESSED_CLASS = "btn-active";
|
|
43
|
+
let linkOpen = $state(false);
|
|
44
|
+
let imageOpen = $state(false);
|
|
45
|
+
let linkSeed = $state({
|
|
46
|
+
href: "",
|
|
47
|
+
title: ""
|
|
48
|
+
});
|
|
49
|
+
/**
|
|
50
|
+
* Reads the link mark touching the caret, so the dialog opens on the link
|
|
51
|
+
* the user is standing in rather than blank over the top of it. Guarded on
|
|
52
|
+
* the schema: an application may use these items with a grammar that has
|
|
53
|
+
* no link mark, and `getMarkRange` would throw for it.
|
|
54
|
+
*/
|
|
55
|
+
function currentLink(editor) {
|
|
56
|
+
if (editor.schema.marks["link"] === undefined) return {
|
|
57
|
+
href: "",
|
|
58
|
+
title: ""
|
|
59
|
+
};
|
|
60
|
+
const range = getMarkRange(editor.state.selection.$from, "link");
|
|
61
|
+
const attrs = range?.mark.attrs ?? {};
|
|
62
|
+
return {
|
|
63
|
+
href: typeof attrs["href"] === "string" ? attrs["href"] : "",
|
|
64
|
+
title: typeof attrs["title"] === "string" ? attrs["title"] : ""
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Tracks whether the run just performed asked for a dialog. */
|
|
68
|
+
let opening = false;
|
|
69
|
+
const dialogs = {
|
|
70
|
+
link: () => {
|
|
71
|
+
opening = true;
|
|
72
|
+
linkSeed = currentLink($editorStore);
|
|
73
|
+
linkOpen = true;
|
|
74
|
+
},
|
|
75
|
+
image: () => {
|
|
76
|
+
opening = true;
|
|
77
|
+
imageOpen = true;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
function run(item) {
|
|
81
|
+
opening = false;
|
|
82
|
+
item.run({
|
|
83
|
+
editor: $editorStore,
|
|
84
|
+
dialogs
|
|
85
|
+
});
|
|
86
|
+
if (!opening) $editorStore.focus();
|
|
87
|
+
}
|
|
88
|
+
function applyLink(attrs) {
|
|
89
|
+
const editor = $editorStore;
|
|
90
|
+
// ProseKit's stock `link` mark declares `href`, `target` and `rel`; a
|
|
91
|
+
// `title` reaches `addLink` for a grammar whose link mark declares one
|
|
92
|
+
// and is dropped by `computeAttrs` for one that does not.
|
|
93
|
+
editor.commands["addLink"]?.(attrs);
|
|
94
|
+
editor.focus();
|
|
95
|
+
}
|
|
96
|
+
function removeLink() {
|
|
97
|
+
const editor = $editorStore;
|
|
98
|
+
editor.commands["removeLink"]?.();
|
|
99
|
+
editor.focus();
|
|
100
|
+
}
|
|
101
|
+
function insertImage(attrs) {
|
|
102
|
+
const editor = $editorStore;
|
|
103
|
+
editor.commands["insertNode"]?.({
|
|
104
|
+
type: "image",
|
|
105
|
+
attrs
|
|
106
|
+
});
|
|
107
|
+
editor.focus();
|
|
108
|
+
}
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<Toolbar.Root
|
|
112
|
+
{...rest}
|
|
113
|
+
{...(id === undefined ? {} : { id })}
|
|
114
|
+
aria-label={label}
|
|
115
|
+
aria-labelledby={labelledBy || undefined}
|
|
116
|
+
class={[
|
|
117
|
+
'border-base-300 bg-base-100 flex flex-wrap items-center gap-1.5 rounded border p-2',
|
|
118
|
+
className,
|
|
119
|
+
]}
|
|
120
|
+
>
|
|
121
|
+
{#each items as item (item.id)}
|
|
122
|
+
{#if isToolbarSeparator(item)}
|
|
123
|
+
<div role="separator" aria-orientation="vertical" class="bg-base-300 mx-1 h-5 w-px"></div>
|
|
124
|
+
{:else}
|
|
125
|
+
<Toolbar.Button
|
|
126
|
+
disabled={item.isEnabled === undefined ? false : !item.isEnabled($editorStore)}
|
|
127
|
+
aria-pressed={item.kind === 'toggle'
|
|
128
|
+
? (item.isActive?.($editorStore) ?? false)
|
|
129
|
+
: undefined}
|
|
130
|
+
class={[
|
|
131
|
+
BUTTON_CLASS,
|
|
132
|
+
item.kind === 'toggle' && item.isActive?.($editorStore) === true && PRESSED_CLASS,
|
|
133
|
+
]}
|
|
134
|
+
onclick={() => run(item)}
|
|
135
|
+
>
|
|
136
|
+
{item.label}
|
|
137
|
+
</Toolbar.Button>
|
|
138
|
+
{/if}
|
|
139
|
+
{/each}
|
|
140
|
+
|
|
141
|
+
<SlashMenu
|
|
142
|
+
commands={slashCommands}
|
|
143
|
+
label={insertLabel}
|
|
144
|
+
menuLabel={insertMenuLabel}
|
|
145
|
+
{dialogs}
|
|
146
|
+
>
|
|
147
|
+
{#snippet trigger({ props })}
|
|
148
|
+
<Toolbar.Button {...props} class={BUTTON_CLASS}>{insertLabel}</Toolbar.Button>
|
|
149
|
+
{/snippet}
|
|
150
|
+
</SlashMenu>
|
|
151
|
+
</Toolbar.Root>
|
|
152
|
+
|
|
153
|
+
<LinkDialog
|
|
154
|
+
bind:open={linkOpen}
|
|
155
|
+
href={linkSeed.href}
|
|
156
|
+
title={linkSeed.title}
|
|
157
|
+
onSubmit={applyLink}
|
|
158
|
+
onRemove={removeLink}
|
|
159
|
+
{...linkDialog}
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
<ImageDialog bind:open={imageOpen} onSubmit={insertImage} {...imageDialog} />
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
import { type SlashCommand, type ToolbarItem, type ToolbarSeparator } from './items.ts';
|
|
3
|
+
/** The label props `BlockToolbar` forwards to `LinkDialog`. */
|
|
4
|
+
interface LinkDialogLabels {
|
|
5
|
+
titleLabel: string;
|
|
6
|
+
hrefLabel: string;
|
|
7
|
+
linkTitleLabel: string;
|
|
8
|
+
submitLabel: string;
|
|
9
|
+
removeLabel: string;
|
|
10
|
+
cancelLabel: string;
|
|
11
|
+
closeLabel: string;
|
|
12
|
+
invalidHrefMessage: string;
|
|
13
|
+
}
|
|
14
|
+
/** The label props `BlockToolbar` forwards to `ImageDialog`. */
|
|
15
|
+
interface ImageDialogLabels {
|
|
16
|
+
titleLabel: string;
|
|
17
|
+
srcLabel: string;
|
|
18
|
+
submitLabel: string;
|
|
19
|
+
cancelLabel: string;
|
|
20
|
+
closeLabel: string;
|
|
21
|
+
invalidSrcMessage: string;
|
|
22
|
+
}
|
|
23
|
+
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'id' | 'aria-label' | 'aria-labelledby'> {
|
|
24
|
+
/** The toolbar element's `id`. */
|
|
25
|
+
id?: string | undefined;
|
|
26
|
+
/** The buttons and separators, in order. */
|
|
27
|
+
items?: ReadonlyArray<ToolbarItem | ToolbarSeparator> | undefined;
|
|
28
|
+
/** The insert menu's entries. */
|
|
29
|
+
slashCommands?: ReadonlyArray<SlashCommand> | undefined;
|
|
30
|
+
/** Accessible name of the toolbar. */
|
|
31
|
+
label?: string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The `id` (or space-separated `id`s) of the visible element that names
|
|
34
|
+
* the toolbar, rendered as `aria-labelledby`. Wins over `label`, which
|
|
35
|
+
* stays the fallback name if no element has the id.
|
|
36
|
+
*/
|
|
37
|
+
labelledBy?: string | undefined;
|
|
38
|
+
/** Visible text and accessible name of the insert button. */
|
|
39
|
+
insertLabel?: string | undefined;
|
|
40
|
+
/** Accessible name of the open insert menu. */
|
|
41
|
+
insertMenuLabel?: string | undefined;
|
|
42
|
+
/** Label overrides for the link dialog. */
|
|
43
|
+
linkDialog?: Partial<LinkDialogLabels> | undefined;
|
|
44
|
+
/** Label overrides for the image dialog. */
|
|
45
|
+
imageDialog?: Partial<ImageDialogLabels> | undefined;
|
|
46
|
+
/** Extra classes on the toolbar. */
|
|
47
|
+
class?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
/** The editor toolbar rendered from `ToolbarItem`s and `SlashCommand`s on a bits-ui `Toolbar` (one tab stop, arrow keys, `aria-pressed` toggles). Render it inside an `Editor`. */
|
|
50
|
+
declare const BlockToolbar: import("svelte").Component<Props, {}, "">;
|
|
51
|
+
type BlockToolbar = ReturnType<typeof BlockToolbar>;
|
|
52
|
+
export default BlockToolbar;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<!-- @component The image prompt as a bits-ui modal, asking only for the source URL the stock `image` node can hold. -->
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
ImageDialog — the image prompt, as a modal. `LinkDialog`'s reasoning,
|
|
5
|
+
applied to the other prompt.
|
|
6
|
+
|
|
7
|
+
One field, deliberately. The applications this package was extracted from
|
|
8
|
+
asked four questions — source, alt text, title, caption — and passed all
|
|
9
|
+
four as node attributes; ProseKit's stock `image` node declares `src`,
|
|
10
|
+
`width` and `height`, so ProseMirror's `computeAttrs` dropped the other
|
|
11
|
+
three on the way in. Three of those four prompts collected text that went
|
|
12
|
+
nowhere. Only the one the schema can hold is asked for here; an
|
|
13
|
+
application whose image node declares more passes its own toolbar item.
|
|
14
|
+
|
|
15
|
+
ARIA contract: bits-ui's, as `LinkDialog`.
|
|
16
|
+
-->
|
|
17
|
+
<script lang="ts">import { isSafeHref } from "@thomasfosterau/prosekit-utilities";
|
|
18
|
+
import { Dialog } from "bits-ui";
|
|
19
|
+
import { getEditorMessages } from "../messages.svelte.js";
|
|
20
|
+
const messages = getEditorMessages();
|
|
21
|
+
let { open = $bindable(false), src = "", onSubmit, titleLabel = messages.imageDialogTitle, srcLabel = messages.imageSrcLabel, submitLabel = messages.imageSubmitLabel, cancelLabel = messages.imageCancelLabel, closeLabel = messages.imageCloseLabel, invalidSrcMessage = messages.imageInvalidSrcMessage, class: className, id, ...rest } = $props();
|
|
22
|
+
const uid = $props.id();
|
|
23
|
+
const srcId = `${uid}-src`;
|
|
24
|
+
const errorId = `${uid}-error`;
|
|
25
|
+
let srcValue = $state("");
|
|
26
|
+
let invalid = $state(false);
|
|
27
|
+
$effect(() => {
|
|
28
|
+
if (!open) return;
|
|
29
|
+
srcValue = src;
|
|
30
|
+
invalid = false;
|
|
31
|
+
});
|
|
32
|
+
function submit(event) {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
const candidate = srcValue.trim();
|
|
35
|
+
if (!isSafeHref(candidate)) {
|
|
36
|
+
invalid = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
onSubmit({ src: candidate });
|
|
40
|
+
open = false;
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<Dialog.Root bind:open>
|
|
45
|
+
<Dialog.Portal>
|
|
46
|
+
<Dialog.Overlay class="bg-base-300/60 fixed inset-0 z-40" />
|
|
47
|
+
<Dialog.Content
|
|
48
|
+
{...rest}
|
|
49
|
+
{...(id === undefined ? {} : { id })}
|
|
50
|
+
class={[
|
|
51
|
+
'modal-box fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2',
|
|
52
|
+
className,
|
|
53
|
+
]}
|
|
54
|
+
>
|
|
55
|
+
<div class="flex items-start justify-between gap-4">
|
|
56
|
+
<Dialog.Title class="text-lg font-semibold">{titleLabel}</Dialog.Title>
|
|
57
|
+
<Dialog.Close class="btn btn-sm btn-ghost -mr-1 -mt-1">{closeLabel}</Dialog.Close>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<form class="mt-4 flex flex-col gap-3" onsubmit={submit}>
|
|
61
|
+
<label class="flex flex-col gap-1 text-sm" for={srcId}>
|
|
62
|
+
{srcLabel}
|
|
63
|
+
<input
|
|
64
|
+
id={srcId}
|
|
65
|
+
class="input input-bordered w-full"
|
|
66
|
+
type="text"
|
|
67
|
+
bind:value={srcValue}
|
|
68
|
+
aria-invalid={invalid ? 'true' : undefined}
|
|
69
|
+
aria-describedby={invalid ? errorId : undefined}
|
|
70
|
+
/>
|
|
71
|
+
</label>
|
|
72
|
+
|
|
73
|
+
{#if invalid}
|
|
74
|
+
<p id={errorId} role="alert" class="text-error text-sm">{invalidSrcMessage}</p>
|
|
75
|
+
{/if}
|
|
76
|
+
|
|
77
|
+
<div class="modal-action">
|
|
78
|
+
<button type="button" class="btn btn-sm btn-ghost" onclick={() => (open = false)}>
|
|
79
|
+
{cancelLabel}
|
|
80
|
+
</button>
|
|
81
|
+
<button type="submit" class="btn btn-sm btn-primary">{submitLabel}</button>
|
|
82
|
+
</div>
|
|
83
|
+
</form>
|
|
84
|
+
</Dialog.Content>
|
|
85
|
+
</Dialog.Portal>
|
|
86
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'id'> {
|
|
3
|
+
/** The dialog element's `id`. */
|
|
4
|
+
id?: string | undefined;
|
|
5
|
+
/** Whether the dialog is shown. Bindable. */
|
|
6
|
+
open?: boolean | undefined;
|
|
7
|
+
/** The URL the form opens with. */
|
|
8
|
+
src?: string | undefined;
|
|
9
|
+
/** Called with the accepted attributes when the form is submitted. */
|
|
10
|
+
onSubmit: (attrs: {
|
|
11
|
+
src: string;
|
|
12
|
+
}) => void;
|
|
13
|
+
/** The dialog's heading, and its accessible name. */
|
|
14
|
+
titleLabel?: string | undefined;
|
|
15
|
+
/** Label of the source field. */
|
|
16
|
+
srcLabel?: string | undefined;
|
|
17
|
+
/** The submit button. */
|
|
18
|
+
submitLabel?: string | undefined;
|
|
19
|
+
/** The cancel button. */
|
|
20
|
+
cancelLabel?: string | undefined;
|
|
21
|
+
/** Accessible name of the corner close button. */
|
|
22
|
+
closeLabel?: string | undefined;
|
|
23
|
+
/** Announced when the source is rejected. */
|
|
24
|
+
invalidSrcMessage?: string | undefined;
|
|
25
|
+
/** Extra classes on the dialog box. */
|
|
26
|
+
class?: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
/** The image prompt as a bits-ui modal, asking only for the source URL the stock `image` node can hold. */
|
|
29
|
+
declare const ImageDialog: import("svelte").Component<Props, {}, "open">;
|
|
30
|
+
type ImageDialog = ReturnType<typeof ImageDialog>;
|
|
31
|
+
export default ImageDialog;
|