@openleaf-editor/plugins-insert 0.1.0-beta.2 → 0.1.0-beta.4

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/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
- function applyAttrs(img, node) {
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
- function imageView(node, view, getPos) {
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.createElement('img');
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('aria-label', 'Resize image');
56
- wrap.append(img, handle);
57
- let dragging = false;
58
- let startX = 0;
59
- let startWidth = 0;
60
- const onMove = (event) => {
61
- if (!dragging)
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 ratio = img.naturalHeight && img.naturalWidth ? img.naturalHeight / img.naturalWidth : 0;
68
- const height = ratio ? String(Math.round(next * ratio)) : null;
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
- const onUp = () => {
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
- return {
552
+ sync(node);
553
+ const nodeView = {
89
554
  dom: wrap,
90
555
  update(updated) {
91
- if (updated.type.name !== 'image')
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
- export function imageResizePlugin() {
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) => imageView(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