mardora 1.2.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 (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/dist/chunk-3OCUX4OO.js +7690 -0
  4. package/dist/chunk-3OCUX4OO.js.map +1 -0
  5. package/dist/chunk-3ZOCCFDL.cjs +74 -0
  6. package/dist/chunk-3ZOCCFDL.cjs.map +1 -0
  7. package/dist/chunk-7JOEPNEV.cjs +7740 -0
  8. package/dist/chunk-7JOEPNEV.cjs.map +1 -0
  9. package/dist/chunk-BIKZQZ6W.js +33 -0
  10. package/dist/chunk-BIKZQZ6W.js.map +1 -0
  11. package/dist/chunk-EQJESPP2.js +234 -0
  12. package/dist/chunk-EQJESPP2.js.map +1 -0
  13. package/dist/chunk-G4SE26YY.js +70 -0
  14. package/dist/chunk-G4SE26YY.js.map +1 -0
  15. package/dist/chunk-KNDWF2DP.cjs +35 -0
  16. package/dist/chunk-KNDWF2DP.cjs.map +1 -0
  17. package/dist/chunk-MLBEBFHB.cjs +2971 -0
  18. package/dist/chunk-MLBEBFHB.cjs.map +1 -0
  19. package/dist/chunk-P7JFCYU3.js +905 -0
  20. package/dist/chunk-P7JFCYU3.js.map +1 -0
  21. package/dist/chunk-SWFUKJDO.cjs +243 -0
  22. package/dist/chunk-SWFUKJDO.cjs.map +1 -0
  23. package/dist/chunk-WFVCG4LD.cjs +926 -0
  24. package/dist/chunk-WFVCG4LD.cjs.map +1 -0
  25. package/dist/chunk-XL6WFGJT.js +2901 -0
  26. package/dist/chunk-XL6WFGJT.js.map +1 -0
  27. package/dist/editor/index.cjs +277 -0
  28. package/dist/editor/index.cjs.map +1 -0
  29. package/dist/editor/index.d.cts +186 -0
  30. package/dist/editor/index.d.ts +186 -0
  31. package/dist/editor/index.js +4 -0
  32. package/dist/editor/index.js.map +1 -0
  33. package/dist/index.cjs +405 -0
  34. package/dist/index.cjs.map +1 -0
  35. package/dist/index.d.cts +13 -0
  36. package/dist/index.d.ts +13 -0
  37. package/dist/index.js +8 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/lib/index.cjs +12 -0
  40. package/dist/lib/index.cjs.map +1 -0
  41. package/dist/lib/index.d.cts +16 -0
  42. package/dist/lib/index.d.ts +16 -0
  43. package/dist/lib/index.js +3 -0
  44. package/dist/lib/index.js.map +1 -0
  45. package/dist/mardora-DCwjomil.d.cts +640 -0
  46. package/dist/mardora-DCwjomil.d.ts +640 -0
  47. package/dist/plugins/index.cjs +104 -0
  48. package/dist/plugins/index.cjs.map +1 -0
  49. package/dist/plugins/index.d.cts +740 -0
  50. package/dist/plugins/index.d.ts +740 -0
  51. package/dist/plugins/index.js +7 -0
  52. package/dist/plugins/index.js.map +1 -0
  53. package/dist/preview/index.cjs +38 -0
  54. package/dist/preview/index.cjs.map +1 -0
  55. package/dist/preview/index.d.cts +101 -0
  56. package/dist/preview/index.d.ts +101 -0
  57. package/dist/preview/index.js +5 -0
  58. package/dist/preview/index.js.map +1 -0
  59. package/dist/types-NBsaxl4d.d.cts +71 -0
  60. package/dist/types-Pw2SWWAR.d.ts +71 -0
  61. package/package.json +92 -0
  62. package/src/editor/attachments/extension.ts +181 -0
  63. package/src/editor/attachments/format.ts +63 -0
  64. package/src/editor/attachments/index.ts +3 -0
  65. package/src/editor/attachments/types.ts +37 -0
  66. package/src/editor/heading-fold/config.ts +25 -0
  67. package/src/editor/heading-fold/extension.ts +268 -0
  68. package/src/editor/heading-fold/extract.ts +88 -0
  69. package/src/editor/heading-fold/index.ts +5 -0
  70. package/src/editor/heading-fold/theme.ts +85 -0
  71. package/src/editor/heading-fold/types.ts +24 -0
  72. package/src/editor/i18n.ts +13 -0
  73. package/src/editor/icons/index.ts +367 -0
  74. package/src/editor/index.ts +16 -0
  75. package/src/editor/mardora.ts +257 -0
  76. package/src/editor/media-lightbox-theme.ts +146 -0
  77. package/src/editor/media-lightbox.ts +125 -0
  78. package/src/editor/plugin.ts +294 -0
  79. package/src/editor/selection-toolbar/activation.ts +123 -0
  80. package/src/editor/selection-toolbar/commands.ts +279 -0
  81. package/src/editor/selection-toolbar/extension.ts +564 -0
  82. package/src/editor/selection-toolbar/i18n.ts +164 -0
  83. package/src/editor/selection-toolbar/index.ts +7 -0
  84. package/src/editor/selection-toolbar/menu.ts +252 -0
  85. package/src/editor/selection-toolbar/position.ts +43 -0
  86. package/src/editor/selection-toolbar/theme.ts +195 -0
  87. package/src/editor/selection-toolbar/types.ts +155 -0
  88. package/src/editor/slash/default-commands.ts +190 -0
  89. package/src/editor/slash/extension.ts +319 -0
  90. package/src/editor/slash/index.ts +7 -0
  91. package/src/editor/slash/insertions.ts +26 -0
  92. package/src/editor/slash/menu.ts +123 -0
  93. package/src/editor/slash/position.ts +61 -0
  94. package/src/editor/slash/query.ts +33 -0
  95. package/src/editor/slash/theme.ts +113 -0
  96. package/src/editor/slash/types.ts +40 -0
  97. package/src/editor/table-of-contents/extension.ts +202 -0
  98. package/src/editor/table-of-contents/extract.ts +53 -0
  99. package/src/editor/table-of-contents/index.ts +7 -0
  100. package/src/editor/table-of-contents/panel.ts +83 -0
  101. package/src/editor/table-of-contents/slug.ts +50 -0
  102. package/src/editor/table-of-contents/storage.ts +35 -0
  103. package/src/editor/table-of-contents/theme.ts +153 -0
  104. package/src/editor/table-of-contents/types.ts +44 -0
  105. package/src/editor/theme.ts +72 -0
  106. package/src/editor/utils.ts +176 -0
  107. package/src/editor/view-plugin.ts +189 -0
  108. package/src/index.ts +5 -0
  109. package/src/lib/index.ts +2 -0
  110. package/src/lib/input-handler.ts +47 -0
  111. package/src/plugins/code-plugin.theme.ts +545 -0
  112. package/src/plugins/code-plugin.ts +1892 -0
  113. package/src/plugins/emoji-plugin.ts +140 -0
  114. package/src/plugins/heading-plugin.ts +194 -0
  115. package/src/plugins/hr-plugin.ts +102 -0
  116. package/src/plugins/html-plugin.ts +353 -0
  117. package/src/plugins/image-plugin.ts +806 -0
  118. package/src/plugins/index.ts +71 -0
  119. package/src/plugins/inline-plugin.ts +311 -0
  120. package/src/plugins/link-plugin.ts +509 -0
  121. package/src/plugins/list-plugin.ts +492 -0
  122. package/src/plugins/math-plugin.ts +526 -0
  123. package/src/plugins/mermaid-plugin.ts +513 -0
  124. package/src/plugins/paragraph-plugin.ts +38 -0
  125. package/src/plugins/quote-plugin.ts +733 -0
  126. package/src/plugins/table-controls-theme.ts +126 -0
  127. package/src/plugins/table-controls.ts +423 -0
  128. package/src/plugins/table-model.ts +661 -0
  129. package/src/plugins/table-plugin.ts +2111 -0
  130. package/src/preview/context.ts +45 -0
  131. package/src/preview/css-generator.ts +64 -0
  132. package/src/preview/default-renderers.ts +29 -0
  133. package/src/preview/index.ts +29 -0
  134. package/src/preview/preview.ts +41 -0
  135. package/src/preview/renderer.ts +184 -0
  136. package/src/preview/syntax-theme.ts +112 -0
  137. package/src/preview/toc.ts +23 -0
  138. package/src/preview/types.ts +89 -0
@@ -0,0 +1,564 @@
1
+ import { Extension, Prec } from "@codemirror/state";
2
+ import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
3
+ import { syntaxTree } from "@codemirror/language";
4
+ import {
5
+ buildBlockTypeChange,
6
+ buildInlineFormatChange,
7
+ buildLinkChange,
8
+ buildListChange,
9
+ detectSelectionBlockType,
10
+ parseSelectedLink,
11
+ } from "./commands";
12
+ import { createSelectionToolbarElement } from "./menu";
13
+ import { computeSelectionToolbarLayout } from "./position";
14
+ import { selectionToolbarTheme } from "./theme";
15
+ import { resolveMardoraLocale } from "../i18n";
16
+ import { getSelectionToolbarMessages } from "./i18n";
17
+ import {
18
+ canActivateFromNativeSelection,
19
+ hasSelectionToolbarExcludedAncestor,
20
+ selectionOverlapsExcludedSyntaxNode,
21
+ } from "./activation";
22
+ import type { MardoraLocale } from "../i18n";
23
+ import type {
24
+ MardoraSelectionToolbarConfig,
25
+ SelectionToolbarAnchorRect,
26
+ SelectionToolbarBoundary,
27
+ SelectionToolbarActionId,
28
+ SelectionToolbarBlockType,
29
+ SelectionToolbarButton,
30
+ SelectionToolbarLinkState,
31
+ SelectionToolbarMenuState,
32
+ SelectionToolbarPaletteItem,
33
+ SelectionToolbarPanel,
34
+ TextCommandResult,
35
+ } from "./types";
36
+
37
+ type MardoraSelectionToolbarRuntimeConfig = MardoraSelectionToolbarConfig & {
38
+ inheritedLocale?: MardoraLocale;
39
+ };
40
+
41
+ const toolbarWidth = 448;
42
+ const toolbarHeight = 40;
43
+ const panelWidth = 336;
44
+ const blockTypePanelHeight = 300;
45
+ const linkPanelHeight = 138;
46
+ const palettePanelHeight = 72;
47
+ const popoverGap = 6;
48
+
49
+ function textColors(messages: ReturnType<typeof getSelectionToolbarMessages>): SelectionToolbarPaletteItem[] {
50
+ return [
51
+ { id: "default", label: messages.colors.defaultText, value: null },
52
+ { id: "gray", label: messages.colors.gray, value: "#71717a" },
53
+ { id: "red", label: messages.colors.red, value: "#dc2626" },
54
+ { id: "orange", label: messages.colors.orange, value: "#ea580c" },
55
+ { id: "yellow", label: messages.colors.yellow, value: "#ca8a04" },
56
+ { id: "green", label: messages.colors.green, value: "#16a34a" },
57
+ { id: "blue", label: messages.colors.blue, value: "#2563eb" },
58
+ { id: "purple", label: messages.colors.purple, value: "#7c3aed" },
59
+ ];
60
+ }
61
+
62
+ function highlightColors(messages: ReturnType<typeof getSelectionToolbarMessages>): SelectionToolbarPaletteItem[] {
63
+ return [
64
+ { id: "default", label: messages.colors.defaultHighlight, value: null },
65
+ { id: "yellow", label: messages.colors.yellowHighlight, value: "#fef08a" },
66
+ { id: "green", label: messages.colors.greenHighlight, value: "#bbf7d0" },
67
+ { id: "blue", label: messages.colors.blueHighlight, value: "#bfdbfe" },
68
+ { id: "pink", label: messages.colors.pinkHighlight, value: "#fbcfe8" },
69
+ { id: "purple", label: messages.colors.purpleHighlight, value: "#ddd6fe" },
70
+ ];
71
+ }
72
+
73
+ function blockTypeOptions(messages: ReturnType<typeof getSelectionToolbarMessages>): SelectionToolbarMenuState["blockTypes"] {
74
+ return [
75
+ { type: "text", label: messages.blockTypes.text, icon: "text-align-start" },
76
+ { type: "heading-1", label: messages.blockTypes["heading-1"], icon: "heading-1" },
77
+ { type: "heading-2", label: messages.blockTypes["heading-2"], icon: "heading-2" },
78
+ { type: "heading-3", label: messages.blockTypes["heading-3"], icon: "heading-3" },
79
+ { type: "heading-4", label: messages.blockTypes["heading-4"], icon: "heading-4" },
80
+ { type: "heading-5", label: messages.blockTypes["heading-5"], icon: "heading-5" },
81
+ { type: "heading-6", label: messages.blockTypes["heading-6"], icon: "heading-6" },
82
+ ];
83
+ }
84
+
85
+ function blockButton(blockType: SelectionToolbarBlockType, messages: ReturnType<typeof getSelectionToolbarMessages>): SelectionToolbarButton {
86
+ return blockType === "text"
87
+ ? { id: "block-type", label: messages.buttons.blockType, icon: "text-align-start" }
88
+ : {
89
+ id: "block-type",
90
+ label: messages.buttons.blockType,
91
+ icon: blockType,
92
+ text: `H${blockType.slice("heading-".length)}`,
93
+ };
94
+ }
95
+
96
+ function defaultButtons(
97
+ messages: ReturnType<typeof getSelectionToolbarMessages>,
98
+ blockType: SelectionToolbarBlockType
99
+ ): SelectionToolbarButton[] {
100
+ return [
101
+ blockButton(blockType, messages),
102
+ { id: "bold", label: messages.buttons.bold, icon: "bold" },
103
+ { id: "italic", label: messages.buttons.italic, icon: "italic" },
104
+ { id: "strike", label: messages.buttons.strike, icon: "strikethrough" },
105
+ { id: "underline", label: messages.buttons.underline, icon: "underline" },
106
+ { id: "code", label: messages.buttons.code, icon: "code" },
107
+ { id: "highlight", label: messages.buttons.highlight, icon: "highlighter" },
108
+ { id: "color", label: messages.buttons.color, icon: "baseline" },
109
+ { id: "link", label: messages.buttons.link, icon: "link" },
110
+ { id: "ordered-list", label: messages.buttons.orderedList, icon: "list-ordered" },
111
+ { id: "unordered-list", label: messages.buttons.unorderedList, icon: "list" },
112
+ { id: "task-list", label: messages.buttons.taskList, icon: "list-todo" },
113
+ ];
114
+ }
115
+
116
+ function isValidUrl(value: string): boolean {
117
+ return /^(https?:\/\/|www\.)[^\s]+$/i.test(value);
118
+ }
119
+
120
+ function normalizedUrl(value: string): string {
121
+ return value.startsWith("http") ? value : `https://${value}`;
122
+ }
123
+
124
+ function floatingSizeForPanel(panel: SelectionToolbarPanel): { width: number; height: number } {
125
+ if (panel === "toolbar" || panel === "block-type") return { width: toolbarWidth, height: toolbarHeight };
126
+ if (panel === "link") return { width: panelWidth, height: linkPanelHeight };
127
+ return { width: panelWidth, height: palettePanelHeight };
128
+ }
129
+
130
+ function boundaryFromRect(rect: DOMRect): SelectionToolbarBoundary {
131
+ return {
132
+ left: rect.left,
133
+ right: rect.right,
134
+ top: rect.top,
135
+ bottom: rect.bottom,
136
+ };
137
+ }
138
+
139
+ class SelectionToolbarViewPlugin {
140
+ private menu: HTMLElement | null = null;
141
+ private panel: SelectionToolbarPanel = "toolbar";
142
+ private savedRange: { from: number; to: number; text: string } | null = null;
143
+ private selectionAnchor: SelectionToolbarAnchorRect | null = null;
144
+ private link: SelectionToolbarLinkState = { title: "", url: "", canRemove: false };
145
+ private renderVersion = 0;
146
+
147
+ private readonly messages;
148
+
149
+ constructor(
150
+ private readonly view: EditorView,
151
+ private readonly config: MardoraSelectionToolbarRuntimeConfig
152
+ ) {
153
+ const locale = resolveMardoraLocale(config.locale ?? config.inheritedLocale);
154
+ this.messages = getSelectionToolbarMessages(locale);
155
+ this.view.dom.ownerDocument.addEventListener("mousedown", this.handleDocumentMouseDown, true);
156
+ this.view.dom.ownerDocument.addEventListener("selectionchange", this.handleDocumentSelectionChange);
157
+ this.updateState();
158
+ }
159
+
160
+ update(update: ViewUpdate): void {
161
+ if (update.docChanged || update.selectionSet || update.viewportChanged || update.focusChanged) {
162
+ this.updateState();
163
+ }
164
+ }
165
+
166
+ destroy(): void {
167
+ this.view.dom.ownerDocument.removeEventListener("mousedown", this.handleDocumentMouseDown, true);
168
+ this.view.dom.ownerDocument.removeEventListener("selectionchange", this.handleDocumentSelectionChange);
169
+ this.removeMenu();
170
+ }
171
+
172
+ closeFromKeyboard(): boolean {
173
+ if (!this.menu) return false;
174
+ if (this.panel !== "toolbar") {
175
+ this.panel = "toolbar";
176
+ this.renderMenu();
177
+ return true;
178
+ }
179
+ this.close();
180
+ return true;
181
+ }
182
+
183
+ private readonly handleDocumentMouseDown = (event: MouseEvent): void => {
184
+ if (!this.menu) return;
185
+ const target = event.target;
186
+ if (target instanceof Node && (this.menu.contains(target) || this.view.dom.contains(target))) return;
187
+ this.close();
188
+ };
189
+
190
+ private readonly handleDocumentSelectionChange = (): void => {
191
+ const doc = this.view.dom.ownerDocument;
192
+ const activeElement = doc.activeElement;
193
+ if (this.menu && activeElement instanceof Node && this.menu.contains(activeElement)) return;
194
+ if (!this.view.hasFocus || this.view.dom.classList.contains("cm-mardora-slash-open")) return;
195
+
196
+ const selection = doc.getSelection();
197
+ if (!selection || !selection.anchorNode || !selection.focusNode) return;
198
+ if (
199
+ !canActivateFromNativeSelection({
200
+ editorSelectionEmpty: this.view.state.selection.main.empty,
201
+ nativeSelectionCollapsed: selection.isCollapsed,
202
+ anchorInEditor: this.view.contentDOM.contains(selection.anchorNode),
203
+ focusInEditor: this.view.contentDOM.contains(selection.focusNode),
204
+ rangeCount: selection.rangeCount,
205
+ anchorExcluded: hasSelectionToolbarExcludedAncestor(selection.anchorNode, this.view.contentDOM),
206
+ focusExcluded: hasSelectionToolbarExcludedAncestor(selection.focusNode, this.view.contentDOM),
207
+ })
208
+ ) {
209
+ return;
210
+ }
211
+
212
+ let anchor: number;
213
+ let head: number;
214
+ try {
215
+ anchor = this.view.posAtDOM(selection.anchorNode, selection.anchorOffset);
216
+ head = this.view.posAtDOM(selection.focusNode, selection.focusOffset);
217
+ } catch {
218
+ return;
219
+ }
220
+ const from = Math.min(anchor, head);
221
+ const to = Math.max(anchor, head);
222
+ if (from === to) return;
223
+ if (this.selectionTouchesExcludedSyntax(from, to)) return;
224
+
225
+ const rect = selection.getRangeAt(0).getBoundingClientRect();
226
+ this.savedRange = {
227
+ from,
228
+ to,
229
+ text: this.view.state.sliceDoc(from, to),
230
+ };
231
+ this.selectionAnchor = {
232
+ left: rect.left,
233
+ right: rect.right,
234
+ top: rect.top,
235
+ bottom: rect.bottom,
236
+ };
237
+ this.renderMenu();
238
+ };
239
+
240
+ private updateState(): void {
241
+ const selection = this.view.state.selection.main;
242
+ if (this.view.dom.classList.contains("cm-mardora-slash-open")) {
243
+ this.close();
244
+ return;
245
+ }
246
+ if (!this.view.hasFocus) {
247
+ if (this.isMenuActive() && this.savedRange) return;
248
+ this.close();
249
+ return;
250
+ }
251
+ if (selection.empty) {
252
+ this.close();
253
+ return;
254
+ }
255
+ if (this.selectionTouchesExcludedSyntax(selection.from, selection.to)) {
256
+ this.close();
257
+ return;
258
+ }
259
+
260
+ this.savedRange = {
261
+ from: selection.from,
262
+ to: selection.to,
263
+ text: this.view.state.sliceDoc(selection.from, selection.to),
264
+ };
265
+ this.selectionAnchor = null;
266
+ this.renderMenu();
267
+ }
268
+
269
+ private isMenuActive(): boolean {
270
+ const activeElement = this.view.dom.ownerDocument.activeElement;
271
+ return !!this.menu && activeElement instanceof Node && this.menu.contains(activeElement);
272
+ }
273
+
274
+ private selectionTouchesExcludedSyntax(from: number, to: number): boolean {
275
+ let excluded = false;
276
+ syntaxTree(this.view.state).iterate({
277
+ from,
278
+ to,
279
+ enter: (node) => {
280
+ if (
281
+ selectionOverlapsExcludedSyntaxNode({
282
+ selectionFrom: from,
283
+ selectionTo: to,
284
+ nodeFrom: node.from,
285
+ nodeTo: node.to,
286
+ nodeName: node.name,
287
+ })
288
+ ) {
289
+ excluded = true;
290
+ return false;
291
+ }
292
+ return undefined;
293
+ },
294
+ });
295
+ return excluded;
296
+ }
297
+
298
+ private close(): void {
299
+ this.panel = "toolbar";
300
+ this.savedRange = null;
301
+ this.selectionAnchor = null;
302
+ this.removeMenu();
303
+ }
304
+
305
+ private removeMenu(): void {
306
+ this.renderVersion += 1;
307
+ this.detachMenu();
308
+ }
309
+
310
+ private detachMenu(): void {
311
+ this.menu?.remove();
312
+ this.menu = null;
313
+ }
314
+
315
+ private menuState(): SelectionToolbarMenuState {
316
+ const range = this.savedRange;
317
+ const blockType = range
318
+ ? detectSelectionBlockType({ doc: this.view.state.doc.toString(), from: range.from, to: range.to })
319
+ : "text";
320
+ return {
321
+ panel: this.panel,
322
+ buttons: defaultButtons(this.messages, blockType),
323
+ blockType,
324
+ blockTypes: blockTypeOptions(this.messages),
325
+ textColors: textColors(this.messages),
326
+ highlightColors: highlightColors(this.messages),
327
+ link: this.link,
328
+ messages: this.messages,
329
+ };
330
+ }
331
+
332
+ private renderMenu(): void {
333
+ const range = this.savedRange;
334
+ if (!range) return;
335
+ const renderVersion = ++this.renderVersion;
336
+ const floating = floatingSizeForPanel(this.panel);
337
+ this.detachMenu();
338
+ const anchorFromSelection = this.selectionAnchor;
339
+
340
+ this.view.requestMeasure({
341
+ read: (view) => {
342
+ const boundary = boundaryFromRect(view.dom.getBoundingClientRect());
343
+ if (anchorFromSelection) return { anchor: anchorFromSelection, boundary };
344
+ const from = view.coordsAtPos(range.from);
345
+ const to = view.coordsAtPos(range.to);
346
+ if (!from || !to) return null;
347
+ const anchor = {
348
+ left: Math.min(from.left, to.left),
349
+ right: Math.max(from.right, to.right),
350
+ top: Math.min(from.top, to.top),
351
+ bottom: Math.max(from.bottom, to.bottom),
352
+ };
353
+ return { anchor, boundary };
354
+ },
355
+ write: (measure) => {
356
+ if (renderVersion !== this.renderVersion || !measure) return;
357
+ const win = this.view.dom.ownerDocument.defaultView ?? window;
358
+ const layout = computeSelectionToolbarLayout({
359
+ anchor: measure.anchor,
360
+ viewport: { width: win.innerWidth, height: win.innerHeight },
361
+ boundary: measure.boundary,
362
+ floating,
363
+ });
364
+ this.menu = createSelectionToolbarElement(this.menuState(), {
365
+ onAction: (id) => this.handleAction(id),
366
+ onBlockType: (type) => this.applyBlockType(type),
367
+ onColor: (value) => this.applyColor(value),
368
+ onHighlight: (value) => this.applyHighlight(value),
369
+ onLinkInput: (field, value) => {
370
+ const next = { ...this.link, [field]: value };
371
+ delete next.error;
372
+ this.link = next;
373
+ },
374
+ onLinkSubmit: () => this.submitLink(),
375
+ onLinkCopy: () => void this.copyLink(),
376
+ onLinkOpen: () => this.openLink(),
377
+ onLinkRemove: () => this.removeLink(),
378
+ onCancelPanel: () => {
379
+ this.panel = "toolbar";
380
+ this.renderMenu();
381
+ },
382
+ });
383
+ this.menu.style.left = `${layout.left}px`;
384
+ this.menu.style.top = `${layout.top}px`;
385
+ this.menu.style.maxHeight = `${layout.maxHeight}px`;
386
+ this.menu.dataset.mardoraSelectionPlacement = layout.placement;
387
+ if (this.panel === "block-type") {
388
+ const opensDown = layout.placement === "bottom";
389
+ const available = opensDown
390
+ ? measure.boundary.bottom - (layout.top + toolbarHeight) - popoverGap
391
+ : layout.top - measure.boundary.top - popoverGap;
392
+ this.menu.style.setProperty(
393
+ "--mardora-selection-toolbar-popover-max-height",
394
+ `${Math.max(96, Math.min(blockTypePanelHeight, Math.floor(available)))}px`
395
+ );
396
+ }
397
+ this.view.dom.appendChild(this.menu);
398
+ },
399
+ });
400
+ }
401
+
402
+ private dispatchResult(result: TextCommandResult): void {
403
+ if (!this.isSavedRangeCurrent()) {
404
+ this.close();
405
+ return;
406
+ }
407
+
408
+ this.view.dispatch({
409
+ changes: result.changes,
410
+ selection: result.selection,
411
+ scrollIntoView: true,
412
+ });
413
+ this.view.focus();
414
+ this.close();
415
+ }
416
+
417
+ private isSavedRangeCurrent(): boolean {
418
+ if (!this.savedRange) return false;
419
+ return this.view.state.sliceDoc(this.savedRange.from, this.savedRange.to) === this.savedRange.text;
420
+ }
421
+
422
+ private handleAction(id: SelectionToolbarActionId): void {
423
+ const range = this.savedRange;
424
+ if (!range) return;
425
+ const doc = this.view.state.doc.toString();
426
+
427
+ if (id === "link") {
428
+ const parsed = parseSelectedLink(range.text);
429
+ this.link = { title: parsed.title || range.text, url: parsed.url, canRemove: parsed.kind === "markdown-link" };
430
+ this.panel = "link";
431
+ this.renderMenu();
432
+ return;
433
+ }
434
+
435
+ if (id === "block-type") {
436
+ this.panel = "block-type";
437
+ this.renderMenu();
438
+ return;
439
+ }
440
+
441
+ if (id === "color") {
442
+ this.panel = "color";
443
+ this.renderMenu();
444
+ return;
445
+ }
446
+
447
+ if (id === "highlight") {
448
+ this.panel = "highlight";
449
+ this.renderMenu();
450
+ return;
451
+ }
452
+
453
+ if (id === "ordered-list" || id === "unordered-list" || id === "task-list") {
454
+ const kind = id === "ordered-list" ? "ordered" : id === "task-list" ? "task" : "unordered";
455
+ this.dispatchResult(buildListChange({ doc, from: range.from, to: range.to, kind }));
456
+ return;
457
+ }
458
+
459
+ if (id === "underline") {
460
+ this.dispatchResult(buildInlineFormatChange({ doc, from: range.from, to: range.to, htmlTag: "u" }));
461
+ return;
462
+ }
463
+
464
+ const marker = id === "bold" ? "**" : id === "italic" ? "*" : id === "strike" ? "~~" : id === "code" ? "`" : null;
465
+ if (!marker) return;
466
+ const result = buildInlineFormatChange({ doc, from: range.from, to: range.to, marker });
467
+ this.dispatchResult(result);
468
+ }
469
+
470
+ private applyBlockType(type: SelectionToolbarBlockType): void {
471
+ const range = this.savedRange;
472
+ if (!range) return;
473
+ const level = type === "text" ? 0 : Number(type.slice("heading-".length));
474
+ if (level < 0 || level > 6) return;
475
+ this.dispatchResult(
476
+ buildBlockTypeChange({
477
+ doc: this.view.state.doc.toString(),
478
+ from: range.from,
479
+ to: range.to,
480
+ level: level as 0 | 1 | 2 | 3 | 4 | 5 | 6,
481
+ })
482
+ );
483
+ }
484
+
485
+ private applyColor(value: string | null): void {
486
+ const range = this.savedRange;
487
+ if (!range) return;
488
+ this.dispatchResult(
489
+ buildInlineFormatChange({
490
+ doc: this.view.state.doc.toString(),
491
+ from: range.from,
492
+ to: range.to,
493
+ spanStyle: { property: "color", value: value ?? "" },
494
+ clear: value === null,
495
+ })
496
+ );
497
+ }
498
+
499
+ private applyHighlight(value: string | null): void {
500
+ const range = this.savedRange;
501
+ if (!range) return;
502
+ const result = value
503
+ ? buildInlineFormatChange({
504
+ doc: this.view.state.doc.toString(),
505
+ from: range.from,
506
+ to: range.to,
507
+ spanStyle: { property: "background-color", value },
508
+ })
509
+ : buildInlineFormatChange({ doc: this.view.state.doc.toString(), from: range.from, to: range.to, marker: "==" });
510
+ this.dispatchResult(result);
511
+ }
512
+
513
+ private submitLink(): void {
514
+ const range = this.savedRange;
515
+ if (!range) return;
516
+ if (!this.link.url || !isValidUrl(this.link.url)) {
517
+ this.link = { ...this.link, error: this.messages.link.invalid };
518
+ this.renderMenu();
519
+ return;
520
+ }
521
+ const title = this.link.title || range.text || this.link.url;
522
+ this.dispatchResult(buildLinkChange({ from: range.from, to: range.to, title, url: this.link.url }));
523
+ }
524
+
525
+ private removeLink(): void {
526
+ const range = this.savedRange;
527
+ if (!range) return;
528
+ this.dispatchResult(
529
+ buildLinkChange({ from: range.from, to: range.to, title: this.link.title || range.text, url: "", remove: true })
530
+ );
531
+ }
532
+
533
+ private async copyLink(): Promise<void> {
534
+ if (!this.link.url) return;
535
+ await this.view.dom.ownerDocument.defaultView?.navigator.clipboard?.writeText(this.link.url);
536
+ this.link = { ...this.link, copied: true };
537
+ this.renderMenu();
538
+ }
539
+
540
+ private openLink(): void {
541
+ if (!this.link.url || !isValidUrl(this.link.url)) return;
542
+ this.view.dom.ownerDocument.defaultView?.open(normalizedUrl(this.link.url), "_blank", "noopener,noreferrer");
543
+ }
544
+ }
545
+
546
+ export function selectionToolbar(config: MardoraSelectionToolbarRuntimeConfig = {}): Extension[] {
547
+ if (config.enabled === false) return [];
548
+ const plugin = ViewPlugin.define((view) => new SelectionToolbarViewPlugin(view, config));
549
+ return [
550
+ selectionToolbarTheme,
551
+ plugin,
552
+ Prec.highest(
553
+ EditorView.domEventHandlers({
554
+ keydown(event, view) {
555
+ const value = view.plugin(plugin);
556
+ if (!value || event.key !== "Escape") return false;
557
+ const handled = value.closeFromKeyboard();
558
+ if (handled) event.preventDefault();
559
+ return handled;
560
+ },
561
+ })
562
+ ),
563
+ ];
564
+ }