leepi 0.0.0 → 0.0.3

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 (86) hide show
  1. package/dist/core/active-marks.d.ts +15 -0
  2. package/dist/core/active-marks.js +57 -0
  3. package/dist/core/commands.d.ts +39 -0
  4. package/dist/core/commands.js +415 -0
  5. package/dist/core/editor.d.ts +25 -0
  6. package/dist/core/editor.js +102 -0
  7. package/dist/core/field-notifier.d.ts +21 -0
  8. package/dist/core/field-notifier.js +56 -0
  9. package/dist/core/highlight-style.js +60 -0
  10. package/dist/core/highlight.d.ts +8 -0
  11. package/dist/core/highlight.js +34 -0
  12. package/dist/core/plugins/blockquote.d.ts +11 -0
  13. package/dist/core/plugins/blockquote.js +78 -0
  14. package/dist/core/plugins/bracket.d.ts +6 -0
  15. package/dist/core/plugins/bracket.js +38 -0
  16. package/dist/core/plugins/code-block.d.ts +27 -0
  17. package/dist/core/plugins/code-block.js +207 -0
  18. package/dist/core/plugins/heading.d.ts +13 -0
  19. package/dist/core/plugins/heading.js +111 -0
  20. package/dist/core/plugins/inline.d.ts +14 -0
  21. package/dist/core/plugins/inline.js +103 -0
  22. package/dist/core/plugins/link.d.ts +25 -0
  23. package/dist/core/plugins/link.js +104 -0
  24. package/dist/core/plugins/list.d.ts +14 -0
  25. package/dist/core/plugins/list.js +91 -0
  26. package/dist/core/plugins/table.d.ts +12 -0
  27. package/dist/core/plugins/table.js +161 -0
  28. package/dist/core/plugins.d.ts +9 -0
  29. package/dist/core/plugins.js +9 -0
  30. package/dist/core/popover.d.ts +9 -0
  31. package/dist/core/popover.js +16 -0
  32. package/dist/core/registry.d.ts +10 -0
  33. package/dist/core/registry.js +8 -0
  34. package/dist/core/types.d.ts +25 -0
  35. package/dist/core/types.js +0 -0
  36. package/dist/core/utils.d.ts +13 -0
  37. package/dist/core/utils.js +32 -0
  38. package/dist/leepi.css +461 -0
  39. package/dist/react/code-block-popover.d.ts +76 -0
  40. package/dist/react/code-block-popover.js +223 -0
  41. package/dist/react/context.d.ts +42 -0
  42. package/dist/react/context.js +88 -0
  43. package/dist/react/editor.d.ts +30 -0
  44. package/dist/react/editor.js +60 -0
  45. package/dist/react/floating-toolbar.d.ts +30 -0
  46. package/dist/react/floating-toolbar.js +87 -0
  47. package/dist/react/link-popover.d.ts +70 -0
  48. package/dist/react/link-popover.js +222 -0
  49. package/dist/react/preview.d.ts +13 -0
  50. package/dist/react/preview.js +56 -0
  51. package/dist/react/toolbar.d.ts +51 -0
  52. package/dist/react/toolbar.js +161 -0
  53. package/package.json +90 -1
  54. package/src/core/active-marks.ts +89 -0
  55. package/src/core/commands.ts +461 -0
  56. package/src/core/editor.ts +139 -0
  57. package/src/core/field-notifier.ts +71 -0
  58. package/src/core/highlight-style.ts +66 -0
  59. package/src/core/highlight.ts +50 -0
  60. package/src/core/plugins/blockquote.ts +108 -0
  61. package/src/core/plugins/bracket.ts +34 -0
  62. package/src/core/plugins/code-block.ts +195 -0
  63. package/src/core/plugins/heading.ts +95 -0
  64. package/src/core/plugins/index.ts +16 -0
  65. package/src/core/plugins/inline.ts +62 -0
  66. package/src/core/plugins/link.ts +124 -0
  67. package/src/core/plugins/list.ts +68 -0
  68. package/src/core/plugins/table.ts +217 -0
  69. package/src/core/popover.ts +17 -0
  70. package/src/core/registry.ts +18 -0
  71. package/src/core/types.ts +25 -0
  72. package/src/core/utils.ts +38 -0
  73. package/src/react/code-block-popover.tsx +387 -0
  74. package/src/react/context.tsx +153 -0
  75. package/src/react/editor.tsx +106 -0
  76. package/src/react/floating-toolbar.tsx +161 -0
  77. package/src/react/link-popover.tsx +354 -0
  78. package/src/react/preview.tsx +80 -0
  79. package/src/react/toolbar.tsx +294 -0
  80. package/src/styles/floating-toolbar.css +52 -0
  81. package/src/styles/leepi.css +2 -0
  82. package/src/styles/popover.css +93 -0
  83. package/src/styles/preview.css +191 -0
  84. package/src/styles/theme.css +99 -0
  85. package/src/styles/tokens.css +63 -0
  86. package/src/styles/toolbar.css +55 -0
@@ -0,0 +1,461 @@
1
+ import type { EditorView } from "@codemirror/view";
2
+ import { type EditorState, EditorSelection } from "@codemirror/state";
3
+ import { syntaxTree } from "@codemirror/language";
4
+ import { isInsideCodeBlock } from "./utils";
5
+ import type { CodeBlockData } from "./plugins/code-block";
6
+
7
+ /**
8
+ * Toggle an inline markdown marker (e.g. `**`, `_`, `` ` ``, `~~`) around
9
+ * each selection range. If the selected text is already wrapped with the
10
+ * marker, the marker is removed; otherwise it is added.
11
+ *
12
+ * When nothing is selected the marker pair is inserted at the cursor and the
13
+ * cursor is placed between them.
14
+ */
15
+ export function toggleMarker(marker: string) {
16
+ return (view: EditorView): boolean => {
17
+ if (isInsideCodeBlock(view)) return false;
18
+ const { state } = view;
19
+ const len = marker.length;
20
+
21
+ const changes = state.changeByRange((range) => {
22
+ const doc = state.doc;
23
+
24
+ if (range.empty) {
25
+ const line = doc.lineAt(range.from);
26
+ const lineText = line.text;
27
+ const cursorOffset = range.from - line.from;
28
+
29
+ const positions: number[] = [];
30
+ let searchFrom = 0;
31
+ while (searchFrom <= lineText.length - len) {
32
+ const idx = lineText.indexOf(marker, searchFrom);
33
+ if (idx === -1) break;
34
+ positions.push(idx);
35
+ searchFrom = idx + len;
36
+ }
37
+
38
+ for (let i = 0; i + 1 < positions.length; i += 2) {
39
+ const openEnd = positions[i] + len;
40
+ const closeStart = positions[i + 1];
41
+ if (cursorOffset >= openEnd && cursorOffset <= closeStart) {
42
+ const absOpen = line.from + positions[i];
43
+ const absClose = line.from + closeStart;
44
+ const innerText = doc.sliceString(absOpen + len, absClose);
45
+ const cursorInInner = range.from - (absOpen + len);
46
+ return {
47
+ changes: {
48
+ from: absOpen,
49
+ to: absClose + len,
50
+ insert: innerText,
51
+ },
52
+ range: EditorSelection.cursor(absOpen + cursorInInner),
53
+ };
54
+ }
55
+ }
56
+
57
+ return {
58
+ changes: { from: range.from, insert: marker + marker },
59
+ range: EditorSelection.cursor(range.from + len),
60
+ };
61
+ }
62
+
63
+ const selected = doc.sliceString(range.from, range.to);
64
+
65
+ if (selected.startsWith(marker) && selected.endsWith(marker) && selected.length >= len * 2) {
66
+ const inner = selected.slice(len, -len);
67
+ return {
68
+ changes: { from: range.from, to: range.to, insert: inner },
69
+ range: EditorSelection.range(range.from, range.from + inner.length),
70
+ };
71
+ }
72
+
73
+ const beforeSel = doc.sliceString(Math.max(0, range.from - len), range.from);
74
+ const afterSel = doc.sliceString(range.to, Math.min(doc.length, range.to + len));
75
+
76
+ if (beforeSel === marker && afterSel === marker) {
77
+ return {
78
+ changes: [
79
+ { from: range.from - len, to: range.from },
80
+ { from: range.to, to: range.to + len },
81
+ ],
82
+ range: EditorSelection.range(range.from - len, range.to - len),
83
+ };
84
+ }
85
+
86
+ return {
87
+ changes: [
88
+ { from: range.from, insert: marker },
89
+ { from: range.to, insert: marker },
90
+ ],
91
+ range: EditorSelection.range(range.from, range.to + len * 2),
92
+ };
93
+ });
94
+
95
+ view.dispatch(changes);
96
+ return true;
97
+ };
98
+ }
99
+
100
+ // --- Blockquote toggle ---
101
+
102
+ const BQ_RE = /^(\s*)>\s?/;
103
+
104
+ export function toggleBlockquote(view: EditorView): boolean {
105
+ if (isInsideCodeBlock(view)) return false;
106
+ const { state } = view;
107
+ const changes = state.changeByRange((range) => {
108
+ const fromLine = state.doc.lineAt(range.from);
109
+ const toLine = state.doc.lineAt(range.to);
110
+ const edits: { from: number; to: number; insert: string }[] = [];
111
+
112
+ let allQuoted = true;
113
+ for (let num = fromLine.number; num <= toLine.number; num++) {
114
+ const line = state.doc.line(num);
115
+ if (!BQ_RE.test(line.text)) {
116
+ allQuoted = false;
117
+ break;
118
+ }
119
+ }
120
+
121
+ for (let num = fromLine.number; num <= toLine.number; num++) {
122
+ const line = state.doc.line(num);
123
+ if (allQuoted) {
124
+ const match = line.text.match(BQ_RE)!;
125
+ edits.push({
126
+ from: line.from,
127
+ to: line.from + match[0].length,
128
+ insert: match[1],
129
+ });
130
+ } else if (!BQ_RE.test(line.text)) {
131
+ const indent = line.text.match(/^(\s*)/)![1];
132
+ edits.push({
133
+ from: line.from + indent.length,
134
+ to: line.from + indent.length,
135
+ insert: "> ",
136
+ });
137
+ }
138
+ }
139
+
140
+ if (edits.length === 0) return { changes: [], range };
141
+
142
+ const firstDelta = edits[0].insert.length - (edits[0].to - edits[0].from);
143
+ const totalDelta = edits.reduce((sum, e) => sum + e.insert.length - (e.to - e.from), 0);
144
+ const newFrom = Math.max(0, range.from + firstDelta);
145
+ return {
146
+ changes: edits,
147
+ range: range.empty
148
+ ? EditorSelection.cursor(newFrom)
149
+ : EditorSelection.range(newFrom, Math.max(newFrom, range.to + totalDelta)),
150
+ };
151
+ });
152
+
153
+ view.dispatch(changes);
154
+ return true;
155
+ }
156
+
157
+ // --- Block prefix helper ---
158
+
159
+ /**
160
+ * Find the offset where content begins after any blockquote markers on a line.
161
+ * Uses the syntax tree to detect `QuoteMark` nodes, handling nested blockquotes.
162
+ */
163
+ function getBlockContentOffset(state: EditorState, lineFrom: number, lineTo: number): number {
164
+ const tree = syntaxTree(state);
165
+ let lastQuoteEnd = lineFrom;
166
+ let foundQuote = false;
167
+
168
+ tree.iterate({
169
+ from: lineFrom,
170
+ to: lineTo,
171
+ enter(node) {
172
+ if (node.name === "QuoteMark" && node.from >= lineFrom && node.to <= lineTo) {
173
+ foundQuote = true;
174
+ lastQuoteEnd = node.to;
175
+ }
176
+ },
177
+ });
178
+
179
+ if (!foundQuote) return 0;
180
+
181
+ // Skip whitespace after the last QuoteMark
182
+ const doc = state.doc;
183
+ let offset = lastQuoteEnd;
184
+ while (offset < lineTo && doc.sliceString(offset, offset + 1) === " ") {
185
+ offset++;
186
+ }
187
+
188
+ return offset - lineFrom;
189
+ }
190
+
191
+ // --- Heading toggle ---
192
+
193
+ export function toggleHeading(level: number) {
194
+ return (view: EditorView): boolean => {
195
+ if (isInsideCodeBlock(view)) return false;
196
+ const { state } = view;
197
+ const line = state.doc.lineAt(state.selection.main.from);
198
+ const contentOffset = getBlockContentOffset(state, line.from, line.to);
199
+ const content = line.text.slice(contentOffset);
200
+ const headingMatch = content.match(/^(#{1,6})\s/);
201
+ const prefix = "#".repeat(level) + " ";
202
+ const contentFrom = line.from + contentOffset;
203
+
204
+ const cursorPos = state.selection.main.from;
205
+
206
+ if (headingMatch && headingMatch[1].length === level) {
207
+ // Removing heading — place cursor at same relative content position
208
+ const delta = -headingMatch[0].length;
209
+ view.dispatch({
210
+ changes: { from: contentFrom, to: contentFrom + headingMatch[0].length, insert: "" },
211
+ selection: EditorSelection.cursor(Math.max(contentFrom, cursorPos + delta)),
212
+ });
213
+ } else if (headingMatch) {
214
+ // Switching heading level — adjust cursor for prefix length change
215
+ const delta = prefix.length - headingMatch[0].length;
216
+ view.dispatch({
217
+ changes: { from: contentFrom, to: contentFrom + headingMatch[0].length, insert: prefix },
218
+ selection: EditorSelection.cursor(Math.max(contentFrom + prefix.length, cursorPos + delta)),
219
+ });
220
+ } else {
221
+ // Adding heading — place cursor after the new prefix
222
+ view.dispatch({
223
+ changes: { from: contentFrom, to: contentFrom, insert: prefix },
224
+ selection: EditorSelection.cursor(
225
+ Math.max(contentFrom + prefix.length, cursorPos + prefix.length),
226
+ ),
227
+ });
228
+ }
229
+ return true;
230
+ };
231
+ }
232
+
233
+ // --- List toggle ---
234
+
235
+ const TASK_RE = /^(\s*)[-*+]\s\[([ xX])\]\s/;
236
+
237
+ type ListKind = "ul" | "ol" | "task" | "none";
238
+
239
+ function detectListKind(
240
+ state: EditorState,
241
+ pos: number,
242
+ contentText: string,
243
+ ): { kind: ListKind; indent: string } {
244
+ const tree = syntaxTree(state);
245
+ let node = tree.resolveInner(pos, 1);
246
+ const indent = contentText.match(/^(\s*)/)![1];
247
+
248
+ while (node) {
249
+ if (node.name === "ListItem" && node.getChild("Task")) {
250
+ return { kind: "task", indent };
251
+ }
252
+ if (node.name === "BulletList" || node.name === "OrderedList") {
253
+ return { kind: node.name === "BulletList" ? "ul" : "ol", indent };
254
+ }
255
+ if (!node.parent) break;
256
+ node = node.parent;
257
+ }
258
+ return { kind: "none", indent: "" };
259
+ }
260
+
261
+ function stripListPrefix(lineText: string): string {
262
+ return lineText.replace(/^(\s*)(?:[-*+]\s\[([ xX])\]\s|[-*+]\s|\d+\.\s)/, "$1");
263
+ }
264
+
265
+ export function toggleListKind(target: ListKind) {
266
+ return (view: EditorView): boolean => {
267
+ if (isInsideCodeBlock(view)) return false;
268
+ const { state } = view;
269
+ const changes = state.changeByRange((range) => {
270
+ const fromLine = state.doc.lineAt(range.from);
271
+ const toLine = state.doc.lineAt(range.to);
272
+ const edits: { from: number; to: number; insert: string }[] = [];
273
+ let olIdx = 1;
274
+
275
+ for (let num = fromLine.number; num <= toLine.number; num++) {
276
+ const line = state.doc.line(num);
277
+ const bqOffset = getBlockContentOffset(state, line.from, line.to);
278
+ const content = line.text.slice(bqOffset);
279
+ const { kind, indent } = detectListKind(state, line.from + bqOffset, content);
280
+ const stripped = stripListPrefix(content);
281
+
282
+ let newPrefix: string;
283
+ if (kind === target) {
284
+ newPrefix = indent;
285
+ } else {
286
+ switch (target) {
287
+ case "ul":
288
+ newPrefix = `${indent}- `;
289
+ break;
290
+ case "ol":
291
+ newPrefix = `${indent}${olIdx++}. `;
292
+ break;
293
+ case "task":
294
+ newPrefix = `${indent}- [ ] `;
295
+ break;
296
+ default:
297
+ newPrefix = indent;
298
+ }
299
+ }
300
+
301
+ const newContent = newPrefix + stripped.slice(indent.length);
302
+ const newLine = line.text.slice(0, bqOffset) + newContent;
303
+ if (newLine !== line.text) {
304
+ edits.push({ from: line.from, to: line.to, insert: newLine });
305
+ }
306
+ }
307
+
308
+ if (edits.length === 0) return { changes: [], range };
309
+
310
+ const firstDelta = edits[0].insert.length - (edits[0].to - edits[0].from);
311
+ const totalDelta = edits.reduce((sum, e) => sum + e.insert.length - (e.to - e.from), 0);
312
+ const newFrom = Math.max(0, range.from + firstDelta);
313
+ return {
314
+ changes: edits,
315
+ range: range.empty
316
+ ? EditorSelection.cursor(newFrom)
317
+ : EditorSelection.range(newFrom, Math.max(newFrom, range.to + totalDelta)),
318
+ };
319
+ });
320
+
321
+ view.dispatch(changes);
322
+ return true;
323
+ };
324
+ }
325
+
326
+ export function toggleTaskCheck(view: EditorView): boolean {
327
+ if (isInsideCodeBlock(view)) return false;
328
+ const { state } = view;
329
+ const changes = state.changeByRange((range) => {
330
+ const fromLine = state.doc.lineAt(range.from);
331
+ const toLine = state.doc.lineAt(range.to);
332
+ const edits: { from: number; to: number; insert: string }[] = [];
333
+
334
+ for (let num = fromLine.number; num <= toLine.number; num++) {
335
+ const line = state.doc.line(num);
336
+ const match = line.text.match(TASK_RE);
337
+ if (!match) continue;
338
+ const isChecked = match[2] !== " ";
339
+ const bracketStart = line.from + line.text.indexOf("[");
340
+ edits.push({
341
+ from: bracketStart,
342
+ to: bracketStart + 3,
343
+ insert: isChecked ? "[ ]" : "[x]",
344
+ });
345
+ }
346
+
347
+ if (edits.length === 0) return { changes: [], range };
348
+ return { changes: edits, range };
349
+ });
350
+
351
+ view.dispatch(changes);
352
+ return true;
353
+ }
354
+
355
+ // --- Link helpers ---
356
+
357
+ export function findLinkAtCursor(
358
+ state: EditorState,
359
+ pos: number,
360
+ ): { from: number; to: number; label: string; url: string } | null {
361
+ const tree = syntaxTree(state);
362
+ let node = tree.resolveInner(pos, -1);
363
+ while (node) {
364
+ if (node.name === "Link") {
365
+ const from = node.from;
366
+ const to = node.to;
367
+ const full = state.sliceDoc(from, to);
368
+ const labelStart = full.indexOf("[");
369
+ const labelEnd = full.indexOf("]");
370
+ const urlStart = full.indexOf("(", labelEnd);
371
+ const urlEnd = full.lastIndexOf(")");
372
+ if (labelStart === -1 || labelEnd === -1 || urlStart === -1 || urlEnd === -1) return null;
373
+ const label = full.slice(labelStart + 1, labelEnd);
374
+ const url = full.slice(urlStart + 1, urlEnd);
375
+ return { from, to, label, url };
376
+ }
377
+ if (!node.parent) break;
378
+ node = node.parent;
379
+ }
380
+ return null;
381
+ }
382
+
383
+ /** Insert a markdown link into the editor, replacing `from..to`. */
384
+ export function insertLink(
385
+ view: EditorView,
386
+ from: number,
387
+ to: number,
388
+ label: string,
389
+ url: string,
390
+ ): void {
391
+ const insert = `[${label}](${url})`;
392
+ view.dispatch({
393
+ changes: { from, to, insert },
394
+ selection: EditorSelection.cursor(from + insert.length),
395
+ });
396
+ view.focus();
397
+ }
398
+
399
+ // --- Code fence helpers ---
400
+
401
+ function parseFenceInfo(info: string): { lang: string; filename: string } {
402
+ const parts = info.trim().split(/\s+/);
403
+ let lang = "";
404
+ let filename = "";
405
+ for (const part of parts) {
406
+ if (part.startsWith("filename=")) {
407
+ filename = part.slice("filename=".length);
408
+ } else if (!lang) {
409
+ lang = part;
410
+ }
411
+ }
412
+ return { lang, filename };
413
+ }
414
+
415
+ export function findCodeFenceAtCursor(view: EditorView): {
416
+ fenceLine: { from: number; to: number };
417
+ lang: string;
418
+ filename: string;
419
+ } | null {
420
+ const { state } = view;
421
+ const pos = state.selection.main.from;
422
+ const tree = syntaxTree(state);
423
+
424
+ let node = tree.resolveInner(pos, -1);
425
+ while (node) {
426
+ if (node.name === "FencedCode") {
427
+ const fenceLine = state.doc.lineAt(node.from);
428
+ const infoNode = node.getChild("CodeInfo");
429
+ const info = infoNode ? state.sliceDoc(infoNode.from, infoNode.to) : "";
430
+ const { lang, filename } = parseFenceInfo(info);
431
+ return { fenceLine: { from: fenceLine.from, to: fenceLine.to }, lang, filename };
432
+ }
433
+ if (!node.parent) break;
434
+ node = node.parent;
435
+ }
436
+ return null;
437
+ }
438
+
439
+ /** Apply code block settings. For existing blocks, replaces the fence line. For new blocks, inserts a fenced block. */
440
+ export function applyCodeBlock(
441
+ view: EditorView,
442
+ data: CodeBlockData,
443
+ lang: string,
444
+ filename: string,
445
+ ): void {
446
+ const info = [lang, filename ? `filename=${filename}` : ""].filter(Boolean).join(" ");
447
+ if (data.isNew) {
448
+ const insert = `\`\`\`${info}\n\n\`\`\``;
449
+ const pos = data.insertPos!;
450
+ view.dispatch({
451
+ changes: { from: pos, to: pos, insert },
452
+ selection: EditorSelection.cursor(pos + 3 + info.length + 1),
453
+ });
454
+ } else {
455
+ const fenceLine = `\`\`\`${info}`;
456
+ view.dispatch({
457
+ changes: { from: data.fenceFrom, to: data.fenceTo, insert: fenceLine },
458
+ });
459
+ }
460
+ view.focus();
461
+ }
@@ -0,0 +1,139 @@
1
+ import { EditorState, EditorSelection, Compartment, type Extension } from "@codemirror/state";
2
+ import { EditorView, keymap, placeholder as cmPlaceholder } from "@codemirror/view";
3
+ import { syntaxHighlighting } from "@codemirror/language";
4
+ import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
5
+ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
6
+ import { styleTags, tags } from "@lezer/highlight";
7
+ import { highlightStyle, editorTheme } from "./highlight-style";
8
+ import { activeMarksPlugin } from "./active-marks";
9
+ import { fieldNotifier } from "./field-notifier";
10
+ import { popoverField } from "./popover";
11
+ import {
12
+ inlinePlugin,
13
+ headingPlugin,
14
+ listPlugin,
15
+ blockquotePlugin,
16
+ linkPlugin,
17
+ codeBlockPlugin,
18
+ tablePlugin,
19
+ bracketPlugin,
20
+ } from "./plugins";
21
+
22
+ export interface CreateEditorStateOptions {
23
+ doc?: string;
24
+ onUpdate?: (doc: string) => void;
25
+ /** Additional extensions (e.g. custom plugins) */
26
+ plugins?: Extension[];
27
+ placeholder?: string;
28
+ readOnly?: boolean;
29
+ /** When false, default plugins are NOT included. Defaults to true. */
30
+ includeDefaultPlugins?: boolean;
31
+ }
32
+
33
+ /** Compartment for swapping the markdown extension after lazy-loading language support */
34
+ export const markdownCompartment: Compartment = new Compartment();
35
+
36
+ /** Compartment for toggling editable state */
37
+ export const editableCompartment: Compartment = new Compartment();
38
+
39
+ const markdownStyleTags = {
40
+ props: [
41
+ styleTags({
42
+ ListMark: tags.processingInstruction,
43
+ QuoteMark: tags.processingInstruction,
44
+ TaskMarker: tags.processingInstruction,
45
+ CodeText: tags.content,
46
+ }),
47
+ ],
48
+ };
49
+
50
+ /** Select the word under the cursor. Does nothing if there is already a selection. */
51
+ function selectWord(view: EditorView): boolean {
52
+ const main = view.state.selection.main;
53
+ if (!main.empty) return false;
54
+ const word = view.state.wordAt(main.head);
55
+ if (!word) return false;
56
+ view.dispatch({ selection: EditorSelection.single(word.from, word.to) });
57
+ return true;
58
+ }
59
+
60
+ /** Default set of all plugins */
61
+ export function defaultPlugins(): Extension {
62
+ return [
63
+ inlinePlugin(),
64
+ headingPlugin(),
65
+ listPlugin(),
66
+ blockquotePlugin(),
67
+ linkPlugin(),
68
+ codeBlockPlugin(),
69
+ tablePlugin(),
70
+ bracketPlugin(),
71
+ ];
72
+ }
73
+
74
+ export function createEditorState(options: CreateEditorStateOptions): EditorState {
75
+ const {
76
+ doc = "",
77
+ onUpdate,
78
+ plugins = [],
79
+ placeholder = "",
80
+ readOnly = false,
81
+ includeDefaultPlugins = true,
82
+ } = options;
83
+
84
+ const extensions: Extension[] = [
85
+ editableCompartment.of(EditorView.editable.of(!readOnly)),
86
+ // Default plugins
87
+ ...(includeDefaultPlugins ? [defaultPlugins()] : []),
88
+ // User plugins
89
+ ...plugins,
90
+ // Core keybindings
91
+ keymap.of([
92
+ { key: "Mod-d", run: selectWord, preventDefault: true },
93
+ ...defaultKeymap,
94
+ ...historyKeymap,
95
+ ]),
96
+ history(),
97
+ popoverField,
98
+ activeMarksPlugin,
99
+ fieldNotifier(),
100
+ markdownCompartment.of(
101
+ markdown({ base: markdownLanguage, extensions: markdownStyleTags, addKeymap: true }),
102
+ ),
103
+ syntaxHighlighting(highlightStyle),
104
+ editorTheme,
105
+ EditorView.lineWrapping,
106
+ ];
107
+
108
+ if (placeholder) {
109
+ extensions.push(cmPlaceholder(placeholder));
110
+ }
111
+
112
+ if (onUpdate) {
113
+ extensions.push(
114
+ EditorView.updateListener.of((update) => {
115
+ if (update.docChanged) {
116
+ onUpdate(update.state.doc.toString());
117
+ }
118
+ }),
119
+ );
120
+ }
121
+
122
+ return EditorState.create({ doc, extensions });
123
+ }
124
+
125
+ /** Lazy-load code language support and reconfigure the markdown extension */
126
+ export function loadLanguageSupport(view: EditorView): void {
127
+ import("@codemirror/language-data").then(({ languages }) => {
128
+ view.dispatch({
129
+ effects: markdownCompartment.reconfigure(
130
+ markdown({
131
+ base: markdownLanguage,
132
+ codeLanguages: languages,
133
+ extensions: markdownStyleTags,
134
+ addKeymap: true,
135
+ }),
136
+ ),
137
+ });
138
+ });
139
+ }
@@ -0,0 +1,71 @@
1
+ import type { StateField, Extension } from "@codemirror/state";
2
+ import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
3
+
4
+ type FieldListener = () => void;
5
+
6
+ const fieldListeners = new WeakMap<EditorView, Map<StateField<unknown>, Set<FieldListener>>>();
7
+ const fieldSnapshots = new WeakMap<EditorView, Map<StateField<unknown>, unknown>>();
8
+
9
+ /**
10
+ * Subscribe to changes of a StateField on a given EditorView.
11
+ * Returns an unsubscribe function.
12
+ */
13
+ export function subscribeToField<T>(
14
+ view: EditorView,
15
+ field: StateField<T>,
16
+ cb: FieldListener,
17
+ ): () => void {
18
+ let viewMap = fieldListeners.get(view);
19
+ if (!viewMap) {
20
+ viewMap = new Map();
21
+ fieldListeners.set(view, viewMap);
22
+ }
23
+ let listeners = viewMap.get(field as StateField<unknown>);
24
+ if (!listeners) {
25
+ listeners = new Set();
26
+ viewMap.set(field as StateField<unknown>, listeners);
27
+ }
28
+ listeners.add(cb);
29
+ return () => {
30
+ listeners!.delete(cb);
31
+ };
32
+ }
33
+
34
+ /** Get the current snapshot of a StateField for useSyncExternalStore */
35
+ export function getFieldSnapshot<T>(view: EditorView, field: StateField<T>): T | undefined {
36
+ const snapMap = fieldSnapshots.get(view);
37
+ return snapMap?.get(field as StateField<unknown>) as T | undefined;
38
+ }
39
+
40
+ /**
41
+ * ViewPlugin that watches all StateFields with subscribers and notifies
42
+ * when their values change (by reference).
43
+ */
44
+ export const fieldNotifierPlugin: Extension = ViewPlugin.fromClass(
45
+ class {
46
+ update(update: ViewUpdate): void {
47
+ const viewMap = fieldListeners.get(update.view);
48
+ if (!viewMap) return;
49
+
50
+ let snapMap = fieldSnapshots.get(update.view);
51
+ if (!snapMap) {
52
+ snapMap = new Map();
53
+ fieldSnapshots.set(update.view, snapMap);
54
+ }
55
+
56
+ for (const [field, listeners] of viewMap) {
57
+ const newVal = update.state.field(field as StateField<unknown>);
58
+ const oldVal = snapMap.get(field);
59
+ if (newVal !== oldVal) {
60
+ snapMap.set(field, newVal);
61
+ for (const cb of listeners) cb();
62
+ }
63
+ }
64
+ }
65
+ },
66
+ );
67
+
68
+ /** Extension to include in createEditorState */
69
+ export function fieldNotifier(): Extension {
70
+ return fieldNotifierPlugin;
71
+ }