docblocks-react-ui 0.1.0 → 0.2.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.
@@ -1,1009 +0,0 @@
1
- import { $t as ENUM_OPTION_PALETTE, At as Plus, C as deriveFlat, D as visibleRows, E as isEmptyChainText, It as Button, M as EnumOptionsMotionSlot, Mt as GripVertical, S as collapseEmptyTextChains, St as Card, T as indentOf$1, _ as useMentionMenu, _n as ensureTrailingParagraph, a as BlockNodeView, an as Input, b as BlockContext, bt as ColorSwatches, d as FormatPaletteContext, f as useFormatToolbar, ht as PopoverContent, i as CanvasView, j as useSlashMenu, k as useEditorContext, kt as X, mn as editorRootOf, n as PresenceCaretOverlay, o as FlatBlockView, on as useBlock, p as FORMAT_PALETTE, pn as SlashMenuContext, pt as Popover, r as useEditing, v as MentionMenuContext, vn as moveSegmentInSequence, w as entrySequenceParent, x as useBlockContext, y as insertParagraphAfter, yn as segmentRange, yt as PopoverTrigger } from "../Editor-CiJY5R4v.mjs";
2
- import { c as cn } from "../constants-D4FTL0ph.mjs";
3
- import { useStore } from "zustand";
4
- import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
- import { BLOCK_REGISTRY, InvalidAttrsError, InvalidOperationError } from "docblocks-core";
6
- import { jsx, jsxs } from "react/jsx-runtime";
7
- import { AnimatePresence, Reorder, useDragControls } from "framer-motion";
8
- //#region src/motion/constants.ts
9
- /** The motion editor's constants: the enter/exit geometry (the spacing
10
- * margin collapses on exit so a leaving row does not keep its gap), the
11
- * enter/exit and layout-slide transition (one snappy ease — the
12
- * between-rows reflow while a drag runs runs the layout arm of this
13
- * transition, gated per-value: 0 while the slide is disarmed), the slide
14
- * settle window (how long the layout slide stays armed after a structural
15
- * render — the slide itself runs 0.18s), the drag affordance style (the
16
- * handle shows the grab cursor in this view only — the rule is scoped to
17
- * the root attribute, so the base editor keeps the stylesheet's plain
18
- * pointer cursor), the text-row gutter types (the text types whose
19
- * built-in renderer renders the row's gutter handle — the row-level one
20
- * stands for every other type). */
21
- /**
22
- * The enter/exit geometry of a row. The spacing margin collapses on exit
23
- * so a leaving row does not keep its gap.
24
- */
25
- const ENTER = {
26
- opacity: 0,
27
- height: 0
28
- };
29
- const ANIMATE = {
30
- opacity: 1,
31
- height: "auto"
32
- };
33
- const EXIT = {
34
- opacity: 0,
35
- height: 0,
36
- marginBottom: 0
37
- };
38
- /**
39
- * The enter/exit and layout-slide transition (one snappy ease). The
40
- * between-rows reflow while a drag runs (the rows slide into each crossed
41
- * slot) runs the layout arm of this transition (gated per-value: 0 while
42
- * the slide is disarmed).
43
- */
44
- const TRANSITION = {
45
- duration: .18,
46
- ease: "easeOut"
47
- };
48
- /**
49
- * The drag affordance: the handle shows the grab cursor in this view
50
- * only — the rule is scoped to the root attribute below, so the base
51
- * editor keeps the stylesheet's plain pointer cursor.
52
- */
53
- const HANDLE_GRAB_STYLE = "[data-docblocks-motion] [data-slot=\"docblocks/gutter\"] { cursor: grab; }";
54
- /** The text types whose built-in renderer renders the row's gutter handle (the row-level one stands for every other type). */
55
- const TEXT_ROW_TYPES = /* @__PURE__ */ new Set([
56
- "paragraph",
57
- "quote",
58
- "heading",
59
- "callout",
60
- "list-item",
61
- "code"
62
- ]);
63
- //#endregion
64
- //#region src/motion/MotionEnumOptionsEditor.tsx
65
- /** The motion's enum's options editor: the framer `Reorder`'s rows (the
66
- * grip's press-drag stands the live reorder with the layout's slide — the
67
- * rows' positions animate over the drag / the commit). The base's stand
68
- * stands the plain editor (the pointer's session — no animation); this
69
- * variant is provided over the `EnumOptionsMotionSlot`'s context by the
70
- * `MotionEditor` (the framer's import stands this subpath only — the
71
- * subpath's boundary's contract). */
72
- /** One option's row as a `Reorder.Item` (the grip's press starts the
73
- * framer's drag through the row's `dragControls` — the grip is the only
74
- * drag trigger, the row's own drag is disarmed). */
75
- function MotionEnumOptionRow({ option, colorOpen, onColorOpen, onSet, onRemove }) {
76
- const dragControls = useDragControls();
77
- const onGripPointerDown = (event) => {
78
- if (event.button !== 0) return;
79
- event.preventDefault();
80
- dragControls.start(event);
81
- };
82
- const onSelectColor = (hex) => {
83
- onSet({ color: hex });
84
- onColorOpen(false);
85
- };
86
- const onSelectNone = () => {
87
- onSet({ color: null });
88
- onColorOpen(false);
89
- };
90
- const onNameChange = (event) => onSet({ name: event.target.value });
91
- return /* @__PURE__ */ jsxs(Reorder.Item, {
92
- as: "div",
93
- "data-slot": "docblocks/database-enum-option-row",
94
- value: option,
95
- dragListener: false,
96
- dragControls,
97
- whileDrag: { zIndex: 10 },
98
- layout: "position",
99
- transition: { layout: TRANSITION },
100
- className: "relative flex h-8 items-center gap-1",
101
- children: [
102
- /* @__PURE__ */ jsx("span", {
103
- "data-slot": "docblocks/database-enum-option-grip",
104
- onPointerDown: onGripPointerDown,
105
- className: "\n flex size-5 shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground\n active:cursor-grabbing\n ",
106
- children: /* @__PURE__ */ jsx(GripVertical, { className: "size-3.5" })
107
- }),
108
- /* @__PURE__ */ jsxs(Popover, {
109
- open: colorOpen,
110
- onOpenChange: onColorOpen,
111
- children: [/* @__PURE__ */ jsx(PopoverTrigger, {
112
- asChild: true,
113
- children: /* @__PURE__ */ jsx(Button, {
114
- "data-slot": "docblocks/database-enum-option-color",
115
- variant: "ghost",
116
- "data-color": option.color ?? "",
117
- title: "Option's color",
118
- className: "size-4 shrink-0 cursor-pointer rounded-full border border-border p-0 focus-visible:ring-0",
119
- style: option.color !== null ? { backgroundColor: option.color } : void 0
120
- })
121
- }), /* @__PURE__ */ jsx(PopoverContent, {
122
- className: "flex items-center p-2",
123
- children: /* @__PURE__ */ jsx(ColorSwatches, {
124
- colors: ENUM_OPTION_PALETTE,
125
- current: option.color ?? void 0,
126
- label: "option color",
127
- allowNone: true,
128
- noneActive: option.color === null,
129
- onSelect: onSelectColor,
130
- onSelectNone
131
- })
132
- })]
133
- }),
134
- /* @__PURE__ */ jsx(Input, {
135
- size: "sm",
136
- type: "text",
137
- "data-slot": "docblocks/database-enum-option-name",
138
- placeholder: "Option",
139
- className: "min-w-0 flex-1 cursor-text text-foreground focus:bg-muted/60",
140
- value: option.name,
141
- onChange: onNameChange
142
- }),
143
- /* @__PURE__ */ jsx(Button, {
144
- "data-slot": "docblocks/database-enum-option-delete",
145
- variant: "ghost",
146
- size: "icon",
147
- className: "\n size-5 shrink-0 cursor-pointer text-muted-foreground\n hover:bg-transparent hover:text-foreground\n dark:hover:bg-transparent\n ",
148
- onClick: onRemove,
149
- children: /* @__PURE__ */ jsx(X, { className: "size-3.5" })
150
- })
151
- ]
152
- });
153
- }
154
- /** The motion's options' editor (the create / the edit's form's stand):
155
- * the `Reorder.Group`'s live reorder over the draft (the release stands
156
- * nothing extra — the draft IS the order; the form's commit stands the
157
- * bridge's write over the system's table). */
158
- function MotionEnumOptionsEditor({ options, onChange }) {
159
- const [colorOpen, setColorOpen] = useState(null);
160
- const setOption = (index, patch) => {
161
- onChange(options.map((option, i) => i === index ? {
162
- ...option,
163
- ...patch
164
- } : option));
165
- };
166
- const colorOpenChange = (index) => (open) => setColorOpen(open ? index : null);
167
- const setOptionAt = (index) => (patch) => setOption(index, patch);
168
- const optionRemove = (index) => () => onChange(options.filter((_, i) => i !== index));
169
- const addOption = () => onChange([...options, {
170
- name: "",
171
- color: null,
172
- uid: Math.random().toString(36).slice(2)
173
- }]);
174
- return /* @__PURE__ */ jsxs("div", {
175
- "data-slot": "docblocks/database-enum-options",
176
- className: "flex flex-col gap-1",
177
- children: [
178
- /* @__PURE__ */ jsx("div", {
179
- className: "text-sm text-foreground",
180
- children: "Options"
181
- }),
182
- /* @__PURE__ */ jsx(Reorder.Group, {
183
- axis: "y",
184
- values: options,
185
- onReorder: onChange,
186
- className: "flex flex-col gap-1",
187
- children: options.map((option, index) => /* @__PURE__ */ jsx(MotionEnumOptionRow, {
188
- option,
189
- colorOpen: colorOpen === index,
190
- onColorOpen: colorOpenChange(index),
191
- onSet: setOptionAt(index),
192
- onRemove: optionRemove(index)
193
- }, option.rowId ?? option.uid ?? `draft-${index}`))
194
- }),
195
- /* @__PURE__ */ jsx(Button, {
196
- "data-slot": "docblocks/database-enum-option-add",
197
- variant: "ghost",
198
- size: "sm",
199
- className: "h-auto cursor-pointer self-start rounded-sm px-1 py-0.5 font-normal text-muted-foreground",
200
- onClick: addOption,
201
- children: "+ Add option"
202
- })
203
- ]
204
- });
205
- }
206
- //#endregion
207
- //#region src/motion/RowItem.tsx
208
- /** The motion editor's flat row: a page block as a `Reorder.Item` (the
209
- * gutter handle's drag trigger, the indent steps' padding, the
210
- * enter/exit geometry, the position-only layout slide). */
211
- /**
212
- * One flat row: a page block as a `Reorder.Item`. The drag is pure framer
213
- * (the row's `dragControls`, started from the gutter handle's pointerdown
214
- * — the handle is the only drag trigger, so a press without movement
215
- * stays the plain block selection). Every page row is a drag source: the
216
- * text types' built-in renderer renders the handle inside its own
217
- * wrapper, every other row carries the row-level handle. The row's left
218
- * padding is the `indent`'s per-level steps (a list item's extra step is
219
- * its marker gutter — the base view's list padding), so the nesting is
220
- * the data, not a wrapper.
221
- */
222
- const RowItem = memo(function RowItem({ node, layoutSlide, isDragged, animated, previewIndent, onDragStart, onDragEnd, onDragMove }) {
223
- const dragControls = useDragControls();
224
- const rowRef = useRef(null);
225
- const { doc, store, selection: selectionRef, viewMode } = useEditorContext();
226
- const blockContext = useBlockContext();
227
- const isList = node.kind !== void 0;
228
- const block = useBlock(node.id);
229
- const selected = useStore(store, (state) => state.selection.kind === "blocks" && state.selection.ids.includes(node.id));
230
- const caretRow = useStore(store, (state) => {
231
- if (state.selection.kind !== "text") return false;
232
- return (state.selection.to !== null ? state.selection.to : state.selection.from).blockId === node.id;
233
- });
234
- const needsRowGutter = block !== void 0 && !TEXT_ROW_TYPES.has(block.type) && !viewMode;
235
- /** The gutter handle's pointerdown starts this row's framer drag. */
236
- const onRowPointerDown = useCallback((event) => {
237
- const target = event.target instanceof HTMLElement ? event.target : null;
238
- if (target === null) return;
239
- if (target.closest("[data-slot=\"docblocks/gutter\"]") === null) return;
240
- dragControls.start(event);
241
- }, [dragControls]);
242
- /** Gutter click: a blocks selection on this row's block (Shift extends from the anchor). */
243
- const onGutterClick = useCallback((event) => {
244
- event.preventDefault();
245
- const view = selectionRef.view;
246
- if (view === null) return;
247
- const id = node.id;
248
- const current = view.get();
249
- if (event.shiftKey && current.kind === "blocks") {
250
- const siblings = doc.getChildren(blockContext.parentId);
251
- const anchorIndex = siblings.indexOf(current.anchorId);
252
- const hereIndex = siblings.indexOf(id);
253
- if (anchorIndex !== -1 && hereIndex !== -1) {
254
- const [low, high] = anchorIndex <= hereIndex ? [anchorIndex, hereIndex] : [hereIndex, anchorIndex];
255
- view.set({
256
- kind: "blocks",
257
- ids: siblings.slice(low, high + 1),
258
- anchorId: current.anchorId
259
- });
260
- } else view.set({
261
- kind: "blocks",
262
- ids: [id],
263
- anchorId: id
264
- });
265
- } else view.set({
266
- kind: "blocks",
267
- ids: [id],
268
- anchorId: id
269
- });
270
- rowRef.current?.focus();
271
- }, [
272
- blockContext,
273
- doc,
274
- node.id,
275
- selectionRef
276
- ]);
277
- /** Keep a mousedown on the handle from moving the text caret. */
278
- const onHandleMouseDown = useCallback((event) => {
279
- event.preventDefault();
280
- }, []);
281
- /** Gutter plus: an empty paragraph under this row's block (one transact), the caret at its start. */
282
- const onGutterPlusClick = useCallback((event) => {
283
- event.preventDefault();
284
- const id = node.id;
285
- insertParagraphAfter(doc, id, blockContext.parentId, indentOf$1(block), editorRootOf(rowRef.current ?? document));
286
- }, [
287
- block,
288
- blockContext,
289
- doc,
290
- node.id,
291
- rowRef
292
- ]);
293
- return /* @__PURE__ */ jsxs(Reorder.Item, {
294
- as: "div",
295
- ref: rowRef,
296
- "data-slot": "docblocks/row",
297
- "data-selected": selected ? "true" : void 0,
298
- value: node.id,
299
- tabIndex: 0,
300
- initial: animated ? ENTER : false,
301
- animate: ANIMATE,
302
- exit: EXIT,
303
- transition: {
304
- opacity: TRANSITION,
305
- height: TRANSITION,
306
- marginBottom: TRANSITION,
307
- layout: layoutSlide && !caretRow ? TRANSITION : { duration: 0 }
308
- },
309
- layout: "position",
310
- dragListener: false,
311
- dragControls,
312
- onDragStart,
313
- onDragEnd,
314
- onDrag: onDragMove,
315
- onPointerDown: onRowPointerDown,
316
- className: cn(needsRowGutter && "group", "outline-none"),
317
- style: {
318
- position: "relative",
319
- paddingLeft: `calc(${previewIndent ?? node.indent} * var(--docblocks-nested-indent) + ${isList ? "var(--docblocks-marker-width)" : "0px"})`,
320
- ...isDragged ? { zIndex: 30 } : void 0
321
- },
322
- children: [
323
- needsRowGutter && /* @__PURE__ */ jsx(Button, {
324
- type: "button",
325
- "data-slot": "docblocks/gutter",
326
- "aria-label": "Select block",
327
- variant: "ghost",
328
- size: "icon",
329
- onClick: onGutterClick,
330
- onMouseDown: onHandleMouseDown,
331
- className: cn("absolute top-[calc((var(--docblocks-line-box-height)-var(--docblocks-gutter-size))/2)]", "cursor-pointer rounded-full opacity-0 group-hover:opacity-100", "text-(--docblocks-gutter-color)", "size-5", selected && "opacity-100"),
332
- style: { left: `calc(${previewIndent ?? node.indent} * var(--docblocks-nested-indent) + var(--docblocks-gutter-left))` },
333
- children: /* @__PURE__ */ jsx(GripVertical, {})
334
- }),
335
- needsRowGutter && /* @__PURE__ */ jsx(Button, {
336
- type: "button",
337
- "data-slot": "docblocks/gutter-plus",
338
- "aria-label": "Add block below",
339
- variant: "ghost",
340
- size: "icon",
341
- onClick: onGutterPlusClick,
342
- onMouseDown: onHandleMouseDown,
343
- className: cn("absolute top-[calc((var(--docblocks-line-box-height)-var(--docblocks-gutter-plus-size))/2)]", "cursor-pointer rounded-full opacity-0 group-hover:opacity-100", "text-(--docblocks-gutter-color)", "size-5", selected && "opacity-100"),
344
- style: { left: `calc(
345
- ${previewIndent ?? node.indent} * var(--docblocks-nested-indent) +
346
- var(--docblocks-gutter-left) + var(--docblocks-gutter-size) + var(--docblocks-gutter-gap)
347
- )` },
348
- children: /* @__PURE__ */ jsx(Plus, {})
349
- }),
350
- /* @__PURE__ */ jsx(BlockNodeView, {
351
- node,
352
- flat: true,
353
- renderBlock: FlatBlockView
354
- })
355
- ]
356
- });
357
- }, (prev, next) => prev.node === next.node && prev.layoutSlide === next.layoutSlide && prev.isDragged === next.isDragged && prev.previewIndent === next.previewIndent);
358
- //#endregion
359
- //#region src/motion/RowList.tsx
360
- /**
361
- * The motion editor's row list: the `Reorder.Group` + the flat rows, the
362
- * memo boundary that keeps framer's `ReorderContext` value (collected per
363
- * Group render, its fresh closures un-memoizable by the consumer) from
364
- * propagating into the rows on parent churn: a text-only change re-renders
365
- * nothing here (the rows' props stand), so the framer fibers and the
366
- * rows' context consumers stand as well.
367
- */
368
- const RowList = memo(function RowList({ order, nodesById, layoutSlide, draggingKey, previewIndent, animated, onReorder, onDragStart, onDragMove, onDragEnd }) {
369
- const handleReorder = (next) => onReorder(next);
370
- const rowDragStart = (id) => () => onDragStart(id);
371
- const rowDragEnd = (id) => (event) => onDragEnd(id, event);
372
- const rowDragMove = (id) => (event) => onDragMove(id, event);
373
- return /* @__PURE__ */ jsx(Reorder.Group, {
374
- as: "div",
375
- "data-slot": "docblocks/rows",
376
- axis: "y",
377
- values: order,
378
- onReorder: handleReorder,
379
- children: /* @__PURE__ */ jsx(AnimatePresence, {
380
- initial: false,
381
- mode: "sync",
382
- children: order.map((id) => {
383
- const node = nodesById.get(id);
384
- if (node === void 0) return null;
385
- return /* @__PURE__ */ jsx(RowItem, {
386
- node,
387
- layoutSlide,
388
- isDragged: draggingKey === id,
389
- animated,
390
- previewIndent: draggingKey === id ? previewIndent : null,
391
- onDragStart: rowDragStart(id),
392
- onDragEnd: rowDragEnd(id),
393
- onDragMove: rowDragMove(id)
394
- }, id);
395
- })
396
- })
397
- });
398
- });
399
- //#endregion
400
- //#region src/motion/hooks.ts
401
- /** The motion editor's imperative root attributes: the grip typing flag
402
- * (hidden while the editor content is typed) and the enter-animation
403
- * gate (rows that mount before the first text input stand up instantly).
404
- */
405
- /**
406
- * The grip typing flag: the editor root holds `data-docblocks-typing` from
407
- * the first text keypress in its own content until the first pointer move
408
- * or the focus leaving the editor — the stylesheet's unlayered rule hides
409
- * the hover grip while the attribute stands (a selected block's grip is a
410
- * selection indicator and stays visible). The attribute is imperative: a
411
- * React state would re-render every row per keypress.
412
- */
413
- function useTypingFlag(rootRef) {
414
- useEffect(() => {
415
- const root = rootRef.current;
416
- if (root === null) return;
417
- const setTyping = (typing) => {
418
- if (typing) root.setAttribute("data-docblocks-typing", "");
419
- else root.removeAttribute("data-docblocks-typing");
420
- };
421
- const onKeydown = (event) => {
422
- const target = event.target instanceof Element ? event.target : null;
423
- if (target === null || target.closest("[data-slot=\"docblocks/editor\"]") !== root) return;
424
- if (target.closest("[data-slot=\"docblocks/block-content\"]") === null) return;
425
- if (event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey) return;
426
- setTyping(true);
427
- };
428
- const onPointerMove = () => setTyping(false);
429
- const onFocusOut = () => {
430
- const active = document.activeElement;
431
- if (active instanceof Node && root.contains(active)) return;
432
- setTyping(false);
433
- };
434
- document.addEventListener("keydown", onKeydown);
435
- window.addEventListener("pointermove", onPointerMove);
436
- document.addEventListener("focusout", onFocusOut);
437
- return () => {
438
- document.removeEventListener("keydown", onKeydown);
439
- window.removeEventListener("pointermove", onPointerMove);
440
- document.removeEventListener("focusout", onFocusOut);
441
- };
442
- }, [rootRef]);
443
- }
444
- /**
445
- * The enter-animation gate: the latch stands on the first text input in
446
- * this editor (a printable keydown or an input event in the editor's
447
- * contenteditable) — rows that mount before it (the mount and the sync
448
- * load) stand up instantly (`initial={false}`: the animate
449
- * values, no height-growth animation); a row that appears after it is an
450
- * insert in progress and runs the enter animation. The gate never clears
451
- * (a load window is a start window, not a session state).
452
- */
453
- function useEnterAnimationGate(rootRef) {
454
- const gateRef = useRef(false);
455
- useEffect(() => {
456
- const root = rootRef.current;
457
- if (root === null) return;
458
- const inThisEditorText = (event) => {
459
- const target = event.target instanceof Element ? event.target : null;
460
- if (target === null) return false;
461
- if (target.closest("[data-slot=\"docblocks/editor\"]") !== root) return false;
462
- return target.closest("[data-slot=\"docblocks/block-content\"]") !== null;
463
- };
464
- const onKeydown = (event) => {
465
- if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return;
466
- if (!inThisEditorText(event)) return;
467
- gateRef.current = true;
468
- };
469
- const onInput = (event) => {
470
- if (!inThisEditorText(event)) return;
471
- gateRef.current = true;
472
- };
473
- document.addEventListener("keydown", onKeydown);
474
- document.addEventListener("input", onInput);
475
- return () => {
476
- document.removeEventListener("keydown", onKeydown);
477
- document.removeEventListener("input", onInput);
478
- };
479
- }, [rootRef]);
480
- return gateRef;
481
- }
482
- //#endregion
483
- //#region src/motion/reorder/seam.ts
484
- function dropCandidateIndent(rows, pointerY, segmentIds, headId) {
485
- const rest = rows.filter((row) => !segmentIds.has(row.id));
486
- if (rest.length === 0) {
487
- const head = rows.find((row) => row.id === headId);
488
- return head === void 0 ? 0 : head.indent;
489
- }
490
- const over = rest.find((row) => pointerY >= row.top && pointerY < row.bottom);
491
- if (over !== void 0) return over.indent;
492
- const belowLocal = rows.find((row) => row.top >= pointerY);
493
- const aboveLocal = [...rows].reverse().find((row) => row.bottom <= pointerY);
494
- if (belowLocal !== void 0 && aboveLocal !== void 0) return (pointerY < (aboveLocal.bottom + belowLocal.top) / 2 ? belowLocal : aboveLocal).indent;
495
- if (belowLocal !== void 0) return segmentIds.has(belowLocal.id) ? rest[0]?.indent ?? 0 : belowLocal.indent;
496
- const last = aboveLocal?.indent ?? 0;
497
- return aboveLocal !== void 0 && segmentIds.has(aboveLocal.id) ? rest[rest.length - 1]?.indent ?? 0 : last;
498
- }
499
- //#endregion
500
- //#region src/motion/reorder/commit.ts
501
- /** Same ids in the same order (order matters). */
502
- function sameIds(a, b) {
503
- return a.length === b.length && a.every((id, i) => id === b[i]);
504
- }
505
- /** The block's effective indent (absent is 0). */
506
- function indentOf(doc, id) {
507
- const block = doc.getBlock(id);
508
- if (block === null || !("attrs" in block)) return 0;
509
- const indent = block.attrs?.indent;
510
- return typeof indent === "number" && Number.isInteger(indent) && indent >= 0 ? indent : 0;
511
- }
512
- /**
513
- * A collapsed list item's hidden segment rows (in scope order): the
514
- * contiguous stretch after the item with `indent` deeper than the item's
515
- * own, up to the first block with `indent` <= the item's (the segment
516
- * minus the item itself). Absent when the item is not in the scope. The
517
- * scope is a superset of the item's segment (its followers are deeper than
518
- * the item, hence inside the scope), so the scoped scan equals the
519
- * full-sequence one.
520
- */
521
- function hiddenSegmentRows(doc, sequence, itemId) {
522
- const start = sequence.indexOf(itemId);
523
- if (start === -1) return [];
524
- const indent = indentOf(doc, itemId);
525
- const rows = [];
526
- for (let i = start + 1; i < sequence.length; i += 1) {
527
- const id = sequence[i];
528
- if (id === void 0) break;
529
- if (indentOf(doc, id) <= indent) break;
530
- rows.push(id);
531
- }
532
- return rows;
533
- }
534
- /**
535
- * The commit of a flat-row drag (the scope and the visible-order mapping:
536
- * the module header). `contextId` is the provider's entry root: the commit
537
- * resolves its sequence container and runs over its SCOPE — the full
538
- * sequence for a sequence-container entry root, the entry root's derived
539
- * segment (in the container's sequence) for a page-block one — and issues
540
- * the range move of the dragged block's derived subtree within the
541
- * CONTAINER's sequence (the scope's indices translated by the scope's
542
- * offset — the scope's membership stands, so a valid permutation of the
543
- * scope always commits: the segment's internal adjacencies and the
544
- * boundary neighbors are unchanged, and the indent-jump rule holds) plus
545
- * the seam's indent assignment (the `candidateIndent`, the module
546
- * header's seam rule).
547
- *
548
- * Returns true when the drag landed (a command issued or a no-op — the
549
- * local order already matches the store); false when the commit was
550
- * dropped (stale — the caller resyncs the local order to the store).
551
- */
552
- function commitFlatDrag(doc, contextId, draggedId, visibleFinalOrder, collapsedIds, candidateIndent) {
553
- const containerId = entrySequenceParent(doc, contextId);
554
- const containerSequence = doc.getChildren(containerId);
555
- let scopeOffset = 0;
556
- let sequence = containerSequence;
557
- if (containerId !== contextId) {
558
- const entry = segmentRange(doc, containerId, contextId);
559
- if (entry === null) return false;
560
- scopeOffset = entry.start;
561
- sequence = containerSequence.slice(entry.start, entry.end);
562
- }
563
- const start = sequence.indexOf(draggedId);
564
- if (start === -1) return false;
565
- const segment = segmentRange(doc, containerId, draggedId);
566
- if (segment === null) return false;
567
- if (segment.start < scopeOffset || segment.end > scopeOffset + sequence.length) return false;
568
- const segmentLength = segment.end - segment.start;
569
- const segmentRows = sequence.slice(start, start + segmentLength);
570
- const segmentOthers = segmentRows.filter((id) => id !== draggedId);
571
- const collapsed = new Set(collapsedIds);
572
- const hiddenByItem = /* @__PURE__ */ new Map();
573
- for (const id of sequence) {
574
- if (id === draggedId) continue;
575
- const block = doc.getBlock(id);
576
- if (block === null || block.type !== "list-item") continue;
577
- if (!collapsed.has(id)) continue;
578
- const rows = hiddenSegmentRows(doc, sequence, id);
579
- if (rows.length > 0) hiddenByItem.set(id, rows);
580
- }
581
- const hiddenRowSet = /* @__PURE__ */ new Set();
582
- for (const rows of hiddenByItem.values()) for (const row of rows) hiddenRowSet.add(row);
583
- const isFixed = (id) => doc.getBlock(id)?.type === "surface" || hiddenRowSet.has(id);
584
- const scopeVisible = sequence.filter((id) => !isFixed(id));
585
- const scopeVisibleSet = new Set(scopeVisible);
586
- const headLanding = visibleFinalOrder.indexOf(draggedId);
587
- if (headLanding === -1) return false;
588
- if (visibleFinalOrder.length !== scopeVisible.length) return false;
589
- if (!visibleFinalOrder.every((id) => scopeVisibleSet.has(id))) return false;
590
- const segmentSet = new Set(segmentRows);
591
- const restNow = [...sequence.slice(0, start), ...sequence.slice(start + segmentLength)];
592
- const restVisible = restNow.filter((id) => !isFixed(id));
593
- if (!sameIds(visibleFinalOrder.filter((id) => !segmentSet.has(id)), restVisible)) return false;
594
- if (headLanding === scopeVisible.indexOf(draggedId)) return true;
595
- let restVisibleBeforeHead = 0;
596
- for (let i = 0; i < headLanding; i += 1) if (!segmentSet.has(visibleFinalOrder[i])) restVisibleBeforeHead += 1;
597
- const restIndexOf = /* @__PURE__ */ new Map();
598
- restNow.forEach((id, i) => restIndexOf.set(id, i));
599
- const precedingVisible = restVisible[restVisibleBeforeHead - 1];
600
- const spliceAt = precedingVisible === void 0 ? 0 : (restIndexOf.get(precedingVisible) ?? 0) + 1;
601
- const finalFull = [
602
- ...restNow.slice(0, spliceAt),
603
- ...segmentRows,
604
- ...restNow.slice(spliceAt)
605
- ];
606
- const target = spliceAt;
607
- let t = target < start ? target : target + segmentLength;
608
- if (target >= start && containerId === doc.meta.id && scopeOffset === 0 && t === sequence.length) {
609
- const segmentLast = segmentRows[segmentRows.length - 1];
610
- const segmentLastBlock = segmentLast === void 0 ? null : doc.getBlock(segmentLast);
611
- const trailingId = containerSequence[containerSequence.length - 1];
612
- const trailingBlock = trailingId === void 0 ? null : doc.getBlock(trailingId);
613
- if (segmentLastBlock !== null && trailingId !== void 0 && trailingBlock !== null && trailingId !== segmentLast && !BLOCK_REGISTRY[segmentLastBlock.type].content && BLOCK_REGISTRY[trailingBlock.type].content && indentOf(doc, trailingId) === 0) t -= 1;
614
- }
615
- let before;
616
- for (let i = target - 1; i >= 0; i -= 1) {
617
- const id = finalFull[i];
618
- if (id !== void 0 && !isFixed(id)) {
619
- before = id;
620
- break;
621
- }
622
- }
623
- let after;
624
- for (let i = target + segmentLength; i < finalFull.length; i += 1) {
625
- const id = finalFull[i];
626
- if (id !== void 0 && !isFixed(id)) {
627
- after = id;
628
- break;
629
- }
630
- }
631
- if (!(before !== void 0 && candidateIndent === indentOf(doc, before) || after !== void 0 && candidateIndent === indentOf(doc, after))) return false;
632
- const delta = candidateIndent - indentOf(doc, draggedId);
633
- const followers = segmentOthers.map((id) => ({
634
- id,
635
- indent: indentOf(doc, id) + delta
636
- }));
637
- if (delta === 0) return moveSegmentInSequence(doc, containerId, scopeOffset + start, segmentLength, scopeOffset + t);
638
- const applyIndents = () => {
639
- doc.updateBlock(draggedId, { indent: candidateIndent });
640
- for (const follower of followers) doc.updateBlock(follower.id, { indent: follower.indent });
641
- };
642
- try {
643
- doc.transact(() => {
644
- if (delta < 0) applyIndents();
645
- const blockIds = doc.getChildren(containerId).slice(scopeOffset + start, scopeOffset + start + segmentLength);
646
- for (let i = 0; i < segmentLength; i += 1) {
647
- const landing = target < start ? scopeOffset + t + i : scopeOffset + t;
648
- doc.moveBlock(blockIds[i], containerId, landing);
649
- }
650
- if (delta > 0) applyIndents();
651
- if (containerId === doc.meta.id) ensureTrailingParagraph(doc, doc.meta.id);
652
- });
653
- } catch (error) {
654
- if (error instanceof InvalidAttrsError || error instanceof InvalidOperationError) return false;
655
- throw error;
656
- }
657
- return true;
658
- }
659
- /**
660
- * The tail seat's clamp (the preview side of the commit's tail landing):
661
- * framer's own reorder lets the dragged row cross the full-doc root's
662
- * trailing text paragraph (the pointer beyond the last row stands it
663
- * last). The commit stands the segment above the seat (the doc's tail
664
- * contract), so the preview stands the crossing too — the dragged row
665
- * clamps to the seat's slot, and the release settles at the preview's
666
- * stand. The seat's own drag stands unclamped (it is a real block — the
667
- * guarantee stands its own stand on release).
668
- */
669
- function clampTailSeatCrossing(next, draggedId, seatId) {
670
- if (seatId === void 0 || draggedId === seatId) return [...next];
671
- const draggedAt = next.indexOf(draggedId);
672
- const seatAt = next.indexOf(seatId);
673
- if (draggedAt < 0 || seatAt < 0 || draggedAt <= seatAt) return [...next];
674
- const rest = next.filter((id) => id !== draggedId);
675
- const seatAfter = rest.indexOf(seatId);
676
- return [
677
- ...rest.slice(0, seatAfter),
678
- draggedId,
679
- ...rest.slice(seatAfter)
680
- ];
681
- }
682
- //#endregion
683
- //#region src/motion/utils.ts
684
- /** The motion editor's pure helpers: the drag event's pointer Y (the
685
- * seam measurement's input) and the tail seat's id (the drag's preview
686
- * clamp). */
687
- /**
688
- * The drag's pointer Y (the seam measurement's input): the pointer/mouse
689
- * event's clientY, or the last changed touch's (the touch release event
690
- * carries no coordinate of its own). Null when the event stands no
691
- * usable coordinate (the measurement is stale — the caller keeps the
692
- * preview or skips the commit).
693
- */
694
- function pointerYOf(event) {
695
- if (event instanceof TouchEvent) {
696
- const touch = event.changedTouches[event.changedTouches.length - 1];
697
- return touch === null || touch === void 0 ? null : touch.clientY;
698
- }
699
- return event.clientY;
700
- }
701
- /**
702
- * The tail seat's id for the drag's preview clamp (or undefined when the
703
- * clamp does not stand): the seat is the full-doc root's trailing row
704
- * (the guaranteed trailing text paragraph), and it stands for a dragged
705
- * row whose derived segment is NON-TEXT-terminated (a text-terminated
706
- * segment lands its text tail last — the doc's tail invariant stands and
707
- * the crossing is clean — no clamp, the end landing is the preview's
708
- * stand). The seat's own drag stands unclamped too (it is a real block).
709
- */
710
- function tailSeatId(doc, rootBlockId, draggedId, trailingRowId) {
711
- if (trailingRowId === void 0 || draggedId === trailingRowId) return void 0;
712
- const segment = segmentRange(doc, rootBlockId, draggedId);
713
- if (segment === null) return void 0;
714
- const lastId = doc.getChildren(rootBlockId)[segment.end - 1];
715
- if (lastId === void 0) return void 0;
716
- const lastBlock = doc.getBlock(lastId);
717
- if (lastBlock === null) return void 0;
718
- if (BLOCK_REGISTRY[lastBlock.type].content) return void 0;
719
- const trailingBlock = doc.getBlock(trailingRowId);
720
- if (trailingBlock === null || trailingRowId === lastId) return void 0;
721
- if (!BLOCK_REGISTRY[trailingBlock.type].content) return void 0;
722
- const trailingAttrs = "attrs" in trailingBlock ? trailingBlock.attrs : void 0;
723
- return (typeof trailingAttrs?.indent === "number" ? trailingAttrs.indent : 0) === 0 ? trailingRowId : void 0;
724
- }
725
- //#endregion
726
- //#region src/motion/use-motion-drag.ts
727
- /** The motion editor's drag-reorder session (pure framer `Reorder` — one
728
- * drag = one range move of the dragged block's derived subtree): the local
729
- * row order (live during a drag, locked to the store otherwise) + the
730
- * committed order's ref + the framer drag key + the in-flight seam
731
- * candidate + the layout slide's arm. The 2 resyncs (the drag's row
732
- * vanished — a peer deleted it mid-drag; a store change (remote or local,
733
- * drag-free) resyncs the local order) + the 3 drag handlers (the row's
734
- * drag start, the drag's live seam candidate, the row's release) + the
735
- * framer reorder's live preview (the tail seat's clamp). The seam
736
- * candidate at the pointer's Y (the drag's live preview and the release's
737
- * commit input share it) — the visible rows' viewport boxes against the
738
- * seam rule. */
739
- /** The local row order: live during a drag (framer's reorder), locked to the store otherwise. */
740
- function useMotionDrag({ doc, rootBlockId, store, rootRef, rowKeys, nodesById }) {
741
- const [order, setOrder] = useState(rowKeys);
742
- const orderRef = useRef(order);
743
- const [draggingKey, setDraggingKey] = useState(null);
744
- const [previewIndent, setPreviewIndent] = useState(null);
745
- const [layoutSlide, setLayoutSlide] = useState(false);
746
- const slideTimerRef = useRef(null);
747
- const prevKeysRef = useRef(rowKeys);
748
- const entryContainerRef = useRef(null);
749
- /**
750
- * Keep the layout slide armed for the settle window after a drag or a
751
- * structural change, then release it so in-place content changes
752
- * reflow instantly again (the zero-duration layout transition).
753
- */
754
- const armSlide = useCallback(() => {
755
- if (slideTimerRef.current !== null) window.clearTimeout(slideTimerRef.current);
756
- slideTimerRef.current = window.setTimeout(() => {
757
- slideTimerRef.current = null;
758
- setLayoutSlide(false);
759
- }, 400);
760
- setLayoutSlide(true);
761
- }, []);
762
- useEffect(() => () => {
763
- if (slideTimerRef.current !== null) window.clearTimeout(slideTimerRef.current);
764
- }, []);
765
- useLayoutEffect(() => {
766
- orderRef.current = order;
767
- }, [order]);
768
- if (draggingKey !== null && !rowKeys.includes(draggingKey)) {
769
- setDraggingKey(null);
770
- setPreviewIndent(null);
771
- }
772
- if (draggingKey === null && !sameIds(order, rowKeys)) setOrder(rowKeys);
773
- if (prevKeysRef.current !== rowKeys) {
774
- const prev = prevKeysRef.current;
775
- const removed = prev.some((key) => !rowKeys.includes(key));
776
- const reordered = !sameIds(prev.filter((key) => rowKeys.includes(key)), rowKeys.filter((key) => prev.includes(key)));
777
- if (removed || reordered) armSlide();
778
- prevKeysRef.current = rowKeys;
779
- }
780
- /**
781
- * The seam candidate at the pointer's Y (the drag's live preview and
782
- * the release's commit input share it): the visible rows' viewport
783
- * boxes against the seam rule (`reorder.ts`). Null when the measurement
784
- * is stale (the segment vanished, or the rows are gone) — the caller
785
- * keeps the preview (move) or skips the commit (release, the
786
- * render-phase resync snaps the order back to the store).
787
- */
788
- const seamCandidateAt = useCallback((id, pointerY) => {
789
- const root = rootRef.current;
790
- if (root === null) return null;
791
- const session = entryContainerRef.current;
792
- const containerId = session !== null && session.rootBlockId === rootBlockId ? session.value : entrySequenceParent(doc, rootBlockId);
793
- const segment = segmentRange(doc, containerId, id);
794
- if (segment === null) return null;
795
- const segmentIds = new Set(doc.getChildren(containerId).slice(segment.start, segment.end));
796
- const rows = [];
797
- for (const el of Array.from(root.querySelectorAll("[data-slot=\"docblocks/row\"]"))) {
798
- const wrapperId = el.querySelector("[data-block-id]")?.getAttribute("data-block-id") ?? null;
799
- if (wrapperId === null) continue;
800
- const node = nodesById.get(wrapperId);
801
- if (node === void 0) continue;
802
- let top = 0;
803
- for (let ancestor = el; ancestor !== null; ancestor = ancestor.offsetParent) top += ancestor.offsetTop;
804
- top -= window.scrollY;
805
- rows.push({
806
- id: wrapperId,
807
- top,
808
- bottom: top + el.offsetHeight,
809
- indent: node.indent
810
- });
811
- }
812
- if (rows.length === 0) return null;
813
- return dropCandidateIndent(rows, pointerY, segmentIds, id);
814
- }, [
815
- doc,
816
- nodesById,
817
- rootBlockId,
818
- rootRef
819
- ]);
820
- return {
821
- order,
822
- draggingKey,
823
- previewIndent,
824
- layoutSlide,
825
- onRowDragStart: useCallback((id) => {
826
- armSlide();
827
- setDraggingKey(id);
828
- setPreviewIndent(null);
829
- entryContainerRef.current = {
830
- rootBlockId,
831
- value: entrySequenceParent(doc, rootBlockId)
832
- };
833
- }, [
834
- armSlide,
835
- doc,
836
- rootBlockId
837
- ]),
838
- onRowDragMove: useCallback((id, event) => {
839
- const pointerY = pointerYOf(event);
840
- if (pointerY === null) return;
841
- const candidate = seamCandidateAt(id, pointerY);
842
- if (candidate === null) return;
843
- setPreviewIndent(candidate);
844
- }, [seamCandidateAt]),
845
- onRowDragEnd: useCallback((id, event) => {
846
- armSlide();
847
- setDraggingKey(null);
848
- rootRef.current?.focus({ preventScroll: true });
849
- const pointerY = pointerYOf(event);
850
- setPreviewIndent(null);
851
- if (pointerY === null) return;
852
- const candidate = seamCandidateAt(id, pointerY);
853
- if (candidate === null) return;
854
- commitFlatDrag(doc, rootBlockId, id, orderRef.current, store.getState().collapsedIds, candidate);
855
- }, [
856
- armSlide,
857
- doc,
858
- orderRef,
859
- rootBlockId,
860
- rootRef,
861
- seamCandidateAt,
862
- store
863
- ]),
864
- onRowReorder: useCallback((next) => {
865
- setOrder(clampTailSeatCrossing(next, draggingKey ?? "", rootBlockId === doc.meta.id && draggingKey !== null ? tailSeatId(doc, rootBlockId, draggingKey, rowKeys[rowKeys.length - 1]) : void 0));
866
- }, [
867
- doc,
868
- draggingKey,
869
- rootBlockId,
870
- rowKeys
871
- ])
872
- };
873
- }
874
- //#endregion
875
- //#region src/motion/MotionEditor.tsx
876
- /**
877
- * `MotionEditor` — the animated editor view, a drop-in replacement for the
878
- * base `Editor` under the same `EditorProvider`: the same root element
879
- * (the focus/keyboard scope), the same block rendering and editing
880
- * wiring, and the same presence overlay. The page view renders the FLAT
881
- * page sequence (every page block is its own `Reorder.Item` row), the
882
- * top-level rows render through `AnimatePresence` (`sync`), and the
883
- * drag-reorder is pure framer `Reorder` (one drag = one range move of the
884
- * dragged block's derived subtree).
885
- */
886
- function MotionEditor({ view = "page", formatPalette = FORMAT_PALETTE }) {
887
- const { doc, store, rootBlockId, viewMode } = useEditorContext();
888
- const rootChildren = useStore(store, (state) => state.rootChildren);
889
- const seqStamp = useStore(store, (state) => state.seqStamp);
890
- const collapsedIds = useStore(store, (state) => state.collapsedIds);
891
- const pageChildren = useMemo(() => rootChildren.filter((id) => store.getState().blocks[id]?.type !== "surface"), [
892
- rootChildren,
893
- seqStamp,
894
- store
895
- ]);
896
- const rootContext = useMemo(() => ({
897
- parentId: entrySequenceParent(doc, rootBlockId),
898
- siblings: rootChildren
899
- }), [
900
- doc,
901
- rootBlockId,
902
- rootChildren
903
- ]);
904
- const { rootRef, onKeyDown, onCopy, onPointerDown: onCrossDragPointerDown } = useEditing();
905
- const slash = useSlashMenu(rootRef);
906
- const mention = useMentionMenu(rootRef);
907
- const format = useFormatToolbar(rootRef, formatPalette);
908
- useTypingFlag(rootRef);
909
- const enterGate = useEnterAnimationGate(rootRef);
910
- const prevRef = useRef(null);
911
- const derivation = useMemo(() => {
912
- const result = deriveFlat(pageChildren, store.getState().blocks, prevRef.current ?? void 0);
913
- prevRef.current = result.byId;
914
- return result;
915
- }, [
916
- pageChildren,
917
- seqStamp,
918
- store
919
- ]);
920
- const rowKeys = useMemo(() => visibleRows(pageChildren, derivation.byId, collapsedIds), [
921
- pageChildren,
922
- derivation,
923
- collapsedIds
924
- ]);
925
- const nodesById = derivation.byId;
926
- const emptyKey = useStore(store, (state) => viewMode ? pageChildren.map((id) => {
927
- const block = state.blocks[id];
928
- return isEmptyChainText(block) ? "o" : block === void 0 ? "-" : ".";
929
- }).join("") : "");
930
- const { order, draggingKey, previewIndent, layoutSlide, onRowDragStart, onRowDragMove, onRowDragEnd, onRowReorder } = useMotionDrag({
931
- doc,
932
- rootBlockId,
933
- store,
934
- rootRef,
935
- rowKeys: useMemo(() => {
936
- if (!viewMode) return rowKeys;
937
- const nodes = [];
938
- for (const id of rowKeys) {
939
- const node = nodesById.get(id);
940
- if (node !== void 0) nodes.push(node);
941
- }
942
- return collapseEmptyTextChains(nodes, (id) => isEmptyChainText(store.getState().blocks[id])).map((node) => node.id);
943
- }, [
944
- rowKeys,
945
- viewMode,
946
- emptyKey,
947
- nodesById,
948
- store
949
- ]),
950
- nodesById
951
- });
952
- const onRootPointerDown = (event) => {
953
- onCrossDragPointerDown(event);
954
- };
955
- return /* @__PURE__ */ jsx(Card, {
956
- className: cn(view === "canvas" && "min-h-0 flex-1"),
957
- children: /* @__PURE__ */ jsx(EnumOptionsMotionSlot.Provider, {
958
- value: MotionEnumOptionsEditor,
959
- children: /* @__PURE__ */ jsxs("div", {
960
- ref: rootRef,
961
- "data-slot": "docblocks/editor",
962
- "data-docblocks-motion": "",
963
- "data-view": view,
964
- "data-readonly": viewMode ? "true" : void 0,
965
- tabIndex: -1,
966
- className: cn("relative min-h-0 flex-1 selection:bg-(--docblocks-selection-background)", "font-(family-name:--docblocks-font-family) text-(length:--docblocks-font-size)", "leading-(--docblocks-line-height) text-(--docblocks-text-color)"),
967
- onKeyDown,
968
- onCopy,
969
- onPointerDown: onRootPointerDown,
970
- children: [
971
- /* @__PURE__ */ jsx("style", { children: HANDLE_GRAB_STYLE }),
972
- /* @__PURE__ */ jsx(MentionMenuContext.Provider, {
973
- value: mention.api,
974
- children: /* @__PURE__ */ jsx(SlashMenuContext.Provider, {
975
- value: slash.api,
976
- children: /* @__PURE__ */ jsx(FormatPaletteContext.Provider, {
977
- value: formatPalette,
978
- children: /* @__PURE__ */ jsxs(BlockContext.Provider, {
979
- value: rootContext,
980
- children: [view === "canvas" ? /* @__PURE__ */ jsx(CanvasView, {}) : /* @__PURE__ */ jsx("div", {
981
- "data-slot": "docblocks/page",
982
- className: "\n mx-auto w-full max-w-(--docblocks-page-column-max-width) px-(--docblocks-page-padding-x)\n ",
983
- children: /* @__PURE__ */ jsx(RowList, {
984
- order,
985
- nodesById,
986
- layoutSlide: layoutSlide || draggingKey !== null,
987
- draggingKey,
988
- previewIndent,
989
- animated: enterGate.current,
990
- onReorder: onRowReorder,
991
- onDragStart: onRowDragStart,
992
- onDragMove: onRowDragMove,
993
- onDragEnd: onRowDragEnd
994
- })
995
- }), viewMode ? null : /* @__PURE__ */ jsx(PresenceCaretOverlay, { rootRef })]
996
- })
997
- })
998
- })
999
- }),
1000
- slash.menuNode,
1001
- mention.menuNode,
1002
- viewMode ? null : format.toolbarNode
1003
- ]
1004
- })
1005
- })
1006
- });
1007
- }
1008
- //#endregion
1009
- export { MotionEditor };