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.
- package/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +336 -0
- package/dist/collab/index.d.ts +14 -0
- package/dist/collab/index.d.ts.map +1 -0
- package/dist/collab/index.js +6 -0
- package/dist/collab/index.js.map +1 -0
- package/dist/core/code-languages.d.ts +3 -0
- package/dist/core/code-languages.d.ts.map +1 -0
- package/dist/core/code-languages.js +130 -0
- package/dist/core/code-languages.js.map +1 -0
- package/dist/core/decoration-utils.d.ts +4 -0
- package/dist/core/decoration-utils.d.ts.map +1 -0
- package/dist/core/decoration-utils.js +25 -0
- package/dist/core/decoration-utils.js.map +1 -0
- package/dist/core/edit-helpers.d.ts +13 -0
- package/dist/core/edit-helpers.d.ts.map +1 -0
- package/dist/core/edit-helpers.js +318 -0
- package/dist/core/edit-helpers.js.map +1 -0
- package/dist/core/icons.d.ts +16 -0
- package/dist/core/icons.d.ts.map +1 -0
- package/dist/core/icons.js +86 -0
- package/dist/core/icons.js.map +1 -0
- package/dist/core/inline-preview.d.ts +32 -0
- package/dist/core/inline-preview.d.ts.map +1 -0
- package/dist/core/inline-preview.js +925 -0
- package/dist/core/inline-preview.js.map +1 -0
- package/dist/core/list-editing.d.ts +10 -0
- package/dist/core/list-editing.d.ts.map +1 -0
- package/dist/core/list-editing.js +244 -0
- package/dist/core/list-editing.js.map +1 -0
- package/dist/core/list-model.d.ts +40 -0
- package/dist/core/list-model.d.ts.map +1 -0
- package/dist/core/list-model.js +114 -0
- package/dist/core/list-model.js.map +1 -0
- package/dist/core/list-navigation.d.ts +3 -0
- package/dist/core/list-navigation.d.ts.map +1 -0
- package/dist/core/list-navigation.js +234 -0
- package/dist/core/list-navigation.js.map +1 -0
- package/dist/core/markdown-context.d.ts +3 -0
- package/dist/core/markdown-context.d.ts.map +1 -0
- package/dist/core/markdown-context.js +10 -0
- package/dist/core/markdown-context.js.map +1 -0
- package/dist/core/preview-activity.d.ts +18 -0
- package/dist/core/preview-activity.d.ts.map +1 -0
- package/dist/core/preview-activity.js +199 -0
- package/dist/core/preview-activity.js.map +1 -0
- package/dist/core/preview-widgets.d.ts +65 -0
- package/dist/core/preview-widgets.d.ts.map +1 -0
- package/dist/core/preview-widgets.js +359 -0
- package/dist/core/preview-widgets.js.map +1 -0
- package/dist/core/read-only.d.ts +26 -0
- package/dist/core/read-only.d.ts.map +1 -0
- package/dist/core/read-only.js +49 -0
- package/dist/core/read-only.js.map +1 -0
- package/dist/core/tree-progress.d.ts +29 -0
- package/dist/core/tree-progress.d.ts.map +1 -0
- package/dist/core/tree-progress.js +143 -0
- package/dist/core/tree-progress.js.map +1 -0
- package/dist/editor.d.ts +219 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +551 -0
- package/dist/editor.js.map +1 -0
- package/dist/features/callout/index.d.ts +9 -0
- package/dist/features/callout/index.d.ts.map +1 -0
- package/dist/features/callout/index.js +223 -0
- package/dist/features/callout/index.js.map +1 -0
- package/dist/features/file-blocks/index.d.ts +12 -0
- package/dist/features/file-blocks/index.d.ts.map +1 -0
- package/dist/features/file-blocks/index.js +397 -0
- package/dist/features/file-blocks/index.js.map +1 -0
- package/dist/features/image/index.d.ts +28 -0
- package/dist/features/image/index.d.ts.map +1 -0
- package/dist/features/image/index.js +699 -0
- package/dist/features/image/index.js.map +1 -0
- package/dist/features/index.d.ts +17 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +9 -0
- package/dist/features/index.js.map +1 -0
- package/dist/features/search/index.d.ts +4 -0
- package/dist/features/search/index.d.ts.map +1 -0
- package/dist/features/search/index.js +131 -0
- package/dist/features/search/index.js.map +1 -0
- package/dist/features/slash-commands/index.d.ts +52 -0
- package/dist/features/slash-commands/index.d.ts.map +1 -0
- package/dist/features/slash-commands/index.js +422 -0
- package/dist/features/slash-commands/index.js.map +1 -0
- package/dist/features/table/index.d.ts +44 -0
- package/dist/features/table/index.d.ts.map +1 -0
- package/dist/features/table/index.js +1262 -0
- package/dist/features/table/index.js.map +1 -0
- package/dist/features/upload/index.d.ts +80 -0
- package/dist/features/upload/index.d.ts.map +1 -0
- package/dist/features/upload/index.js +790 -0
- package/dist/features/upload/index.js.map +1 -0
- package/dist/features/wiki-links/index.d.ts +27 -0
- package/dist/features/wiki-links/index.d.ts.map +1 -0
- package/dist/features/wiki-links/index.js +577 -0
- package/dist/features/wiki-links/index.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/styles/content.css +247 -0
- package/dist/styles/inline-preview.css +2116 -0
- package/dist/styles/tokens.css +234 -0
- package/dist/syntax/highlight.d.ts +10 -0
- package/dist/syntax/highlight.d.ts.map +1 -0
- package/dist/syntax/highlight.js +75 -0
- package/dist/syntax/highlight.js.map +1 -0
- package/dist/syntax/index.d.ts +16 -0
- package/dist/syntax/index.d.ts.map +1 -0
- package/dist/syntax/index.js +29 -0
- package/dist/syntax/index.js.map +1 -0
- package/dist/theme/index.d.ts +6 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/theme/index.js +204 -0
- package/dist/theme/index.js.map +1 -0
- 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 (``) 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 ``;
|
|
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
|
+
// `` — no pipe — and the whole alt text doubles as
|
|
514
|
+
// the visible caption. To give alt and caption different text,
|
|
515
|
+
// use ``. A trailing pipe (``)
|
|
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
|
+
// `` 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 — `` and
|
|
602
|
+
// `` — 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
|