@bloklabs/vue 1.16.1 → 2.0.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,740 +0,0 @@
1
- // src/vue/createVueBlock.ts
2
- import {
3
- defineComponent,
4
- h,
5
- nextTick,
6
- onMounted,
7
- onUpdated,
8
- ref,
9
- shallowRef,
10
- toRaw,
11
- type Component,
12
- type ComponentPublicInstance,
13
- type PropType,
14
- type ShallowRef,
15
- type VNodeChild,
16
- } from 'vue';
17
-
18
- import type { API } from '@/types';
19
- import type { BlockAPI } from '@/types/api';
20
- import type {
21
- BlockOrigin,
22
- BlockToolConstructable,
23
- BlockToolConstructorOptions,
24
- BlockToolData,
25
- ToolboxConfig,
26
- } from '@/types/tools';
27
- import {
28
- applyChildDecoration,
29
- BlockChildrenMounted,
30
- createChildDecorationLedger,
31
- DATA_ATTR,
32
- deepEqual,
33
- mountChildBlocks,
34
- withSlotlessDescendants,
35
- } from '@bloklabs/core/adapters';
36
- import { fillDefaults, type PropSchema } from '@bloklabs/core/adapters';
37
-
38
- import {
39
- BLOK_PORTAL_REGISTRY_CONFIG_KEY,
40
- type BlockPortalRegistry,
41
- } from './block-portal-registry';
42
-
43
- export type { PropSchema, PropSchemaEntry } from '@bloklabs/core/adapters';
44
-
45
- /**
46
- * Every STATIC member of core's block-tool contract a Vue block may declare for
47
- * itself — `ownsChildren`, `keepsChildrenOnEnter`, `conversionConfig`,
48
- * `pasteConfig`, `sanitize`, `shortcut`, `upgradeData`, and whatever core adds
49
- * next. Derived from
50
- * `BlockToolConstructable` rather than enumerated, so a new core static needs no
51
- * adapter change to become reachable.
52
- *
53
- * `toolbox` and `isReadOnlySupported` are excluded because the factory owns
54
- * them: `toolbox` is authored as {@link CreateVueBlockSpec.toolbox}, and
55
- * in-place read-only support is unconditional.
56
- */
57
- export type BlockToolStatics = Omit<BlockToolConstructable, 'toolbox' | 'isReadOnlySupported'>;
58
-
59
- /** Statics the generated class owns; an authored `statics` bag can never take them over. */
60
- const RESERVED_STATICS: readonly string[] = ['toolbox', 'isReadOnlySupported', '__isBlokVueBlock'];
61
-
62
- /**
63
- * Origins that mean "the author just made this block" — the only ones that fire
64
- * {@link CreateVueBlockSpec.onCreated}. Written as an allow-list so a future
65
- * core origin fails CLOSED (no creation signal) instead of silently opting in.
66
- *
67
- * `undefined` is included because core always supplies an origin: an absent one
68
- * means a host hand-built the constructor options, which is an explicit
69
- * creation — the same reading core's own container tools apply.
70
- *
71
- * Note what is NOT the axis here: `origin === 'user'`. A container gated on that
72
- * alone would refuse to seed for `api.blocks.insert('steps')` and for a
73
- * turn-into, leaving an empty, unusable container.
74
- */
75
- const CREATION_ORIGINS: ReadonlySet<BlockOrigin | undefined> = new Set<BlockOrigin | undefined>([
76
- undefined,
77
- 'user',
78
- 'api',
79
- 'convert',
80
- ]);
81
-
82
- /**
83
- * One child's decoration: attribute name → value. `null`/`undefined` removes the
84
- * attribute; a boolean or number is stringified (so `false` writes
85
- * `data-active="false"`, which CSS can select, rather than dropping the hook).
86
- */
87
- export type ChildAttributes = Record<string, string | number | boolean | null | undefined>;
88
-
89
- /** The `BlockChildren` per-child decorator (see {@link VueBlockRenderProps.BlockChildren}). */
90
- export type ChildAttributesFn = (child: BlockAPI, index: number) => ChildAttributes;
91
-
92
- /**
93
- * Announce that a container's child holders have settled in its slot — the
94
- * signal a host waits on before putting the caret into a freshly inserted
95
- * child, since the teleport commits a tick after core's `rendered()`.
96
- *
97
- * Optional-chained: the editor always supplies `events`, but a host unit test
98
- * may hand a block a partial `api`, and an observability signal must never be
99
- * able to break a render.
100
- * @param api - the editor-level API
101
- * @param blockId - the container block's id
102
- * @param children - the container's model children, in model order
103
- */
104
- const emitChildrenMounted = (api: API, blockId: string, children: BlockAPI[]): void => {
105
- api?.events?.emit(BlockChildrenMounted, {
106
- blockId,
107
- childIds: children.map(child => child.id),
108
- });
109
- };
110
-
111
- /** Context handed to a Vue block's `setup` (the only data write path is `commit`). */
112
- export interface VueBlockRenderProps<Data> {
113
- /** Reactive, FROZEN snapshot of the block data. Read `data.value`; never mutate. */
114
- data: ShallowRef<Readonly<Data>>;
115
- /** The ONLY data write path: merge a partial patch and sync once. */
116
- commit: (patch: Partial<Data>) => void;
117
- /** This block's per-block API (id, connection methods, dispatchChange…). */
118
- block: BlockAPI;
119
- /**
120
- * The EDITOR-level API this block belongs to (`api.blocks`, `api.caret`,
121
- * `api.toolbar`…) — the same object a vanilla tool receives in its
122
- * constructor. Reach for it when a block has to drive the document around it
123
- * instead of routing everything through `block.call()` string dispatch.
124
- *
125
- * For the reactive, id/parentId-relative view of the tree (and to re-render
126
- * when your own children change), pair `useBlocks` with `useBlokInstance()`
127
- * instead — the api handle itself is not reactive.
128
- */
129
- api: API;
130
- /**
131
- * Reactive read-only flag. Read `readOnly.value` in render to disable editing
132
- * (e.g. drop `contenteditable`, hide controls). Toggled IN PLACE by core's
133
- * read-only switch — the component reacts without a remount, so ephemeral state
134
- * survives. Honor it: a block that ignores `readOnly` stays interactive when
135
- * the editor is read-only (same contract as a vanilla tool's `setReadOnly`).
136
- */
137
- readOnly: Readonly<ShallowRef<boolean>>;
138
- /**
139
- * Engine-owned child slot — render `h(BlockChildren)` for a container block.
140
- *
141
- * Accepts one optional prop, `childAttributes: (child, index) => ChildAttributes`,
142
- * applied to each child's HOLDER after the holders are mounted. Named hooks
143
- * (`data-step-index`, `data-active`…) replace positional `:nth-child()` CSS
144
- * over Blok's holders, which breaks the moment a child is inserted, removed or
145
- * reordered. The holders stay DIRECT children of the slot — core requires that
146
- * (hierarchy reparenting and caret navigation compare `holder.parentElement`
147
- * by identity), so decoration is attributes, never wrapper elements. Attributes
148
- * the callback stops producing are removed on the next pass.
149
- *
150
- * A second prop, `childContentAttributes`, is the same decoration applied one
151
- * level IN — on each child's `[data-blok-element-content]` wrapper instead of
152
- * its holder. Core's decoration law blesses both, and a container needs both:
153
- * the holder is the child's outer box (rails, indices, hover states), the
154
- * content wrapper is where the child's own text box begins, which is what a
155
- * numbered rail or a connector line has to align to. Reach for it instead of
156
- * walking core's wrapper chain from a holder hook (`[data-step] >
157
- * [data-blok-element-content] > …`) — those selectors encode engine DOM in host
158
- * CSS and break when that structure changes. A child whose DOM has not
159
- * committed yet has no wrapper to write to and is stamped on the next pass.
160
- *
161
- * Writing on a child's holder is inert by design: core's mutation filter drops
162
- * a holder-targeted attribute record for the child block and suppresses it for
163
- * the container. The guarantee stops at the holder and its
164
- * `[data-blok-element-content]` wrapper — writing AT or BELOW a child's tool
165
- * root DOES score as that child's edit.
166
- */
167
- BlockChildren: Component;
168
- /**
169
- * Attach this to the element the +/drag toolbar should vertically center on:
170
- * `h('div', { ref: ctx.toolbarAnchorRef })`, or `:ref="ctx.toolbarAnchorRef"`
171
- * in a template. The Vue-native way to answer core's
172
- * `getToolbarAnchorElement`, which is otherwise a `(host, block) => Element`
173
- * hook resolved OUTSIDE the render tree — so pointing at an element you render
174
- * meant inventing a data attribute and `querySelector`-ing for it from
175
- * {@link CreateVueBlockSpec.getToolbarAnchorElement}.
176
- *
177
- * A container block whose own chrome is not editable needs an anchor: with
178
- * none, core centers the toolbar on the first `[contenteditable]` under the
179
- * host, which for a container is its FIRST CHILD BLOCK — parking the +/drag
180
- * handles halfway down, beside content that has a toolbar of its own.
181
- *
182
- * The ref is stable across renders and outranks
183
- * {@link CreateVueBlockSpec.getToolbarAnchorElement} while it holds a MOUNTED
184
- * element; when the element unmounts (Vue calls the ref with null, or it is
185
- * detached) the declared hook takes over again, so the toolbar is never
186
- * positioned against a stale node. Leave it unattached to keep core's default.
187
- */
188
- toolbarAnchorRef: (element: Element | ComponentPublicInstance | null) => void;
189
- }
190
-
191
- /**
192
- * Second argument of {@link CreateVueBlockSpec.onMounted} and
193
- * {@link CreateVueBlockSpec.onCreated} — everything the block cannot read off
194
- * its own `BlockAPI`.
195
- */
196
- export interface VueBlockMountedContext {
197
- /**
198
- * Why this block instance was constructed: a CREATION origin (`user`, `api`,
199
- * `convert`) means the author just made it, so seeding default children is
200
- * correct; a RESTORE origin (`load`, `replay`, `paste`) means the document
201
- * already says what the children are. `probe` is an off-tree instance built
202
- * only to read a tool's default data — it must not touch the block tree at
203
- * all. Defaults to `'api'` when the constructor was handed no origin.
204
- */
205
- origin: BlockOrigin;
206
- /** The editor-level API (`api.blocks`, `api.caret`, `api.events`…). */
207
- api: API;
208
- }
209
-
210
- /** Spec for {@link createVueBlock}. Authored as a `.ts` render function (no SFC). */
211
- export interface CreateVueBlockSpec<Data = BlockToolData> {
212
- /** Tool type name (registered key). */
213
- type: string;
214
- /** Optional toolbox entry. */
215
- toolbox?: ToolboxConfig;
216
- /** Declarative defaults that also define the exact `save()` key set. */
217
- propSchema: PropSchema;
218
- /** Returns a render function (the `defineComponent` + `setup`-returns-`h` shape). */
219
- setup: (props: VueBlockRenderProps<Data>) => () => VNodeChild;
220
- /**
221
- * Static members of core's tool contract, forwarded verbatim onto the
222
- * generated tool class — the single channel for everything core reads off the
223
- * CLASS rather than the instance (`ownsChildren`, `keepsChildrenOnEnter`,
224
- * `conversionConfig`, `pasteConfig`, `sanitize`, `shortcut`, `upgradeData`…).
225
- * Without it the only way to declare one was to subclass the generated class.
226
- *
227
- * `keepsChildrenOnEnter` is the per-tool Enter POLICY: declare it and Enter on
228
- * this container's empty LAST child creates the new line INSIDE the container
229
- * instead of escaping to the container's parent (Blok's default, which is
230
- * Notion's callout behaviour). Core cannot read that off the DOM — a callout
231
- * renders the same `data-blok-nested-blocks` slot as a column yet wants the
232
- * escape — so before it existed a layout container (a card, a `steps` block)
233
- * had to hijack the editor-global `config.onEnter` and re-derive containment.
234
- *
235
- * `toolbox` and `isReadOnlySupported` are owned by the factory and cannot be
236
- * overridden here (see {@link BlockToolStatics}).
237
- */
238
- statics?: BlockToolStatics;
239
- /**
240
- * The element the +/drag toolbar should vertically center on — core's
241
- * `getToolbarAnchorElement` hook, resolved against this block's host element
242
- * on every call (never cached, so it tracks re-renders).
243
- *
244
- * A container block whose own chrome is not editable needs it: with no anchor,
245
- * core centers the toolbar on the first `[contenteditable]` under the host,
246
- * which for a container is its FIRST CHILD BLOCK. Return `null`/`undefined`
247
- * (or omit the field) to keep core's default.
248
- * @param host - this block's mutation-free host element
249
- * @param block - this block's per-block API
250
- */
251
- getToolbarAnchorElement?: (host: HTMLElement, block: BlockAPI) => HTMLElement | null | undefined;
252
- /**
253
- * Enter (or Cmd/Ctrl+Enter) on this block while it is the keyboard
254
- * navigation target — core's `onNavigationEnter` hook. Return true when the
255
- * block handled it (e.g. opened a link); omit it, or return false, to let
256
- * core put the caret in the block.
257
- * @param event - the Enter keydown
258
- * @param block - this block's per-block API
259
- */
260
- onNavigationEnter?: (event: KeyboardEvent, block: BlockAPI) => boolean;
261
- /** Optional lifecycle callbacks mapped from Blok's block hooks. */
262
- onRendered?: (block: BlockAPI) => void;
263
- /**
264
- * Fired ONCE per block instance, after the teleport's FIRST commit — the
265
- * first moment this block's rendered DOM exists (and, for a container, the
266
- * moment `BlockChildren` has adopted the child holders). `onRendered` cannot
267
- * mean that: core calls `rendered()` in the same tick as `render()`, before
268
- * Vue has drawn anything, which is why hosts ended up setting the caret twice
269
- * around a `requestAnimationFrame`.
270
- *
271
- * It is also the create-vs-restore signal: `context.origin` says whether the
272
- * author just made this block (`user`/`api`/`convert`) or the document is
273
- * being re-materialised (`load`/`replay`/`paste`) — so a container can seed
274
- * its default children here exactly once, without the "children are
275
- * transiently empty during a replay" trap.
276
- * @example
277
- * ```ts
278
- * onMounted: (block, { origin, api }) => {
279
- * if (origin === 'user' && block.getChildren().length === 0) {
280
- * api.blocks.insertInsideParent(block.id);
281
- * }
282
- * }
283
- * ```
284
- */
285
- onMounted?: (block: BlockAPI, context: VueBlockMountedContext) => void;
286
- /**
287
- * The SEEDING hook: `onMounted`, narrowed to a genuine creation. Fired ONCE
288
- * per block instance, after the teleport's first commit, and only when this
289
- * instance is the author making a new block (`origin` of `user`, `api` or
290
- * `convert`) — never for a `load`/`replay`/`paste` restore, and never for the
291
- * off-tree `probe` instance core builds to read a tool's default data.
292
- *
293
- * That predicate is why the hook exists rather than leaving every block to
294
- * read `context.origin` in `onMounted`: the intuitive `origin === 'user'` test
295
- * is wrong. It drops `api.blocks.insert('steps')` and turn-into, so a
296
- * container seeded that way comes up empty for every path except a keystroke.
297
- * Core refused to ship that axis into its own `column`/`column_list`; this
298
- * encodes the correct one once, here.
299
- *
300
- * The `context` is the same object {@link CreateVueBlockSpec.onMounted}
301
- * receives, so a block that only seeds can read `origin` for finer decisions.
302
- * @example
303
- * ```ts
304
- * onCreated: (block, { api }) => {
305
- * if (block.getChildren().length === 0) {
306
- * api.blocks.insertInsideParent(block.id);
307
- * }
308
- * }
309
- * ```
310
- */
311
- onCreated?: (block: BlockAPI, context: VueBlockMountedContext) => void;
312
- onMoved?: (block: BlockAPI) => void;
313
- onRemoved?: (block: BlockAPI) => void;
314
- }
315
-
316
- /**
317
- * Author a first-party Vue block. Returns a `BlockToolConstructable` registered
318
- * exactly like a vanilla tool (`tools: { type: { class: createVueBlock(...) } }`).
319
- *
320
- * The factory owns the host element (`data-blok-mutation-free`), a frozen
321
- * defaults-filled data mirror, and a reactive `shallowRef` the component reads.
322
- * It teleports the component into the host via the editor's shared portal
323
- * registry (the TipTap `VueNodeViewRenderer` pattern), and bridges Blok's block
324
- * lifecycle to Vue:
325
- * - `render()` creates the host and registers the portal entry (returned sync).
326
- * - `setData()` is async: it dedups, swaps the reactive snapshot, and `await`s
327
- * Vue's commit before resolving `true` — defeating core's default remount and
328
- * landing the update inside core's RAF-extended Yjs suppression window.
329
- * - `save()` returns the complete frozen mirror (never the DOM, never partial).
330
- * - `commit()` merges a patch and fires `dispatchChange` exactly once.
331
- * - `removed()`/`destroy()` unregister the portal (deterministic unmount).
332
- */
333
- export function createVueBlock<Data = BlockToolData>(
334
- spec: CreateVueBlockSpec<Data>
335
- ): (new (options: BlockToolConstructorOptions) => {
336
- render(): HTMLElement;
337
- save(): BlockToolData;
338
- setData(newData: BlockToolData): Promise<boolean>;
339
- setReadOnly(state: boolean): void;
340
- getToolbarAnchorElement(): HTMLElement | undefined;
341
- onNavigationEnter(event: KeyboardEvent): boolean;
342
- rendered(): void;
343
- moved(): void;
344
- removed(): void;
345
- destroy(): void;
346
- }) & BlockToolStatics & {
347
- readonly __isBlokVueBlock: true;
348
- readonly toolbox: ToolboxConfig | undefined;
349
- readonly isReadOnlySupported: boolean;
350
- } {
351
- /**
352
- * One wrapped component definition per BLOCK INSTANCE.
353
- *
354
- * Per-TYPE looks equivalent — each instance gets its own context through the
355
- * `ctx` prop — but the portal host keys its `<Teleport>` by BLOCK ID, and core
356
- * composes a replacement block (registering under that same id) BEFORE it
357
- * destroys the block it replaces. With a shared type, Vue sees same key + same
358
- * type and PATCHES the mounted instance instead of replacing it: `setup` has
359
- * already run and its render closure captured the SUPERSEDED instance's `ctx`,
360
- * so the live host renders the old block's data forever. A distinct type per
361
- * instance makes Vue replace the instance, so the replacement's ctx renders.
362
- * Within one instance the type is stable, so in-place prop updates still never
363
- * remount.
364
- */
365
- const defineWrappedComponent = (): Component =>
366
- defineComponent({
367
- name: `VueBlock(${spec.type})`,
368
- props: {
369
- ctx: { type: Object as PropType<VueBlockRenderProps<Data>>, required: true },
370
- /**
371
- * Adapter-internal: the tool instance's post-commit notifier. Declared as
372
- * a prop (not part of `ctx`) so it never reaches the authored `setup`.
373
- */
374
- notifyMounted: { type: Function as PropType<() => void>, default: undefined },
375
- },
376
- setup(props) {
377
- // The block's DOM exists from here on — this is the first commit core's
378
- // `rendered()` hook could not wait for.
379
- onMounted(() => props.notifyMounted?.());
380
-
381
- // props is shallow-reactive: `props.ctx` is the raw context object, so the
382
- // `data` ref inside it is NOT unwrapped (no reactive ref-unwrap gotcha).
383
- return spec.setup(props.ctx);
384
- },
385
- });
386
-
387
- const VueBlockTool = class VueBlockTool {
388
- /** Marker so `useBlok` can detect vue-block tools and inject the registry. */
389
- public static readonly __isBlokVueBlock = true as const;
390
-
391
- public static get toolbox(): ToolboxConfig | undefined {
392
- return spec.toolbox;
393
- }
394
-
395
- /**
396
- * Vue blocks support read-only mode: `setReadOnly` flips a reactive flag the
397
- * component reads, so the block re-renders read-only IN PLACE. Without this
398
- * flag core's ReadOnly module throws a critical error when read-only is
399
- * enabled and a Vue block is present.
400
- */
401
- public static get isReadOnlySupported(): boolean {
402
- return true;
403
- }
404
-
405
- private readonly blockApi: BlockAPI;
406
- /** The editor-level API, handed to the block setup as `ctx.api`. */
407
- private readonly api: API;
408
- private readonly registry: BlockPortalRegistry | undefined;
409
- private readonly pointerDrag: () => boolean;
410
- private readonly dataRef: ShallowRef<Readonly<Data>>;
411
- /** Reactive read-only flag handed to setup; flipped in place by setReadOnly. */
412
- private readonly readOnlyRef: ShallowRef<boolean>;
413
- private readonly childrenComponent: Component;
414
- /** This instance's own wrapper type — see {@link defineWrappedComponent}. */
415
- private readonly wrappedComponent: Component = defineWrappedComponent();
416
- /** Why core built this instance — gates `onCreated`, handed to `onMounted`. */
417
- private readonly origin: BlockOrigin;
418
- /** True once the post-mount hooks fired; a re-mounted teleport must not re-fire them. */
419
- private mountSignalled = false;
420
- private mirror: Readonly<Data>;
421
- /** Dedup baseline (Risk R3/R6): skip a redundant render of identical data. */
422
- private lastRendered: Readonly<Data>;
423
- private hostEl: HTMLElement | null = null;
424
- /** Latest patch queued while a pointer drag suppresses dispatchChange. */
425
- private pendingDispatch = false;
426
- /** Element the block pointed `toolbarAnchorRef` at, if any. */
427
- private anchorEl: HTMLElement | null = null;
428
- /**
429
- * The `toolbarAnchorRef` context member. A bound field so its identity is
430
- * STABLE across renders — a fresh function ref each render would make Vue
431
- * detach (null) and re-attach it on every patch.
432
- * @param element - what Vue attached: an element, a component instance, or
433
- * null on detach
434
- */
435
- private readonly toolbarAnchorRef = (
436
- element: Element | ComponentPublicInstance | null
437
- ): void => {
438
- // A ref placed on a child COMPONENT hands over its instance, not its DOM.
439
- const resolved = element === null || element instanceof Element
440
- ? element
441
- : (element.$el as unknown);
442
-
443
- this.anchorEl = resolved instanceof HTMLElement ? resolved : null;
444
- };
445
-
446
- public constructor(options: BlockToolConstructorOptions) {
447
- this.blockApi = options.block;
448
-
449
- const config = (options.config ?? {}) as Record<string, unknown>;
450
-
451
- this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
452
-
453
- this.api = options.api;
454
-
455
- // Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
456
- // (core silently drops a dispatchChange while a drag is active).
457
- const drag = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
458
-
459
- this.pointerDrag = (): boolean => drag?.blocks?.isPointerDragActive === true;
460
-
461
- this.mirror = fillDefaults<Data>(spec.propSchema, toRaw(options.data ?? {}) as Record<string, unknown>);
462
- this.lastRendered = this.mirror;
463
- this.dataRef = shallowRef(this.mirror);
464
- this.readOnlyRef = shallowRef(options.readOnly);
465
- // Absent origin means a caller that predates the signal; 'api' is core's
466
- // own default, so it is never mistaken for a user gesture.
467
- this.origin = options.origin ?? 'api';
468
-
469
- const blockApi = this.blockApi;
470
- const api = this.api;
471
-
472
- // Per-instance child slot: a childless ref'd div Blok owns; mountChildBlocks
473
- // appends the real child holders imperatively (Vue never reconciles them).
474
- this.childrenComponent = defineComponent({
475
- name: `VueBlockChildren(${spec.type})`,
476
- props: {
477
- childAttributes: {
478
- type: Function as PropType<ChildAttributesFn>,
479
- default: undefined,
480
- },
481
- childContentAttributes: {
482
- type: Function as PropType<ChildAttributesFn>,
483
- default: undefined,
484
- },
485
- },
486
- setup(props) {
487
- const slot = ref<HTMLElement | null>(null);
488
- // What the last decoration pass wrote, so a dropped key is cleaned up.
489
- const ledger = createChildDecorationLedger();
490
- const mountKids = (): void => {
491
- if (slot.value === null) {
492
- return;
493
- }
494
-
495
- const children = blockApi.getChildren();
496
-
497
- // A slotless child's own children live in this slot too. Decoration
498
- // and the mounted event stay per DIRECT child.
499
- mountChildBlocks(slot.value, withSlotlessDescendants(children, id => api.blocks.getChildren(id)));
500
- applyChildDecoration(ledger, children, {
501
- childAttributes: props.childAttributes,
502
- childContentAttributes: props.childContentAttributes,
503
- });
504
- emitChildrenMounted(api, blockApi.id, children);
505
- };
506
-
507
- onMounted(mountKids);
508
- onUpdated(mountKids);
509
-
510
- return () => h('div', { ref: slot, [DATA_ATTR.nestedBlocks]: '' });
511
- },
512
- });
513
- }
514
-
515
- public render(): HTMLElement {
516
- const host = document.createElement('div');
517
-
518
- // The Blok-owned host: Vue reconciles the chrome teleported INTO it, but
519
- // this attribute makes core's MutationObserver ignore those mutations, so
520
- // Vue's reconciliation never registers as a user edit.
521
- host.setAttribute('data-blok-mutation-free', 'true');
522
- this.hostEl = host;
523
-
524
- const ctx: VueBlockRenderProps<Data> = {
525
- data: this.dataRef,
526
- commit: this.commit,
527
- block: this.blockApi,
528
- api: this.api,
529
- readOnly: this.readOnlyRef,
530
- BlockChildren: this.childrenComponent,
531
- toolbarAnchorRef: this.toolbarAnchorRef,
532
- };
533
-
534
- this.registry?.register(this.blockApi.id, {
535
- hostEl: host,
536
- component: this.wrappedComponent,
537
- props: { ctx, notifyMounted: this.notifyMounted },
538
- });
539
-
540
- // Returned synchronously; Vue flushes the teleport on the next tick.
541
- return host;
542
- }
543
-
544
- public rendered(): void {
545
- spec.onRendered?.(this.blockApi);
546
- }
547
-
548
- /**
549
- * The wrapped component's post-commit callback: the first moment this
550
- * block's DOM (and, for a container, its mounted child holders) exists.
551
- * Fires the post-mount hooks once per block instance — a teleport that
552
- * re-mounts must not repeat a creation signal.
553
- */
554
- private readonly notifyMounted = (): void => {
555
- if (this.mountSignalled) {
556
- return;
557
- }
558
-
559
- this.mountSignalled = true;
560
-
561
- const context = { origin: this.origin, api: this.api };
562
-
563
- spec.onMounted?.(this.blockApi, context);
564
-
565
- // Creation-only, and after onMounted: a seeding hook must see whatever
566
- // the mount hook already put in place.
567
- if (CREATION_ORIGINS.has(this.origin)) {
568
- spec.onCreated?.(this.blockApi, context);
569
- }
570
- };
571
-
572
- public save(): BlockToolData {
573
- // The complete, frozen, toRaw-clean mirror — never the DOM, never partial.
574
- return this.mirror;
575
- }
576
-
577
- public async setData(newData: BlockToolData): Promise<boolean> {
578
- const next = fillDefaults<Data>(spec.propSchema, toRaw(newData ?? {}));
579
-
580
- // Dedup (Risk R3): identical data → skip the render entirely, but still
581
- // return true so core keeps the block in place (no remount).
582
- if (deepEqual(next, this.lastRendered)) {
583
- return true;
584
- }
585
-
586
- this.mirror = next;
587
- this.lastRendered = next;
588
- this.dataRef.value = next;
589
-
590
- // Await Vue's commit. Core drives setData asynchronously inside its
591
- // RAF-extended Yjs-suppression window, so awaiting nextTick lands the
592
- // re-render before suppression lifts — without ever remounting.
593
- await nextTick();
594
-
595
- return true;
596
- }
597
-
598
- /**
599
- * In-place read-only toggle. Flips the reactive flag the component reads via
600
- * `ctx.readOnly`, so the block re-renders read-only WITHOUT a remount
601
- * (ephemeral component state survives). A regular prototype method (not an
602
- * arrow field) so core's `supportsInPlaceReadOnly` — which probes the
603
- * constructable's PROTOTYPE for `setReadOnly` — selects the in-place path.
604
- */
605
- public setReadOnly(state: boolean): void {
606
- this.readOnlyRef.value = state;
607
- }
608
-
609
- /**
610
- * Core's toolbar-anchor hook. Always defined so the delegation is one
611
- * `typeof … === 'function'` probe away, and always resolved fresh against
612
- * the LIVE host — the anchor element is Vue-rendered, so it does not exist
613
- * yet when the tool is constructed and may be replaced on any re-render. A
614
- * spec without a resolver returns undefined, which is exactly what core's
615
- * default positioning already assumes.
616
- */
617
- public getToolbarAnchorElement(): HTMLElement | undefined {
618
- /**
619
- * A ref the block attached wins — it names the exact element, chosen from
620
- * inside the render tree. `isConnected` is the guard that matters: Vue
621
- * nulls the ref on unmount, but a block that re-parents its anchor can
622
- * leave a detached node here, and positioning the toolbar against one
623
- * silently parks it at 0,0.
624
- */
625
- const fromRef = this.anchorEl;
626
-
627
- if (fromRef !== null && fromRef.isConnected) {
628
- return fromRef;
629
- }
630
-
631
- const host = this.hostEl;
632
-
633
- if (host === null || spec.getToolbarAnchorElement === undefined) {
634
- return undefined;
635
- }
636
-
637
- return spec.getToolbarAnchorElement(host, this.blockApi) ?? undefined;
638
- }
639
-
640
- /**
641
- * Core's navigation-mode Enter hook. Only a real `true` from the spec
642
- * counts as handled.
643
- */
644
- public onNavigationEnter(event: KeyboardEvent): boolean {
645
- return spec.onNavigationEnter?.(event, this.blockApi) === true;
646
- }
647
-
648
- public moved(): void {
649
- // No remount: Blok relocates the host element; the teleport follows it.
650
- spec.onMoved?.(this.blockApi);
651
- }
652
-
653
- public removed(): void {
654
- // Ownership-scoped: core composes a REPLACEMENT block (which registers
655
- // under the SAME id) before it tears this one down, so an unqualified
656
- // unregister here would delete the live entry and blank the block.
657
- this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
658
- spec.onRemoved?.(this.blockApi);
659
- }
660
-
661
- public destroy(): void {
662
- // Idempotent with removed(); unregister is safe when already absent.
663
- this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
664
- }
665
-
666
- /**
667
- * The only data write path. Merges the patch into the frozen mirror, swaps
668
- * the reactive snapshot, records the dedup baseline, and fires
669
- * dispatchChange EXACTLY once — deferring it while a pointer drag is active
670
- * (core would otherwise silently drop it).
671
- */
672
- private readonly commit = (patch: Partial<Data>): void => {
673
- const next = fillDefaults<Data>(spec.propSchema, {
674
- ...(this.mirror as Record<string, unknown>),
675
- ...(patch as Record<string, unknown>),
676
- });
677
-
678
- // Idempotent: a patch that changes nothing is a full no-op — no reactive
679
- // swap, no dispatchChange — so a watcher echoing the current value back
680
- // through commit can never loop.
681
- if (deepEqual(next, this.mirror)) {
682
- return;
683
- }
684
-
685
- this.mirror = next;
686
- this.lastRendered = next;
687
- this.dataRef.value = next;
688
-
689
- this.flushDispatch();
690
- };
691
-
692
- /** Dispatch the change, or retry on the next frame if a drag is in progress. */
693
- private flushDispatch(): void {
694
- if (!this.pointerDrag()) {
695
- this.pendingDispatch = false;
696
- this.blockApi.dispatchChange();
697
-
698
- return;
699
- }
700
-
701
- if (this.pendingDispatch) {
702
- return;
703
- }
704
-
705
- this.pendingDispatch = true;
706
-
707
- const retry = (): void => {
708
- if (this.pointerDrag()) {
709
- requestAnimationFrame(retry);
710
-
711
- return;
712
- }
713
-
714
- this.pendingDispatch = false;
715
- this.blockApi.dispatchChange();
716
- };
717
-
718
- requestAnimationFrame(retry);
719
- }
720
- };
721
-
722
- // Forward the authored statics onto the generated class. `defineProperty`
723
- // (not assignment) because `toolbox`/`isReadOnlySupported` are accessors and a
724
- // plain write would throw in strict mode; the reserved list keeps a stray bag
725
- // from taking those — or the adapter's own marker — over.
726
- for (const [key, value] of Object.entries(spec.statics ?? {})) {
727
- if (RESERVED_STATICS.includes(key)) {
728
- continue;
729
- }
730
-
731
- Object.defineProperty(VueBlockTool, key, {
732
- value,
733
- writable: true,
734
- enumerable: true,
735
- configurable: true,
736
- });
737
- }
738
-
739
- return VueBlockTool;
740
- }