@llui/markdown-editor 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/editor.d.ts +15 -2
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +12 -2
- package/dist/editor.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/block-drag.d.ts +74 -0
- package/dist/plugins/block-drag.d.ts.map +1 -0
- package/dist/plugins/block-drag.js +983 -0
- package/dist/plugins/block-drag.js.map +1 -0
- package/dist/plugins/code-language.d.ts +60 -0
- package/dist/plugins/code-language.d.ts.map +1 -0
- package/dist/plugins/code-language.js +308 -0
- package/dist/plugins/code-language.js.map +1 -0
- package/dist/plugins/frontmatter.d.ts +45 -0
- package/dist/plugins/frontmatter.d.ts.map +1 -0
- package/dist/plugins/frontmatter.js +281 -0
- package/dist/plugins/frontmatter.js.map +1 -0
- package/dist/plugins/overlay.d.ts +1 -0
- package/dist/plugins/overlay.d.ts.map +1 -1
- package/dist/plugins/overlay.js +7 -0
- package/dist/plugins/overlay.js.map +1 -1
- package/dist/plugins/wikilink.d.ts +110 -0
- package/dist/plugins/wikilink.d.ts.map +1 -0
- package/dist/plugins/wikilink.js +692 -0
- package/dist/plugins/wikilink.js.map +1 -0
- package/dist/styles/block-drag.css +118 -0
- package/dist/styles/editor.css +112 -0
- package/dist/transformers/code.d.ts +24 -0
- package/dist/transformers/code.d.ts.map +1 -0
- package/dist/transformers/code.js +167 -0
- package/dist/transformers/code.js.map +1 -0
- package/dist/transformers/gfm.d.ts.map +1 -1
- package/dist/transformers/gfm.js +7 -2
- package/dist/transformers/gfm.js.map +1 -1
- package/dist/transformers/registry.d.ts +3 -0
- package/dist/transformers/registry.d.ts.map +1 -1
- package/dist/transformers/registry.js +26 -0
- package/dist/transformers/registry.js.map +1 -1
- package/package.json +42 -28
- package/src/editor.ts +27 -4
- package/src/index.ts +58 -1
- package/src/plugins/block-drag.ts +1175 -0
- package/src/plugins/code-language.ts +378 -0
- package/src/plugins/frontmatter.ts +324 -0
- package/src/plugins/overlay.ts +7 -0
- package/src/plugins/wikilink.ts +843 -0
- package/src/styles/block-drag.css +118 -0
- package/src/styles/editor.css +112 -0
- package/src/transformers/code.ts +174 -0
- package/src/transformers/gfm.ts +6 -2
- package/src/transformers/registry.ts +27 -0
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
// Block drag-and-drop reordering — a hover gutter grip that reorders TOP-LEVEL
|
|
2
|
+
// blocks, by pointer drag or by keyboard.
|
|
3
|
+
//
|
|
4
|
+
// ## Why not HTML5 drag-and-drop
|
|
5
|
+
// `draggable=true` + `dragover`/`drop` cannot work here: the drag source lives in
|
|
6
|
+
// a portaled overlay while the drop zone is a `contenteditable`, and Lexical owns
|
|
7
|
+
// the native drag/drop pipeline for content insertion. Hijacking it fights the
|
|
8
|
+
// editor. Raw `mousedown` → document `mousemove`/`mouseup` (the same mechanics the
|
|
9
|
+
// reference ProseMirror implementation uses) keeps the editor's own drag handling
|
|
10
|
+
// untouched and gives exact control over the drop-slot geometry.
|
|
11
|
+
//
|
|
12
|
+
// ## Why the reorder goes through Lexical, never the DOM
|
|
13
|
+
// Moving DOM nodes under a `contenteditable` desynchronizes Lexical's node map
|
|
14
|
+
// from the DOM and is reverted on the next reconcile. The commit is a single
|
|
15
|
+
// `target.insertAfter(source)` / `insertBefore` inside one `editor.update`, so it
|
|
16
|
+
// is ONE undo step, replays correctly through the collab CRDT (`@lexical/yjs`
|
|
17
|
+
// observes node moves, not DOM mutations), and preserves the moved node's key —
|
|
18
|
+
// selection anchored inside it (or inside any other block) survives.
|
|
19
|
+
//
|
|
20
|
+
// ## Shape
|
|
21
|
+
// All geometry is PURE (see {@link blockAtPoint} / {@link findDropTarget} /
|
|
22
|
+
// {@link indicatorRect}): the DOM layer only measures rects and feeds numbers in.
|
|
23
|
+
// Everything rendered lives in this plugin's JSON state slice and is drawn with
|
|
24
|
+
// the package's shared {@link overlayRoot} primitive — no competing overlay
|
|
25
|
+
// system, no imperative DOM writes in the view.
|
|
26
|
+
//
|
|
27
|
+
// ## Stacking
|
|
28
|
+
// Both surfaces sit BELOW the `OVERLAY_Z` scale in `overlay.ts` (60+): the gutter
|
|
29
|
+
// grip and the drop indicator are chrome for the document body and must never
|
|
30
|
+
// cover a typeahead, the context menu, or the floating toolbar.
|
|
31
|
+
import { $getNodeByKey, $getRoot, $getSelection, $isElementNode, $isRangeSelection, COMMAND_PRIORITY_LOW, KEY_DOWN_COMMAND, } from 'lexical';
|
|
32
|
+
import { mergeRegister } from '@lexical/utils';
|
|
33
|
+
import { button, div, onMount, text } from '@llui/dom';
|
|
34
|
+
import { definePluginUI } from './ui.js';
|
|
35
|
+
import { onViewportChange, overlayRoot } from './overlay.js';
|
|
36
|
+
/** Stacking levels for this plugin's two surfaces — deliberately below the
|
|
37
|
+
* shared `OVERLAY_Z` scale (60+) so document chrome never covers a menu. */
|
|
38
|
+
export const BLOCK_DRAG_Z = { handle: 58, indicator: 59, menu: 62 };
|
|
39
|
+
/** Pixels of pointer travel before a grip mousedown becomes a drag (below the
|
|
40
|
+
* threshold it is a click, which toggles keyboard grab mode instead). */
|
|
41
|
+
const DRAG_THRESHOLD = 5;
|
|
42
|
+
/** Vertical slack when deciding which block the pointer is "on" — block margins
|
|
43
|
+
* leave real gaps between rects, and the gutter must not flicker across them. */
|
|
44
|
+
const HOVER_TOLERANCE = 6;
|
|
45
|
+
/** How far LEFT of the editor's content box the hover zone extends, i.e. how
|
|
46
|
+
* much room the gutter grip is given. */
|
|
47
|
+
const GUTTER_WIDTH = 40;
|
|
48
|
+
// ── Pure geometry ───────────────────────────────────────────────────────────
|
|
49
|
+
/**
|
|
50
|
+
* The block whose vertical band contains `clientY`, or `null` when the pointer
|
|
51
|
+
* is in no block's band.
|
|
52
|
+
*
|
|
53
|
+
* TWO passes, and the order matters. A block's OWN rect always wins outright;
|
|
54
|
+
* only a point in no block at all falls through to the widened search, where
|
|
55
|
+
* the NEAREST band within `tolerance` wins (ties biased upward, matching how a
|
|
56
|
+
* reader attributes a gap to the block above it).
|
|
57
|
+
*
|
|
58
|
+
* A single widened pass with first-match-wins — which this was — is wrong
|
|
59
|
+
* wherever two rects touch or nearly touch, and touching rects are the common
|
|
60
|
+
* case, not the exotic one: list items, table rows, consecutive lines, and any
|
|
61
|
+
* margin-collapsed heading. With `tolerance = 6` and adjacent rects [0,20] and
|
|
62
|
+
* [20,40], every y in [20,26] resolved to the FIRST block, so the block below
|
|
63
|
+
* lost the top 6px of its own body — the grip targeted, grabbed and dragged the
|
|
64
|
+
* wrong block. Generally, for an inter-block gap `g < tolerance`, block N stole
|
|
65
|
+
* the first `tolerance - g` px of block N+1.
|
|
66
|
+
*/
|
|
67
|
+
export function blockAtPoint(blocks, clientY, tolerance = HOVER_TOLERANCE) {
|
|
68
|
+
for (const block of blocks) {
|
|
69
|
+
if (clientY >= block.top && clientY <= block.bottom)
|
|
70
|
+
return block;
|
|
71
|
+
}
|
|
72
|
+
let best = null;
|
|
73
|
+
let bestDistance = Infinity;
|
|
74
|
+
for (const block of blocks) {
|
|
75
|
+
const distance = clientY < block.top
|
|
76
|
+
? block.top - clientY
|
|
77
|
+
: clientY > block.bottom
|
|
78
|
+
? clientY - block.bottom
|
|
79
|
+
: 0;
|
|
80
|
+
if (distance <= tolerance && distance < bestDistance) {
|
|
81
|
+
best = block;
|
|
82
|
+
bestDistance = distance;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return best;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The slot `clientY` points at, expressed relative to a neighbouring block.
|
|
89
|
+
*
|
|
90
|
+
* The document has `n + 1` slots for `n` blocks; the slot index is the count of
|
|
91
|
+
* blocks whose vertical midpoint is above the pointer. Two of those slots are
|
|
92
|
+
* where `sourceKey` already sits — dropping there is a no-op, so both return
|
|
93
|
+
* `null` and the caller shows no indicator and commits nothing. That check is
|
|
94
|
+
* what stops a 1px twitch from producing a spurious undo entry.
|
|
95
|
+
*/
|
|
96
|
+
export function findDropTarget(blocks, clientY, sourceKey) {
|
|
97
|
+
const sourceIndex = blocks.findIndex((b) => b.key === sourceKey);
|
|
98
|
+
if (sourceIndex === -1)
|
|
99
|
+
return null;
|
|
100
|
+
let slot = 0;
|
|
101
|
+
for (const block of blocks) {
|
|
102
|
+
if (clientY > (block.top + block.bottom) / 2)
|
|
103
|
+
slot++;
|
|
104
|
+
else
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
// The two boundaries of the source's own slot are both no-ops.
|
|
108
|
+
if (slot === sourceIndex || slot === sourceIndex + 1)
|
|
109
|
+
return null;
|
|
110
|
+
if (slot === 0)
|
|
111
|
+
return { key: blocks[0].key, place: 'before' };
|
|
112
|
+
return { key: blocks[slot - 1].key, place: 'after' };
|
|
113
|
+
}
|
|
114
|
+
/** Where to draw the indicator line for a resolved {@link DropTarget}: on the
|
|
115
|
+
* target's top edge for `before`, its bottom edge for `after`. */
|
|
116
|
+
export function indicatorRect(blocks, target) {
|
|
117
|
+
const block = blocks.find((b) => b.key === target.key);
|
|
118
|
+
if (!block)
|
|
119
|
+
return null;
|
|
120
|
+
return {
|
|
121
|
+
x: block.left,
|
|
122
|
+
y: target.place === 'before' ? block.top : block.bottom,
|
|
123
|
+
width: block.width,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// ── Lexical reads / writes ──────────────────────────────────────────────────
|
|
127
|
+
/** Measure every top-level block. Blocks with no rendered element (never the
|
|
128
|
+
* case in practice, but possible mid-reconcile) are skipped rather than
|
|
129
|
+
* measured as zeros, which would corrupt every midpoint comparison. */
|
|
130
|
+
function readBlockRects(editor) {
|
|
131
|
+
const keys = editor.getEditorState().read(() => $getRoot()
|
|
132
|
+
.getChildren()
|
|
133
|
+
.map((n) => n.getKey()));
|
|
134
|
+
const rects = [];
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
const el = editor.getElementByKey(key);
|
|
137
|
+
if (!el)
|
|
138
|
+
continue;
|
|
139
|
+
const r = el.getBoundingClientRect();
|
|
140
|
+
rects.push({ key, top: r.top, bottom: r.bottom, left: r.left, width: r.width });
|
|
141
|
+
}
|
|
142
|
+
return rects;
|
|
143
|
+
}
|
|
144
|
+
/** A short, speakable name for a block: its leading text, else its node type. */
|
|
145
|
+
function blockLabel(node) {
|
|
146
|
+
const content = node.getTextContent().trim().replace(/\s+/g, ' ');
|
|
147
|
+
if (content === '')
|
|
148
|
+
return node.getType();
|
|
149
|
+
return content.length > 32 ? `${content.slice(0, 32)}…` : content;
|
|
150
|
+
}
|
|
151
|
+
/** Move `sourceKey` to `place` `targetKey` among the root's children, inside the
|
|
152
|
+
* caller's `editor.update`. Returns `null` (no mutation, so no undo entry) when
|
|
153
|
+
* either key is stale, they are the same node, or the move is a no-op. */
|
|
154
|
+
function $moveBlock(sourceKey, targetKey, place) {
|
|
155
|
+
if (sourceKey === targetKey)
|
|
156
|
+
return null;
|
|
157
|
+
const root = $getRoot();
|
|
158
|
+
const children = root.getChildren();
|
|
159
|
+
const source = children.find((n) => n.getKey() === sourceKey);
|
|
160
|
+
const target = children.find((n) => n.getKey() === targetKey);
|
|
161
|
+
if (!source || !target)
|
|
162
|
+
return null;
|
|
163
|
+
const sourceIndex = source.getIndexWithinParent();
|
|
164
|
+
const targetIndex = target.getIndexWithinParent();
|
|
165
|
+
// Already in that slot — mutating would push an empty undo step.
|
|
166
|
+
if (place === 'after' && targetIndex === sourceIndex - 1)
|
|
167
|
+
return null;
|
|
168
|
+
if (place === 'before' && targetIndex === sourceIndex + 1)
|
|
169
|
+
return null;
|
|
170
|
+
const label = blockLabel(source);
|
|
171
|
+
// `insertAfter`/`insertBefore` detach the node from its current parent first
|
|
172
|
+
// and re-anchor an element-anchored selection, so this is a true move.
|
|
173
|
+
if (place === 'after')
|
|
174
|
+
target.insertAfter(source);
|
|
175
|
+
else
|
|
176
|
+
target.insertBefore(source);
|
|
177
|
+
return {
|
|
178
|
+
key: sourceKey,
|
|
179
|
+
announcement: `${label} moved to position ${source.getIndexWithinParent() + 1} of ${root.getChildrenSize()}.`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Move the block one slot up (`-1`) or down (`1`). Clamped at both ends, where
|
|
183
|
+
* it reports the boundary rather than silently doing nothing. */
|
|
184
|
+
function $shiftBlock(key, direction) {
|
|
185
|
+
const root = $getRoot();
|
|
186
|
+
const children = root.getChildren();
|
|
187
|
+
const index = children.findIndex((n) => n.getKey() === key);
|
|
188
|
+
if (index === -1)
|
|
189
|
+
return null;
|
|
190
|
+
const next = index + direction;
|
|
191
|
+
if (next < 0)
|
|
192
|
+
return { key, announcement: 'Already at the first position.' };
|
|
193
|
+
if (next >= children.length)
|
|
194
|
+
return { key, announcement: 'Already at the last position.' };
|
|
195
|
+
const neighbour = children[next];
|
|
196
|
+
return $moveBlock(key, neighbour.getKey(), direction === 1 ? 'after' : 'before');
|
|
197
|
+
}
|
|
198
|
+
/** Deep-clone a node with FRESH keys via serialize→deserialize. `constructor.clone`
|
|
199
|
+
* is unusable here — it preserves the key, so the "copy" would collide with the
|
|
200
|
+
* original. `importJSON(exportJSON())` mints a new node; element children are not
|
|
201
|
+
* carried by `exportJSON`, so they are cloned and re-appended recursively. */
|
|
202
|
+
function $cloneNode(node) {
|
|
203
|
+
const klass = node.constructor;
|
|
204
|
+
const clone = klass.importJSON(node.exportJSON());
|
|
205
|
+
if ($isElementNode(node) && $isElementNode(clone)) {
|
|
206
|
+
for (const child of node.getChildren())
|
|
207
|
+
clone.append($cloneNode(child));
|
|
208
|
+
}
|
|
209
|
+
return clone;
|
|
210
|
+
}
|
|
211
|
+
/** Insert a deep copy of the top-level block `key` immediately after it. */
|
|
212
|
+
function $duplicateBlock(key) {
|
|
213
|
+
const node = $getNodeByKey(key);
|
|
214
|
+
if (node === null)
|
|
215
|
+
return null;
|
|
216
|
+
const clone = $cloneNode(node);
|
|
217
|
+
node.insertAfter(clone);
|
|
218
|
+
return { key: clone.getKey(), announcement: `${blockLabel(node)} duplicated.` };
|
|
219
|
+
}
|
|
220
|
+
/** Remove the top-level block `key`. Returns the announcement, or `null` when the
|
|
221
|
+
* key is stale. */
|
|
222
|
+
function $deleteBlock(key) {
|
|
223
|
+
const node = $getNodeByKey(key);
|
|
224
|
+
if (node === null)
|
|
225
|
+
return null;
|
|
226
|
+
const label = blockLabel(node);
|
|
227
|
+
node.remove();
|
|
228
|
+
return { announcement: `${label} deleted.` };
|
|
229
|
+
}
|
|
230
|
+
/** Put a collapsed selection at the start of block `key` so a selection-based
|
|
231
|
+
* command (the turn-into items) acts on THAT block. Returns whether it selected. */
|
|
232
|
+
function $selectBlockStart(key) {
|
|
233
|
+
const node = $getNodeByKey(key);
|
|
234
|
+
if (node === null)
|
|
235
|
+
return false;
|
|
236
|
+
node.selectStart();
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
const INITIAL = {
|
|
240
|
+
handleVisible: false,
|
|
241
|
+
handleX: 0,
|
|
242
|
+
handleY: 0,
|
|
243
|
+
hoverKey: '',
|
|
244
|
+
dragging: false,
|
|
245
|
+
indicatorVisible: false,
|
|
246
|
+
indicatorX: 0,
|
|
247
|
+
indicatorY: 0,
|
|
248
|
+
indicatorWidth: 0,
|
|
249
|
+
grabbedKey: '',
|
|
250
|
+
menuOpen: false,
|
|
251
|
+
menuKey: '',
|
|
252
|
+
menuX: 0,
|
|
253
|
+
menuY: 0,
|
|
254
|
+
announce: '',
|
|
255
|
+
announceNonce: 0,
|
|
256
|
+
keyboardReveal: false,
|
|
257
|
+
};
|
|
258
|
+
function hideIndicator(state) {
|
|
259
|
+
return state.indicatorVisible ? { ...state, indicatorVisible: false } : state;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* INVARIANT: a grab cannot outlive the grip that owns it —
|
|
263
|
+
* `handleVisible === false` implies `grabbedKey === ''`.
|
|
264
|
+
*
|
|
265
|
+
* Enforced in ONE place, on every reducer result, rather than at each case that
|
|
266
|
+
* hides the handle. Violating it wedges the gutter permanently and
|
|
267
|
+
* unrecoverably: `hover` and `hoverOut` are both hard-gated on
|
|
268
|
+
* `grabbedKey === ''`, so once the handle is hidden with a grab still pending,
|
|
269
|
+
* every subsequent hover is swallowed and the grip never renders again — and the
|
|
270
|
+
* only senders of `releaseGrab` are the grip's own `onKeyDown`/`onBlur`, which
|
|
271
|
+
* cannot fire on a grip that is not in the DOM.
|
|
272
|
+
*
|
|
273
|
+
* It was originally three separate clears (`dragStart`, `dragEnd`, `drop`).
|
|
274
|
+
* That worked, but it was untestable: the three were mutually redundant, so
|
|
275
|
+
* deleting any ONE of them left every test green while re-arming the bug for the
|
|
276
|
+
* next path someone added. A single invariant is both DRY and pinnable.
|
|
277
|
+
*/
|
|
278
|
+
function normalize(state) {
|
|
279
|
+
if (state.handleVisible || state.grabbedKey === '')
|
|
280
|
+
return state;
|
|
281
|
+
return { ...state, grabbedKey: '' };
|
|
282
|
+
}
|
|
283
|
+
function reduceRaw(state, msg) {
|
|
284
|
+
switch (msg.type) {
|
|
285
|
+
case 'hover':
|
|
286
|
+
// While a block is grabbed, being dragged, or the menu is open the gutter is
|
|
287
|
+
// pinned — a stray pointer move must not silently re-target the pending op.
|
|
288
|
+
if (state.grabbedKey !== '' || state.dragging || state.menuOpen)
|
|
289
|
+
return state;
|
|
290
|
+
if (state.handleVisible &&
|
|
291
|
+
state.hoverKey === msg.key &&
|
|
292
|
+
state.handleX === msg.x &&
|
|
293
|
+
state.handleY === msg.y)
|
|
294
|
+
return state;
|
|
295
|
+
return {
|
|
296
|
+
...state,
|
|
297
|
+
handleVisible: true,
|
|
298
|
+
hoverKey: msg.key,
|
|
299
|
+
handleX: msg.x,
|
|
300
|
+
handleY: msg.y,
|
|
301
|
+
keyboardReveal: false,
|
|
302
|
+
};
|
|
303
|
+
case 'revealAtSelection':
|
|
304
|
+
// The KEYBOARD entry point. The grip is rendered only while
|
|
305
|
+
// `handleVisible`, which `hover` alone could set — and `hover` is produced
|
|
306
|
+
// only by `mousemove`. So without this message the grip was never in the
|
|
307
|
+
// DOM and never in the tab order for a keyboard or screen-reader user, and
|
|
308
|
+
// the unconditional help text ("Press Enter or Space to grab this block…")
|
|
309
|
+
// described an affordance they could not reach. Reordering was mouse-only.
|
|
310
|
+
// Reveal AND grab in one step: the grip's Enter/Space now opens the actions
|
|
311
|
+
// menu, so the keyboard reorder path (Mod+Shift+D / the "Move block" command)
|
|
312
|
+
// enters grab mode directly rather than only revealing a grip the user would
|
|
313
|
+
// then have to Enter — which would open the menu instead of grabbing.
|
|
314
|
+
return [
|
|
315
|
+
{
|
|
316
|
+
...state,
|
|
317
|
+
handleVisible: true,
|
|
318
|
+
hoverKey: msg.key,
|
|
319
|
+
handleX: msg.x,
|
|
320
|
+
handleY: msg.y,
|
|
321
|
+
keyboardReveal: true,
|
|
322
|
+
grabbedKey: msg.key,
|
|
323
|
+
},
|
|
324
|
+
[{ type: 'describe', key: msg.key, grabbed: true }],
|
|
325
|
+
];
|
|
326
|
+
case 'hoverOut':
|
|
327
|
+
if (state.grabbedKey !== '' || state.dragging || state.menuOpen)
|
|
328
|
+
return state;
|
|
329
|
+
return state.handleVisible ? { ...state, handleVisible: false } : state;
|
|
330
|
+
case 'dragStart':
|
|
331
|
+
// The grip is hidden for the duration: it sits under the cursor and would
|
|
332
|
+
// otherwise read as a second, stationary drop affordance.
|
|
333
|
+
//
|
|
334
|
+
// Any pending grab is dropped by `normalize` below, because the handle is
|
|
335
|
+
// going away.
|
|
336
|
+
return { ...state, dragging: true, handleVisible: false };
|
|
337
|
+
case 'dragOver':
|
|
338
|
+
return {
|
|
339
|
+
...state,
|
|
340
|
+
indicatorVisible: true,
|
|
341
|
+
indicatorX: msg.x,
|
|
342
|
+
indicatorY: msg.y,
|
|
343
|
+
indicatorWidth: msg.width,
|
|
344
|
+
};
|
|
345
|
+
case 'dragOverNone':
|
|
346
|
+
return hideIndicator(state);
|
|
347
|
+
case 'dragEnd':
|
|
348
|
+
return { ...hideIndicator(state), dragging: false };
|
|
349
|
+
case 'drop':
|
|
350
|
+
return [
|
|
351
|
+
{ ...state, dragging: false, indicatorVisible: false, handleVisible: false },
|
|
352
|
+
[{ type: 'move', sourceKey: msg.sourceKey, targetKey: msg.targetKey, place: msg.place }],
|
|
353
|
+
];
|
|
354
|
+
case 'toggleGrab': {
|
|
355
|
+
if (state.grabbedKey !== '')
|
|
356
|
+
return [
|
|
357
|
+
{ ...state, grabbedKey: '' },
|
|
358
|
+
[{ type: 'describe', key: state.grabbedKey, grabbed: false }],
|
|
359
|
+
];
|
|
360
|
+
if (state.hoverKey === '')
|
|
361
|
+
return state;
|
|
362
|
+
return [
|
|
363
|
+
{ ...state, grabbedKey: state.hoverKey },
|
|
364
|
+
[{ type: 'describe', key: state.hoverKey, grabbed: true }],
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
case 'moveGrabbed':
|
|
368
|
+
if (state.grabbedKey === '')
|
|
369
|
+
return state;
|
|
370
|
+
return [state, [{ type: 'shift', key: state.grabbedKey, direction: msg.direction }]];
|
|
371
|
+
case 'releaseGrab':
|
|
372
|
+
// Announce the cancellation rather than BLANKING the live region. This is
|
|
373
|
+
// also the `onBlur` handler, so clearing it meant Escape (and any focus
|
|
374
|
+
// loss) silently ended a grab with nothing spoken.
|
|
375
|
+
return state.grabbedKey === ''
|
|
376
|
+
? state
|
|
377
|
+
: {
|
|
378
|
+
...state,
|
|
379
|
+
grabbedKey: '',
|
|
380
|
+
announce: 'Reorder cancelled.',
|
|
381
|
+
announceNonce: state.announceNonce + 1,
|
|
382
|
+
};
|
|
383
|
+
case 'openMenu':
|
|
384
|
+
// The grip stays visible as the menu's anchor; the hover gate below then
|
|
385
|
+
// pins the gutter (a stray pointer move must not re-target while the menu
|
|
386
|
+
// is open). A menu with no target block is meaningless.
|
|
387
|
+
if (msg.key === '')
|
|
388
|
+
return state;
|
|
389
|
+
return { ...state, menuOpen: true, menuKey: msg.key, menuX: msg.x, menuY: msg.y };
|
|
390
|
+
case 'closeMenu':
|
|
391
|
+
return state.menuOpen ? { ...state, menuOpen: false } : state;
|
|
392
|
+
case 'shiftBlock':
|
|
393
|
+
return [
|
|
394
|
+
{ ...state, menuOpen: false },
|
|
395
|
+
[{ type: 'shift', key: msg.key, direction: msg.direction }],
|
|
396
|
+
];
|
|
397
|
+
case 'duplicate':
|
|
398
|
+
return [{ ...state, menuOpen: false }, [{ type: 'duplicate', key: msg.key }]];
|
|
399
|
+
case 'deleteBlock':
|
|
400
|
+
return [{ ...state, menuOpen: false }, [{ type: 'delete', key: msg.key }]];
|
|
401
|
+
case 'announce':
|
|
402
|
+
// The nonce is what makes a REPEATED announcement audible. `aria-live`
|
|
403
|
+
// regions re-announce on text CHANGE, so two identical strings in a row —
|
|
404
|
+
// pressing ArrowUp twice at the top of the document, which yields
|
|
405
|
+
// 'Already at the first position.' both times — left the second one
|
|
406
|
+
// silent. The nonce is not rendered; it only forces the text binding to
|
|
407
|
+
// re-commit. (Bumped unconditionally, so even a deduped-by-value message
|
|
408
|
+
// still speaks.)
|
|
409
|
+
return { ...state, announce: msg.text, announceNonce: state.announceNonce + 1 };
|
|
410
|
+
case 'reposition':
|
|
411
|
+
return state.handleX === msg.x && state.handleY === msg.y
|
|
412
|
+
? state
|
|
413
|
+
: { ...state, handleX: msg.x, handleY: msg.y };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function reduce(state, msg) {
|
|
417
|
+
const out = reduceRaw(state, msg);
|
|
418
|
+
return Array.isArray(out) ? [normalize(out[0]), out[1]] : normalize(out);
|
|
419
|
+
}
|
|
420
|
+
let seq = 0;
|
|
421
|
+
/**
|
|
422
|
+
* Reorder top-level blocks by dragging a hover gutter grip, or from the keyboard
|
|
423
|
+
* (focus the grip, Enter/Space to grab, ↑/↓ to move, Enter/Space to drop, Escape
|
|
424
|
+
* to cancel). Every reorder is one Lexical node move, hence one undo step.
|
|
425
|
+
*/
|
|
426
|
+
export function blockDragPlugin(options = {}) {
|
|
427
|
+
const gutterOffset = options.gutterOffset ?? 28;
|
|
428
|
+
const sessions = new WeakMap();
|
|
429
|
+
/** Per-editor keyboard entry point, so the command item can reach it. */
|
|
430
|
+
const revealers = new WeakMap();
|
|
431
|
+
const uid = `md-block-drag-${++seq}`;
|
|
432
|
+
const helpId = `${uid}-help`;
|
|
433
|
+
const gripId = `${uid}-grip`;
|
|
434
|
+
const menuId = `${uid}-menu`;
|
|
435
|
+
// The block-type conversions offered in the actions menu's "Turn into" section,
|
|
436
|
+
// captured from the merged command items so any plugin's block type appears
|
|
437
|
+
// automatically. Turn-into items are the block/list-group items that report an
|
|
438
|
+
// active block type (`isActive`), which excludes inserts and this plugin's own
|
|
439
|
+
// "Move block" command. Read once at construction.
|
|
440
|
+
let turnIntoItems = [];
|
|
441
|
+
return {
|
|
442
|
+
name: 'blockDrag',
|
|
443
|
+
onItems: (all) => {
|
|
444
|
+
turnIntoItems = all.filter((i) => (i.group === 'block' || i.group === 'list') && i.isActive !== undefined);
|
|
445
|
+
},
|
|
446
|
+
// The command-surface half of the keyboard entry point (Mod+Shift+D is the
|
|
447
|
+
// direct binding). Without one of these two, the entire reorder protocol —
|
|
448
|
+
// and the help text that describes it — is unreachable without a pointer.
|
|
449
|
+
items: [
|
|
450
|
+
{
|
|
451
|
+
id: 'blockDrag',
|
|
452
|
+
label: 'Move block',
|
|
453
|
+
icon: 'blockDrag',
|
|
454
|
+
group: 'block',
|
|
455
|
+
keywords: ['move', 'reorder', 'drag', 'block'],
|
|
456
|
+
run: (editor) => {
|
|
457
|
+
revealers.get(editor)?.();
|
|
458
|
+
},
|
|
459
|
+
surfaces: ['slash', 'context'],
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
// Hover tracking only. The drag session itself is owned by the view (it is
|
|
463
|
+
// started by the grip's own mousedown), which keeps the two concerns apart:
|
|
464
|
+
// `register` answers "which block is the pointer near", the view answers
|
|
465
|
+
// "where would a drop land".
|
|
466
|
+
register: (editor, ctx) => {
|
|
467
|
+
const session = { dragging: false };
|
|
468
|
+
sessions.set(editor, session);
|
|
469
|
+
let lastKey = '';
|
|
470
|
+
let pending = 0;
|
|
471
|
+
const measure = (event) => {
|
|
472
|
+
// A read-only editor gets no reorder affordance at all.
|
|
473
|
+
if (!editor.isEditable() || session.dragging)
|
|
474
|
+
return;
|
|
475
|
+
const root = editor.getRootElement();
|
|
476
|
+
if (!root)
|
|
477
|
+
return;
|
|
478
|
+
const bounds = root.getBoundingClientRect();
|
|
479
|
+
// The live zone is the content box widened LEFT by the gutter, so the
|
|
480
|
+
// pointer can travel onto the grip without the handle vanishing.
|
|
481
|
+
const inside = event.clientX >= bounds.left - GUTTER_WIDTH &&
|
|
482
|
+
event.clientX <= bounds.right &&
|
|
483
|
+
event.clientY >= bounds.top - HOVER_TOLERANCE &&
|
|
484
|
+
event.clientY <= bounds.bottom + HOVER_TOLERANCE;
|
|
485
|
+
if (!inside) {
|
|
486
|
+
if (lastKey !== '') {
|
|
487
|
+
lastKey = '';
|
|
488
|
+
ctx.emit({ type: 'plugin', name: 'blockDrag', msg: { type: 'hoverOut' } });
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const blocks = readBlockRects(editor);
|
|
493
|
+
const block = blockAtPoint(blocks, event.clientY);
|
|
494
|
+
if (!block) {
|
|
495
|
+
if (lastKey !== '') {
|
|
496
|
+
lastKey = '';
|
|
497
|
+
ctx.emit({ type: 'plugin', name: 'blockDrag', msg: { type: 'hoverOut' } });
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
lastKey = block.key;
|
|
502
|
+
ctx.emit({
|
|
503
|
+
type: 'plugin',
|
|
504
|
+
name: 'blockDrag',
|
|
505
|
+
msg: {
|
|
506
|
+
type: 'hover',
|
|
507
|
+
key: block.key,
|
|
508
|
+
x: block.left - gutterOffset,
|
|
509
|
+
y: block.top,
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
};
|
|
513
|
+
// Coalesce to one measurement per frame: `mousemove` fires far faster than
|
|
514
|
+
// the handle can meaningfully move, and each tick costs a layout read.
|
|
515
|
+
// Keep the LATEST event, not the first of the frame. Discarding the rest
|
|
516
|
+
// positioned the grip from stale coordinates: during a fast sweep the
|
|
517
|
+
// pointer can cross two or three blocks inside one frame, and the handle
|
|
518
|
+
// would resolve to the block the pointer was over when the frame STARTED.
|
|
519
|
+
let latest = null;
|
|
520
|
+
const onMouseMove = (event) => {
|
|
521
|
+
latest = event;
|
|
522
|
+
if (pending)
|
|
523
|
+
return;
|
|
524
|
+
pending = requestAnimationFrame(() => {
|
|
525
|
+
pending = 0;
|
|
526
|
+
if (latest)
|
|
527
|
+
measure(latest);
|
|
528
|
+
});
|
|
529
|
+
};
|
|
530
|
+
const hide = () => {
|
|
531
|
+
if (session.dragging)
|
|
532
|
+
return;
|
|
533
|
+
lastKey = '';
|
|
534
|
+
ctx.emit({ type: 'plugin', name: 'blockDrag', msg: { type: 'hoverOut' } });
|
|
535
|
+
};
|
|
536
|
+
/**
|
|
537
|
+
* Reveal the grip for the block containing the caret and focus it, so the
|
|
538
|
+
* whole reorder protocol is reachable without a pointer. Bound to
|
|
539
|
+
* Mod+Shift+D below and also exposed as a command item.
|
|
540
|
+
*/
|
|
541
|
+
const revealAtSelection = () => {
|
|
542
|
+
if (!editor.isEditable())
|
|
543
|
+
return false;
|
|
544
|
+
const key = editor.getEditorState().read(() => {
|
|
545
|
+
const selection = $getSelection();
|
|
546
|
+
if (!$isRangeSelection(selection))
|
|
547
|
+
return null;
|
|
548
|
+
return selection.anchor.getNode().getTopLevelElement()?.getKey() ?? null;
|
|
549
|
+
});
|
|
550
|
+
if (!key)
|
|
551
|
+
return false;
|
|
552
|
+
const block = readBlockRects(editor).find((b) => b.key === key);
|
|
553
|
+
if (!block)
|
|
554
|
+
return false;
|
|
555
|
+
lastKey = key;
|
|
556
|
+
ctx.emit({
|
|
557
|
+
type: 'plugin',
|
|
558
|
+
name: 'blockDrag',
|
|
559
|
+
msg: {
|
|
560
|
+
type: 'revealAtSelection',
|
|
561
|
+
key,
|
|
562
|
+
x: block.left - gutterOffset,
|
|
563
|
+
y: block.top,
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
return true;
|
|
567
|
+
};
|
|
568
|
+
revealers.set(editor, revealAtSelection);
|
|
569
|
+
// Right-click a block → the same actions menu, at the pointer. Scoped to the
|
|
570
|
+
// editor's own content (a contextmenu elsewhere keeps the native menu).
|
|
571
|
+
const onContextMenu = (event) => {
|
|
572
|
+
if (!editor.isEditable() || session.dragging)
|
|
573
|
+
return;
|
|
574
|
+
const root = editor.getRootElement();
|
|
575
|
+
if (!root || !(event.target instanceof Node) || !root.contains(event.target))
|
|
576
|
+
return;
|
|
577
|
+
const block = blockAtPoint(readBlockRects(editor), event.clientY);
|
|
578
|
+
if (!block)
|
|
579
|
+
return;
|
|
580
|
+
event.preventDefault();
|
|
581
|
+
ctx.emit({
|
|
582
|
+
type: 'plugin',
|
|
583
|
+
name: 'blockDrag',
|
|
584
|
+
msg: { type: 'openMenu', key: block.key, x: event.clientX, y: event.clientY },
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
document.addEventListener('mousemove', onMouseMove, { passive: true });
|
|
588
|
+
document.addEventListener('contextmenu', onContextMenu);
|
|
589
|
+
return mergeRegister(() => {
|
|
590
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
591
|
+
document.removeEventListener('contextmenu', onContextMenu);
|
|
592
|
+
if (pending)
|
|
593
|
+
cancelAnimationFrame(pending);
|
|
594
|
+
sessions.delete(editor);
|
|
595
|
+
revealers.delete(editor);
|
|
596
|
+
}, editor.registerCommand(KEY_DOWN_COMMAND, (event) => {
|
|
597
|
+
if (event.key !== 'D' && event.key !== 'd')
|
|
598
|
+
return false;
|
|
599
|
+
if (!event.shiftKey || !(event.metaKey || event.ctrlKey))
|
|
600
|
+
return false;
|
|
601
|
+
if (!revealAtSelection())
|
|
602
|
+
return false;
|
|
603
|
+
event.preventDefault();
|
|
604
|
+
return true;
|
|
605
|
+
}, COMMAND_PRIORITY_LOW),
|
|
606
|
+
// Any scroll invalidates the measured gutter position; recomputing from a
|
|
607
|
+
// stale pointer is wrong, so simply retract the handle.
|
|
608
|
+
onViewportChange(hide));
|
|
609
|
+
},
|
|
610
|
+
ui: definePluginUI({
|
|
611
|
+
init: () => ({ ...INITIAL }),
|
|
612
|
+
update: reduce,
|
|
613
|
+
onEffect: (effect, ctx) => {
|
|
614
|
+
const editor = ctx.editor();
|
|
615
|
+
if (!editor)
|
|
616
|
+
return;
|
|
617
|
+
if (effect.type === 'describe') {
|
|
618
|
+
const label = editor.getEditorState().read(() => {
|
|
619
|
+
const node = $getRoot()
|
|
620
|
+
.getChildren()
|
|
621
|
+
.find((n) => n.getKey() === effect.key);
|
|
622
|
+
return node ? blockLabel(node) : '';
|
|
623
|
+
});
|
|
624
|
+
ctx.send({
|
|
625
|
+
type: 'announce',
|
|
626
|
+
text: effect.grabbed
|
|
627
|
+
? `${label} grabbed. Use the up and down arrow keys to move it, Enter to drop, Escape to cancel.`
|
|
628
|
+
: `${label} dropped.`,
|
|
629
|
+
});
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
// Held in a box rather than a bare `let`: the assignment happens inside
|
|
633
|
+
// the update callback, which TypeScript's control-flow analysis cannot
|
|
634
|
+
// see, so a plain local would narrow to `null` at every read below. `key`
|
|
635
|
+
// is the block to re-anchor the gutter to (`null` for delete — the block
|
|
636
|
+
// is gone, so there is nothing to follow).
|
|
637
|
+
const box = {
|
|
638
|
+
outcome: null,
|
|
639
|
+
};
|
|
640
|
+
editor.update(() => {
|
|
641
|
+
switch (effect.type) {
|
|
642
|
+
case 'move':
|
|
643
|
+
box.outcome = $moveBlock(effect.sourceKey, effect.targetKey, effect.place);
|
|
644
|
+
break;
|
|
645
|
+
case 'shift':
|
|
646
|
+
box.outcome = $shiftBlock(effect.key, effect.direction);
|
|
647
|
+
break;
|
|
648
|
+
case 'duplicate':
|
|
649
|
+
box.outcome = $duplicateBlock(effect.key);
|
|
650
|
+
break;
|
|
651
|
+
case 'delete': {
|
|
652
|
+
const removed = $deleteBlock(effect.key);
|
|
653
|
+
box.outcome = removed ? { key: null, announcement: removed.announcement } : null;
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}, {
|
|
658
|
+
// Re-measure AFTER reconciliation so the gutter follows the block it
|
|
659
|
+
// is pinned to; measuring inside the update would read the old layout.
|
|
660
|
+
onUpdate: () => {
|
|
661
|
+
const moved = box.outcome;
|
|
662
|
+
if (!moved || moved.key === null)
|
|
663
|
+
return;
|
|
664
|
+
const el = editor.getElementByKey(moved.key);
|
|
665
|
+
if (!el)
|
|
666
|
+
return;
|
|
667
|
+
const r = el.getBoundingClientRect();
|
|
668
|
+
ctx.send({ type: 'reposition', x: r.left - gutterOffset, y: r.top });
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
const moved = box.outcome;
|
|
672
|
+
if (moved)
|
|
673
|
+
ctx.send({ type: 'announce', text: moved.announcement });
|
|
674
|
+
},
|
|
675
|
+
view: ({ state, send, editor }) => {
|
|
676
|
+
// The pointer drag session. Installed on `document` for the duration of a
|
|
677
|
+
// single drag and torn down on mouseup, so nothing lingers between drags;
|
|
678
|
+
// `abort` covers the unmount-mid-drag case.
|
|
679
|
+
let abort = null;
|
|
680
|
+
const beginDrag = (event) => {
|
|
681
|
+
const live = editor();
|
|
682
|
+
if (!live || !live.isEditable())
|
|
683
|
+
return;
|
|
684
|
+
const sourceKey = state.peek().hoverKey;
|
|
685
|
+
if (sourceKey === '')
|
|
686
|
+
return;
|
|
687
|
+
// Keep the editor's SELECTION exactly where it was — a reorder must
|
|
688
|
+
// never move the caret.
|
|
689
|
+
event.preventDefault();
|
|
690
|
+
// …but `preventDefault` on mousedown also cancels the default FOCUS
|
|
691
|
+
// action, and the sub-threshold path below deliberately falls through
|
|
692
|
+
// to the keyboard protocol. Without an explicit focus the grip was
|
|
693
|
+
// never `document.activeElement`, so `onKeyDown` (bound to the grip)
|
|
694
|
+
// never saw the arrow keys — they went to the contenteditable and
|
|
695
|
+
// moved the caret instead of the block, while `aria-pressed="true"`
|
|
696
|
+
// and the grabbed highlight claimed otherwise. `:focus-visible` never
|
|
697
|
+
// applied and `onBlur`/`releaseGrab` could never fire, so the user was
|
|
698
|
+
// stuck in a visible mode with no working keys.
|
|
699
|
+
if (event.currentTarget instanceof HTMLElement)
|
|
700
|
+
event.currentTarget.focus();
|
|
701
|
+
const session = sessions.get(live);
|
|
702
|
+
const startX = event.clientX;
|
|
703
|
+
const startY = event.clientY;
|
|
704
|
+
let dragging = false;
|
|
705
|
+
let target = null;
|
|
706
|
+
const onMove = (ev) => {
|
|
707
|
+
if (!dragging) {
|
|
708
|
+
if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) < DRAG_THRESHOLD)
|
|
709
|
+
return;
|
|
710
|
+
dragging = true;
|
|
711
|
+
if (session)
|
|
712
|
+
session.dragging = true;
|
|
713
|
+
send({ type: 'dragStart' });
|
|
714
|
+
}
|
|
715
|
+
const blocks = readBlockRects(live);
|
|
716
|
+
target = findDropTarget(blocks, ev.clientY, sourceKey);
|
|
717
|
+
const rect = target ? indicatorRect(blocks, target) : null;
|
|
718
|
+
if (rect)
|
|
719
|
+
send({ type: 'dragOver', ...rect });
|
|
720
|
+
else
|
|
721
|
+
send({ type: 'dragOverNone' });
|
|
722
|
+
};
|
|
723
|
+
const finish = () => {
|
|
724
|
+
document.removeEventListener('mousemove', onMove);
|
|
725
|
+
document.removeEventListener('mouseup', onUp);
|
|
726
|
+
abort = null;
|
|
727
|
+
if (session)
|
|
728
|
+
session.dragging = false;
|
|
729
|
+
};
|
|
730
|
+
const onUp = () => {
|
|
731
|
+
finish();
|
|
732
|
+
if (!dragging) {
|
|
733
|
+
// Below the threshold this was a click, not a drag: open the block
|
|
734
|
+
// actions menu anchored at the grip. (Mouse users still get free
|
|
735
|
+
// reordering from the drag itself, or Move up/down in the menu;
|
|
736
|
+
// keyboard-grab is Mod+Shift+D.)
|
|
737
|
+
const s = state.peek();
|
|
738
|
+
send({ type: 'openMenu', key: sourceKey, x: s.handleX, y: s.handleY });
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (target) {
|
|
742
|
+
send({ type: 'drop', sourceKey, targetKey: target.key, place: target.place });
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
send({ type: 'dragEnd' });
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
abort = () => {
|
|
749
|
+
finish();
|
|
750
|
+
send({ type: 'dragEnd' });
|
|
751
|
+
};
|
|
752
|
+
document.addEventListener('mousemove', onMove);
|
|
753
|
+
document.addEventListener('mouseup', onUp);
|
|
754
|
+
};
|
|
755
|
+
const onKeyDown = (event) => {
|
|
756
|
+
switch (event.key) {
|
|
757
|
+
case 'Enter':
|
|
758
|
+
case ' ': {
|
|
759
|
+
// Handled here rather than via `click` so Space cannot ALSO fire the
|
|
760
|
+
// button's synthetic click and act twice. While grabbed, Enter/Space
|
|
761
|
+
// DROPS the block; otherwise it opens the actions menu at the grip.
|
|
762
|
+
event.preventDefault();
|
|
763
|
+
const s = state.peek();
|
|
764
|
+
if (s.grabbedKey !== '')
|
|
765
|
+
send({ type: 'toggleGrab' });
|
|
766
|
+
else
|
|
767
|
+
send({ type: 'openMenu', key: s.hoverKey, x: s.handleX, y: s.handleY });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
case 'ArrowUp':
|
|
771
|
+
event.preventDefault();
|
|
772
|
+
send({ type: 'moveGrabbed', direction: -1 });
|
|
773
|
+
return;
|
|
774
|
+
case 'ArrowDown':
|
|
775
|
+
event.preventDefault();
|
|
776
|
+
send({ type: 'moveGrabbed', direction: 1 });
|
|
777
|
+
return;
|
|
778
|
+
case 'Escape':
|
|
779
|
+
event.preventDefault();
|
|
780
|
+
send({ type: 'releaseGrab' });
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
const grabbed = state.map((s) => s.grabbedKey !== '');
|
|
785
|
+
/** Convert the menu's target block by reusing a merged command item: put a
|
|
786
|
+
* selection at the block, then run the item (which converts the selection).
|
|
787
|
+
* A no-op `send` — these items never talk back to the host here. */
|
|
788
|
+
const runTurnInto = (item) => {
|
|
789
|
+
const live = editor();
|
|
790
|
+
if (!live)
|
|
791
|
+
return;
|
|
792
|
+
const key = state.peek().menuKey;
|
|
793
|
+
if (key === '')
|
|
794
|
+
return;
|
|
795
|
+
live.update(() => {
|
|
796
|
+
$selectBlockStart(key);
|
|
797
|
+
});
|
|
798
|
+
item.run(live, { send: () => { } });
|
|
799
|
+
send({ type: 'closeMenu' });
|
|
800
|
+
};
|
|
801
|
+
const menuButton = (label, onClick) => button({
|
|
802
|
+
type: 'button',
|
|
803
|
+
role: 'menuitem',
|
|
804
|
+
'data-scope': 'md-block-drag',
|
|
805
|
+
'data-part': 'menu-item',
|
|
806
|
+
tabindex: '-1',
|
|
807
|
+
// `mousedown` preventDefault keeps focus/selection stable; the action
|
|
808
|
+
// runs on click.
|
|
809
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
810
|
+
onClick,
|
|
811
|
+
}, [text(label)]);
|
|
812
|
+
/** Roving focus + dismissal for the menu. Native click/Enter activates a
|
|
813
|
+
* focused item; here we add ↑/↓ traversal and Escape-to-close. */
|
|
814
|
+
const onMenuKeyDown = (event) => {
|
|
815
|
+
const menu = event.currentTarget;
|
|
816
|
+
if (!(menu instanceof HTMLElement))
|
|
817
|
+
return;
|
|
818
|
+
if (event.key === 'Escape') {
|
|
819
|
+
event.preventDefault();
|
|
820
|
+
send({ type: 'closeMenu' });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
|
|
824
|
+
return;
|
|
825
|
+
event.preventDefault();
|
|
826
|
+
const items = [...menu.querySelectorAll('[data-part="menu-item"]')];
|
|
827
|
+
if (items.length === 0)
|
|
828
|
+
return;
|
|
829
|
+
const active = menu.ownerDocument.activeElement;
|
|
830
|
+
const current = items.findIndex((el) => el === active);
|
|
831
|
+
const delta = event.key === 'ArrowDown' ? 1 : -1;
|
|
832
|
+
const nextIndex = (current + delta + items.length) % items.length;
|
|
833
|
+
items[nextIndex]?.focus();
|
|
834
|
+
};
|
|
835
|
+
return [
|
|
836
|
+
// The live region and the instructions are rendered UNCONDITIONALLY.
|
|
837
|
+
// A live region injected at announcement time is frequently missed by
|
|
838
|
+
// screen readers, and `aria-describedby` must resolve even before the
|
|
839
|
+
// grip exists. `sr-only` is inlined rather than left to the stylesheet
|
|
840
|
+
// because a missing rule here would leak debug text into the page.
|
|
841
|
+
div({
|
|
842
|
+
'data-scope': 'md-block-drag',
|
|
843
|
+
'data-part': 'a11y',
|
|
844
|
+
style: 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0',
|
|
845
|
+
}, [
|
|
846
|
+
div({ 'data-scope': 'md-block-drag', 'data-part': 'help', id: helpId }, [
|
|
847
|
+
text('Press Enter or Space to grab this block, then the up and down arrow keys to move it. Press Enter to drop, or Escape to cancel.'),
|
|
848
|
+
]),
|
|
849
|
+
div({
|
|
850
|
+
'data-scope': 'md-block-drag',
|
|
851
|
+
'data-part': 'announcer',
|
|
852
|
+
role: 'status',
|
|
853
|
+
'aria-live': 'polite',
|
|
854
|
+
'aria-atomic': 'true',
|
|
855
|
+
}, [
|
|
856
|
+
text(state.map((s) =>
|
|
857
|
+
// The zero-width space alternates with the nonce, so a
|
|
858
|
+
// repeated boundary bump ('Already at the first
|
|
859
|
+
// position.') is a NEW string and gets spoken again. It
|
|
860
|
+
// renders as nothing and is not selectable text.
|
|
861
|
+
s.announce === '' ? '' : s.announce + (s.announceNonce % 2 ? '\u200B' : ''))),
|
|
862
|
+
]),
|
|
863
|
+
]),
|
|
864
|
+
...overlayRoot({
|
|
865
|
+
open: state.at('handleVisible'),
|
|
866
|
+
x: state.at('handleX'),
|
|
867
|
+
y: state.at('handleY'),
|
|
868
|
+
zIndex: BLOCK_DRAG_Z.handle,
|
|
869
|
+
attrs: { 'data-scope': 'md-block-drag', 'data-part': 'handle' },
|
|
870
|
+
children: () => [
|
|
871
|
+
button({
|
|
872
|
+
type: 'button',
|
|
873
|
+
'data-scope': 'md-block-drag',
|
|
874
|
+
'data-part': 'grip',
|
|
875
|
+
id: gripId,
|
|
876
|
+
'aria-label': 'Reorder block',
|
|
877
|
+
'aria-roledescription': 'draggable block handle',
|
|
878
|
+
'aria-describedby': helpId,
|
|
879
|
+
'aria-pressed': grabbed.map((g) => (g ? 'true' : 'false')),
|
|
880
|
+
'data-grabbed': grabbed.map((g) => (g ? '' : undefined)),
|
|
881
|
+
title: 'Drag to reorder — or press Enter and use the arrow keys',
|
|
882
|
+
onMouseDown: beginDrag,
|
|
883
|
+
onKeyDown,
|
|
884
|
+
// Losing focus abandons a keyboard grab rather than leaving an
|
|
885
|
+
// invisible mode armed on an element the user can no longer see.
|
|
886
|
+
onBlur: () => send({ type: 'releaseGrab' }),
|
|
887
|
+
}, [
|
|
888
|
+
text('⠿'),
|
|
889
|
+
// Take focus ONLY when the keyboard revealed us. `overlayRoot`
|
|
890
|
+
// rebuilds this subtree each time it opens, so this runs once
|
|
891
|
+
// per reveal. Focusing on a mouse hover would yank the caret
|
|
892
|
+
// out of the contenteditable on every pointer move.
|
|
893
|
+
onMount((root) => {
|
|
894
|
+
if (!state.peek().keyboardReveal)
|
|
895
|
+
return;
|
|
896
|
+
// Looked up by id on the OWNER DOCUMENT, not under `root`:
|
|
897
|
+
// `onMount` hands back the component's root element, while
|
|
898
|
+
// `overlayRoot` portals the grip to a body-level sibling, so
|
|
899
|
+
// the grip is never a descendant of `root`. The id is derived
|
|
900
|
+
// from this plugin instance's `uid`, which keeps it correct
|
|
901
|
+
// when several editors are mounted at once.
|
|
902
|
+
const grip = root.ownerDocument.getElementById(gripId);
|
|
903
|
+
grip?.focus();
|
|
904
|
+
}),
|
|
905
|
+
]),
|
|
906
|
+
],
|
|
907
|
+
}),
|
|
908
|
+
...overlayRoot({
|
|
909
|
+
open: state.at('menuOpen'),
|
|
910
|
+
x: state.at('menuX'),
|
|
911
|
+
y: state.at('menuY'),
|
|
912
|
+
zIndex: BLOCK_DRAG_Z.menu,
|
|
913
|
+
attrs: { 'data-scope': 'md-block-drag', 'data-part': 'menu-root' },
|
|
914
|
+
children: () => [
|
|
915
|
+
div({
|
|
916
|
+
'data-scope': 'md-block-drag',
|
|
917
|
+
'data-part': 'menu',
|
|
918
|
+
id: menuId,
|
|
919
|
+
role: 'menu',
|
|
920
|
+
'aria-label': 'Block actions',
|
|
921
|
+
onKeyDown: onMenuKeyDown,
|
|
922
|
+
}, [
|
|
923
|
+
...(turnIntoItems.length > 0
|
|
924
|
+
? [
|
|
925
|
+
div({ 'data-scope': 'md-block-drag', 'data-part': 'menu-label' }, [
|
|
926
|
+
text('Turn into'),
|
|
927
|
+
]),
|
|
928
|
+
...turnIntoItems.map((item) => menuButton(item.label, () => runTurnInto(item))),
|
|
929
|
+
div({ 'data-scope': 'md-block-drag', 'data-part': 'menu-sep' }, []),
|
|
930
|
+
]
|
|
931
|
+
: []),
|
|
932
|
+
menuButton('Duplicate', () => send({ type: 'duplicate', key: state.peek().menuKey })),
|
|
933
|
+
menuButton('Move up', () => send({ type: 'shiftBlock', key: state.peek().menuKey, direction: -1 })),
|
|
934
|
+
menuButton('Move down', () => send({ type: 'shiftBlock', key: state.peek().menuKey, direction: 1 })),
|
|
935
|
+
menuButton('Delete', () => send({ type: 'deleteBlock', key: state.peek().menuKey })),
|
|
936
|
+
// Focus the first item on open, and close on an outside pointer.
|
|
937
|
+
// The mousedown listener is armed on the NEXT tick so it never
|
|
938
|
+
// catches the very click that opened the menu.
|
|
939
|
+
onMount((root) => {
|
|
940
|
+
const menu = root.ownerDocument.getElementById(menuId);
|
|
941
|
+
menu?.querySelector('[data-part="menu-item"]')?.focus();
|
|
942
|
+
const onDocDown = (e) => {
|
|
943
|
+
if (menu && e.target instanceof Node && menu.contains(e.target))
|
|
944
|
+
return;
|
|
945
|
+
send({ type: 'closeMenu' });
|
|
946
|
+
};
|
|
947
|
+
const armed = setTimeout(() => document.addEventListener('mousedown', onDocDown), 0);
|
|
948
|
+
return () => {
|
|
949
|
+
clearTimeout(armed);
|
|
950
|
+
document.removeEventListener('mousedown', onDocDown);
|
|
951
|
+
};
|
|
952
|
+
}),
|
|
953
|
+
]),
|
|
954
|
+
],
|
|
955
|
+
}),
|
|
956
|
+
...overlayRoot({
|
|
957
|
+
open: state.at('indicatorVisible'),
|
|
958
|
+
x: state.at('indicatorX'),
|
|
959
|
+
y: state.at('indicatorY'),
|
|
960
|
+
zIndex: BLOCK_DRAG_Z.indicator,
|
|
961
|
+
attrs: { 'data-scope': 'md-block-drag', 'data-part': 'indicator-root' },
|
|
962
|
+
children: () => [
|
|
963
|
+
div({
|
|
964
|
+
'data-scope': 'md-block-drag',
|
|
965
|
+
'data-part': 'indicator',
|
|
966
|
+
'aria-hidden': 'true',
|
|
967
|
+
// Only the WIDTH is inline (it is measured, hence dynamic); the
|
|
968
|
+
// line's appearance belongs to `styles/block-drag.css`.
|
|
969
|
+
style: state.at('indicatorWidth').map((w) => `width:${w}px`),
|
|
970
|
+
}),
|
|
971
|
+
],
|
|
972
|
+
}),
|
|
973
|
+
// Abandon any in-flight pointer session if the editor unmounts mid-drag,
|
|
974
|
+
// so the document listeners never outlive the component. `onMount`'s
|
|
975
|
+
// returned cleanup is the sanctioned teardown hook; the Mountable MUST
|
|
976
|
+
// be placed in the view array or it registers nothing.
|
|
977
|
+
onMount(() => () => abort?.()),
|
|
978
|
+
];
|
|
979
|
+
},
|
|
980
|
+
}),
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
//# sourceMappingURL=block-drag.js.map
|