@openleaf-editor/plugins-insert 0.1.0-beta.2 → 0.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -1
- package/dist/glyphs.d.ts +8 -0
- package/dist/glyphs.d.ts.map +1 -1
- package/dist/glyphs.js +8 -0
- package/dist/glyphs.js.map +1 -1
- package/dist/grid.d.ts.map +1 -1
- package/dist/grid.js +144 -15
- package/dist/grid.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/openleaf-insert.css +59 -2
- package/dist/prompts.d.ts +9 -0
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +86 -19
- package/dist/prompts.js.map +1 -1
- package/dist/resize.d.ts +23 -0
- package/dist/resize.d.ts.map +1 -1
- package/dist/resize.js +574 -26
- package/dist/resize.js.map +1 -1
- package/dist/styles.d.ts +1 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +59 -2
- package/dist/styles.js.map +1 -1
- package/package.json +16 -5
package/dist/resize.js
CHANGED
|
@@ -24,87 +24,635 @@ function setDimension(img, name, raw) {
|
|
|
24
24
|
img.removeAttribute(name);
|
|
25
25
|
img.style.setProperty(name, value);
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The node types this plugin gives a resize handle.
|
|
29
|
+
*
|
|
30
|
+
* Not `audio`: its spec declares no `width`/`height`, because an audio player
|
|
31
|
+
* has no intrinsic box -- only the height of whatever controls the browser
|
|
32
|
+
* draws. A handle there would write dimensions the schema drops on the next
|
|
33
|
+
* parse, which is a control that appears to work and does not.
|
|
34
|
+
*/
|
|
35
|
+
export const RESIZABLE_MEDIA = ['image', 'video'];
|
|
36
|
+
function createElement(kind, doc) {
|
|
37
|
+
return kind === 'video' ? doc.createElement('video') : doc.createElement('img');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The element's own idea of its size, for aspect ratio and for the drag ceiling.
|
|
41
|
+
*
|
|
42
|
+
* An image knows this as soon as it decodes; a video only once it has fetched
|
|
43
|
+
* enough to have metadata, and reports 0 until then. Both are therefore treated
|
|
44
|
+
* as "may not know yet" rather than "knows now", which is what the `|| 0` guards
|
|
45
|
+
* at the call sites are for.
|
|
46
|
+
*/
|
|
47
|
+
function isVideo(el) {
|
|
48
|
+
// `localName`, not `instanceof`. An editor mounted in an iframe builds its
|
|
49
|
+
// elements from that document, so they are instances of the iframe's
|
|
50
|
+
// `HTMLVideoElement` and not the outer window's -- `instanceof` is false
|
|
51
|
+
// across realms, and every video would have been treated as an image: no
|
|
52
|
+
// poster, no `<source>` children, and source-only players rendering blank.
|
|
53
|
+
return el.localName === 'video';
|
|
54
|
+
}
|
|
55
|
+
function intrinsic(el) {
|
|
56
|
+
if (isVideo(el))
|
|
57
|
+
return { width: el.videoWidth, height: el.videoHeight };
|
|
58
|
+
return { width: el.naturalWidth, height: el.naturalHeight };
|
|
59
|
+
}
|
|
60
|
+
function applyImageAttrs(img, node) {
|
|
28
61
|
img.src = node.attrs['src'];
|
|
29
62
|
const alt = node.attrs['alt'];
|
|
30
63
|
if (alt !== null)
|
|
31
64
|
img.alt = alt;
|
|
32
65
|
else
|
|
33
66
|
img.removeAttribute('alt');
|
|
34
|
-
const title = node.attrs['title'];
|
|
35
|
-
if (title)
|
|
36
|
-
img.title = title;
|
|
37
|
-
else
|
|
38
|
-
img.removeAttribute('title');
|
|
39
|
-
setDimension(img, 'width', node.attrs['width']);
|
|
40
|
-
setDimension(img, 'height', node.attrs['height']);
|
|
41
67
|
const align = node.attrs['align'];
|
|
42
68
|
const classes = [align ? IMAGE_ALIGN_CLASS[align] : '', node.attrs['className'] ?? '']
|
|
43
69
|
.filter((part) => part !== '')
|
|
44
70
|
.join(' ');
|
|
45
71
|
img.className = classes;
|
|
46
72
|
}
|
|
47
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The furniture markup each video's children were last built from.
|
|
75
|
+
*
|
|
76
|
+
* Weakly keyed, so an element that goes out of the document takes its entry with
|
|
77
|
+
* it. This exists so `applyVideoAttrs` can tell a real furniture change from the
|
|
78
|
+
* every-update churn it is otherwise called with -- see the note at the rebuild.
|
|
79
|
+
*/
|
|
80
|
+
const appliedFurniture = new WeakMap();
|
|
81
|
+
/**
|
|
82
|
+
* Put the player's addresses and poster on the element.
|
|
83
|
+
*
|
|
84
|
+
* The `<source>` and `<track>` children live in the node's `furniture` attribute
|
|
85
|
+
* as a markup string -- the shape core stores them in -- so they are rebuilt here
|
|
86
|
+
* rather than set as properties. Rebuilt whenever that string changes, because a
|
|
87
|
+
* dialog that removes a source has to remove it from the live DOM too, and there
|
|
88
|
+
* is no diffing worth doing on two or three elements. Not rebuilt when it has
|
|
89
|
+
* not changed: that would discard live `TextTrack` state on every update.
|
|
90
|
+
*
|
|
91
|
+
* The player is rendered *without* controls, and made inert by CSS, so that in
|
|
92
|
+
* the editor it is a preview rather than a working player. That is not a
|
|
93
|
+
* limitation being accepted quietly -- it is the only arrangement in which the
|
|
94
|
+
* author can select the thing at all.
|
|
95
|
+
*
|
|
96
|
+
* A `<video controls>` handles pointer events in its native control chrome, and
|
|
97
|
+
* Firefox handles them for the whole element: no `pointerdown`, `mousedown` or
|
|
98
|
+
* `click` listener anywhere in the editor's DOM ever fires, so ProseMirror never
|
|
99
|
+
* sees the gesture and never makes a `NodeSelection`. Since selecting the player
|
|
100
|
+
* is how the toolbar knows to edit rather than insert, a video in Firefox could
|
|
101
|
+
* be inserted and then never edited again. Chromium and WebKit let a click on
|
|
102
|
+
* the picture area through, which is exactly the sort of difference that ships.
|
|
103
|
+
*
|
|
104
|
+
* This is the *default* state, not the only one: `mediaView` adds a play button
|
|
105
|
+
* of its own to a selected video, and activating it hands that one element its
|
|
106
|
+
* controls and its pointer events back for as long as it stays selected. The
|
|
107
|
+
* preview has to remain the default because it is what makes the node
|
|
108
|
+
* selectable, and being selectable is what makes it editable.
|
|
109
|
+
*
|
|
110
|
+
* Neither `controls` nor the stored markup is affected: stored HTML is
|
|
111
|
+
* serialized from the node, not from this DOM, so what the document says about
|
|
112
|
+
* controls is untouched and the player is fully interactive on the page.
|
|
113
|
+
*/
|
|
114
|
+
function applyVideoAttrs(el, node) {
|
|
115
|
+
// Written only when it changes. Setting the `src` attribute runs the media
|
|
116
|
+
// load algorithm even for an identical value, which rewinds a clip the author
|
|
117
|
+
// is watching in the activated state -- and this function runs on every node
|
|
118
|
+
// update, so a resize would otherwise restart playback.
|
|
119
|
+
const src = node.attrs['src'];
|
|
120
|
+
if (src === null)
|
|
121
|
+
el.removeAttribute('src');
|
|
122
|
+
else if (el.getAttribute('src') !== src)
|
|
123
|
+
el.setAttribute('src', src);
|
|
124
|
+
const poster = node.attrs['poster'];
|
|
125
|
+
if (poster === null)
|
|
126
|
+
el.removeAttribute('poster');
|
|
127
|
+
else if (el.getAttribute('poster') !== poster)
|
|
128
|
+
el.setAttribute('poster', poster);
|
|
129
|
+
// Not `controls = true`: see the note above. The element also must not
|
|
130
|
+
// advertise a control bar it will not honour. `mediaView` puts them back when
|
|
131
|
+
// the author activates the player, which is why this runs before that sync
|
|
132
|
+
// rather than instead of it.
|
|
133
|
+
el.controls = false;
|
|
134
|
+
// Enough to paint a first frame for a player with no poster, without
|
|
135
|
+
// fetching the whole file into an editor nobody is watching it in.
|
|
136
|
+
el.preload = 'metadata';
|
|
137
|
+
// Rebuilt only when the markup changed, for the same reason `src` is written
|
|
138
|
+
// only when it changes -- and it matters more here. A rebuild replaces every
|
|
139
|
+
// `<track>` with a new element, and a new `<track>` means a new `TextTrack`,
|
|
140
|
+
// whose `mode` starts out `disabled`. Since this runs on every node update, an
|
|
141
|
+
// author who turned captions on and then resized the player they were watching
|
|
142
|
+
// had them turned back off. An update that did not touch the furniture has no
|
|
143
|
+
// business rebuilding it.
|
|
144
|
+
const furniture = node.attrs['furniture'] ?? '';
|
|
145
|
+
if (appliedFurniture.get(el) === furniture)
|
|
146
|
+
return;
|
|
147
|
+
appliedFurniture.set(el, furniture);
|
|
148
|
+
for (const child of Array.from(el.children))
|
|
149
|
+
child.remove();
|
|
150
|
+
if (furniture) {
|
|
151
|
+
const tpl = el.ownerDocument.createElement('template');
|
|
152
|
+
tpl.innerHTML = furniture;
|
|
153
|
+
// Only the furniture tags, and only their addresses: this string has been
|
|
154
|
+
// through core's `scrub` already, and re-adopting it wholesale into the live
|
|
155
|
+
// document is the class of mistake #64 exists to prevent.
|
|
156
|
+
for (const source of Array.from(tpl.content.children)) {
|
|
157
|
+
const name = source.nodeName.toLowerCase();
|
|
158
|
+
if (name !== 'source' && name !== 'track')
|
|
159
|
+
continue;
|
|
160
|
+
const copy = el.ownerDocument.createElement(name);
|
|
161
|
+
for (const attr of ['src', 'type', 'kind', 'srclang', 'label']) {
|
|
162
|
+
const value = source.getAttribute(attr);
|
|
163
|
+
if (value !== null)
|
|
164
|
+
copy.setAttribute(attr, value);
|
|
165
|
+
}
|
|
166
|
+
el.appendChild(copy);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function applyAttrs(el, node) {
|
|
171
|
+
if (isVideo(el))
|
|
172
|
+
applyVideoAttrs(el, node);
|
|
173
|
+
else
|
|
174
|
+
applyImageAttrs(el, node);
|
|
175
|
+
const title = node.attrs['title'];
|
|
176
|
+
if (title)
|
|
177
|
+
el.title = title;
|
|
178
|
+
else
|
|
179
|
+
el.removeAttribute('title');
|
|
180
|
+
setDimension(el, 'width', node.attrs['width']);
|
|
181
|
+
setDimension(el, 'height', node.attrs['height']);
|
|
182
|
+
}
|
|
183
|
+
/** One arrow press, and one Shift+arrow press, in CSS pixels. */
|
|
184
|
+
const STEP = 10;
|
|
185
|
+
const BIG_STEP = 50;
|
|
186
|
+
/** Below this an image is a dot, and the handle cannot be hit again by pointer. */
|
|
187
|
+
const MIN_WIDTH = 16;
|
|
188
|
+
/** On the wrapper while one video has been handed back its own pointer events. */
|
|
189
|
+
const LIVE_CLASS = 'ol-media-live';
|
|
190
|
+
/**
|
|
191
|
+
* ProseMirror's own selection ring class.
|
|
192
|
+
*
|
|
193
|
+
* A node view that defines `selectNode` replaces the default implementation
|
|
194
|
+
* rather than extending it, and adding this class is all the default did for an
|
|
195
|
+
* atom with no `contentDOM`. So it is added here, or a selected video would lose
|
|
196
|
+
* the outline every other selected node gets.
|
|
197
|
+
*/
|
|
198
|
+
const SELECTED_CLASS = 'ProseMirror-selectednode';
|
|
199
|
+
/**
|
|
200
|
+
* The resize handle.
|
|
201
|
+
*
|
|
202
|
+
* It was a real `<button>` with an `aria-label` and a `pointerdown` listener and
|
|
203
|
+
* nothing else -- announced as "Resize image, button", focusable, and inert to
|
|
204
|
+
* every key. A dead tab stop advertising a capability it does not have is worse
|
|
205
|
+
* than no control at all, because the author has to work out for themselves that
|
|
206
|
+
* the thing they just tabbed to does nothing.
|
|
207
|
+
*
|
|
208
|
+
* It is `role="slider"` now rather than a button, because that is what it is: a
|
|
209
|
+
* value the arrow keys move. The role brings the announcement with it -- a
|
|
210
|
+
* screen reader speaks `aria-valuetext` on every change, so the new width is
|
|
211
|
+
* read out without a live region racing it and saying the same thing twice.
|
|
212
|
+
*/
|
|
213
|
+
function mediaView(kind, node, view, getPos, availability) {
|
|
48
214
|
const wrap = view.dom.ownerDocument.createElement('span');
|
|
49
215
|
wrap.className = 'ol-img-resize';
|
|
50
|
-
const img = view.dom.ownerDocument
|
|
216
|
+
const img = createElement(kind, view.dom.ownerDocument);
|
|
51
217
|
applyAttrs(img, node);
|
|
52
218
|
const handle = view.dom.ownerDocument.createElement('button');
|
|
53
219
|
handle.type = 'button';
|
|
54
220
|
handle.className = 'ol-img-handle';
|
|
55
|
-
handle.setAttribute('
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
221
|
+
handle.setAttribute('role', 'slider');
|
|
222
|
+
handle.setAttribute('aria-label', kind === 'video' ? 'Video width' : 'Image width');
|
|
223
|
+
handle.setAttribute('aria-orientation', 'horizontal');
|
|
224
|
+
handle.setAttribute('aria-valuemin', String(MIN_WIDTH));
|
|
225
|
+
/**
|
|
226
|
+
* Click-to-activate.
|
|
227
|
+
*
|
|
228
|
+
* The inert preview above is what makes a video selectable, and that must not
|
|
229
|
+
* regress -- but it also means an author cannot play or scrub a clip without
|
|
230
|
+
* leaving the editor. So one explicit gesture hands a single element its own
|
|
231
|
+
* pointer events back, and only for as long as it stays selected.
|
|
232
|
+
*
|
|
233
|
+
* The gesture is *our* button rather than a listener on the element, because a
|
|
234
|
+
* listener on the element cannot work: Firefox routes pointer events for the
|
|
235
|
+
* whole of a `<video controls>` into its native chrome, and the `pointerdown`
|
|
236
|
+
* never arrives. It was tried first.
|
|
237
|
+
*
|
|
238
|
+
* The button appears only once the node is selected, so the first click on a
|
|
239
|
+
* video is still the one that selects it. That makes the activation gesture a
|
|
240
|
+
* second click in practice, while being a real, labelled, focusable control
|
|
241
|
+
* rather than a click count nobody can see.
|
|
242
|
+
*
|
|
243
|
+
* `null` for an image: it has nothing to play, and giving it a `selectNode` of
|
|
244
|
+
* our own would mean reimplementing the selection ring for no reason.
|
|
245
|
+
*/
|
|
246
|
+
const media = isVideo(img) ? img : null;
|
|
247
|
+
const play = media === null ? null : view.dom.ownerDocument.createElement('button');
|
|
248
|
+
if (play !== null) {
|
|
249
|
+
play.type = 'button';
|
|
250
|
+
play.className = 'ol-media-play';
|
|
251
|
+
play.setAttribute('aria-label', 'Play video');
|
|
252
|
+
play.hidden = true;
|
|
253
|
+
}
|
|
254
|
+
// The handle goes last so it paints over the play button, which is centred and
|
|
255
|
+
// could otherwise cover a handle on a small video.
|
|
256
|
+
wrap.append(img, ...(play === null ? [] : [play]), handle);
|
|
257
|
+
let selected = false;
|
|
258
|
+
let live = false;
|
|
259
|
+
/** Put `live` and `selected` onto the DOM. The single place either is read. */
|
|
260
|
+
const syncLive = () => {
|
|
261
|
+
if (media === null || play === null)
|
|
262
|
+
return;
|
|
263
|
+
media.controls = live;
|
|
264
|
+
wrap.classList.toggle(LIVE_CLASS, live);
|
|
265
|
+
play.hidden = live || !selected;
|
|
266
|
+
};
|
|
267
|
+
const activate = () => {
|
|
268
|
+
if (media === null || live)
|
|
269
|
+
return;
|
|
270
|
+
live = true;
|
|
271
|
+
syncLive();
|
|
272
|
+
// The button that was just pressed is hidden now, so focus cannot stay on
|
|
273
|
+
// it. It belongs on the player: the native control bar is reachable from
|
|
274
|
+
// there with the keyboard, and the Escape handler below needs a focused
|
|
275
|
+
// element inside the wrapper to hear the key at all. ProseMirror keeps the
|
|
276
|
+
// node selection across a blur, so this does not deselect the node.
|
|
277
|
+
media.focus();
|
|
278
|
+
// Best effort. The click that got here is a user activation, so an engine
|
|
279
|
+
// should allow it; if one does not, the author is left looking at working
|
|
280
|
+
// controls and can press play themselves. jsdom has no playback at all.
|
|
281
|
+
try {
|
|
282
|
+
void media.play()?.catch(() => undefined);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
/* No playback here. Activation is the part that matters. */
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Hand the element back to the preview state.
|
|
290
|
+
*
|
|
291
|
+
* The pause is not tidiness. Once the element is inert again there is no
|
|
292
|
+
* control left to stop it with, so a clip left playing could not be silenced
|
|
293
|
+
* without hunting it down and selecting it a second time.
|
|
294
|
+
*/
|
|
295
|
+
const release = () => {
|
|
296
|
+
if (media === null)
|
|
297
|
+
return;
|
|
298
|
+
if (live) {
|
|
299
|
+
live = false;
|
|
300
|
+
try {
|
|
301
|
+
media.pause();
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
/* No playback here either. */
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
syncLive();
|
|
308
|
+
};
|
|
309
|
+
play?.addEventListener('click', (event) => {
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
activate();
|
|
312
|
+
});
|
|
313
|
+
// Escape is the way out for a keyboard author: the native control bar is
|
|
314
|
+
// focusable, and leaving focus inside one that is about to stop taking
|
|
315
|
+
// pointer events is a dead end. The node stays selected, so the toolbar can
|
|
316
|
+
// still edit it.
|
|
317
|
+
wrap.addEventListener('keydown', (event) => {
|
|
318
|
+
if (!live || event.key !== 'Escape')
|
|
319
|
+
return;
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
event.stopPropagation();
|
|
322
|
+
release();
|
|
323
|
+
view.focus();
|
|
324
|
+
});
|
|
325
|
+
/** How wide the author may go: the line the image sits on, or its natural size. */
|
|
326
|
+
const maxWidth = () => {
|
|
327
|
+
const box = Math.round(view.dom.getBoundingClientRect().width);
|
|
328
|
+
if (box > MIN_WIDTH)
|
|
329
|
+
return box;
|
|
330
|
+
return Math.max(intrinsic(img).width || 0, currentWidth() * 2, 1000);
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* The width the next key press moves from.
|
|
334
|
+
*
|
|
335
|
+
* The stored attribute first, because it is what the document says and what a
|
|
336
|
+
* repeated press has to accumulate on; the rendered box only as a fallback for
|
|
337
|
+
* an image that has never been given one.
|
|
338
|
+
*/
|
|
339
|
+
function currentWidth() {
|
|
340
|
+
const pos = getPos();
|
|
341
|
+
const stored = pos === undefined ? null : view.state.doc.nodeAt(pos)?.attrs['width'];
|
|
342
|
+
const value = Number(stored);
|
|
343
|
+
if (Number.isFinite(value) && value > 0)
|
|
344
|
+
return value;
|
|
345
|
+
return Math.round(img.getBoundingClientRect().width) || 0;
|
|
346
|
+
}
|
|
347
|
+
const sync = (updated) => {
|
|
348
|
+
const stored = updated.attrs['width'];
|
|
349
|
+
const value = Number(stored);
|
|
350
|
+
const numeric = Number.isFinite(value) && value > 0;
|
|
351
|
+
const shown = numeric ? String(value) : (stored === null ? '' : String(stored));
|
|
352
|
+
handle.setAttribute('aria-valuenow', String(numeric ? value : currentWidth()));
|
|
353
|
+
handle.setAttribute('aria-valuemax', String(maxWidth()));
|
|
354
|
+
syncAvailability();
|
|
355
|
+
// A percentage width is legal in the storage format, and "50%" is a truer
|
|
356
|
+
// thing to say than the pixel count it happens to render at right now.
|
|
357
|
+
handle.setAttribute('aria-valuetext', shown === '' ? 'Automatic' : numeric ? `${shown} pixels` : shown);
|
|
358
|
+
};
|
|
359
|
+
/**
|
|
360
|
+
* Whether the handle advertises that it can do anything.
|
|
361
|
+
*
|
|
362
|
+
* This handle is a real button inside a node view, so it sits outside
|
|
363
|
+
* ProseMirror's `editable` gate the way the table context menu does -- and a
|
|
364
|
+
* read-only document could be resized with the arrow keys. The guards are in
|
|
365
|
+
* `resizeTo` and on `pointerdown`; this is the part that says so, because a
|
|
366
|
+
* control that silently does nothing is worse than one that admits it.
|
|
367
|
+
*
|
|
368
|
+
* Unavailable rather than absent, matching the toolbar. `aria-disabled` rather
|
|
369
|
+
* than `disabled` keeps a slider focusable, so an author can still read the
|
|
370
|
+
* current width off it.
|
|
371
|
+
*
|
|
372
|
+
* Separate from `sync` and registered with the plugin, because read-only can
|
|
373
|
+
* be toggled after mount and a node view's `update` only runs when its NODE
|
|
374
|
+
* changes. It touches one attribute and reads no layout, so refreshing every
|
|
375
|
+
* media view on the transition costs nothing -- which `sync` could not claim,
|
|
376
|
+
* since `maxWidth()` measures.
|
|
377
|
+
*/
|
|
378
|
+
const syncAvailability = () => {
|
|
379
|
+
handle.setAttribute('aria-disabled', view.editable ? 'false' : 'true');
|
|
380
|
+
};
|
|
381
|
+
availability.add(syncAvailability);
|
|
382
|
+
/**
|
|
383
|
+
* Pixel height that keeps the element's aspect ratio, or null if it is
|
|
384
|
+
* unknown -- a video that has not loaded metadata yet reports 0x0, and
|
|
385
|
+
* guessing a height for it would squash the frame once it arrives.
|
|
386
|
+
*/
|
|
387
|
+
const heightFor = (width) => {
|
|
388
|
+
const { width: nw, height: nh } = intrinsic(img);
|
|
389
|
+
const ratio = nh && nw ? nh / nw : 0;
|
|
390
|
+
return ratio ? String(Math.round(width * ratio)) : null;
|
|
391
|
+
};
|
|
392
|
+
const resizeTo = (raw) => {
|
|
393
|
+
// See `syncAvailability`: this path is not behind ProseMirror's own gate.
|
|
394
|
+
if (!view.editable)
|
|
62
395
|
return;
|
|
63
|
-
const next = Math.max(16, Math.round(startWidth + (event.clientX - startX)));
|
|
64
396
|
const pos = getPos();
|
|
65
397
|
if (pos === undefined)
|
|
66
398
|
return;
|
|
67
|
-
const
|
|
68
|
-
const height =
|
|
399
|
+
const next = Math.max(MIN_WIDTH, Math.round(raw));
|
|
400
|
+
const height = heightFor(next);
|
|
69
401
|
view.dispatch(view.state.tr.setNodeMarkup(pos, undefined, {
|
|
70
402
|
...view.state.doc.nodeAt(pos)?.attrs,
|
|
71
403
|
width: String(next),
|
|
72
404
|
height,
|
|
73
405
|
}));
|
|
74
406
|
};
|
|
75
|
-
|
|
407
|
+
/*
|
|
408
|
+
* The drag is a CSS preview, and exactly one transaction at the end.
|
|
409
|
+
*
|
|
410
|
+
* Dispatching per `pointermove` was the obvious implementation and the wrong
|
|
411
|
+
* one twice over. A pointer reports at 60-120 Hz, and every one of those was a
|
|
412
|
+
* `docChanged` transaction: the full per-keystroke bill -- plugin
|
|
413
|
+
* `appendTransaction`s, decoration rebuilds, the host's change listener --
|
|
414
|
+
* ninety times a second, on a document the author was not editing. And each
|
|
415
|
+
* one landed in the undo history, so a two-second drag cost the author a
|
|
416
|
+
* hundred and eighty presses of Ctrl-Z to get back past it.
|
|
417
|
+
*
|
|
418
|
+
* So the drag paints `img.style` instead, coalesced to one write per animation
|
|
419
|
+
* frame because paint is the only thing that consumes it and the browser only
|
|
420
|
+
* paints once per frame anyway. The document learns the final size once, on
|
|
421
|
+
* `pointerup`, which is also the only size the author ever meant.
|
|
422
|
+
*/
|
|
423
|
+
let dragging = false;
|
|
424
|
+
let startX = 0;
|
|
425
|
+
let startWidth = 0;
|
|
426
|
+
let previewWidth = 0;
|
|
427
|
+
let moved = false;
|
|
428
|
+
let frame = 0;
|
|
429
|
+
const win = () => wrap.ownerDocument.defaultView;
|
|
430
|
+
const paint = () => {
|
|
431
|
+
frame = 0;
|
|
432
|
+
if (!dragging)
|
|
433
|
+
return;
|
|
434
|
+
img.style.width = `${previewWidth}px`;
|
|
435
|
+
const height = heightFor(previewWidth);
|
|
436
|
+
if (height !== null)
|
|
437
|
+
img.style.height = `${height}px`;
|
|
438
|
+
};
|
|
439
|
+
/** Drop the preview so the node's own attributes are what shows again. */
|
|
440
|
+
const clearPreview = () => {
|
|
441
|
+
if (frame !== 0)
|
|
442
|
+
win()?.cancelAnimationFrame(frame);
|
|
443
|
+
frame = 0;
|
|
444
|
+
img.style.removeProperty('width');
|
|
445
|
+
img.style.removeProperty('height');
|
|
446
|
+
};
|
|
447
|
+
const onMove = (event) => {
|
|
448
|
+
if (!dragging)
|
|
449
|
+
return;
|
|
450
|
+
previewWidth = Math.max(16, Math.round(startWidth + (event.clientX - startX)));
|
|
451
|
+
moved = true;
|
|
452
|
+
const frames = win();
|
|
453
|
+
if (!frames?.requestAnimationFrame) {
|
|
454
|
+
paint();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (frame === 0)
|
|
458
|
+
frame = frames.requestAnimationFrame(paint);
|
|
459
|
+
};
|
|
460
|
+
const stop = () => {
|
|
461
|
+
if (!dragging)
|
|
462
|
+
return;
|
|
76
463
|
dragging = false;
|
|
464
|
+
clearPreview();
|
|
77
465
|
window.removeEventListener('pointermove', onMove);
|
|
78
466
|
window.removeEventListener('pointerup', onUp);
|
|
467
|
+
window.removeEventListener('pointercancel', onUp);
|
|
468
|
+
};
|
|
469
|
+
const onUp = () => {
|
|
470
|
+
const commit = dragging && moved;
|
|
471
|
+
const width = previewWidth;
|
|
472
|
+
stop();
|
|
473
|
+
if (!commit)
|
|
474
|
+
return;
|
|
475
|
+
// Rechecked at the commit, not only at the `pointerdown` that started the
|
|
476
|
+
// drag. `readonly` can arrive mid-gesture -- a permission change, a lock, a
|
|
477
|
+
// host toggling the attribute -- and the start-only guard has already passed
|
|
478
|
+
// by then, so this is the last point at which a width can be refused. `stop`
|
|
479
|
+
// above has already dropped the preview, so refusing here leaves the element
|
|
480
|
+
// at the width the document actually says.
|
|
481
|
+
if (!view.editable)
|
|
482
|
+
return;
|
|
483
|
+
const pos = getPos();
|
|
484
|
+
if (pos === undefined)
|
|
485
|
+
return;
|
|
486
|
+
view.dispatch(view.state.tr.setNodeMarkup(pos, undefined, {
|
|
487
|
+
...view.state.doc.nodeAt(pos)?.attrs,
|
|
488
|
+
width: String(width),
|
|
489
|
+
height: heightFor(width),
|
|
490
|
+
}));
|
|
79
491
|
};
|
|
80
492
|
handle.addEventListener('pointerdown', (event) => {
|
|
493
|
+
// Guarded at the start of the drag rather than at its commit, so a read-only
|
|
494
|
+
// document does not even show a resize preview.
|
|
495
|
+
if (!view.editable)
|
|
496
|
+
return;
|
|
81
497
|
event.preventDefault();
|
|
82
498
|
dragging = true;
|
|
499
|
+
moved = false;
|
|
83
500
|
startX = event.clientX;
|
|
84
501
|
startWidth = img.getBoundingClientRect().width;
|
|
502
|
+
previewWidth = startWidth;
|
|
503
|
+
// The pointer belongs to the handle until it is released, so a fast drag
|
|
504
|
+
// that outruns the cursor -- or leaves the editor entirely -- still reports
|
|
505
|
+
// to us instead of to whatever it happens to be over. Guarded because jsdom
|
|
506
|
+
// has no pointer capture; the window listeners below are what makes the
|
|
507
|
+
// fallback work, and capture retargets events without stopping them
|
|
508
|
+
// reaching an ancestor, so both paths see the same stream.
|
|
509
|
+
try {
|
|
510
|
+
handle.setPointerCapture(event.pointerId);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
/* No pointer capture here. The window listeners are the whole fallback. */
|
|
514
|
+
}
|
|
85
515
|
window.addEventListener('pointermove', onMove);
|
|
86
516
|
window.addEventListener('pointerup', onUp);
|
|
517
|
+
window.addEventListener('pointercancel', onUp);
|
|
518
|
+
});
|
|
519
|
+
handle.addEventListener('keydown', (event) => {
|
|
520
|
+
const step = event.shiftKey ? BIG_STEP : STEP;
|
|
521
|
+
const width = currentWidth();
|
|
522
|
+
let next = null;
|
|
523
|
+
switch (event.key) {
|
|
524
|
+
case 'ArrowRight':
|
|
525
|
+
case 'ArrowUp':
|
|
526
|
+
next = width + step;
|
|
527
|
+
break;
|
|
528
|
+
case 'ArrowLeft':
|
|
529
|
+
case 'ArrowDown':
|
|
530
|
+
next = width - step;
|
|
531
|
+
break;
|
|
532
|
+
case 'PageUp':
|
|
533
|
+
next = width + BIG_STEP;
|
|
534
|
+
break;
|
|
535
|
+
case 'PageDown':
|
|
536
|
+
next = width - BIG_STEP;
|
|
537
|
+
break;
|
|
538
|
+
case 'Home':
|
|
539
|
+
next = MIN_WIDTH;
|
|
540
|
+
break;
|
|
541
|
+
case 'End':
|
|
542
|
+
next = maxWidth();
|
|
543
|
+
break;
|
|
544
|
+
default:
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
event.preventDefault();
|
|
548
|
+
// The same arrow key moves the caret out of the image if the editor sees it.
|
|
549
|
+
event.stopPropagation();
|
|
550
|
+
resizeTo(next);
|
|
87
551
|
});
|
|
88
|
-
|
|
552
|
+
sync(node);
|
|
553
|
+
const nodeView = {
|
|
89
554
|
dom: wrap,
|
|
90
555
|
update(updated) {
|
|
91
|
-
if (updated.type.name !==
|
|
556
|
+
if (updated.type.name !== kind)
|
|
92
557
|
return false;
|
|
93
558
|
applyAttrs(img, updated);
|
|
559
|
+
sync(updated);
|
|
560
|
+
// `applyAttrs` restores the preview -- controls off -- because that is the
|
|
561
|
+
// default state. An activated player has to survive its own resize, so the
|
|
562
|
+
// live state is reasserted here.
|
|
563
|
+
syncLive();
|
|
94
564
|
return true;
|
|
95
565
|
},
|
|
96
566
|
destroy() {
|
|
97
|
-
onUp
|
|
567
|
+
// `stop`, not `onUp`: a node view torn down mid-drag must not dispatch
|
|
568
|
+
// into a view that is being dismantled, and the size the author was
|
|
569
|
+
// dragging towards is not one they ever committed to.
|
|
570
|
+
stop();
|
|
571
|
+
// A detached media element keeps playing. Nothing would be able to stop it.
|
|
572
|
+
release();
|
|
573
|
+
availability.delete(syncAvailability);
|
|
98
574
|
},
|
|
99
575
|
};
|
|
576
|
+
if (media !== null) {
|
|
577
|
+
nodeView.selectNode = () => {
|
|
578
|
+
wrap.classList.add(SELECTED_CLASS);
|
|
579
|
+
selected = true;
|
|
580
|
+
syncLive();
|
|
581
|
+
};
|
|
582
|
+
nodeView.deselectNode = () => {
|
|
583
|
+
wrap.classList.remove(SELECTED_CLASS);
|
|
584
|
+
selected = false;
|
|
585
|
+
// At most one live player: the selection moving on is what ends this one.
|
|
586
|
+
release();
|
|
587
|
+
};
|
|
588
|
+
/**
|
|
589
|
+
* Keep ProseMirror out of the activated player's events.
|
|
590
|
+
*
|
|
591
|
+
* Not a nicety. ProseMirror's own `mousedown` handling calls
|
|
592
|
+
* `preventDefault()` when the gesture lands on a selectable atom, which
|
|
593
|
+
* stops a native control bar responding at all -- the seek bar would not
|
|
594
|
+
* drag. While the element is inert this never comes up, because the CSS
|
|
595
|
+
* means it receives no pointer events in the first place.
|
|
596
|
+
*/
|
|
597
|
+
nodeView.stopEvent = (event) => {
|
|
598
|
+
const target = event.target;
|
|
599
|
+
if (target === null || !(typeof target === 'object') || !('nodeType' in target))
|
|
600
|
+
return false;
|
|
601
|
+
const node = target;
|
|
602
|
+
if (play !== null && (play === node || play.contains(node)))
|
|
603
|
+
return true;
|
|
604
|
+
return live && (media === node || media.contains(node));
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
return nodeView;
|
|
100
608
|
}
|
|
101
|
-
|
|
609
|
+
/**
|
|
610
|
+
* Drag-resize handles for the media that has a box to drag.
|
|
611
|
+
*
|
|
612
|
+
* One plugin for both kinds rather than one per kind: a `nodeViews` prop is a
|
|
613
|
+
* map keyed by node name, so two plugins each claiming `image` would not
|
|
614
|
+
* compose -- the later registration would simply win, and which one that is
|
|
615
|
+
* depends on install order.
|
|
616
|
+
*/
|
|
617
|
+
export function mediaResizePlugin() {
|
|
618
|
+
/** Every live handle's availability refresher. See `syncAvailability`. */
|
|
619
|
+
const availability = new Set();
|
|
102
620
|
return new Plugin({
|
|
621
|
+
/*
|
|
622
|
+
* Only to notice read-only being toggled after mount.
|
|
623
|
+
*
|
|
624
|
+
* A node view's `update` runs when its node changes, and a change of
|
|
625
|
+
* editability is not that -- so without this a handle created while the
|
|
626
|
+
* document was editable went on saying so after `readonly` arrived. Gated on
|
|
627
|
+
* the transition rather than run every transaction: this fires on every
|
|
628
|
+
* keystroke, and doing per-node work here is exactly the per-keystroke cost
|
|
629
|
+
* that was removed from this editor once already.
|
|
630
|
+
*/
|
|
631
|
+
view(editor) {
|
|
632
|
+
let editable = editor.editable;
|
|
633
|
+
return {
|
|
634
|
+
update(updated) {
|
|
635
|
+
if (updated.editable === editable)
|
|
636
|
+
return;
|
|
637
|
+
editable = updated.editable;
|
|
638
|
+
for (const refresh of availability)
|
|
639
|
+
refresh();
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
},
|
|
103
643
|
props: {
|
|
104
644
|
nodeViews: {
|
|
105
|
-
image: (node, view, getPos) =>
|
|
645
|
+
image: (node, view, getPos) => mediaView('image', node, view, getPos, availability),
|
|
646
|
+
video: (node, view, getPos) => mediaView('video', node, view, getPos, availability),
|
|
106
647
|
},
|
|
107
648
|
},
|
|
108
649
|
});
|
|
109
650
|
}
|
|
651
|
+
/**
|
|
652
|
+
* @deprecated Use `mediaResizePlugin`, which also handles video. Kept because
|
|
653
|
+
* this name is in the published API of 0.1.0-beta.2.
|
|
654
|
+
*/
|
|
655
|
+
export function imageResizePlugin() {
|
|
656
|
+
return mediaResizePlugin();
|
|
657
|
+
}
|
|
110
658
|
//# sourceMappingURL=resize.js.map
|