@marianmeres/stuic 3.108.0 → 3.110.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.
@@ -0,0 +1,172 @@
1
+ # MarkdownEditor
2
+
3
+ A textarea-like STUIC field for authoring longer markdown content. It presents a
4
+ **quasi-WYSIWYG** surface ([Milkdown](https://milkdown.dev), the markdown-first
5
+ ProseMirror distribution) that toggles to a **raw markdown source** editor
6
+ ([CodeMirror 6](https://codemirror.net)). A single bindable markdown string is
7
+ the source of truth across both modes.
8
+
9
+ It is shipped as an **optional subpath export**, separate from the main barrel,
10
+ so its (heavy) editor dependencies never reach consumers who don't use it.
11
+
12
+ ## Installation
13
+
14
+ The editor libraries are **optional peer dependencies** — install them only if
15
+ you use this component:
16
+
17
+ ```sh
18
+ pnpm add @milkdown/core @milkdown/ctx @milkdown/transformer @milkdown/prose \
19
+ @milkdown/preset-commonmark @milkdown/preset-gfm \
20
+ @milkdown/plugin-listener @milkdown/plugin-history @milkdown/utils \
21
+ @codemirror/state @codemirror/view @codemirror/commands \
22
+ @codemirror/language @codemirror/lang-markdown @codemirror/language-data
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```svelte
28
+ <script lang="ts">
29
+ import { MarkdownEditor } from "@marianmeres/stuic/markdown-editor";
30
+
31
+ let value = $state("# Hello\n\nSome **markdown**.");
32
+ let mode = $state<"wysiwyg" | "source">("wysiwyg");
33
+ </script>
34
+
35
+ <MarkdownEditor bind:value bind:mode label="Article body" required />
36
+ ```
37
+
38
+ ### Imperative API
39
+
40
+ Bind the component instance to access:
41
+
42
+ - `validate()` – run validation now, returns the `ValidationResult`
43
+ - `clearValidation()` – clear the inline message
44
+ - `getValidation()` – current validation state
45
+ - `focus()` – focus the active editor
46
+ - `scrollIntoView(opts?)`
47
+ - `getMarkdown()` – read current markdown from the active editor
48
+ - `toggleMode()` – switch WYSIWYG ⇄ source
49
+
50
+ ## Key props
51
+
52
+ | Prop | Type | Default | Notes |
53
+ | ---------------- | ---------------------------- | ----------- | ------------------------------------------------------------------- |
54
+ | `value` | `string` (bindable) | `""` | Markdown, single source of truth |
55
+ | `mode` | `"wysiwyg" \| "source"` | `"wysiwyg"` | Active surface (bindable) |
56
+ | `label` | `THC \| Snippet` | – | Field label |
57
+ | `description` | `THC \| Snippet` | – | Collapsible hint |
58
+ | `required` | `boolean` | `false` | Enforced over the markdown string |
59
+ | `disabled` | `boolean` | `false` | |
60
+ | `validate` | `boolean \| ValidateOptions` | – | Pass a `customValidator` to validate markdown |
61
+ | `renderSize` | `"sm" \| "md" \| "lg"` | `"md"` | |
62
+ | `toolbar` | `boolean \| ToolbarItem[]` | `true` | `true` = default toolbar, `false` = none, or an ordered button list |
63
+ | `showModeToggle` | `boolean` | `true` | Show the WYSIWYG/source toggle |
64
+ | `name` | `string` | – | Hidden input name for form submission |
65
+
66
+ Plus the standard `InputWrapClassProps` (`classLabel`, `classInputBox`, …),
67
+ `labelLeft*`, `placeholder`, `onChange`, and `classInput` / `classToolbar`.
68
+
69
+ ### Toolbar
70
+
71
+ Pass an array of `ToolbarItem`s to choose exactly which buttons appear and in
72
+ what order; use `"|"` for a separator. Each button works in both WYSIWYG and
73
+ source modes.
74
+
75
+ ```svelte
76
+ <script lang="ts">
77
+ import { MarkdownEditor, type ToolbarItem } from "@marianmeres/stuic/markdown-editor";
78
+ const toolbar: ToolbarItem[] = [
79
+ "bold",
80
+ "italic",
81
+ "|",
82
+ "blockquote",
83
+ "codeBlock",
84
+ "|",
85
+ "undo",
86
+ "redo",
87
+ ];
88
+ </script>
89
+
90
+ <MarkdownEditor bind:value {toolbar} />
91
+ ```
92
+
93
+ Available items: `bold`, `italic`, `heading1`, `heading2`, `heading3`, `link`,
94
+ `image`, `bulletList`, `orderedList`, `blockquote`, `codeBlock`, `hr`,
95
+ `hardBreak`, `undo`, `redo`, and `"|"` (separator). The default layout is
96
+ exported as `DEFAULT_TOOLBAR`.
97
+
98
+ ### Link / image URL prompt
99
+
100
+ By default the link and image buttons ask for a URL via the native
101
+ `window.prompt`. Pass a `window.prompt`-compatible function (sync or async) to
102
+ the `prompt` prop to replace it — e.g. STUIC's ACP dialog via `createPrompt`:
103
+
104
+ ```svelte
105
+ <script lang="ts">
106
+ import { MarkdownEditor } from "@marianmeres/stuic/markdown-editor";
107
+ import {
108
+ AlertConfirmPrompt,
109
+ AlertConfirmPromptStack,
110
+ createPrompt,
111
+ } from "@marianmeres/stuic";
112
+
113
+ const acp = new AlertConfirmPromptStack();
114
+ </script>
115
+
116
+ <MarkdownEditor bind:value prompt={createPrompt(acp)} />
117
+
118
+ <!-- mount the ACP provider once, anywhere in the app -->
119
+ <AlertConfirmPrompt {acp} />
120
+ ```
121
+
122
+ ### Mobile / touch
123
+
124
+ `contenteditable` (the WYSIWYG view) is rougher on phones than a plain text
125
+ field, so on touch devices the editor adapts:
126
+
127
+ - **Starts in `source` mode** (the steadier CodeMirror view) — toggle
128
+ `autoSourceOnMobile={false}` to keep WYSIWYG.
129
+ - **Shows a reduced toolbar** (`DEFAULT_MOBILE_TOOLBAR`) — override with
130
+ `mobileToolbar` (same shape as `toolbar`).
131
+ - **Larger touch targets** (40px) via a `(pointer: coarse)` media query.
132
+
133
+ "Mobile" is defined by `mobileQuery`
134
+ (default `"(pointer: coarse) and (max-width: 640px)"`). The applied-once source
135
+ default never fights a later manual toggle. Both toggles are exported:
136
+ `DEFAULT_MOBILE_TOOLBAR`.
137
+
138
+ ## Theming
139
+
140
+ All visuals are CSS-variable driven. Component tokens fall back to the shared
141
+ input/global tokens:
142
+
143
+ ```css
144
+ --stuic-markdown-editor-radius /* → --stuic-input-radius → --stuic-radius */
145
+ --stuic-markdown-editor-border
146
+ --stuic-markdown-editor-border-focus
147
+ --stuic-markdown-editor-bg
148
+ --stuic-markdown-editor-min-height /* default 12rem (9/16rem for sm/lg) */
149
+ --stuic-markdown-editor-max-height
150
+ --stuic-markdown-editor-font-size
151
+ --stuic-markdown-editor-font-mono
152
+ --stuic-markdown-editor-code-bg
153
+ ```
154
+
155
+ ## Notes & caveats
156
+
157
+ - **CSS is imported locally** by this component (not via the central
158
+ `src/lib/index.css`). This is a deliberate deviation from the STUIC convention,
159
+ required so the styles ship only to subpath users. See `index.css` for the
160
+ rationale.
161
+ - **SSR-safe / lazy-loaded.** Both editors are browser-only and loaded via
162
+ dynamic `import()` inside a client-only effect, so they never run during SSR
163
+ and split into their own chunk. The server renders only the field chrome.
164
+ - **Round-trip is normalizing, not byte-preserving.** The WYSIWYG leg sends
165
+ markdown → ProseMirror → markdown via remark, which normalizes formatting:
166
+ blank-line collapse, emphasis/bullet marker unification, Setext (`===`)
167
+ headings → ATX (`#`), hard-break changes, and raw HTML that the schema can't
168
+ model may be dropped/escaped. GFM constructs (tables, strikethrough, task
169
+ lists) are supported via `@milkdown/preset-gfm`. **The editor owns canonical
170
+ formatting after the first edit.** The raw **source** mode is lossless.
171
+ - Switching modes flushes the current content into `value` before tearing the
172
+ editor down, so no edits are lost on toggle.
@@ -0,0 +1,2 @@
1
+ import type { EditorHandle, MountOptions } from "./types.js";
2
+ export declare function mountCodeMirror(host: HTMLElement, opts: MountOptions): EditorHandle;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Raw markdown source backend, built on CodeMirror 6.
3
+ *
4
+ * This module statically imports all `@codemirror/*` packages — that is fine
5
+ * because `MarkdownEditor.svelte` only ever reaches it via a dynamic
6
+ * `import()` inside a client-only `$effect`, so the code is both code-split and
7
+ * never evaluated during SSR. CodeMirror ships no CSS file; all theming is done
8
+ * here via `EditorView.theme` plus `[data-size]` rules in the component's
9
+ * `index.css`.
10
+ */
11
+ import { defaultKeymap, history, historyKeymap, redo, undo } from "@codemirror/commands";
12
+ import { markdown } from "@codemirror/lang-markdown";
13
+ import { languages } from "@codemirror/language-data";
14
+ import { EditorState } from "@codemirror/state";
15
+ import { EditorView, keymap, placeholder as cmPlaceholder } from "@codemirror/view";
16
+ /** Theme that inherits STUIC look via CSS vars rather than hard-coded values. */
17
+ const stuicTheme = EditorView.theme({
18
+ "&": {
19
+ fontSize: "var(--stuic-markdown-editor-font-size, 0.9375rem)",
20
+ color: "inherit",
21
+ backgroundColor: "transparent",
22
+ },
23
+ ".cm-content": {
24
+ fontFamily: "var(--stuic-markdown-editor-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
25
+ padding: "0",
26
+ caretColor: "currentColor",
27
+ },
28
+ "&.cm-focused": { outline: "none" },
29
+ ".cm-line": { padding: "0" },
30
+ ".cm-gutters": { display: "none" },
31
+ ".cm-scroller": { fontFamily: "inherit", lineHeight: "1.6" },
32
+ ".cm-placeholder": {
33
+ color: "var(--stuic-input-placeholder, currentColor)",
34
+ opacity: "0.5",
35
+ },
36
+ });
37
+ /** Wrap the current selection with `before`/`after` markers (e.g. `**bold**`). */
38
+ function wrapSelection(view, before, after = before) {
39
+ const { from, to } = view.state.selection.main;
40
+ const selected = view.state.sliceDoc(from, to);
41
+ view.dispatch({
42
+ changes: { from, to, insert: `${before}${selected}${after}` },
43
+ selection: {
44
+ anchor: from + before.length,
45
+ head: to + before.length + selected.length,
46
+ },
47
+ });
48
+ view.focus();
49
+ }
50
+ /** Toggle an ATX heading of `level` on the cursor's line: add it, switch level,
51
+ * or strip it when the same level is already present. */
52
+ function toggleHeadingLine(view, level) {
53
+ const { from } = view.state.selection.main;
54
+ const line = view.state.doc.lineAt(from);
55
+ const m = line.text.match(/^(#{1,6}) /);
56
+ const curLevel = m ? m[1].length : 0;
57
+ const delLen = m ? m[0].length : 0;
58
+ const insert = curLevel === level ? "" : `${"#".repeat(level)} `;
59
+ view.dispatch({ changes: { from: line.from, to: line.from + delLen, insert } });
60
+ view.focus();
61
+ }
62
+ /** Insert a markdown link, prompting for the URL (parity with the WYSIWYG link). */
63
+ async function insertLink(view, prompt) {
64
+ // Capture the selection before the (possibly async) prompt — a modal dialog
65
+ // blocks editing, so these positions stay valid.
66
+ const { from, to } = view.state.selection.main;
67
+ const text = view.state.sliceDoc(from, to) || "link";
68
+ const href = await prompt("Link URL", "https://");
69
+ if (href === null)
70
+ return view.focus();
71
+ const url = href.trim() || "url";
72
+ view.dispatch({
73
+ changes: { from, to, insert: `[${text}](${url})` },
74
+ selection: { anchor: from + 1, head: from + 1 + text.length },
75
+ });
76
+ view.focus();
77
+ }
78
+ /**
79
+ * Toggle a line prefix (list marker, blockquote `> `, …) across the selected
80
+ * lines. If every non-empty line already matches `marker`, the marker is
81
+ * stripped (toggle off); otherwise it is added — so repeated clicks behave like
82
+ * a real toggle instead of stacking `- - ` / `> > ` prefixes.
83
+ */
84
+ function toggleLinePrefix(view, add, marker) {
85
+ const { from, to } = view.state.selection.main;
86
+ const startLine = view.state.doc.lineAt(from).number;
87
+ const endLine = view.state.doc.lineAt(to).number;
88
+ const lines = [];
89
+ let allMarked = true;
90
+ for (let n = startLine; n <= endLine; n++) {
91
+ const line = view.state.doc.line(n);
92
+ lines.push(line);
93
+ if (line.text.trim() !== "" && !marker.test(line.text))
94
+ allMarked = false;
95
+ }
96
+ const changes = [];
97
+ for (const line of lines) {
98
+ const isBlank = line.text.trim() === "";
99
+ if (allMarked) {
100
+ const m = line.text.match(marker);
101
+ if (m)
102
+ changes.push({ from: line.from, to: line.from + m[0].length, insert: "" });
103
+ }
104
+ else if (!isBlank || lines.length === 1) {
105
+ changes.push({ from: line.from, insert: add });
106
+ }
107
+ }
108
+ view.dispatch({ changes });
109
+ view.focus();
110
+ }
111
+ /** Insert a markdown image, prompting for the URL (parity with WYSIWYG). */
112
+ async function insertImage(view, prompt) {
113
+ const { from, to } = view.state.selection.main;
114
+ const alt = view.state.sliceDoc(from, to) || "alt";
115
+ const src = await prompt("Image URL", "https://");
116
+ if (src === null)
117
+ return view.focus();
118
+ const url = src.trim() || "url";
119
+ view.dispatch({
120
+ changes: { from, to, insert: `![${alt}](${url})` },
121
+ selection: { anchor: from + 2, head: from + 2 + alt.length },
122
+ });
123
+ view.focus();
124
+ }
125
+ /** Wrap the selection in a fenced code block (or insert an empty one). */
126
+ function insertCodeBlock(view) {
127
+ const { from, to } = view.state.selection.main;
128
+ const sel = view.state.sliceDoc(from, to);
129
+ const insert = `\`\`\`\n${sel}\n\`\`\`\n`;
130
+ view.dispatch({
131
+ changes: { from, to, insert },
132
+ selection: { anchor: from + 4 }, // just inside the opening fence
133
+ });
134
+ view.focus();
135
+ }
136
+ /** Insert a thematic break (horizontal rule) after the current line. */
137
+ function insertHorizontalRule(view) {
138
+ const at = view.state.doc.lineAt(view.state.selection.main.from).to;
139
+ const insert = "\n\n---\n";
140
+ view.dispatch({
141
+ changes: { from: at, insert },
142
+ selection: { anchor: at + insert.length },
143
+ });
144
+ view.focus();
145
+ }
146
+ /** Insert a markdown hard line break at the cursor. */
147
+ function insertHardBreak(view) {
148
+ const { from, to } = view.state.selection.main;
149
+ view.dispatch({
150
+ changes: { from, to, insert: "\\\n" },
151
+ selection: { anchor: from + 2 },
152
+ });
153
+ view.focus();
154
+ }
155
+ export function mountCodeMirror(host, opts) {
156
+ const view = new EditorView({
157
+ parent: host,
158
+ state: EditorState.create({
159
+ doc: opts.value ?? "",
160
+ extensions: [
161
+ history(),
162
+ keymap.of([...defaultKeymap, ...historyKeymap]),
163
+ markdown({ codeLanguages: languages }),
164
+ EditorView.lineWrapping,
165
+ EditorView.editable.of(!opts.disabled),
166
+ EditorState.readOnly.of(!!opts.disabled),
167
+ ...(opts.placeholder ? [cmPlaceholder(opts.placeholder)] : []),
168
+ stuicTheme,
169
+ EditorView.updateListener.of((u) => {
170
+ if (u.docChanged)
171
+ opts.onChange(u.state.doc.toString());
172
+ }),
173
+ ],
174
+ }),
175
+ });
176
+ return {
177
+ destroy: () => view.destroy(),
178
+ getMarkdown: () => view.state.doc.toString(),
179
+ setMarkdown: (md) => {
180
+ if (md === view.state.doc.toString())
181
+ return;
182
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: md } });
183
+ },
184
+ focus: () => view.focus(),
185
+ commands: {
186
+ bold: () => wrapSelection(view, "**"),
187
+ italic: () => wrapSelection(view, "_"),
188
+ heading: (level) => toggleHeadingLine(view, Math.min(6, Math.max(1, level))),
189
+ link: () => insertLink(view, opts.prompt),
190
+ image: () => insertImage(view, opts.prompt),
191
+ bulletList: () => toggleLinePrefix(view, "- ", /^\s*[-*+] /),
192
+ orderedList: () => toggleLinePrefix(view, "1. ", /^\s*\d+\. /),
193
+ blockquote: () => toggleLinePrefix(view, "> ", /^\s*> /),
194
+ codeBlock: () => insertCodeBlock(view),
195
+ hr: () => insertHorizontalRule(view),
196
+ hardBreak: () => insertHardBreak(view),
197
+ undo: () => undo(view),
198
+ redo: () => redo(view),
199
+ },
200
+ };
201
+ }
@@ -0,0 +1,2 @@
1
+ import type { EditorHandle, MountOptions } from "./types.js";
2
+ export declare function mountMilkdown(host: HTMLElement, opts: MountOptions): Promise<EditorHandle>;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * WYSIWYG backend, built on Milkdown (the markdown-first ProseMirror
3
+ * distribution + remark). Headless: Milkdown ships no CSS, so all visuals come
4
+ * from the component's `index.css` styling `.milkdown .ProseMirror`.
5
+ *
6
+ * Like the CodeMirror backend, the heavy `@milkdown/*` imports are static here
7
+ * but only reached via a dynamic `import()` from the component, keeping them out
8
+ * of SSR and the main bundle.
9
+ *
10
+ * IMPORTANT: never recreate the Editor when `value` changes — only `replaceAll`
11
+ * (Milkdown #598 double-instance trap). The host component enforces this.
12
+ */
13
+ import { Editor, defaultValueCtx, editorViewCtx, editorViewOptionsCtx, rootCtx, } from "@milkdown/core";
14
+ import { history, redoCommand, undoCommand } from "@milkdown/plugin-history";
15
+ import { listener, listenerCtx } from "@milkdown/plugin-listener";
16
+ import { commonmark, createCodeBlockCommand, insertHardbreakCommand, insertHrCommand, insertImageCommand, liftListItemCommand, toggleEmphasisCommand, toggleStrongCommand, turnIntoTextCommand, wrapInBlockquoteCommand, wrapInBulletListCommand, wrapInHeadingCommand, wrapInOrderedListCommand, } from "@milkdown/preset-commonmark";
17
+ import { gfm } from "@milkdown/preset-gfm";
18
+ import { lift } from "@milkdown/prose/commands";
19
+ import { callCommand, getMarkdown, replaceAll } from "@milkdown/utils";
20
+ // List node type names differ by Milkdown internals (snake vs camel) — match
21
+ // both so list detection is robust regardless of the registered schema name.
22
+ const BULLET_LIST_NAMES = new Set(["bullet_list", "bulletList"]);
23
+ const ORDERED_LIST_NAMES = new Set(["ordered_list", "orderedList"]);
24
+ /** Which kind of list the selection sits inside, if any. */
25
+ function currentListType(state) {
26
+ const { $from } = state.selection;
27
+ for (let d = $from.depth; d > 0; d--) {
28
+ const name = $from.node(d).type.name;
29
+ if (BULLET_LIST_NAMES.has(name))
30
+ return "bullet";
31
+ if (ORDERED_LIST_NAMES.has(name))
32
+ return "ordered";
33
+ }
34
+ return null;
35
+ }
36
+ /** Heading level of the block the cursor is in, or null if it's not a heading. */
37
+ function currentHeadingLevel(state) {
38
+ const node = state.selection.$from.parent;
39
+ if (node?.type.name === "heading")
40
+ return node.attrs.level ?? null;
41
+ return null;
42
+ }
43
+ /** Whether the selection sits anywhere inside a blockquote. */
44
+ function inBlockquote(state) {
45
+ const { $from } = state.selection;
46
+ for (let d = $from.depth; d > 0; d--) {
47
+ if ($from.node(d).type.name === "blockquote")
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+ export async function mountMilkdown(host, opts) {
53
+ const editor = await Editor.make()
54
+ .config((ctx) => {
55
+ ctx.set(rootCtx, host);
56
+ ctx.set(defaultValueCtx, opts.value ?? "");
57
+ ctx.update(editorViewOptionsCtx, (prev) => ({
58
+ ...prev,
59
+ editable: () => !opts.disabled,
60
+ attributes: { class: "stuic-markdown-prose", spellcheck: "true" },
61
+ }));
62
+ ctx.get(listenerCtx).markdownUpdated((_ctx, markdown) => {
63
+ opts.onChange(markdown);
64
+ });
65
+ })
66
+ .use(commonmark)
67
+ .use(gfm)
68
+ .use(history)
69
+ .use(listener)
70
+ .create();
71
+ // Real list toggle: `wrapIn*` only wraps non-list content, so a second click
72
+ // while already in a list is a no-op. Instead, lift out when already in the
73
+ // same list kind, and lift-then-wrap to switch kinds.
74
+ const toggleList = (kind) => {
75
+ editor.action((ctx) => {
76
+ const view = ctx.get(editorViewCtx);
77
+ const wrapKey = kind === "bullet" ? wrapInBulletListCommand.key : wrapInOrderedListCommand.key;
78
+ const current = currentListType(view.state);
79
+ if (current === kind) {
80
+ callCommand(liftListItemCommand.key)(ctx); // toggle off
81
+ }
82
+ else if (current) {
83
+ callCommand(liftListItemCommand.key)(ctx); // switch kind
84
+ callCommand(wrapKey)(ctx);
85
+ }
86
+ else {
87
+ callCommand(wrapKey)(ctx); // wrap fresh content
88
+ }
89
+ view.focus();
90
+ });
91
+ };
92
+ // Heading toggle: `wrapInHeadingCommand` sets the block type (level < 1 means
93
+ // paragraph) but does NOT toggle, so a second click on the same level is a
94
+ // no-op. Detect the current level and pass 0 to turn it back into a paragraph.
95
+ const toggleHeading = (level) => {
96
+ editor.action((ctx) => {
97
+ const view = ctx.get(editorViewCtx);
98
+ const next = currentHeadingLevel(view.state) === level ? 0 : level;
99
+ callCommand(wrapInHeadingCommand.key, next)(ctx);
100
+ view.focus();
101
+ });
102
+ };
103
+ // Link: `toggleLinkCommand` requires an `href` payload (the link mark has no
104
+ // default href, so calling it bare throws "No value supplied for attribute
105
+ // href"). Prompt for a URL and add/update/remove the link mark directly.
106
+ // The prompt may be async (e.g. STUIC's ACP dialog); selection positions are
107
+ // captured up front and stay valid because a modal blocks editing.
108
+ const applyLink = async () => {
109
+ const view = editor.action((ctx) => ctx.get(editorViewCtx));
110
+ const linkMark = view.state.schema.marks.link;
111
+ if (!linkMark)
112
+ return;
113
+ const { from, to, empty } = view.state.selection;
114
+ let existingHref = null;
115
+ const scanTo = empty ? Math.min(from + 1, view.state.doc.content.size) : to;
116
+ view.state.doc.nodesBetween(from, scanTo, (node) => {
117
+ const m = node.marks.find((mk) => mk.type === linkMark);
118
+ if (m)
119
+ existingHref = m.attrs.href;
120
+ });
121
+ const href = await opts.prompt("Link URL (leave empty to remove)", existingHref ?? "https://");
122
+ if (href === null)
123
+ return view.focus(); // cancelled
124
+ const url = href.trim();
125
+ if (url === "") {
126
+ if (!empty)
127
+ view.dispatch(view.state.tr.removeMark(from, to, linkMark));
128
+ }
129
+ else if (empty) {
130
+ // No selection: insert the URL as linked text.
131
+ const tr = view.state.tr.insertText(url, from);
132
+ tr.addMark(from, from + url.length, linkMark.create({ href: url }));
133
+ view.dispatch(tr);
134
+ }
135
+ else {
136
+ view.dispatch(view.state.tr.addMark(from, to, linkMark.create({ href: url })));
137
+ }
138
+ view.focus();
139
+ };
140
+ // Blockquote toggle: wrap, or lift back out when already inside one.
141
+ const toggleBlockquote = () => {
142
+ editor.action((ctx) => {
143
+ const view = ctx.get(editorViewCtx);
144
+ if (inBlockquote(view.state))
145
+ lift(view.state, view.dispatch);
146
+ else
147
+ callCommand(wrapInBlockquoteCommand.key)(ctx);
148
+ view.focus();
149
+ });
150
+ };
151
+ // Code-block toggle: code-block node types have `spec.code === true`. Turn
152
+ // into a paragraph when already in one, else create a code block.
153
+ const toggleCodeBlock = () => {
154
+ editor.action((ctx) => {
155
+ const view = ctx.get(editorViewCtx);
156
+ const inCode = view.state.selection.$from.parent.type.spec.code === true;
157
+ callCommand(inCode ? turnIntoTextCommand.key : createCodeBlockCommand.key)(ctx);
158
+ view.focus();
159
+ });
160
+ };
161
+ // Image: prompt for a source URL (insertImageCommand needs a `src` payload).
162
+ const insertImage = async () => {
163
+ const view = editor.action((ctx) => ctx.get(editorViewCtx));
164
+ const src = await opts.prompt("Image URL", "https://");
165
+ if (src === null || src.trim() === "")
166
+ return view.focus();
167
+ editor.action(callCommand(insertImageCommand.key, { src: src.trim() }));
168
+ view.focus();
169
+ };
170
+ return {
171
+ // editor.destroy() is async; we intentionally don't await — the host's
172
+ // `disposed` flag guards against mounting into a torn-down node.
173
+ destroy: () => {
174
+ void editor.destroy();
175
+ },
176
+ getMarkdown: () => editor.action(getMarkdown()),
177
+ setMarkdown: (md) => {
178
+ editor.action(replaceAll(md));
179
+ },
180
+ focus: () => {
181
+ editor.action((ctx) => ctx.get(editorViewCtx).focus());
182
+ },
183
+ commands: {
184
+ bold: () => editor.action(callCommand(toggleStrongCommand.key)),
185
+ italic: () => editor.action(callCommand(toggleEmphasisCommand.key)),
186
+ heading: (level) => toggleHeading(level),
187
+ link: () => applyLink(),
188
+ image: () => insertImage(),
189
+ bulletList: () => toggleList("bullet"),
190
+ orderedList: () => toggleList("ordered"),
191
+ blockquote: () => toggleBlockquote(),
192
+ codeBlock: () => toggleCodeBlock(),
193
+ hr: () => editor.action(callCommand(insertHrCommand.key)),
194
+ hardBreak: () => editor.action(callCommand(insertHardbreakCommand.key)),
195
+ undo: () => editor.action(callCommand(undoCommand.key)),
196
+ redo: () => editor.action(callCommand(redoCommand.key)),
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared contract between the two editor backends (Milkdown WYSIWYG and
3
+ * CodeMirror raw source). `MarkdownEditor.svelte` only ever talks to an
4
+ * `EditorHandle`, so the two `_internal` modules are fully interchangeable and
5
+ * the heavy library code stays behind the dynamic-`import()` boundary.
6
+ */
7
+ /** Imperative toolbar commands. Each backend implements them in its own way. */
8
+ export interface EditorCommands {
9
+ bold: () => void;
10
+ italic: () => void;
11
+ /** Toggle a heading of the given level (1-6) on the current block/line. */
12
+ heading: (level: number) => void;
13
+ link: () => void;
14
+ image: () => void;
15
+ bulletList: () => void;
16
+ orderedList: () => void;
17
+ blockquote: () => void;
18
+ codeBlock: () => void;
19
+ /** Insert a horizontal rule / thematic break. */
20
+ hr: () => void;
21
+ /** Insert a hard line break. */
22
+ hardBreak: () => void;
23
+ undo: () => void;
24
+ redo: () => void;
25
+ }
26
+ /** Handle returned by a backend mount fn. The single source of truth is the
27
+ * markdown string `value` in the host component — the handle just lets it
28
+ * read, write, focus, and destroy whichever editor is currently active. */
29
+ export interface EditorHandle {
30
+ /** Tear down the editor and release DOM/resources. */
31
+ destroy: () => void;
32
+ /** Current document serialized to markdown. Read this before destroying. */
33
+ getMarkdown: () => string;
34
+ /** Replace the whole document with `md` (used on external `value` change). */
35
+ setMarkdown: (md: string) => void;
36
+ /** Focus the editable surface. */
37
+ focus: () => void;
38
+ /** Toolbar command map. */
39
+ commands: EditorCommands;
40
+ }
41
+ /**
42
+ * A `window.prompt`-compatible function used for the link/image URL inputs. May
43
+ * be async — e.g. STUIC's ACP dialog via `createPrompt(acpStack)`. Resolves to
44
+ * the entered string, or `null` if cancelled.
45
+ */
46
+ export type PromptFn = (message: string, defaultValue?: string) => string | null | Promise<string | null>;
47
+ export interface MountOptions {
48
+ /** Initial markdown content. */
49
+ value: string;
50
+ /** Called whenever the user edits. Backend echoes from `setMarkdown` are the
51
+ * caller's responsibility to guard (see the sync logic in the component). */
52
+ onChange: (markdown: string) => void;
53
+ /** Prompt used for link/image URLs (defaults to `window.prompt`). */
54
+ prompt: PromptFn;
55
+ disabled?: boolean;
56
+ placeholder?: string;
57
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Shared contract between the two editor backends (Milkdown WYSIWYG and
3
+ * CodeMirror raw source). `MarkdownEditor.svelte` only ever talks to an
4
+ * `EditorHandle`, so the two `_internal` modules are fully interchangeable and
5
+ * the heavy library code stays behind the dynamic-`import()` boundary.
6
+ */
7
+ export {};