mossmd 0.8.1

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 (119) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +336 -0
  4. package/dist/collab/index.d.ts +14 -0
  5. package/dist/collab/index.d.ts.map +1 -0
  6. package/dist/collab/index.js +6 -0
  7. package/dist/collab/index.js.map +1 -0
  8. package/dist/core/code-languages.d.ts +3 -0
  9. package/dist/core/code-languages.d.ts.map +1 -0
  10. package/dist/core/code-languages.js +130 -0
  11. package/dist/core/code-languages.js.map +1 -0
  12. package/dist/core/decoration-utils.d.ts +4 -0
  13. package/dist/core/decoration-utils.d.ts.map +1 -0
  14. package/dist/core/decoration-utils.js +25 -0
  15. package/dist/core/decoration-utils.js.map +1 -0
  16. package/dist/core/edit-helpers.d.ts +13 -0
  17. package/dist/core/edit-helpers.d.ts.map +1 -0
  18. package/dist/core/edit-helpers.js +318 -0
  19. package/dist/core/edit-helpers.js.map +1 -0
  20. package/dist/core/icons.d.ts +16 -0
  21. package/dist/core/icons.d.ts.map +1 -0
  22. package/dist/core/icons.js +86 -0
  23. package/dist/core/icons.js.map +1 -0
  24. package/dist/core/inline-preview.d.ts +32 -0
  25. package/dist/core/inline-preview.d.ts.map +1 -0
  26. package/dist/core/inline-preview.js +925 -0
  27. package/dist/core/inline-preview.js.map +1 -0
  28. package/dist/core/list-editing.d.ts +10 -0
  29. package/dist/core/list-editing.d.ts.map +1 -0
  30. package/dist/core/list-editing.js +244 -0
  31. package/dist/core/list-editing.js.map +1 -0
  32. package/dist/core/list-model.d.ts +40 -0
  33. package/dist/core/list-model.d.ts.map +1 -0
  34. package/dist/core/list-model.js +114 -0
  35. package/dist/core/list-model.js.map +1 -0
  36. package/dist/core/list-navigation.d.ts +3 -0
  37. package/dist/core/list-navigation.d.ts.map +1 -0
  38. package/dist/core/list-navigation.js +234 -0
  39. package/dist/core/list-navigation.js.map +1 -0
  40. package/dist/core/markdown-context.d.ts +3 -0
  41. package/dist/core/markdown-context.d.ts.map +1 -0
  42. package/dist/core/markdown-context.js +10 -0
  43. package/dist/core/markdown-context.js.map +1 -0
  44. package/dist/core/preview-activity.d.ts +18 -0
  45. package/dist/core/preview-activity.d.ts.map +1 -0
  46. package/dist/core/preview-activity.js +199 -0
  47. package/dist/core/preview-activity.js.map +1 -0
  48. package/dist/core/preview-widgets.d.ts +65 -0
  49. package/dist/core/preview-widgets.d.ts.map +1 -0
  50. package/dist/core/preview-widgets.js +359 -0
  51. package/dist/core/preview-widgets.js.map +1 -0
  52. package/dist/core/read-only.d.ts +26 -0
  53. package/dist/core/read-only.d.ts.map +1 -0
  54. package/dist/core/read-only.js +49 -0
  55. package/dist/core/read-only.js.map +1 -0
  56. package/dist/core/tree-progress.d.ts +29 -0
  57. package/dist/core/tree-progress.d.ts.map +1 -0
  58. package/dist/core/tree-progress.js +143 -0
  59. package/dist/core/tree-progress.js.map +1 -0
  60. package/dist/editor.d.ts +219 -0
  61. package/dist/editor.d.ts.map +1 -0
  62. package/dist/editor.js +551 -0
  63. package/dist/editor.js.map +1 -0
  64. package/dist/features/callout/index.d.ts +9 -0
  65. package/dist/features/callout/index.d.ts.map +1 -0
  66. package/dist/features/callout/index.js +223 -0
  67. package/dist/features/callout/index.js.map +1 -0
  68. package/dist/features/file-blocks/index.d.ts +12 -0
  69. package/dist/features/file-blocks/index.d.ts.map +1 -0
  70. package/dist/features/file-blocks/index.js +397 -0
  71. package/dist/features/file-blocks/index.js.map +1 -0
  72. package/dist/features/image/index.d.ts +28 -0
  73. package/dist/features/image/index.d.ts.map +1 -0
  74. package/dist/features/image/index.js +699 -0
  75. package/dist/features/image/index.js.map +1 -0
  76. package/dist/features/index.d.ts +17 -0
  77. package/dist/features/index.d.ts.map +1 -0
  78. package/dist/features/index.js +9 -0
  79. package/dist/features/index.js.map +1 -0
  80. package/dist/features/search/index.d.ts +4 -0
  81. package/dist/features/search/index.d.ts.map +1 -0
  82. package/dist/features/search/index.js +131 -0
  83. package/dist/features/search/index.js.map +1 -0
  84. package/dist/features/slash-commands/index.d.ts +52 -0
  85. package/dist/features/slash-commands/index.d.ts.map +1 -0
  86. package/dist/features/slash-commands/index.js +422 -0
  87. package/dist/features/slash-commands/index.js.map +1 -0
  88. package/dist/features/table/index.d.ts +44 -0
  89. package/dist/features/table/index.d.ts.map +1 -0
  90. package/dist/features/table/index.js +1262 -0
  91. package/dist/features/table/index.js.map +1 -0
  92. package/dist/features/upload/index.d.ts +80 -0
  93. package/dist/features/upload/index.d.ts.map +1 -0
  94. package/dist/features/upload/index.js +790 -0
  95. package/dist/features/upload/index.js.map +1 -0
  96. package/dist/features/wiki-links/index.d.ts +27 -0
  97. package/dist/features/wiki-links/index.d.ts.map +1 -0
  98. package/dist/features/wiki-links/index.js +577 -0
  99. package/dist/features/wiki-links/index.js.map +1 -0
  100. package/dist/index.d.ts +17 -0
  101. package/dist/index.d.ts.map +1 -0
  102. package/dist/index.js +12 -0
  103. package/dist/index.js.map +1 -0
  104. package/dist/styles/content.css +247 -0
  105. package/dist/styles/inline-preview.css +2116 -0
  106. package/dist/styles/tokens.css +234 -0
  107. package/dist/syntax/highlight.d.ts +10 -0
  108. package/dist/syntax/highlight.d.ts.map +1 -0
  109. package/dist/syntax/highlight.js +75 -0
  110. package/dist/syntax/highlight.js.map +1 -0
  111. package/dist/syntax/index.d.ts +16 -0
  112. package/dist/syntax/index.d.ts.map +1 -0
  113. package/dist/syntax/index.js +29 -0
  114. package/dist/syntax/index.js.map +1 -0
  115. package/dist/theme/index.d.ts +6 -0
  116. package/dist/theme/index.d.ts.map +1 -0
  117. package/dist/theme/index.js +204 -0
  118. package/dist/theme/index.js.map +1 -0
  119. package/package.json +237 -0
@@ -0,0 +1,699 @@
1
+ import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
2
+ import { StateField, } from '@codemirror/state';
3
+ import { Decoration, EditorView, ViewPlugin, WidgetType, } from '@codemirror/view';
4
+ import { Check, Eye, Image as ImageIcon, Maximize2, Pencil, X } from 'lucide-react';
5
+ import { appendMossIcon, mossLucideIcon, } from '../../core/icons';
6
+ import { readOnlyFacet } from '../../core/read-only';
7
+ import { treeGrowthEffect, treeProgressPlugin } from '../../core/tree-progress';
8
+ const DEFAULT_IMAGE_ICONS = {
9
+ edit: mossLucideIcon(Pencil, { size: 16 }),
10
+ preview: mossLucideIcon(Eye, { size: 16 }),
11
+ resize: mossLucideIcon(Maximize2, { size: 16 }),
12
+ save: mossLucideIcon(Check, { size: 15 }),
13
+ cancel: mossLucideIcon(X, { size: 15 }),
14
+ placeholder: mossLucideIcon(ImageIcon, { size: 36 }),
15
+ };
16
+ const IMAGE_WIDTH_RE = /^(?:\d+(?:\.\d+)?)(?:%|px|rem|em|vw)$/;
17
+ const MIN_IMAGE_WIDTH = 160;
18
+ // Image blocks.
19
+ //
20
+ // When a markdown image (`![alt](url)`) appears in the doc, we render
21
+ // the actual image as a block-level widget immediately below the line
22
+ // that contains its source. The markdown source stays hidden while the
23
+ // rendered widget owns the presentation. Its edit control and full-range
24
+ // selection keep the source editable without adding a visible source row.
25
+ //
26
+ // Block widgets can't come from a ViewPlugin (CM6 requires them to
27
+ // originate from a StateField or a mandatory facet), so this lives
28
+ // in its own StateField alongside the ViewPlugin-based inline
29
+ // decorations. The two compose naturally — CM6 layers their
30
+ // decoration sets at render time.
31
+ //
32
+ // Scope: we emit one image widget per Image node. Images inside
33
+ // otherwise-text paragraphs still get a widget below the paragraph;
34
+ // it's visually slightly awkward but matches the "always render
35
+ // the image" invariant. Most markdown in practice has images on
36
+ // their own line, where this looks right.
37
+ // Session-lifetime cache of observed natural image dimensions, keyed
38
+ // by URL. CM6's virtualizer unmounts line DOM when it leaves the
39
+ // viewport and calls `toDOM` again on the way back. Without a
40
+ // cache, the `<img>` starts with no intrinsic size on each remount,
41
+ // lays out as a zero-height box, measures, then snaps to its real
42
+ // size once decode completes — and the heightmap grows under the
43
+ // scroll animation. On iOS that reads as an anchor conflict and
44
+ // halts kinetic scroll, but only in the direction where the growth
45
+ // opposes the scroll (e.g. scrolling up past a remounting image
46
+ // that just grew taller pushes content down against the motion).
47
+ // Setting `width` and `height` attrs from this cache pins the
48
+ // aspect ratio on mount, so there's no grow-after-mount event.
49
+ const dimensionCache = new Map();
50
+ const activeImagePreviews = new WeakMap();
51
+ function resolveImageIcons(config) {
52
+ return { ...DEFAULT_IMAGE_ICONS, ...config.icons };
53
+ }
54
+ function openImagePreview(view, image, icons) {
55
+ activeImagePreviews.get(view)?.();
56
+ const previousFocus = document.activeElement;
57
+ const backdrop = document.createElement('div');
58
+ backdrop.className = 'cm-moss-image-preview-backdrop';
59
+ backdrop.setAttribute('role', 'presentation');
60
+ const dialog = document.createElement('div');
61
+ dialog.className = 'cm-moss-image-preview-dialog';
62
+ dialog.setAttribute('role', 'dialog');
63
+ dialog.setAttribute('aria-modal', 'true');
64
+ dialog.setAttribute('aria-label', image.alt || 'Image preview');
65
+ const closeButton = document.createElement('button');
66
+ closeButton.type = 'button';
67
+ closeButton.className = 'cm-moss-image-preview-close';
68
+ appendMossIcon(closeButton, icons.cancel);
69
+ closeButton.setAttribute('aria-label', 'Close image preview');
70
+ closeButton.title = 'Close preview';
71
+ const preview = document.createElement('img');
72
+ preview.src = image.src;
73
+ preview.alt = image.alt;
74
+ preview.decoding = 'async';
75
+ preview.loading = 'eager';
76
+ dialog.append(closeButton, preview);
77
+ if (image.caption) {
78
+ const caption = document.createElement('div');
79
+ caption.className = 'cm-moss-image-preview-caption';
80
+ caption.textContent = image.caption;
81
+ dialog.appendChild(caption);
82
+ }
83
+ backdrop.appendChild(dialog);
84
+ const close = () => {
85
+ document.removeEventListener('keydown', onKeyDown);
86
+ backdrop.remove();
87
+ if (activeImagePreviews.get(view) === close)
88
+ activeImagePreviews.delete(view);
89
+ if (previousFocus instanceof HTMLElement && previousFocus.isConnected) {
90
+ previousFocus.focus();
91
+ }
92
+ };
93
+ const onKeyDown = (event) => {
94
+ if (event.key === 'Escape') {
95
+ event.preventDefault();
96
+ close();
97
+ }
98
+ };
99
+ activeImagePreviews.set(view, close);
100
+ closeButton.addEventListener('click', close);
101
+ backdrop.addEventListener('click', (event) => {
102
+ if (event.target === backdrop)
103
+ close();
104
+ });
105
+ document.addEventListener('keydown', onKeyDown);
106
+ document.body.appendChild(backdrop);
107
+ closeButton.focus();
108
+ }
109
+ function parseImageMarkdown(raw) {
110
+ const match = raw.match(/^!\[([^\]]*)\]\(([^\s)"']+)(?:\s+["'][^)]*["'])?\)$/);
111
+ if (!match)
112
+ return null;
113
+ const [, altRaw, src] = match;
114
+ if (!src)
115
+ return null;
116
+ const pipeIdx = altRaw.indexOf('|');
117
+ if (pipeIdx < 0) {
118
+ return { src, alt: altRaw, caption: altRaw || null, width: null };
119
+ }
120
+ const left = altRaw.slice(0, pipeIdx);
121
+ let right = altRaw.slice(pipeIdx + 1);
122
+ let width = null;
123
+ const widthMatch = right.match(/^(.*)\|width=(\d+(?:\.\d+)?(?:%|px|rem|em|vw))$/);
124
+ if (widthMatch) {
125
+ right = widthMatch[1];
126
+ width = widthMatch[2];
127
+ }
128
+ if (right === '')
129
+ return { src, alt: left, caption: null, width };
130
+ if (left === '')
131
+ return { src, alt: right, caption: right, width };
132
+ return { src, alt: left, caption: right, width };
133
+ }
134
+ function serializeImage(image) {
135
+ const caption = image.caption ?? '';
136
+ const width = image.width ? `|width=${image.width}` : '';
137
+ return `![${image.alt}|${caption}${width}](${image.src})`;
138
+ }
139
+ function imageRangeAtWidget(view, wrap, expectedSrc) {
140
+ const widgetPos = view.posAtDOM(wrap);
141
+ if (widgetPos < 0 || widgetPos > view.state.doc.length)
142
+ return null;
143
+ const line = view.state.doc.lineAt(Math.max(0, widgetPos - 1));
144
+ const tree = ensureSyntaxTree(view.state, view.state.doc.length, 200) ?? syntaxTree(view.state);
145
+ let result = null;
146
+ tree.iterate({
147
+ from: line.from,
148
+ to: line.to,
149
+ enter: (node) => {
150
+ if (result || node.name !== 'Image')
151
+ return;
152
+ const parsed = parseImageMarkdown(view.state.doc.sliceString(node.from, node.to));
153
+ if (parsed && (!expectedSrc || parsed.src === expectedSrc)) {
154
+ result = { from: node.from, to: node.to };
155
+ }
156
+ },
157
+ });
158
+ return result;
159
+ }
160
+ function imageIsAloneOnLine(state, imageFrom, imageTo) {
161
+ const line = state.doc.lineAt(imageFrom);
162
+ if (state.doc.lineAt(imageTo).from !== line.from)
163
+ return false;
164
+ return (state.doc.sliceString(line.from, imageFrom).trim() === '' &&
165
+ state.doc.sliceString(imageTo, line.to).trim() === '');
166
+ }
167
+ function startImageResize(view, wrap, frame, img, image, startEvent) {
168
+ const containerWidth = wrap.getBoundingClientRect().width;
169
+ const startWidth = frame.getBoundingClientRect().width;
170
+ if (containerWidth <= 0 || startWidth <= 0)
171
+ return;
172
+ const minWidth = Math.min(MIN_IMAGE_WIDTH, containerWidth);
173
+ let currentWidth = Math.max(startWidth, minWidth);
174
+ if (currentWidth !== startWidth) {
175
+ frame.style.width = `${currentWidth}px`;
176
+ img.style.width = '100%';
177
+ }
178
+ const update = (event) => {
179
+ event.preventDefault();
180
+ currentWidth = Math.max(minWidth, Math.min(containerWidth, startWidth + event.clientX - startEvent.clientX));
181
+ frame.style.width = `${currentWidth}px`;
182
+ img.style.width = '100%';
183
+ };
184
+ const finish = () => {
185
+ window.removeEventListener('pointermove', update);
186
+ window.removeEventListener('pointerup', finish);
187
+ window.removeEventListener('pointercancel', finish);
188
+ if (view.state.facet(readOnlyFacet))
189
+ return;
190
+ const percentage = Math.round((currentWidth / containerWidth) * 1000) / 10;
191
+ const range = imageRangeAtWidget(view, wrap, image.src);
192
+ if (!range)
193
+ return;
194
+ view.dispatch({
195
+ changes: {
196
+ from: range.from,
197
+ to: range.to,
198
+ insert: serializeImage({ ...image, width: `${percentage}%` }),
199
+ },
200
+ });
201
+ };
202
+ window.addEventListener('pointermove', update);
203
+ window.addEventListener('pointerup', finish);
204
+ window.addEventListener('pointercancel', finish);
205
+ }
206
+ const activeImageEditors = new WeakMap();
207
+ function addImageEditorField(form, labelText, value, field, type = 'text') {
208
+ const label = document.createElement('label');
209
+ label.className = 'cm-moss-image-editor-field';
210
+ label.textContent = labelText;
211
+ const input = document.createElement('input');
212
+ input.type = type;
213
+ input.value = value;
214
+ input.dataset.imageField = field;
215
+ input.autocomplete = 'off';
216
+ label.appendChild(input);
217
+ form.appendChild(label);
218
+ return input;
219
+ }
220
+ function openImageEditor(view, wrap, image, icons) {
221
+ activeImageEditors.get(view)?.();
222
+ const form = document.createElement('form');
223
+ form.className = 'cm-moss-image-editor';
224
+ form.setAttribute('aria-label', 'Edit image');
225
+ const heading = document.createElement('div');
226
+ heading.className = 'cm-moss-image-editor-title';
227
+ heading.textContent = 'Edit image';
228
+ form.appendChild(heading);
229
+ const altInput = addImageEditorField(form, 'Alt text', image.alt, 'alt');
230
+ const captionInput = addImageEditorField(form, 'Caption', image.caption ?? '', 'caption');
231
+ const widthInput = addImageEditorField(form, 'Width', image.width ?? '', 'width');
232
+ const srcInput = addImageEditorField(form, 'Image URL', image.src, 'src', 'url');
233
+ const error = document.createElement('div');
234
+ error.className = 'cm-moss-image-editor-error';
235
+ error.setAttribute('role', 'alert');
236
+ form.appendChild(error);
237
+ const actions = document.createElement('div');
238
+ actions.className = 'cm-moss-image-editor-actions';
239
+ const cancel = document.createElement('button');
240
+ cancel.type = 'button';
241
+ cancel.className = 'cm-moss-image-editor-button';
242
+ appendMossIcon(cancel, icons.cancel);
243
+ cancel.setAttribute('aria-label', 'Cancel image edit');
244
+ cancel.title = 'Cancel';
245
+ const save = document.createElement('button');
246
+ save.type = 'submit';
247
+ save.className = 'cm-moss-image-editor-button is-primary';
248
+ appendMossIcon(save, icons.save);
249
+ save.setAttribute('aria-label', 'Save image');
250
+ save.title = 'Save';
251
+ actions.append(cancel, save);
252
+ form.appendChild(actions);
253
+ const close = () => {
254
+ form.remove();
255
+ if (activeImageEditors.get(view) === close)
256
+ activeImageEditors.delete(view);
257
+ };
258
+ activeImageEditors.set(view, close);
259
+ cancel.addEventListener('click', close);
260
+ form.addEventListener('keydown', (event) => {
261
+ if (event.key === 'Escape') {
262
+ event.preventDefault();
263
+ close();
264
+ }
265
+ });
266
+ form.addEventListener('submit', (event) => {
267
+ event.preventDefault();
268
+ if (view.state.facet(readOnlyFacet)) {
269
+ close();
270
+ return;
271
+ }
272
+ const next = {
273
+ alt: altInput.value.trim(),
274
+ caption: captionInput.value.trim() || null,
275
+ src: srcInput.value.trim(),
276
+ width: widthInput.value.trim() || null,
277
+ };
278
+ if (!next.src) {
279
+ error.textContent = 'Image URL is required.';
280
+ srcInput.focus();
281
+ return;
282
+ }
283
+ if (/[^\S\r\n]|[)"']/.test(next.src)) {
284
+ error.textContent = 'Image URL cannot contain spaces, quotes, or ).';
285
+ srcInput.focus();
286
+ return;
287
+ }
288
+ if (/[\]|\r\n]/.test(next.alt) || /[\]\r\n]/.test(next.caption ?? '')) {
289
+ error.textContent = 'Alt text cannot contain ], |; fields cannot contain line breaks.';
290
+ return;
291
+ }
292
+ if (next.width && !IMAGE_WIDTH_RE.test(next.width)) {
293
+ error.textContent = 'Width must use a CSS unit such as 72%, 640px, or 24rem.';
294
+ widthInput.focus();
295
+ return;
296
+ }
297
+ const range = imageRangeAtWidget(view, wrap, image.src);
298
+ if (!range) {
299
+ error.textContent = 'The image is no longer available.';
300
+ return;
301
+ }
302
+ close();
303
+ view.dispatch({ changes: { from: range.from, to: range.to, insert: serializeImage(next) } });
304
+ });
305
+ wrap.appendChild(form);
306
+ altInput.focus();
307
+ altInput.select();
308
+ }
309
+ class ImageWidget extends WidgetType {
310
+ constructor(src, alt, caption, width, canEdit, canResize, canPreview, icons) {
311
+ super();
312
+ Object.defineProperty(this, "src", {
313
+ enumerable: true,
314
+ configurable: true,
315
+ writable: true,
316
+ value: src
317
+ });
318
+ Object.defineProperty(this, "alt", {
319
+ enumerable: true,
320
+ configurable: true,
321
+ writable: true,
322
+ value: alt
323
+ });
324
+ Object.defineProperty(this, "caption", {
325
+ enumerable: true,
326
+ configurable: true,
327
+ writable: true,
328
+ value: caption
329
+ });
330
+ Object.defineProperty(this, "width", {
331
+ enumerable: true,
332
+ configurable: true,
333
+ writable: true,
334
+ value: width
335
+ });
336
+ Object.defineProperty(this, "canEdit", {
337
+ enumerable: true,
338
+ configurable: true,
339
+ writable: true,
340
+ value: canEdit
341
+ });
342
+ Object.defineProperty(this, "canResize", {
343
+ enumerable: true,
344
+ configurable: true,
345
+ writable: true,
346
+ value: canResize
347
+ });
348
+ Object.defineProperty(this, "canPreview", {
349
+ enumerable: true,
350
+ configurable: true,
351
+ writable: true,
352
+ value: canPreview
353
+ });
354
+ Object.defineProperty(this, "icons", {
355
+ enumerable: true,
356
+ configurable: true,
357
+ writable: true,
358
+ value: icons
359
+ });
360
+ }
361
+ eq(other) {
362
+ return (other.src === this.src &&
363
+ other.alt === this.alt &&
364
+ other.caption === this.caption &&
365
+ other.width === this.width &&
366
+ other.canEdit === this.canEdit &&
367
+ other.canResize === this.canResize &&
368
+ other.canPreview === this.canPreview &&
369
+ other.icons.edit === this.icons.edit &&
370
+ other.icons.preview === this.icons.preview &&
371
+ other.icons.resize === this.icons.resize &&
372
+ other.icons.save === this.icons.save &&
373
+ other.icons.cancel === this.icons.cancel &&
374
+ other.icons.placeholder === this.icons.placeholder);
375
+ }
376
+ toDOM(view) {
377
+ const wrap = document.createElement('div');
378
+ wrap.className = 'cm-moss-image';
379
+ if (this.caption)
380
+ wrap.classList.add('cm-moss-image-has-caption');
381
+ const frame = document.createElement('div');
382
+ frame.className = 'cm-moss-image-frame';
383
+ const cached = dimensionCache.get(this.src);
384
+ let placeholder = null;
385
+ const clearPlaceholder = () => {
386
+ frame.classList.remove('cm-moss-image-frame-placeholder');
387
+ placeholder?.remove();
388
+ placeholder = null;
389
+ };
390
+ if (!cached) {
391
+ frame.classList.add('cm-moss-image-frame-placeholder');
392
+ placeholder = document.createElement('div');
393
+ placeholder.className = 'cm-moss-image-placeholder';
394
+ appendMossIcon(placeholder, this.icons.placeholder);
395
+ placeholder.setAttribute('aria-hidden', 'true');
396
+ frame.appendChild(placeholder);
397
+ }
398
+ const img = document.createElement('img');
399
+ img.alt = this.alt;
400
+ img.loading = 'lazy';
401
+ // Set intrinsic dims from the cache so the widget reserves the
402
+ // right box before the image decodes — prevents the remount +
403
+ // resize cycle that halts iOS momentum scroll. On first-ever
404
+ // mount the cache is cold; the `load` listener below records
405
+ // the natural dims so subsequent remounts come up pre-sized.
406
+ // CSS (`max-width: 100%; height: auto` on the img) still lets
407
+ // the browser scale the attributes to the content column.
408
+ if (cached) {
409
+ img.width = cached.w;
410
+ img.height = cached.h;
411
+ }
412
+ else {
413
+ img.addEventListener('load', () => {
414
+ if (img.naturalWidth > 0 && img.naturalHeight > 0) {
415
+ dimensionCache.set(this.src, {
416
+ w: img.naturalWidth,
417
+ h: img.naturalHeight,
418
+ });
419
+ }
420
+ clearPlaceholder();
421
+ });
422
+ }
423
+ if (this.width) {
424
+ frame.classList.add('cm-moss-image-frame-sized');
425
+ frame.style.width = this.width;
426
+ img.style.width = '100%';
427
+ }
428
+ img.src = this.src;
429
+ frame.appendChild(img);
430
+ wrap.appendChild(frame);
431
+ wrap.addEventListener('pointerdown', (event) => {
432
+ if (event.button !== 0 || view.state.facet(readOnlyFacet))
433
+ return;
434
+ const target = event.target;
435
+ if (target instanceof Element && target.closest('button, input, form'))
436
+ return;
437
+ const range = imageRangeAtWidget(view, wrap, this.src);
438
+ if (!range)
439
+ return;
440
+ event.preventDefault();
441
+ event.stopPropagation();
442
+ view.focus();
443
+ view.dispatch({ selection: { anchor: range.from, head: range.to } });
444
+ });
445
+ if (this.canPreview) {
446
+ const preview = document.createElement('button');
447
+ preview.type = 'button';
448
+ preview.className = 'cm-moss-image-preview';
449
+ appendMossIcon(preview, this.icons.preview);
450
+ preview.setAttribute('aria-label', 'Preview image');
451
+ preview.title = 'Preview image';
452
+ preview.addEventListener('pointerdown', (event) => {
453
+ event.preventDefault();
454
+ event.stopPropagation();
455
+ });
456
+ preview.addEventListener('click', (event) => {
457
+ event.preventDefault();
458
+ event.stopPropagation();
459
+ openImagePreview(view, {
460
+ src: this.src,
461
+ alt: this.alt,
462
+ caption: this.caption,
463
+ width: this.width,
464
+ }, this.icons);
465
+ });
466
+ frame.appendChild(preview);
467
+ }
468
+ if (this.canEdit) {
469
+ const edit = document.createElement('button');
470
+ edit.type = 'button';
471
+ edit.className = 'cm-moss-image-edit';
472
+ appendMossIcon(edit, this.icons.edit);
473
+ edit.setAttribute('aria-label', 'Edit image');
474
+ edit.title = 'Edit image';
475
+ edit.addEventListener('pointerdown', (event) => {
476
+ event.preventDefault();
477
+ event.stopPropagation();
478
+ });
479
+ edit.addEventListener('click', (event) => {
480
+ event.preventDefault();
481
+ event.stopPropagation();
482
+ if (!view.state.facet(readOnlyFacet)) {
483
+ openImageEditor(view, wrap, {
484
+ src: this.src,
485
+ alt: this.alt,
486
+ caption: this.caption,
487
+ width: this.width,
488
+ }, this.icons);
489
+ }
490
+ });
491
+ frame.appendChild(edit);
492
+ }
493
+ if (this.canResize) {
494
+ const resize = document.createElement('button');
495
+ resize.type = 'button';
496
+ resize.className = 'cm-moss-image-resize';
497
+ appendMossIcon(resize, this.icons.resize);
498
+ resize.setAttribute('aria-label', 'Resize image');
499
+ resize.title = 'Resize image';
500
+ resize.addEventListener('pointerdown', (event) => {
501
+ event.preventDefault();
502
+ event.stopPropagation();
503
+ startImageResize(view, wrap, frame, img, {
504
+ src: this.src,
505
+ alt: this.alt,
506
+ caption: this.caption,
507
+ width: this.width,
508
+ }, event);
509
+ });
510
+ frame.appendChild(resize);
511
+ }
512
+ // Caption: rendered below the image. The default syntax is
513
+ // `![alt](url)` — no pipe — and the whole alt text doubles as
514
+ // the visible caption. To give alt and caption different text,
515
+ // use `![alt|caption](url)`. A trailing pipe (`![alt|](url)`)
516
+ // is the explicit "no caption" form. `img.alt` always gets the
517
+ // accessibility text.
518
+ if (this.caption) {
519
+ const caption = document.createElement('figcaption');
520
+ caption.className = 'cm-moss-image-caption';
521
+ caption.textContent = this.caption;
522
+ wrap.appendChild(caption);
523
+ }
524
+ return wrap;
525
+ }
526
+ // Block CM6's mouse handling so clicking the rendered image does not
527
+ // activate the hidden markdown source line. The edit button and its
528
+ // form own their events inside the widget.
529
+ ignoreEvent() {
530
+ return true;
531
+ }
532
+ }
533
+ function imageRangeIsSelected(view, wrap) {
534
+ if (!view.hasFocus || view.state.facet(readOnlyFacet))
535
+ return false;
536
+ const range = imageRangeAtWidget(view, wrap);
537
+ if (!range)
538
+ return false;
539
+ return view.state.selection.ranges.some((selection) => selection.from === range.from && selection.to === range.to);
540
+ }
541
+ const imageSelectionPlugin = ViewPlugin.fromClass(class {
542
+ constructor(view) {
543
+ Object.defineProperty(this, "view", {
544
+ enumerable: true,
545
+ configurable: true,
546
+ writable: true,
547
+ value: view
548
+ });
549
+ Object.defineProperty(this, "editorRoot", {
550
+ enumerable: true,
551
+ configurable: true,
552
+ writable: true,
553
+ value: void 0
554
+ });
555
+ this.editorRoot = view.dom.parentElement;
556
+ this.sync();
557
+ }
558
+ update(update) {
559
+ if (update.docChanged || update.selectionSet || update.focusChanged) {
560
+ this.sync();
561
+ }
562
+ }
563
+ sync() {
564
+ let hasSelectedImage = false;
565
+ for (const wrap of this.view.dom.querySelectorAll('.cm-moss-image')) {
566
+ const selected = imageRangeIsSelected(this.view, wrap);
567
+ wrap.classList.toggle('cm-moss-image-selected', selected);
568
+ hasSelectedImage || (hasSelectedImage = selected);
569
+ }
570
+ this.editorRoot?.classList.toggle('moss-cm-image-selection-active', hasSelectedImage);
571
+ }
572
+ destroy() {
573
+ this.editorRoot?.classList.remove('moss-cm-image-selection-active');
574
+ }
575
+ });
576
+ function buildImageBlocks(state, config) {
577
+ const ranges = [];
578
+ const icons = resolveImageIcons(config);
579
+ // Push the parser to cover the whole doc so image nodes in
580
+ // regions CM6 hasn't yet parsed get widgetized. Without this, for
581
+ // moderately long atoms the initial parse doesn't reach the
582
+ // bottom and images past the initial parse window render as raw
583
+ // `![alt](url)` text forever — the StateField only rebuilds on
584
+ // doc change, not on parser advance. 200ms is a generous
585
+ // upper bound; typical atoms finish in well under 10ms.
586
+ const tree = ensureSyntaxTree(state, state.doc.length, 200) ?? syntaxTree(state);
587
+ tree.iterate({
588
+ enter: (node) => {
589
+ if (node.name !== 'Image')
590
+ return;
591
+ // Skip Images inside tables — the table widget renders them
592
+ // as inline `<img>` elements in their cells. Emitting a
593
+ // block widget below the table row would double-render and
594
+ // the source it points at is hidden behind the table's
595
+ // block-replace anyway.
596
+ for (let p = node.node.parent; p; p = p.parent) {
597
+ if (p.name === 'Table')
598
+ return;
599
+ }
600
+ // Slice the whole image source and regex out src / alt. This
601
+ // handles the common shapes — `![alt](url)` and
602
+ // `![alt](url "title")` — without us walking the lezer tree
603
+ // for each piece. We don't go to heroic lengths on edge cases
604
+ // (escaped parens etc.); the regex fails safely by skipping
605
+ // the widget.
606
+ const parsed = parseImageMarkdown(state.doc.sliceString(node.from, node.to));
607
+ if (!parsed)
608
+ return;
609
+ const line = state.doc.lineAt(node.from);
610
+ if (imageIsAloneOnLine(state, node.from, node.to)) {
611
+ ranges.push(Decoration.line({ class: 'cm-moss-image-source-line' }).range(line.from));
612
+ }
613
+ ranges.push(Decoration.widget({
614
+ widget: new ImageWidget(parsed.src, parsed.alt, parsed.caption, parsed.width, config.editable !== false && !state.facet(readOnlyFacet), config.resizable !== false &&
615
+ config.editable !== false &&
616
+ !state.facet(readOnlyFacet), config.previewable !== false, icons),
617
+ block: true,
618
+ // side: 1 places the block widget after the line's content,
619
+ // so the image appears below its source line.
620
+ side: 1,
621
+ }).range(line.to));
622
+ },
623
+ });
624
+ return Decoration.set(ranges, true);
625
+ }
626
+ // Detect whether a doc change could have added, removed, or modified
627
+ // an Image node. Two cheap signals:
628
+ //
629
+ // 1. Any existing image decoration overlaps the changed range. That
630
+ // covers edits to (or deletions of) an image already in the doc.
631
+ // 2. Any line touched by the change now contains the `![` marker.
632
+ // That catches new images being typed AND edits that complete a
633
+ // partially-typed image on an existing line.
634
+ //
635
+ // If neither signal fires, the change can't affect image widgets and
636
+ // we can skip `buildImageBlocks` entirely — `deco.map(tr.changes)`
637
+ // shifts existing decoration positions to the post-change doc, which
638
+ // is what we want for an unaffected edit. Turns per-keystroke cost
639
+ // from O(doc) to O(change size) on plain-prose edits of large atoms.
640
+ function changeAffectsImages(tr, existing) {
641
+ let affected = false;
642
+ tr.changes.iterChanges((fromA, toA) => {
643
+ if (affected)
644
+ return;
645
+ existing.between(fromA, toA, () => {
646
+ affected = true;
647
+ return false;
648
+ });
649
+ });
650
+ if (affected)
651
+ return true;
652
+ const state = tr.state;
653
+ tr.changes.iterChanges((_fromA, _toA, fromB, toB) => {
654
+ if (affected)
655
+ return;
656
+ const startLine = state.doc.lineAt(fromB);
657
+ const endLine = toB > startLine.to ? state.doc.lineAt(toB) : startLine;
658
+ for (let n = startLine.number; n <= endLine.number; n++) {
659
+ if (state.doc.line(n).text.includes('![')) {
660
+ affected = true;
661
+ break;
662
+ }
663
+ }
664
+ });
665
+ return affected;
666
+ }
667
+ export function mossImages(config = {}) {
668
+ const imageBlocksField = StateField.define({
669
+ create: (state) => buildImageBlocks(state, config),
670
+ update(deco, tr) {
671
+ // Tree-growth effect: the background parser caught up to a
672
+ // region that wasn't parsed when we last built. Rebuild so any
673
+ // newly-visible Image nodes get their widget.
674
+ for (const effect of tr.effects) {
675
+ if (effect.is(treeGrowthEffect))
676
+ return buildImageBlocks(tr.state, config);
677
+ }
678
+ const readOnlyChanged = tr.startState.facet(readOnlyFacet) !== tr.state.facet(readOnlyFacet);
679
+ if (readOnlyChanged)
680
+ return buildImageBlocks(tr.state, config);
681
+ // Selection and viewport changes don't affect the widget set
682
+ // (though they do affect whether the surrounding markdown is
683
+ // shown, which is handled by the inline-preview ViewPlugin).
684
+ if (!tr.docChanged)
685
+ return deco;
686
+ // Most keystrokes on a large atom are in plain prose with no
687
+ // image nearby. Map existing decorations through the change and
688
+ // skip the full-doc walk unless the change actually touches an
689
+ // image.
690
+ const mapped = deco.map(tr.changes);
691
+ if (!changeAffectsImages(tr, deco))
692
+ return mapped;
693
+ return buildImageBlocks(tr.state, config);
694
+ },
695
+ provide: (f) => EditorView.decorations.from(f),
696
+ });
697
+ return [imageBlocksField, imageSelectionPlugin, treeProgressPlugin];
698
+ }
699
+ //# sourceMappingURL=index.js.map