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,806 @@
1
+ import { Decoration, EditorView, KeyBinding, WidgetType } from "@codemirror/view";
2
+ import { syntaxTree } from "@codemirror/language";
3
+ import { DecorationContext, DecorationPlugin } from "../editor/plugin";
4
+ import { createTheme } from "../editor";
5
+ import { consumeMediaLightboxTrigger, openMediaLightbox } from "../editor/media-lightbox";
6
+ import { mediaLightboxTheme } from "../editor/media-lightbox-theme";
7
+ import { createMardoraIcon } from "../editor/icons";
8
+ import type { PreviewContext } from "../preview/types";
9
+ import { SyntaxNode } from "@lezer/common";
10
+
11
+ /**
12
+ * Mark decorations for image syntax elements
13
+ */
14
+ const imageMarkDecorations = {
15
+ "image-block": Decoration.line({ class: "cm-mardora-image-block" }),
16
+ "image-marker": Decoration.mark({ class: "cm-mardora-image-marker" }),
17
+ "image-alt": Decoration.mark({ class: "cm-mardora-image-alt" }),
18
+ "image-url": Decoration.mark({ class: "cm-mardora-image-url" }),
19
+ "image-hidden": Decoration.mark({ class: "cm-mardora-image-hidden" }),
20
+ };
21
+
22
+ export interface ParsedImageMarkdown {
23
+ readonly alt: string;
24
+ readonly url: string;
25
+ readonly title?: string;
26
+ readonly width?: number;
27
+ }
28
+
29
+ export interface ImageMarkdownChange {
30
+ readonly from: number;
31
+ readonly to: number;
32
+ readonly insert: string;
33
+ }
34
+
35
+ interface ImageMarkdownRange {
36
+ readonly from: number;
37
+ readonly imageTo: number;
38
+ readonly to: number;
39
+ readonly markdown: string;
40
+ readonly width?: number;
41
+ readonly widthAttrFrom?: number;
42
+ readonly widthAttrTo?: number;
43
+ }
44
+
45
+ const imageWidthAttributePattern = /^(\s*)\{width=(\d+)\}/;
46
+ const minImageWidth = 120;
47
+
48
+ /**
49
+ * Parse image markdown to extract alt text, URL, optional title, and optional pixel width.
50
+ * Format: ![alt text](url "optional title"){width=420}
51
+ */
52
+ export function parseImageMarkdown(content: string): ParsedImageMarkdown | null {
53
+ const trimmed = content.trim();
54
+ const widthMatch = trimmed.match(/\{width=(\d+)\}$/);
55
+ const markdown = widthMatch ? trimmed.slice(0, widthMatch.index).trimEnd() : trimmed;
56
+
57
+ // Regex to match: ![alt](url) or ![alt](url "title")
58
+ const match = markdown.match(/^!\[([^\]]*)\]\(([^"\s)]+)(?:\s+"([^"]*)")?\s*\)$/);
59
+ if (!match) return null;
60
+
61
+ const result: ParsedImageMarkdown = {
62
+ alt: match[1] || "",
63
+ url: match[2]!,
64
+ ...(widthMatch ? { width: Number(widthMatch[1]) } : {}),
65
+ };
66
+
67
+ if (match[3] !== undefined) {
68
+ return { ...result, title: match[3] };
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ function readImageMarkdownRange(doc: string, from: number, imageTo: number): ImageMarkdownRange {
75
+ const lineEnd = doc.indexOf("\n", imageTo);
76
+ const scanTo = lineEnd === -1 ? doc.length : lineEnd;
77
+ const afterImage = doc.slice(imageTo, scanTo);
78
+ const widthMatch = afterImage.match(imageWidthAttributePattern);
79
+ if (widthMatch) {
80
+ const widthAttrTo = imageTo + widthMatch[0].length;
81
+ return {
82
+ from,
83
+ imageTo,
84
+ to: widthAttrTo,
85
+ markdown: doc.slice(from, widthAttrTo),
86
+ width: Number(widthMatch[2]),
87
+ widthAttrFrom: imageTo,
88
+ widthAttrTo,
89
+ };
90
+ }
91
+
92
+ return {
93
+ from,
94
+ imageTo,
95
+ to: imageTo,
96
+ markdown: doc.slice(from, imageTo),
97
+ };
98
+ }
99
+
100
+ export function resolveImageWidthChange(input: {
101
+ readonly doc: string;
102
+ readonly from: number;
103
+ readonly to: number;
104
+ readonly width: number;
105
+ }): ImageMarkdownChange {
106
+ const range = readImageMarkdownRange(input.doc, input.from, input.to);
107
+ const width = Math.max(1, Math.round(input.width));
108
+ return {
109
+ from: range.widthAttrFrom ?? input.to,
110
+ to: range.widthAttrTo ?? input.to,
111
+ insert: `{width=${width}}`,
112
+ };
113
+ }
114
+
115
+ export function resolveImageResetWidthChange(input: {
116
+ readonly doc: string;
117
+ readonly from: number;
118
+ readonly to: number;
119
+ }): ImageMarkdownChange | null {
120
+ const range = readImageMarkdownRange(input.doc, input.from, input.to);
121
+ if (range.widthAttrFrom === undefined || range.widthAttrTo === undefined) return null;
122
+ return { from: range.widthAttrFrom, to: range.widthAttrTo, insert: "" };
123
+ }
124
+
125
+ export function resolveImageDeleteChange(input: {
126
+ readonly doc: string;
127
+ readonly from: number;
128
+ readonly to: number;
129
+ }): ImageMarkdownChange {
130
+ const range = readImageMarkdownRange(input.doc, input.from, input.to);
131
+ return { from: input.from, to: range.to, insert: "" };
132
+ }
133
+
134
+ /**
135
+ * Widget to render an image with optional caption using figure/figcaption
136
+ * Placed below the markdown syntax as a widget decoration
137
+ */
138
+ class ImageWidget extends WidgetType {
139
+ constructor(
140
+ readonly url: string,
141
+ readonly alt: string,
142
+ readonly from: number,
143
+ readonly imageTo: number,
144
+ readonly to: number,
145
+ readonly width?: number,
146
+ readonly title?: string
147
+ ) {
148
+ super();
149
+ }
150
+
151
+ override eq(other: ImageWidget): boolean {
152
+ return (
153
+ other.url === this.url &&
154
+ other.alt === this.alt &&
155
+ other.from === this.from &&
156
+ other.imageTo === this.imageTo &&
157
+ other.to === this.to &&
158
+ other.width === this.width &&
159
+ other.title === this.title
160
+ );
161
+ }
162
+
163
+ toDOM(view: EditorView) {
164
+ // Create figure element for semantic image container
165
+ const figure = document.createElement("figure");
166
+ figure.className = "cm-mardora-image-figure cm-mardora-media-preview";
167
+ figure.setAttribute("role", "figure");
168
+ figure.style.cursor = "pointer";
169
+ if (this.width) {
170
+ figure.style.width = `${this.width}px`;
171
+ }
172
+ if (this.title) {
173
+ figure.setAttribute("aria-label", this.title);
174
+ }
175
+
176
+ const activateFigure = () => figure.classList.add("cm-mardora-image-figure-active");
177
+ const deactivateFigure = () => {
178
+ if (!figure.matches(":focus-within")) figure.classList.remove("cm-mardora-image-figure-active");
179
+ };
180
+ figure.addEventListener("pointerenter", activateFigure);
181
+ figure.addEventListener("mouseenter", activateFigure);
182
+ figure.addEventListener("focusin", activateFigure);
183
+ figure.addEventListener("pointerleave", deactivateFigure);
184
+ figure.addEventListener("mouseleave", deactivateFigure);
185
+ figure.addEventListener("focusout", deactivateFigure);
186
+
187
+ // Click handler to select the raw markdown text
188
+ figure.addEventListener("click", (e) => {
189
+ e.preventDefault();
190
+ e.stopPropagation();
191
+ view.dispatch({
192
+ selection: { anchor: this.from, head: this.to },
193
+ scrollIntoView: true,
194
+ });
195
+ view.focus();
196
+ });
197
+
198
+ // Create image element with accessibility attributes
199
+ const img = document.createElement("img");
200
+ img.className = "cm-mardora-image";
201
+ img.src = this.url;
202
+ img.alt = this.alt;
203
+ img.setAttribute("loading", "lazy");
204
+ img.setAttribute("decoding", "async");
205
+ if (this.title) {
206
+ img.title = this.title;
207
+ }
208
+ if (this.width) {
209
+ img.style.width = "100%";
210
+ }
211
+
212
+ // Handle image loading error
213
+ img.onerror = () => {
214
+ img.style.display = "none";
215
+ const errorSpan = document.createElement("span");
216
+ errorSpan.className = "cm-mardora-image-error";
217
+ errorSpan.setAttribute("role", "alert");
218
+ errorSpan.textContent = `[Image not found: ${this.alt || this.url}]`;
219
+ figure.appendChild(errorSpan);
220
+ };
221
+
222
+ figure.appendChild(img);
223
+ figure.appendChild(this.createToolbar(view, figure));
224
+ figure.appendChild(this.createResizeHandle(view, figure, "left"));
225
+ figure.appendChild(this.createResizeHandle(view, figure, "right"));
226
+
227
+ // Add figcaption if title exists
228
+ if (this.title) {
229
+ const figcaption = document.createElement("figcaption");
230
+ figcaption.className = "cm-mardora-image-caption";
231
+ figcaption.textContent = this.title;
232
+ figure.appendChild(figcaption);
233
+ }
234
+
235
+ return figure;
236
+ }
237
+
238
+ override ignoreEvent(event: Event) {
239
+ return !["click", "mousedown", "mouseup", "mousemove", "pointerdown", "pointermove", "pointerup"].includes(
240
+ event.type
241
+ );
242
+ }
243
+
244
+ private createToolbar(view: EditorView, figure: HTMLElement): HTMLElement {
245
+ const toolbar = figure.ownerDocument.createElement("div");
246
+ toolbar.className = "cm-mardora-image-toolbar";
247
+ toolbar.appendChild(
248
+ this.createToolButton(figure.ownerDocument, "还原默认大小", "rotate-ccw", () => {
249
+ const change = resolveImageResetWidthChange({
250
+ doc: view.state.doc.toString(),
251
+ from: this.from,
252
+ to: this.imageTo,
253
+ });
254
+ if (!change) return;
255
+ view.dispatch({ changes: change });
256
+ view.focus();
257
+ })
258
+ );
259
+ toolbar.appendChild(
260
+ this.createToolButton(figure.ownerDocument, "放大查看图片", "maximize-2", () => {
261
+ openMediaLightbox(figure.ownerDocument, {
262
+ content: {
263
+ kind: "image",
264
+ src: this.url,
265
+ alt: this.alt,
266
+ ...(this.title === undefined ? {} : { title: this.title }),
267
+ },
268
+ returnFocus: toolbar.querySelector(".cm-mardora-image-preview-button") as HTMLElement | null,
269
+ });
270
+ }, "cm-mardora-image-preview-button")
271
+ );
272
+ toolbar.appendChild(
273
+ this.createToolButton(figure.ownerDocument, "删除图片", "trash-2", () => {
274
+ view.dispatch({
275
+ changes: resolveImageDeleteChange({
276
+ doc: view.state.doc.toString(),
277
+ from: this.from,
278
+ to: this.imageTo,
279
+ }),
280
+ });
281
+ view.focus();
282
+ })
283
+ );
284
+ return toolbar;
285
+ }
286
+
287
+ private createToolButton(
288
+ ownerDocument: Document,
289
+ label: string,
290
+ iconName: string,
291
+ onActivate: () => void,
292
+ extraClass = ""
293
+ ): HTMLButtonElement {
294
+ const button = ownerDocument.createElement("button");
295
+ button.type = "button";
296
+ button.className = `cm-mardora-image-tool-button${extraClass ? ` ${extraClass}` : ""}`;
297
+ button.setAttribute("aria-label", label);
298
+ button.title = label;
299
+ const icon = createMardoraIcon(iconName);
300
+ if (icon) button.appendChild(icon);
301
+ button.addEventListener("click", (event) => {
302
+ consumeMediaLightboxTrigger(event);
303
+ onActivate();
304
+ });
305
+ return button;
306
+ }
307
+
308
+ private createResizeHandle(view: EditorView, figure: HTMLElement, side: "left" | "right"): HTMLElement {
309
+ const handle = figure.ownerDocument.createElement("span");
310
+ handle.className = `cm-mardora-image-resize-handle cm-mardora-image-resize-handle-${side}`;
311
+ handle.setAttribute("role", "separator");
312
+ handle.setAttribute("aria-label", side === "left" ? "向左拖拽调整图片宽度" : "向右拖拽调整图片宽度");
313
+ if (figure.ownerDocument.defaultView?.PointerEvent) {
314
+ handle.addEventListener("pointerdown", (event) => this.startResize(event, view, figure, side));
315
+ } else {
316
+ handle.addEventListener("mousedown", (event) => this.startResize(event, view, figure, side));
317
+ }
318
+ return handle;
319
+ }
320
+
321
+ private startResize(event: PointerEvent | MouseEvent, view: EditorView, figure: HTMLElement, side: "left" | "right") {
322
+ consumeMediaLightboxTrigger(event);
323
+ const ownerDocument = figure.ownerDocument;
324
+ const startX = event.clientX;
325
+ const startWidth = Math.max(minImageWidth, figure.getBoundingClientRect().width || this.width || minImageWidth);
326
+ const maxWidth = resolveImageMaxWidth(view, figure);
327
+ const direction = side === "right" ? 1 : -1;
328
+ const image = figure.querySelector("img");
329
+ if (image) image.style.width = "100%";
330
+ let nextWidth = startWidth;
331
+
332
+ const moveEvent = event.type.startsWith("pointer") ? "pointermove" : "mousemove";
333
+ const upEvent = event.type.startsWith("pointer") ? "pointerup" : "mouseup";
334
+ const onMove = (move: Event) => {
335
+ const pointer = move as PointerEvent | MouseEvent;
336
+ consumeMediaLightboxTrigger(pointer);
337
+ nextWidth = clampImageWidth(startWidth + (pointer.clientX - startX) * direction, maxWidth);
338
+ figure.style.width = `${nextWidth}px`;
339
+ };
340
+ const onUp = (up: Event) => {
341
+ consumeMediaLightboxTrigger(up);
342
+ ownerDocument.removeEventListener(moveEvent, onMove);
343
+ ownerDocument.removeEventListener(upEvent, onUp);
344
+ view.dispatch({
345
+ changes: resolveImageWidthChange({
346
+ doc: view.state.doc.toString(),
347
+ from: this.from,
348
+ to: this.imageTo,
349
+ width: nextWidth,
350
+ }),
351
+ });
352
+ view.focus();
353
+ };
354
+
355
+ ownerDocument.addEventListener(moveEvent, onMove);
356
+ ownerDocument.addEventListener(upEvent, onUp);
357
+ }
358
+ }
359
+
360
+ function clampImageWidth(width: number, maxWidth: number): number {
361
+ return Math.max(minImageWidth, Math.min(Math.round(width), maxWidth));
362
+ }
363
+
364
+ function resolveImageMaxWidth(view: EditorView, figure: HTMLElement): number {
365
+ const content = figure.closest(".cm-content") ?? view.contentDOM ?? view.dom;
366
+ const width = content.getBoundingClientRect().width;
367
+ return Math.max(minImageWidth, Math.round(width || figure.getBoundingClientRect().width || 800));
368
+ }
369
+
370
+ /**
371
+ * ImagePlugin - Decorates and renders images in markdown
372
+ *
373
+ * Supports the full image syntax: ![alt text](url "optional title")
374
+ * - Shows image widget below the node when cursor is not in range
375
+ * - Hides the markdown syntax when cursor is not in range
376
+ * - Shows raw markdown when cursor is in the image syntax
377
+ * - Uses figure/figcaption for semantic HTML with accessibility attributes
378
+ */
379
+ export class ImagePlugin extends DecorationPlugin {
380
+ readonly name = "image";
381
+ readonly version = "1.0.0";
382
+ override decorationPriority = 25;
383
+ override readonly requiredNodes = ["Image"] as const;
384
+
385
+ constructor() {
386
+ super();
387
+ }
388
+
389
+ /**
390
+ * Plugin theme
391
+ */
392
+ override get theme() {
393
+ return theme;
394
+ }
395
+
396
+ /**
397
+ * Keyboard shortcuts for image formatting
398
+ */
399
+ override getKeymap(): KeyBinding[] {
400
+ return [
401
+ {
402
+ key: "Mod-Shift-i",
403
+ run: (view) => this.toggleImage(view),
404
+ preventDefault: true,
405
+ },
406
+ ];
407
+ }
408
+
409
+ /**
410
+ * URL regex pattern
411
+ */
412
+ private readonly urlPattern = /^(https?:\/\/|www\.)[^\s]+$/i;
413
+
414
+ /**
415
+ * Toggle image on selection
416
+ * - If text selected and is a URL: ![Alt Text](url) with cursor in brackets
417
+ * - If text selected (not URL): ![text]() with cursor in parentheses
418
+ * - If nothing selected: ![Alt Text]() with cursor in parentheses
419
+ * - If already an image: remove syntax, leave just the URL
420
+ */
421
+ private toggleImage(view: EditorView): boolean {
422
+ const { state } = view;
423
+ const { from, to, empty } = state.selection.main;
424
+ const selectedText = state.sliceDoc(from, to);
425
+
426
+ // Check if selection is already an image ![alt](url)
427
+ const imageMatch = selectedText.match(/^!\[([^\]]*)\]\(([^)]*)\)$/);
428
+ if (imageMatch) {
429
+ // Already an image - extract just the URL and replace
430
+ const imageUrl = imageMatch[2] || "";
431
+ view.dispatch({
432
+ changes: { from, to, insert: imageUrl },
433
+ selection: { anchor: from, head: from + imageUrl.length },
434
+ });
435
+ return true;
436
+ }
437
+
438
+ // Check if we're inside an image by looking at surrounding context
439
+ const lineStart = state.doc.lineAt(from).from;
440
+ const lineEnd = state.doc.lineAt(to).to;
441
+ const lineText = state.sliceDoc(lineStart, lineEnd);
442
+
443
+ // Find image pattern in line that contains the selection
444
+ const imageRegex = /!\[([^\]]*)\]\(([^)]*)\)/g;
445
+ let match;
446
+ while ((match = imageRegex.exec(lineText)) !== null) {
447
+ const matchFrom = lineStart + match.index;
448
+ const matchTo = matchFrom + match[0].length;
449
+
450
+ // Check if selection is within this image
451
+ if (from >= matchFrom && to <= matchTo) {
452
+ // Remove the image syntax, leave just the URL
453
+ const imageUrl = match[2] || "";
454
+ view.dispatch({
455
+ changes: { from: matchFrom, to: matchTo, insert: imageUrl },
456
+ selection: { anchor: matchFrom, head: matchFrom + imageUrl.length },
457
+ });
458
+ return true;
459
+ }
460
+ }
461
+
462
+ if (empty) {
463
+ // No selection - insert ![Alt Text]() and place cursor in parentheses
464
+ const defaultAlt = "Alt Text";
465
+ const newText = `![${defaultAlt}]()`;
466
+ view.dispatch({
467
+ changes: { from, insert: newText },
468
+ selection: { anchor: from + defaultAlt.length + 4 }, // After ![Alt Text](
469
+ });
470
+ } else if (this.urlPattern.test(selectedText)) {
471
+ // Selected text is a URL - put it in parentheses with default alt text
472
+ const defaultAlt = "Alt Text";
473
+ const newText = `![${defaultAlt}](${selectedText})`;
474
+ view.dispatch({
475
+ changes: { from, to, insert: newText },
476
+ selection: { anchor: from + 2, head: from + 2 + defaultAlt.length }, // Select "Alt Text"
477
+ });
478
+ } else {
479
+ // Selected text is not a URL - use as alt text, cursor in parentheses
480
+ const newText = `![${selectedText}]()`;
481
+ view.dispatch({
482
+ changes: { from, to, insert: newText },
483
+ selection: { anchor: from + selectedText.length + 4 }, // After ![text](
484
+ });
485
+ }
486
+
487
+ return true;
488
+ }
489
+
490
+ buildDecorations(ctx: DecorationContext): void {
491
+ const { view, decorations } = ctx;
492
+ const tree = syntaxTree(view.state);
493
+
494
+ tree.iterate({
495
+ enter: (node) => {
496
+ const { from, to, name } = node;
497
+
498
+ // Handle Image nodes
499
+ if (name === "Image") {
500
+ const imageRange = readImageMarkdownRange(view.state.doc.toString(), from, to);
501
+ const parsed = parseImageMarkdown(imageRange.markdown);
502
+
503
+ if (!parsed) return;
504
+
505
+ const cursorInRange = ctx.selectionOverlapsRange(from, imageRange.to);
506
+
507
+ // Add line decoration for image
508
+ decorations.push(imageMarkDecorations["image-block"].range(from));
509
+
510
+ // Always add the image widget below the node
511
+ decorations.push(
512
+ Decoration.widget({
513
+ widget: new ImageWidget(parsed.url, parsed.alt, from, to, imageRange.to, parsed.width, parsed.title),
514
+ side: 1, // Place after the position
515
+ block: false, // Don't create a new line
516
+ }).range(imageRange.to)
517
+ );
518
+
519
+ if (cursorInRange) {
520
+ // Cursor in range: show raw markdown with styling
521
+ this.decorateRawImage(node.node, decorations, view);
522
+ } else {
523
+ // Cursor out of range: hide the raw markdown text
524
+ decorations.push(imageMarkDecorations["image-hidden"].range(from, imageRange.to));
525
+ }
526
+ }
527
+ },
528
+ });
529
+ }
530
+
531
+ /**
532
+ * Decorate raw image markdown when cursor is in range
533
+ */
534
+ private decorateRawImage(
535
+ node: SyntaxNode,
536
+ decorations: import("@codemirror/state").Range<Decoration>[],
537
+ view: import("@codemirror/view").EditorView
538
+ ): void {
539
+ // Find and style child nodes
540
+ for (let child = node.firstChild; child; child = child.nextSibling) {
541
+ if (child.name === "URL") {
542
+ decorations.push(imageMarkDecorations["image-url"].range(child.from, child.to));
543
+ }
544
+ }
545
+
546
+ // Style the markers (! [ ] ( ))
547
+ const content = view.state.sliceDoc(node.from, node.to);
548
+ const bangBracket = node.from; // Position of !
549
+ if (content.startsWith("![")) {
550
+ decorations.push(imageMarkDecorations["image-marker"].range(bangBracket, bangBracket + 2));
551
+ }
552
+
553
+ // Find and style closing bracket and parentheses
554
+ const altEnd = content.indexOf("](");
555
+ if (altEnd !== -1) {
556
+ const altStart = 2;
557
+ // Style alt text
558
+ if (altEnd > altStart) {
559
+ decorations.push(imageMarkDecorations["image-alt"].range(node.from + altStart, node.from + altEnd));
560
+ }
561
+ // Style ]( markers
562
+ decorations.push(imageMarkDecorations["image-marker"].range(node.from + altEnd, node.from + altEnd + 2));
563
+ // Style closing )
564
+ decorations.push(imageMarkDecorations["image-marker"].range(node.to - 1, node.to));
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Render image to HTML for preview mode using figure/figcaption
570
+ */
571
+ override renderToHTML(
572
+ node: SyntaxNode,
573
+ _children: string,
574
+ ctx: PreviewContext
575
+ ): string | null {
576
+ if (node.name !== "Image") return null;
577
+
578
+ const range = readImageMarkdownRange(ctx.doc, node.from, node.to);
579
+ const content = ctx.sliceDoc(node.from, range.to);
580
+ const parsed = parseImageMarkdown(content);
581
+ if (!parsed) return null;
582
+
583
+ const altAttr = ctx.sanitize(parsed.alt);
584
+ const titleAttr = parsed.title ? ` title="${ctx.sanitize(parsed.title)}"` : "";
585
+ const ariaLabel = parsed.title ? ` aria-label="${ctx.sanitize(parsed.title)}"` : "";
586
+ const widthStyle = parsed.width ? ` style="width: ${parsed.width}px;"` : "";
587
+
588
+ let html = `<figure class="cm-mardora-image-figure" role="figure"${ariaLabel}>`;
589
+ html += `<img class="cm-mardora-image" src="${ctx.sanitize(parsed.url)}" alt="${altAttr}"${titleAttr}${widthStyle} loading="lazy" decoding="async" />`;
590
+
591
+ if (parsed.title) {
592
+ html += `<figcaption class="cm-mardora-image-caption">${ctx.sanitize(parsed.title)}</figcaption>`;
593
+ }
594
+
595
+ html += `</figure>`;
596
+ return html;
597
+ }
598
+
599
+ override getPreviewConsumedTo(node: SyntaxNode, ctx: { doc: string }): number | null {
600
+ if (node.name !== "Image") return null;
601
+ return readImageMarkdownRange(ctx.doc, node.from, node.to).to;
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Theme for image styling
607
+ */
608
+ const imageTheme = createTheme({
609
+ default: {
610
+ ".cm-mardora-image-block br": {
611
+ display: "none",
612
+ },
613
+
614
+ // Image markers (! [ ] ( ))
615
+ ".cm-mardora-image-marker": {
616
+ color: "#6a737d",
617
+ fontFamily: "var(--font-jetbrains-mono, monospace)",
618
+ },
619
+
620
+ // Alt text
621
+ ".cm-mardora-image-alt": {
622
+ color: "#22863a",
623
+ fontStyle: "italic",
624
+ },
625
+
626
+ // URL
627
+ ".cm-mardora-image-url": {
628
+ color: "#0366d6",
629
+ textDecoration: "underline",
630
+ },
631
+
632
+ // Hidden markdown syntax (when cursor is not in range)
633
+ ".cm-mardora-image-hidden": {
634
+ display: "none",
635
+ },
636
+
637
+ // Figure container — fill the content width so the image aligns with
638
+ // surrounding text lines instead of shrinking to its intrinsic size and
639
+ // leaving a gap on the right. Inline width (from {width=} / drag-resize)
640
+ // overrides this via the style attribute.
641
+ ".cm-mardora-image-figure": {
642
+ display: "flex",
643
+ flexDirection: "column",
644
+ alignItems: "start",
645
+ width: "100%",
646
+ maxWidth: "100%",
647
+ padding: "0",
648
+ },
649
+
650
+ ".cm-mardora-image-toolbar": {
651
+ position: "absolute",
652
+ top: "0.5rem",
653
+ right: "0.5rem",
654
+ display: "inline-flex",
655
+ alignItems: "center",
656
+ gap: "0.125rem",
657
+ padding: "0.1875rem",
658
+ border: "1px solid rgba(148, 163, 184, 0.4)",
659
+ borderRadius: "0.375rem",
660
+ backgroundColor: "rgba(255, 255, 255, 0.9)",
661
+ boxShadow: "0 8px 24px rgba(15, 23, 42, 0.14)",
662
+ opacity: "0",
663
+ pointerEvents: "auto",
664
+ transition: "opacity 120ms ease, background-color 120ms ease, border-color 120ms ease",
665
+ zIndex: "3",
666
+ },
667
+
668
+ ".cm-mardora-image-figure:hover .cm-mardora-image-toolbar, .cm-mardora-image-figure-active .cm-mardora-image-toolbar, .cm-mardora-image-toolbar:focus-within": {
669
+ opacity: "1",
670
+ },
671
+
672
+ ".cm-mardora-image-tool-button": {
673
+ width: "1.75rem",
674
+ height: "1.75rem",
675
+ border: "0",
676
+ borderRadius: "0.25rem",
677
+ backgroundColor: "transparent",
678
+ color: "#334155",
679
+ display: "inline-flex",
680
+ alignItems: "center",
681
+ justifyContent: "center",
682
+ padding: "0",
683
+ cursor: "pointer",
684
+ transition: "background-color 120ms ease, color 120ms ease",
685
+ },
686
+
687
+ ".cm-mardora-image-tool-button:hover, .cm-mardora-image-tool-button:focus-visible": {
688
+ backgroundColor: "rgba(37, 99, 235, 0.1)",
689
+ color: "#2563eb",
690
+ outline: "none",
691
+ },
692
+
693
+ ".cm-mardora-image-tool-button svg": {
694
+ width: "1rem",
695
+ height: "1rem",
696
+ },
697
+
698
+ ".cm-mardora-image-resize-handle": {
699
+ position: "absolute",
700
+ top: "50%",
701
+ width: "0.375rem",
702
+ height: "2.75rem",
703
+ borderRadius: "999px",
704
+ backgroundColor: "#3b82f6",
705
+ boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.9), 0 8px 20px rgba(37, 99, 235, 0.24)",
706
+ cursor: "ew-resize",
707
+ opacity: "0",
708
+ transform: "translateY(-50%)",
709
+ transition: "opacity 120ms ease, background-color 120ms ease",
710
+ zIndex: "2",
711
+ },
712
+
713
+ ".cm-mardora-image-figure:hover .cm-mardora-image-resize-handle, .cm-mardora-image-figure-active .cm-mardora-image-resize-handle, .cm-mardora-image-resize-handle:focus-visible": {
714
+ opacity: "1",
715
+ },
716
+
717
+ ".cm-mardora-image-resize-handle:hover, .cm-mardora-image-resize-handle:focus-visible": {
718
+ backgroundColor: "#2563eb",
719
+ outline: "none",
720
+ },
721
+
722
+ ".cm-mardora-image-resize-handle-left": {
723
+ left: "-0.1875rem",
724
+ },
725
+
726
+ ".cm-mardora-image-resize-handle-right": {
727
+ right: "-0.1875rem",
728
+ },
729
+
730
+ // Image element — fill the figure by default so the image stretches to
731
+ // the content width. When an explicit width is set, img gets an inline
732
+ // width:100% too (figure carries the pixel width), so this stays consistent.
733
+ ".cm-mardora-image": {
734
+ width: "100%",
735
+ maxWidth: "100%",
736
+ maxHeight: "800px",
737
+ height: "auto",
738
+ borderRadius: "4px",
739
+ },
740
+
741
+ // Figcaption
742
+ ".cm-mardora-image-caption": {
743
+ display: "block",
744
+ width: "100%",
745
+ fontSize: "0.875em",
746
+ color: "#6a737d",
747
+ marginTop: "0.5em",
748
+ textAlign: "center",
749
+ fontStyle: "italic",
750
+ },
751
+
752
+ // Error state
753
+ ".cm-mardora-image-error": {
754
+ display: "inline-block",
755
+ padding: "0.5em 1em",
756
+ backgroundColor: "rgba(255, 0, 0, 0.1)",
757
+ color: "#d73a49",
758
+ borderRadius: "4px",
759
+ fontSize: "0.875em",
760
+ fontStyle: "italic",
761
+ },
762
+ },
763
+
764
+ dark: {
765
+ ".cm-mardora-image-marker": {
766
+ color: "#8b949e",
767
+ },
768
+
769
+ ".cm-mardora-image-alt": {
770
+ color: "#7ee787",
771
+ },
772
+
773
+ ".cm-mardora-image-url": {
774
+ color: "#58a6ff",
775
+ },
776
+
777
+ ".cm-mardora-image-caption": {
778
+ color: "#8b949e",
779
+ },
780
+
781
+ ".cm-mardora-image-toolbar": {
782
+ borderColor: "rgba(71, 85, 105, 0.72)",
783
+ backgroundColor: "rgba(15, 23, 42, 0.88)",
784
+ boxShadow: "0 8px 24px rgba(0, 0, 0, 0.32)",
785
+ },
786
+
787
+ ".cm-mardora-image-tool-button": {
788
+ color: "#cbd5e1",
789
+ },
790
+
791
+ ".cm-mardora-image-tool-button:hover, .cm-mardora-image-tool-button:focus-visible": {
792
+ backgroundColor: "rgba(96, 165, 250, 0.18)",
793
+ color: "#93c5fd",
794
+ },
795
+
796
+ ".cm-mardora-image-error": {
797
+ backgroundColor: "rgba(255, 0, 0, 0.15)",
798
+ color: "#f85149",
799
+ },
800
+ },
801
+ });
802
+
803
+ const theme = (theme: Parameters<typeof imageTheme>[0]) => ({
804
+ ...imageTheme(theme),
805
+ ...mediaLightboxTheme(theme),
806
+ });