@elabs-ai/components-editor 4.0.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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/chunk-LBC5VJBD.js +3748 -0
  4. package/dist/chunk-LBC5VJBD.js.map +1 -0
  5. package/dist/index.css +233 -0
  6. package/dist/index.css.map +1 -0
  7. package/dist/index.d.ts +199 -0
  8. package/dist/index.js +362 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/monaco-environment.d.ts +2 -0
  11. package/dist/lib/monaco-environment.js +44 -0
  12. package/dist/lib/monaco-environment.js.map +1 -0
  13. package/dist/markdown/frontmatter.d.ts +11 -0
  14. package/dist/markdown/frontmatter.js +33 -0
  15. package/dist/markdown/frontmatter.js.map +1 -0
  16. package/dist/markdown/index.css +233 -0
  17. package/dist/markdown/index.css.map +1 -0
  18. package/dist/markdown/index.d.ts +1486 -0
  19. package/dist/markdown/index.js +4882 -0
  20. package/dist/markdown/index.js.map +1 -0
  21. package/dist/markdown/parse.d.ts +25 -0
  22. package/dist/markdown/parse.js +14 -0
  23. package/dist/markdown/parse.js.map +1 -0
  24. package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
  25. package/package.json +101 -0
  26. package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
  27. package/src/ai-objects/ai-objects.stories.tsx +233 -0
  28. package/src/ai-objects/ai-objects.test.tsx +205 -0
  29. package/src/ai-objects/decision-card.tsx +178 -0
  30. package/src/ai-objects/directives.ts +136 -0
  31. package/src/ai-objects/entity.tsx +181 -0
  32. package/src/ai-objects/index.ts +34 -0
  33. package/src/ai-objects/knowledge-card.tsx +143 -0
  34. package/src/calc-block/calc-block.stories.tsx +297 -0
  35. package/src/calc-block/calc-block.test.tsx +164 -0
  36. package/src/calc-block/calc-block.tsx +398 -0
  37. package/src/calc-block/calc-editor-monaco.ts +214 -0
  38. package/src/calc-block/calc-editor-prose.ts +128 -0
  39. package/src/calc-block/calc-editor.css +80 -0
  40. package/src/calc-block/calc-editor.stories.tsx +272 -0
  41. package/src/calc-block/calc-editor.test.ts +228 -0
  42. package/src/calc-block/calc-editor.ts +270 -0
  43. package/src/calc-block/calc-inline.stories.tsx +44 -0
  44. package/src/calc-block/calc-inline.test.tsx +44 -0
  45. package/src/calc-block/calc-inline.tsx +75 -0
  46. package/src/calc-block/index.ts +20 -0
  47. package/src/calc-block/types.ts +164 -0
  48. package/src/code-editor/code-editor.stories.tsx +218 -0
  49. package/src/code-editor/code-editor.test.tsx +134 -0
  50. package/src/code-editor/code-editor.tsx +250 -0
  51. package/src/code-editor/index.ts +6 -0
  52. package/src/code-workspace/code-workspace.stories.tsx +51 -0
  53. package/src/code-workspace/code-workspace.test.tsx +146 -0
  54. package/src/code-workspace/code-workspace.tsx +253 -0
  55. package/src/code-workspace/index.ts +6 -0
  56. package/src/copy-button/copy-button.tsx +51 -0
  57. package/src/copy-button/index.ts +1 -0
  58. package/src/css.d.ts +3 -0
  59. package/src/diff-editor/diff-editor.stories.tsx +44 -0
  60. package/src/diff-editor/diff-editor.test.tsx +68 -0
  61. package/src/diff-editor/diff-editor.tsx +151 -0
  62. package/src/diff-editor/index.ts +1 -0
  63. package/src/editor-context-menu/editor-context-menu.tsx +120 -0
  64. package/src/editor-context-menu/index.ts +1 -0
  65. package/src/editor-toolbar/editor-toolbar.tsx +83 -0
  66. package/src/editor-toolbar/index.ts +1 -0
  67. package/src/index.ts +48 -0
  68. package/src/lib/editor-completions-monaco.test.ts +206 -0
  69. package/src/lib/editor-completions-monaco.ts +127 -0
  70. package/src/lib/editor-completions.test.ts +156 -0
  71. package/src/lib/editor-completions.ts +153 -0
  72. package/src/lib/editor-content-access-prose.test.ts +411 -0
  73. package/src/lib/editor-content-access-prose.ts +172 -0
  74. package/src/lib/editor-content-access.test.ts +182 -0
  75. package/src/lib/editor-content-access.ts +109 -0
  76. package/src/lib/languages.ts +32 -0
  77. package/src/lib/markdown/diff.test.ts +80 -0
  78. package/src/lib/markdown/diff.ts +194 -0
  79. package/src/lib/markdown/directives.ts +342 -0
  80. package/src/lib/markdown/frontmatter.ts +50 -0
  81. package/src/lib/markdown/markdown-scale.test.ts +65 -0
  82. package/src/lib/markdown/markdown-scale.ts +62 -0
  83. package/src/lib/markdown/merge.test.ts +64 -0
  84. package/src/lib/markdown/merge.ts +158 -0
  85. package/src/lib/markdown/slugify.test.ts +156 -0
  86. package/src/lib/markdown/slugify.ts +47 -0
  87. package/src/lib/monaco-environment.ts +103 -0
  88. package/src/lib/monaco-theme-bridge.ts +293 -0
  89. package/src/lib/use-data-theme.ts +61 -0
  90. package/src/markdown/frontmatter.ts +14 -0
  91. package/src/markdown/index.ts +323 -0
  92. package/src/markdown/parse.test.ts +60 -0
  93. package/src/markdown/parse.ts +38 -0
  94. package/src/markdown-academic/citations.test.ts +77 -0
  95. package/src/markdown-academic/citations.tsx +442 -0
  96. package/src/markdown-academic/footnotes.tsx +235 -0
  97. package/src/markdown-academic/index.ts +32 -0
  98. package/src/markdown-academic/math.tsx +163 -0
  99. package/src/markdown-academic/toc.tsx +88 -0
  100. package/src/markdown-editor/completions/completions-menu.tsx +96 -0
  101. package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
  102. package/src/markdown-editor/completions/completions-prose.ts +313 -0
  103. package/src/markdown-editor/completions/completions-widget.tsx +75 -0
  104. package/src/markdown-editor/completions/index.ts +37 -0
  105. package/src/markdown-editor/directive-nodes.ts +151 -0
  106. package/src/markdown-editor/directive-views.test.tsx +189 -0
  107. package/src/markdown-editor/directive-views.tsx +795 -0
  108. package/src/markdown-editor/exit-keymap.test.ts +88 -0
  109. package/src/markdown-editor/exit-keymap.ts +87 -0
  110. package/src/markdown-editor/index.ts +6 -0
  111. package/src/markdown-editor/markdown-editor.css +250 -0
  112. package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
  113. package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
  114. package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
  115. package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
  116. package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
  117. package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
  118. package/src/markdown-editor/markdown-editor.tsx +560 -0
  119. package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
  120. package/src/markdown-editor/milkdown-react/index.ts +8 -0
  121. package/src/markdown-editor/milkdown-react/types.ts +27 -0
  122. package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
  123. package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
  124. package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
  125. package/src/markdown-editor/paste-embed.ts +355 -0
  126. package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
  127. package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
  128. package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
  129. package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
  130. package/src/markdown-editor/slash/index.ts +107 -0
  131. package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
  132. package/src/markdown-editor/slash/insert-directive.ts +345 -0
  133. package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
  134. package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
  135. package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
  136. package/src/markdown-editor/slash/shortcut.test.ts +157 -0
  137. package/src/markdown-editor/slash/shortcut.ts +57 -0
  138. package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
  139. package/src/markdown-editor/slash/slash-menu.tsx +115 -0
  140. package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
  141. package/src/markdown-editor/slash/slash-widget.tsx +97 -0
  142. package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
  143. package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
  144. package/src/markdown-editor/table-view.tsx +249 -0
  145. package/src/markdown-iteration/directive.tsx +92 -0
  146. package/src/markdown-iteration/edit-context.ts +56 -0
  147. package/src/markdown-iteration/index.ts +24 -0
  148. package/src/markdown-iteration/iteration-block.test.tsx +138 -0
  149. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
  150. package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
  151. package/src/markdown-iteration/iteration-builder.test.ts +252 -0
  152. package/src/markdown-iteration/iteration-builder.ts +303 -0
  153. package/src/markdown-iteration/iteration.test.ts +56 -0
  154. package/src/markdown-iteration/iteration.tsx +333 -0
  155. package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
  156. package/src/markdown-iteration/template-dialog.test.tsx +104 -0
  157. package/src/markdown-iteration/template-dialog.tsx +116 -0
  158. package/src/markdown-outline/document-outline.stories.tsx +76 -0
  159. package/src/markdown-outline/document-outline.tsx +83 -0
  160. package/src/markdown-outline/index.ts +2 -0
  161. package/src/markdown-outline/markdown-outline.test.ts +49 -0
  162. package/src/markdown-outline/markdown-outline.ts +60 -0
  163. package/src/markdown-preview/code-fence.tsx +193 -0
  164. package/src/markdown-preview/index.ts +8 -0
  165. package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
  166. package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
  167. package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
  168. package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
  169. package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
  170. package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
  171. package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
  172. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
  173. package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
  174. package/src/markdown-preview/markdown-preview.test.tsx +411 -0
  175. package/src/markdown-preview/markdown-preview.tsx +1541 -0
  176. package/src/markdown-toolbar/index.ts +8 -0
  177. package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
  178. package/src/markdown-toolbar/markdown-commands.ts +104 -0
  179. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
  180. package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
  181. package/src/markdown-workspace/focus-writing.test.ts +52 -0
  182. package/src/markdown-workspace/focus-writing.ts +38 -0
  183. package/src/markdown-workspace/index.ts +6 -0
  184. package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
  185. package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
  186. package/src/markdown-workspace/markdown-workspace.tsx +787 -0
  187. package/src/mermaid-diagram/index.ts +1 -0
  188. package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
  189. package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
  190. package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
  191. package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
  192. package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
  193. package/src/mermaid-diagram/real-parse.test.ts +42 -0
  194. package/src/mermaid-diagram/remediate.test.ts +60 -0
  195. package/src/mermaid-diagram/remediate.ts +124 -0
  196. package/src/mermaid-workspace/index.ts +1 -0
  197. package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
  198. package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
  199. package/src/metric-block/index.ts +1 -0
  200. package/src/metric-block/metric-block.stories.tsx +40 -0
  201. package/src/metric-block/metric-block.tsx +14 -0
  202. package/src/monaco-workers.d.ts +10 -0
  203. package/src/prose/index.ts +14 -0
  204. package/src/prose/prose.stories.tsx +44 -0
  205. package/src/prose/prose.test.tsx +44 -0
  206. package/src/prose/prose.tsx +21 -0
  207. package/src/timeline/index.ts +18 -0
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Unit tests for proseMirrorContentAccess — the Milkdown adapter.
3
+ *
4
+ * Uses a REAL headless Milkdown editor (`makeEditor()`) — ProseMirror IS
5
+ * testable in jsdom (the brand-slash-plugin.test.ts / insert-directive.test.ts
6
+ * precedent). Monaco-specific behavior is tested in editor-content-access.test.ts.
7
+ *
8
+ * These tests verify:
9
+ * - getSelection().text of a bold selection → markdown fidelity
10
+ * - collapsed selection → empty:true, text:""
11
+ * - replaceSelection("## H") → heading in doc (via getMarkdown round-trip)
12
+ * - malformed parse → plain-text fallback (no throw)
13
+ * - onSelectionChange fires on setSelection tr; unsubscribe stops further calls
14
+ */
15
+ import { Editor, defaultValueCtx, editorViewCtx, rootCtx } from "@milkdown/kit/core";
16
+ import { parserCtx, serializerCtx } from "@milkdown/kit/core";
17
+ import { commonmark } from "@milkdown/kit/preset/commonmark";
18
+ import { gfm } from "@milkdown/kit/preset/gfm";
19
+ import type { Node as ProseNode } from "@milkdown/kit/prose/model";
20
+ import { TextSelection } from "@milkdown/kit/prose/state";
21
+ import type { EditorView } from "@milkdown/kit/prose/view";
22
+ import { getMarkdown } from "@milkdown/kit/utils";
23
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
24
+
25
+ import type { EditorSelection } from "./editor-content-access";
26
+ import {
27
+ proseMirrorContentAccess,
28
+ selectionWatchPlugin,
29
+ type ProseMirrorContentAccessDeps,
30
+ } from "./editor-content-access-prose";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Headless Milkdown helpers (same pattern as brand-slash-plugin.test.ts)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ let editorRoot: HTMLDivElement | null = null;
37
+
38
+ async function makeEditor(initial = ""): Promise<Editor> {
39
+ const root = document.createElement("div");
40
+ document.body.appendChild(root);
41
+ editorRoot = root;
42
+ const editor = await Editor.make()
43
+ .config((ctx) => {
44
+ ctx.set(rootCtx, root);
45
+ ctx.set(defaultValueCtx, initial);
46
+ })
47
+ .use(commonmark)
48
+ .use(gfm)
49
+ .create();
50
+ return editor;
51
+ }
52
+
53
+ async function makeEditorWithSelectionWatch(initial = ""): Promise<Editor> {
54
+ const root = document.createElement("div");
55
+ document.body.appendChild(root);
56
+ editorRoot = root;
57
+ const listeners = new Set<(sel: EditorSelection) => void>();
58
+ let serializeSliceFn: (view: EditorView) => string = () => "";
59
+
60
+ const editor = await Editor.make()
61
+ .config((ctx) => {
62
+ ctx.set(rootCtx, root);
63
+ ctx.set(defaultValueCtx, initial);
64
+ })
65
+ .use(commonmark)
66
+ .use(gfm)
67
+ .use(
68
+ selectionWatchPlugin(
69
+ () => listeners,
70
+ () => serializeSliceFn,
71
+ ),
72
+ )
73
+ .create();
74
+
75
+ // Attach so callers can register listeners and update serialize.
76
+ (
77
+ editor as Editor & {
78
+ _listeners: typeof listeners;
79
+ _setSerialize: (fn: typeof serializeSliceFn) => void;
80
+ }
81
+ )._listeners = listeners;
82
+ (
83
+ editor as Editor & {
84
+ _listeners: typeof listeners;
85
+ _setSerialize: (fn: typeof serializeSliceFn) => void;
86
+ }
87
+ )._setSerialize = (fn) => {
88
+ serializeSliceFn = fn;
89
+ };
90
+ return editor;
91
+ }
92
+
93
+ function getView(editor: Editor): EditorView {
94
+ return editor.action((ctx) => ctx.get(editorViewCtx));
95
+ }
96
+
97
+ function serializeDoc(editor: Editor): string {
98
+ return editor.action(getMarkdown());
99
+ }
100
+
101
+ /** Build the deps object the adapter needs, using real Milkdown ctx. */
102
+ function makeDeps(
103
+ editor: Editor,
104
+ listeners: Set<(sel: EditorSelection) => void>,
105
+ ): ProseMirrorContentAccessDeps {
106
+ const serializeSlice = (view: EditorView): string => {
107
+ const { selection } = view.state;
108
+ if (selection.empty) return "";
109
+ try {
110
+ return editor
111
+ .action((ctx) => {
112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
+ const serialize = (ctx as any).get(serializerCtx) as (node: ProseNode) => string;
114
+ const content = selection.content().content;
115
+ const wrapper = view.state.doc.type.schema.topNodeType.create(null, content);
116
+ return serialize(wrapper);
117
+ })
118
+ .trim();
119
+ } catch {
120
+ return view.state.doc.textBetween(selection.from, selection.to, "\n");
121
+ }
122
+ };
123
+
124
+ const parseAndReplace = (view: EditorView, md: string): void => {
125
+ try {
126
+ editor.action((ctx) => {
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ const parse = (ctx as any).get(parserCtx) as (md: string) => ProseNode | null;
129
+ const parsed = parse(md);
130
+ if (!parsed) {
131
+ view.dispatch(view.state.tr.insertText(md));
132
+ return;
133
+ }
134
+ view.dispatch(view.state.tr.replaceSelectionWith(parsed));
135
+ });
136
+ } catch {
137
+ try {
138
+ view.dispatch(view.state.tr.insertText(md));
139
+ } catch {
140
+ // silently no-op
141
+ }
142
+ }
143
+ };
144
+
145
+ return {
146
+ getView: () => {
147
+ try {
148
+ return getView(editor);
149
+ } catch {
150
+ return null;
151
+ }
152
+ },
153
+ getText: () => serializeDoc(editor),
154
+ serializeSlice,
155
+ parseAndReplace,
156
+ listeners,
157
+ };
158
+ }
159
+
160
+ beforeEach(() => {
161
+ editorRoot = null;
162
+ });
163
+ afterEach(() => {
164
+ editorRoot?.remove();
165
+ editorRoot = null;
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // getText
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe("proseMirrorContentAccess — getText", () => {
173
+ it("returns the full document markdown", async () => {
174
+ const editor = await makeEditor("Hello **world**\n");
175
+ const listeners = new Set<(sel: EditorSelection) => void>();
176
+ const access = proseMirrorContentAccess(makeDeps(editor, listeners));
177
+ const text = access.getText();
178
+ expect(text).toMatch(/Hello/);
179
+ expect(text).toMatch(/world/);
180
+ });
181
+ });
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // getSelection
185
+ // ---------------------------------------------------------------------------
186
+
187
+ describe("proseMirrorContentAccess — getSelection", () => {
188
+ it("returns empty:true / text:'' for a collapsed caret", async () => {
189
+ const editor = await makeEditor("Hello\n");
190
+ const listeners = new Set<(sel: EditorSelection) => void>();
191
+ const access = proseMirrorContentAccess(makeDeps(editor, listeners));
192
+ const sel = access.getSelection();
193
+ expect(sel.empty).toBe(true);
194
+ expect(sel.text).toBe("");
195
+ });
196
+
197
+ it("serializes a bold selection to markdown (fidelity)", async () => {
198
+ const editor = await makeEditor("**bold**\n");
199
+ const listeners = new Set<(sel: EditorSelection) => void>();
200
+ const access = proseMirrorContentAccess(makeDeps(editor, listeners));
201
+
202
+ // Select the entire bold word (positions 1..5 in the paragraph node).
203
+ await editor.action((ctx) => {
204
+ const view = ctx.get(editorViewCtx);
205
+ const { doc } = view.state;
206
+ // Find the text node inside the paragraph. The paragraph starts at pos 1;
207
+ // the strong mark wraps the text. Select positions that cover "bold".
208
+ const from = 1;
209
+ const to = doc.content.size - 1; // exclude the trailing paragraph boundary
210
+ view.dispatch(
211
+ view.state.tr.setSelection(TextSelection.create(doc, from, to)).scrollIntoView(),
212
+ );
213
+ });
214
+
215
+ const sel = access.getSelection();
216
+ expect(sel.empty).toBe(false);
217
+ // Milkdown serializes the selected slice; the round-trip yields bold markdown.
218
+ expect(sel.text).toMatch(/bold/);
219
+ });
220
+ });
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // fidelity: "plainText"
224
+ // ---------------------------------------------------------------------------
225
+
226
+ describe("proseMirrorContentAccess — fidelity:plainText", () => {
227
+ it("reads the selection as raw text (no markdown markup)", async () => {
228
+ const editor = await makeEditor("**bold**\n");
229
+ const listeners = new Set<(sel: EditorSelection) => void>();
230
+ const access = proseMirrorContentAccess(makeDeps(editor, listeners), {
231
+ fidelity: "plainText",
232
+ });
233
+
234
+ await editor.action((ctx) => {
235
+ const view = ctx.get(editorViewCtx);
236
+ const { doc } = view.state;
237
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, 1, doc.content.size - 1)));
238
+ });
239
+
240
+ const sel = access.getSelection();
241
+ expect(sel.empty).toBe(false);
242
+ // plainText → the unformatted characters, NOT `**bold**`.
243
+ expect(sel.text).toBe("bold");
244
+ expect(sel.text).not.toContain("*");
245
+ });
246
+
247
+ it("inserts incoming text literally (no markdown parse)", async () => {
248
+ const editor = await makeEditor("x\n");
249
+ const listeners = new Set<(sel: EditorSelection) => void>();
250
+ const access = proseMirrorContentAccess(makeDeps(editor, listeners), {
251
+ fidelity: "plainText",
252
+ });
253
+
254
+ await editor.action((ctx) => {
255
+ const view = ctx.get(editorViewCtx);
256
+ const { doc } = view.state;
257
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, 1, doc.content.size - 1)));
258
+ });
259
+
260
+ access.replaceSelection("## not a heading");
261
+ // plainText inserts literal characters into the paragraph — NOT a parsed
262
+ // heading node. Assert on raw textContent (markdown serialization would escape
263
+ // the leading `##`, so check the doc text directly).
264
+ const { textContent, isHeading } = editor.action((ctx) => {
265
+ const doc = ctx.get(editorViewCtx).state.doc;
266
+ return { textContent: doc.textContent, isHeading: doc.firstChild?.type.name === "heading" };
267
+ });
268
+ expect(textContent).toContain("## not a heading");
269
+ expect(isHeading).toBe(false);
270
+ });
271
+ });
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // replaceSelection / insertAtCursor
275
+ // ---------------------------------------------------------------------------
276
+
277
+ describe("proseMirrorContentAccess — replaceSelection", () => {
278
+ it("replaces the current selection with parsed markdown content", async () => {
279
+ const editor = await makeEditor("old content\n");
280
+ const listeners = new Set<(sel: EditorSelection) => void>();
281
+ const access = proseMirrorContentAccess(makeDeps(editor, listeners));
282
+
283
+ // Select all text.
284
+ await editor.action((ctx) => {
285
+ const view = ctx.get(editorViewCtx);
286
+ const { doc } = view.state;
287
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, 1, doc.content.size - 1)));
288
+ });
289
+
290
+ access.replaceSelection("## Heading\n");
291
+
292
+ const md = serializeDoc(editor);
293
+ expect(md).toMatch(/##\s+Heading/);
294
+ // Original content should be gone.
295
+ expect(md).not.toContain("old content");
296
+ });
297
+
298
+ it("insertAtCursor works identically to replaceSelection (same primitive)", async () => {
299
+ const editor = await makeEditor("start\n");
300
+ const listeners = new Set<(sel: EditorSelection) => void>();
301
+ const access = proseMirrorContentAccess(makeDeps(editor, listeners));
302
+
303
+ // Collapsed caret at doc start → insert.
304
+ await editor.action((ctx) => {
305
+ const view = ctx.get(editorViewCtx);
306
+ const { doc } = view.state;
307
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, 1, 1)));
308
+ });
309
+
310
+ access.insertAtCursor("inserted ");
311
+ const md = serializeDoc(editor);
312
+ expect(md).toContain("inserted");
313
+ });
314
+
315
+ it("malformed/unparseable input falls back to plain-text insert (no throw)", async () => {
316
+ const editor = await makeEditor("existing\n");
317
+ const listeners = new Set<(sel: EditorSelection) => void>();
318
+ // Override parseAndReplace to always throw on the first attempt.
319
+ const deps = makeDeps(editor, listeners);
320
+ const originalParse = deps.parseAndReplace;
321
+ let callCount = 0;
322
+ deps.parseAndReplace = (view, md) => {
323
+ if (callCount++ === 0) throw new Error("simulated parse error");
324
+ originalParse(view, md);
325
+ };
326
+ const access = proseMirrorContentAccess(deps);
327
+ // Should not throw.
328
+ expect(() => access.replaceSelection("fallback text")).not.toThrow();
329
+ });
330
+ });
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // focus
334
+ // ---------------------------------------------------------------------------
335
+
336
+ describe("proseMirrorContentAccess — focus", () => {
337
+ it("calls view.focus() without throwing", async () => {
338
+ const editor = await makeEditor("text\n");
339
+ const listeners = new Set<(sel: EditorSelection) => void>();
340
+ const deps = makeDeps(editor, listeners);
341
+ const focusSpy = vi.spyOn(getView(editor), "focus").mockImplementation(() => undefined);
342
+ const access = proseMirrorContentAccess(deps);
343
+ expect(() => access.focus()).not.toThrow();
344
+ focusSpy.mockRestore();
345
+ });
346
+ });
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // onSelectionChange (via selectionWatchPlugin)
350
+ // ---------------------------------------------------------------------------
351
+
352
+ describe("selectionWatchPlugin + onSelectionChange", () => {
353
+ it("fires the listener when a setSelection transaction changes the selection", async () => {
354
+ const editor = await makeEditorWithSelectionWatch("Hello world\n");
355
+ const ext = editor as Editor & {
356
+ _listeners: Set<(sel: EditorSelection) => void>;
357
+ _setSerialize: (fn: (view: EditorView) => string) => void;
358
+ };
359
+
360
+ const listener = vi.fn();
361
+ ext._listeners.add(listener);
362
+ ext._setSerialize(() => "Hello");
363
+
364
+ // Dispatch a setSelection transaction to trigger the plugin.
365
+ await editor.action((ctx) => {
366
+ const view = ctx.get(editorViewCtx);
367
+ const { doc } = view.state;
368
+ const tr = view.state.tr.setSelection(TextSelection.create(doc, 1, 6));
369
+ view.dispatch(tr);
370
+ });
371
+
372
+ expect(listener).toHaveBeenCalledTimes(1);
373
+ const sel: EditorSelection = listener.mock.calls[0]![0] as EditorSelection;
374
+ expect(typeof sel.text).toBe("string");
375
+ expect(typeof sel.empty).toBe("boolean");
376
+ });
377
+
378
+ it("unsubscribing removes the listener — further selection changes don't call it", async () => {
379
+ const editor = await makeEditorWithSelectionWatch("Hello world\n");
380
+ const ext = editor as Editor & {
381
+ _listeners: Set<(sel: EditorSelection) => void>;
382
+ _setSerialize: (fn: (view: EditorView) => string) => void;
383
+ };
384
+ ext._setSerialize(() => "");
385
+
386
+ // Wire via proseMirrorContentAccess.onSelectionChange so we test the full path.
387
+ const listeners = ext._listeners;
388
+ const deps = makeDeps(editor, listeners);
389
+ const access = proseMirrorContentAccess(deps);
390
+
391
+ const listener = vi.fn();
392
+ const unsub = access.onSelectionChange(listener);
393
+
394
+ // Move selection → listener fires.
395
+ await editor.action((ctx) => {
396
+ const view = ctx.get(editorViewCtx);
397
+ const { doc } = view.state;
398
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, 1, 5)));
399
+ });
400
+ expect(listener).toHaveBeenCalledTimes(1);
401
+
402
+ // Unsubscribe → further moves must NOT fire the listener.
403
+ unsub();
404
+ await editor.action((ctx) => {
405
+ const view = ctx.get(editorViewCtx);
406
+ const { doc } = view.state;
407
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, 2, 6)));
408
+ });
409
+ expect(listener).toHaveBeenCalledTimes(1); // still 1, not 2
410
+ });
411
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * ProseMirror / Milkdown adapter for the engine-agnostic EditorContentAccess.
3
+ *
4
+ * ISOLATION: this file is MARKDOWN-ONLY — it imports `@milkdown/kit/*` at runtime.
5
+ * It is exported ONLY from `packages/editor/src/markdown/index.ts` (`./markdown`
6
+ * subpath) and NEVER from `src/index.ts` (`.` barrel), so `@milkdown` never leaks
7
+ * into the Monaco-only graph (#271-inverse). The TYPES from `editor-content-access`
8
+ * are type-erased imports — no runtime edge back to the monaco file.
9
+ */
10
+ import type { MilkdownPlugin } from "@milkdown/kit/ctx";
11
+ import { Plugin, PluginKey } from "@milkdown/kit/prose/state";
12
+ import type { EditorView } from "@milkdown/kit/prose/view";
13
+ import { $prose } from "@milkdown/kit/utils";
14
+
15
+ import type { EditorContentAccess, EditorSelection } from "./editor-content-access";
16
+
17
+ export interface ProseMirrorContentAccessOptions {
18
+ /** "markdown" (default) round-trips formatting; "plainText" uses raw text only. */
19
+ fidelity?: "markdown" | "plainText";
20
+ }
21
+
22
+ /**
23
+ * Dependency-injected shape for the Milkdown content-access adapter.
24
+ *
25
+ * The handle owns the serialize/parse closures (built via `getInstance().action(ctx
26
+ * => ...)`) and passes them here. This keeps the adapter free of `Ctx` token
27
+ * plumbing and matches the `readBodyMarkdown`/`writeBodyMarkdown` technique in
28
+ * `directive-views.tsx` exactly.
29
+ */
30
+ export interface ProseMirrorContentAccessDeps {
31
+ /** Return the live `EditorView`, or null while the engine is booting. */
32
+ getView: () => EditorView | null;
33
+ /** Return the full document serialized to markdown. */
34
+ getText: () => string;
35
+ /**
36
+ * Serialize the CURRENT selection's slice to markdown (or "" for a collapsed
37
+ * selection). Built by the handle from `serializerCtx`.
38
+ */
39
+ serializeSlice: (view: EditorView) => string;
40
+ /**
41
+ * Parse `md` as a markdown fragment and replace the current selection with the
42
+ * parsed content. On parse failure degrades to a plain-text insert. Built by
43
+ * the handle from `parserCtx`.
44
+ */
45
+ parseAndReplace: (view: EditorView, md: string) => void;
46
+ /**
47
+ * The registered `onSelectionChange` listeners set. Shared between the adapter
48
+ * and the `selectionWatchPlugin` so the plugin can notify subscribers without
49
+ * coupling to the adapter instance.
50
+ */
51
+ listeners: Set<(sel: EditorSelection) => void>;
52
+ }
53
+
54
+ const SELECTION_WATCH_KEY = new PluginKey("editorContentAccess_selectionWatch");
55
+
56
+ /**
57
+ * Build the ProseMirror selection-watch plugin as a Milkdown `$prose` plugin.
58
+ * Compares `prevState.selection` to `view.state.selection` on every transaction and
59
+ * notifies listeners in the shared `listeners` set when they differ.
60
+ *
61
+ * Add to the editor `.use(...)` chain ONCE (always-present; the listeners set is
62
+ * populated / depopulated at subscription time — no per-subscription state in the
63
+ * plugin). Follows the `calcProsePlugins` pattern exactly.
64
+ *
65
+ * @param getListeners - Thunk returning the current listener set. Using a thunk
66
+ * (rather than the set directly) lets the adapter swap the set out if needed —
67
+ * in practice the set is stable; the thunk keeps this composable.
68
+ * @param getSerializeSlice - Thunk: serialize the current selection → markdown.
69
+ */
70
+ export function selectionWatchPlugin(
71
+ getListeners: () => Set<(sel: EditorSelection) => void>,
72
+ getSerializeSlice: () => (view: EditorView) => string,
73
+ ): MilkdownPlugin {
74
+ const plugin = $prose(
75
+ () =>
76
+ new Plugin({
77
+ key: SELECTION_WATCH_KEY,
78
+ view() {
79
+ return {
80
+ update(view, prevState) {
81
+ const ls = getListeners();
82
+ if (ls.size === 0) return;
83
+ if (prevState.selection.eq(view.state.selection)) return;
84
+ const { selection } = view.state;
85
+ const text = selection.empty ? "" : getSerializeSlice()(view);
86
+ const sel: EditorSelection = { text, empty: selection.empty };
87
+ ls.forEach((l) => l(sel));
88
+ },
89
+ };
90
+ },
91
+ }),
92
+ );
93
+ return plugin as unknown as MilkdownPlugin;
94
+ }
95
+
96
+ /**
97
+ * Wrap a Milkdown ProseMirror view as {@link EditorContentAccess}.
98
+ *
99
+ * Receives dependency-injected closures (`serializeSlice`, `parseAndReplace`) that
100
+ * the `MarkdownEditorHandle` builds via `getInstance().action(ctx => ...)`, reusing
101
+ * the `serializerCtx`/`parserCtx` round-trip already shipped in `directive-views.tsx`.
102
+ *
103
+ * The `selectionWatchPlugin` must be added to the editor's `.use(...)` chain so it
104
+ * is installed when the editor boots. The `deps.listeners` set is shared between the
105
+ * plugin and this adapter.
106
+ *
107
+ * Markdown fidelity (default): selection → markdown, incoming text parsed as
108
+ * markdown fragment, with a graceful plain-text fallback on parse failure.
109
+ * Normalization (the WI-1 lesson): the serializer may re-emit formatting differently
110
+ * (e.g. `*` vs `_`). This is the same normalization the WYSIWYG editor applies on
111
+ * every keystroke — consistent, not new. For raw text use `plainText`.
112
+ *
113
+ * D5: content manipulation only — no model/transport.
114
+ */
115
+ export function proseMirrorContentAccess(
116
+ deps: ProseMirrorContentAccessDeps,
117
+ options: ProseMirrorContentAccessOptions = {},
118
+ ): EditorContentAccess {
119
+ const { getView, getText, serializeSlice, parseAndReplace, listeners } = deps;
120
+ // "plainText" bypasses the injected markdown closures and uses pure ProseMirror
121
+ // (`doc.textBetween` / `tr.insertText`) — no `serializerCtx`/`parserCtx`, so a
122
+ // selection reads as raw text and an insert lands as literal characters.
123
+ const plainText = options.fidelity === "plainText";
124
+
125
+ function readSelection(view: EditorView): EditorSelection {
126
+ const { selection } = view.state;
127
+ if (selection.empty) return { text: "", empty: true };
128
+ const text = plainText
129
+ ? view.state.doc.textBetween(selection.from, selection.to, "\n")
130
+ : serializeSlice(view);
131
+ return { text, empty: false };
132
+ }
133
+
134
+ // replaceSelection and insertAtCursor are the same primitive (replace the active
135
+ // range; an empty range is the caret) — two names for two reader intents.
136
+ const apply = (text: string): void => {
137
+ const view = getView();
138
+ if (!view) return;
139
+ try {
140
+ if (plainText) {
141
+ view.dispatch(view.state.tr.insertText(text).scrollIntoView());
142
+ } else {
143
+ parseAndReplace(view, text);
144
+ }
145
+ } catch {
146
+ // Graceful degradation: parseAndReplace handles parse failures internally,
147
+ // but if anything throws, never propagate to the caller (spec: never throws).
148
+ }
149
+ };
150
+
151
+ return {
152
+ getText,
153
+
154
+ getSelection(): EditorSelection {
155
+ const view = getView();
156
+ if (!view) return { text: "", empty: true };
157
+ return readSelection(view);
158
+ },
159
+
160
+ replaceSelection: apply,
161
+ insertAtCursor: apply,
162
+
163
+ focus(): void {
164
+ getView()?.focus();
165
+ },
166
+
167
+ onSelectionChange(listener: (selection: EditorSelection) => void): () => void {
168
+ listeners.add(listener);
169
+ return () => listeners.delete(listener);
170
+ },
171
+ };
172
+ }