@bloklabs/vue 1.6.2 → 1.8.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.
@@ -14,13 +14,16 @@ import {
14
14
  type VNodeChild,
15
15
  } from 'vue';
16
16
 
17
+ import type { API } from '@/types';
17
18
  import type { BlockAPI } from '@/types/api';
18
19
  import type {
20
+ BlockOrigin,
21
+ BlockToolConstructable,
19
22
  BlockToolConstructorOptions,
20
23
  BlockToolData,
21
24
  ToolboxConfig,
22
25
  } from '@/types/tools';
23
- import { DATA_ATTR , deepEqual , mountChildBlocks } from '@bloklabs/core/adapters';
26
+ import { BlockChildrenMounted, DATA_ATTR , deepEqual , mountChildBlocks } from '@bloklabs/core/adapters';
24
27
  import { fillDefaults, type PropSchema } from '@bloklabs/core/adapters';
25
28
 
26
29
  import {
@@ -30,6 +33,121 @@ import {
30
33
 
31
34
  export type { PropSchema, PropSchemaEntry } from '@bloklabs/core/adapters';
32
35
 
36
+ /**
37
+ * Every STATIC member of core's block-tool contract a Vue block may declare for
38
+ * itself — `ownsChildren`, `keepsChildrenOnEnter`, `conversionConfig`,
39
+ * `pasteConfig`, `sanitize`, `shortcut`, `upgradeData`, and whatever core adds
40
+ * next. Derived from
41
+ * `BlockToolConstructable` rather than enumerated, so a new core static needs no
42
+ * adapter change to become reachable.
43
+ *
44
+ * `toolbox` and `isReadOnlySupported` are excluded because the factory owns
45
+ * them: `toolbox` is authored as {@link CreateVueBlockSpec.toolbox}, and
46
+ * in-place read-only support is unconditional.
47
+ */
48
+ export type BlockToolStatics = Omit<BlockToolConstructable, 'toolbox' | 'isReadOnlySupported'>;
49
+
50
+ /** Statics the generated class owns; an authored `statics` bag can never take them over. */
51
+ const RESERVED_STATICS: readonly string[] = ['toolbox', 'isReadOnlySupported', '__isBlokVueBlock'];
52
+
53
+ /**
54
+ * Origins that mean "the author just made this block" — the only ones that fire
55
+ * {@link CreateVueBlockSpec.onCreated}. Written as an allow-list so a future
56
+ * core origin fails CLOSED (no creation signal) instead of silently opting in.
57
+ *
58
+ * `undefined` is included because core always supplies an origin: an absent one
59
+ * means a host hand-built the constructor options, which is an explicit
60
+ * creation — the same reading core's own container tools apply.
61
+ *
62
+ * Note what is NOT the axis here: `origin === 'user'`. A container gated on that
63
+ * alone would refuse to seed for `api.blocks.insert('steps')` and for a
64
+ * turn-into, leaving an empty, unusable container.
65
+ */
66
+ const CREATION_ORIGINS: ReadonlySet<BlockOrigin | undefined> = new Set<BlockOrigin | undefined>([
67
+ undefined,
68
+ 'user',
69
+ 'api',
70
+ 'convert',
71
+ ]);
72
+
73
+ /**
74
+ * One child's decoration: attribute name → value. `null`/`undefined` removes the
75
+ * attribute; a boolean or number is stringified (so `false` writes
76
+ * `data-active="false"`, which CSS can select, rather than dropping the hook).
77
+ */
78
+ export type ChildAttributes = Record<string, string | number | boolean | null | undefined>;
79
+
80
+ /** The `BlockChildren` per-child decorator (see {@link VueBlockRenderProps.BlockChildren}). */
81
+ export type ChildAttributesFn = (child: BlockAPI, index: number) => ChildAttributes;
82
+
83
+ /** What the previous decoration pass wrote for one child, so it can be undone. */
84
+ interface StampedChild {
85
+ holder: HTMLElement;
86
+ names: string[];
87
+ }
88
+
89
+ /**
90
+ * Apply one pass of per-child holder attributes and clear the previous pass's
91
+ * leftovers — including on a child that has since left the container, which
92
+ * would otherwise keep a dead index forever.
93
+ * @param stamped - the slot's ledger of what the last pass wrote (mutated)
94
+ * @param children - the container's model children, in model order
95
+ * @param decorate - the authored per-child decorator, if any
96
+ */
97
+ const applyChildAttributes = (
98
+ stamped: Map<string, StampedChild>,
99
+ children: BlockAPI[],
100
+ decorate: ChildAttributesFn | undefined
101
+ ): void => {
102
+ const next = new Map<string, StampedChild>();
103
+
104
+ children.forEach((child, index) => {
105
+ const names: string[] = [];
106
+
107
+ for (const [name, value] of Object.entries(decorate?.(child, index) ?? {})) {
108
+ if (value === null || value === undefined) {
109
+ child.holder.removeAttribute(name);
110
+ continue;
111
+ }
112
+
113
+ child.holder.setAttribute(name, String(value));
114
+ names.push(name);
115
+ }
116
+
117
+ next.set(child.id, { holder: child.holder, names });
118
+ });
119
+
120
+ for (const [id, previous] of stamped) {
121
+ const current = next.get(id);
122
+
123
+ previous.names
124
+ .filter(name => current === undefined || !current.names.includes(name))
125
+ .forEach(name => previous.holder.removeAttribute(name));
126
+ }
127
+
128
+ stamped.clear();
129
+ next.forEach((entry, id) => stamped.set(id, entry));
130
+ };
131
+
132
+ /**
133
+ * Announce that a container's child holders have settled in its slot — the
134
+ * signal a host waits on before putting the caret into a freshly inserted
135
+ * child, since the teleport commits a tick after core's `rendered()`.
136
+ *
137
+ * Optional-chained: the editor always supplies `events`, but a host unit test
138
+ * may hand a block a partial `api`, and an observability signal must never be
139
+ * able to break a render.
140
+ * @param api - the editor-level API
141
+ * @param blockId - the container block's id
142
+ * @param children - the container's model children, in model order
143
+ */
144
+ const emitChildrenMounted = (api: API, blockId: string, children: BlockAPI[]): void => {
145
+ api?.events?.emit(BlockChildrenMounted, {
146
+ blockId,
147
+ childIds: children.map(child => child.id),
148
+ });
149
+ };
150
+
33
151
  /** Context handed to a Vue block's `setup` (the only data write path is `commit`). */
34
152
  export interface VueBlockRenderProps<Data> {
35
153
  /** Reactive, FROZEN snapshot of the block data. Read `data.value`; never mutate. */
@@ -38,6 +156,17 @@ export interface VueBlockRenderProps<Data> {
38
156
  commit: (patch: Partial<Data>) => void;
39
157
  /** This block's per-block API (id, connection methods, dispatchChange…). */
40
158
  block: BlockAPI;
159
+ /**
160
+ * The EDITOR-level API this block belongs to (`api.blocks`, `api.caret`,
161
+ * `api.toolbar`…) — the same object a vanilla tool receives in its
162
+ * constructor. Reach for it when a block has to drive the document around it
163
+ * instead of routing everything through `block.call()` string dispatch.
164
+ *
165
+ * For the reactive, id/parentId-relative view of the tree (and to re-render
166
+ * when your own children change), pair `useBlocks` with `useBlokInstance()`
167
+ * instead — the api handle itself is not reactive.
168
+ */
169
+ api: API;
41
170
  /**
42
171
  * Reactive read-only flag. Read `readOnly.value` in render to disable editing
43
172
  * (e.g. drop `contenteditable`, hide controls). Toggled IN PLACE by core's
@@ -46,10 +175,46 @@ export interface VueBlockRenderProps<Data> {
46
175
  * the editor is read-only (same contract as a vanilla tool's `setReadOnly`).
47
176
  */
48
177
  readOnly: Readonly<ShallowRef<boolean>>;
49
- /** Engine-owned child slot — render `h(BlockChildren)` for a container block. */
178
+ /**
179
+ * Engine-owned child slot — render `h(BlockChildren)` for a container block.
180
+ *
181
+ * Accepts one optional prop, `childAttributes: (child, index) => ChildAttributes`,
182
+ * applied to each child's HOLDER after the holders are mounted. Named hooks
183
+ * (`data-step-index`, `data-active`…) replace positional `:nth-child()` CSS
184
+ * over Blok's holders, which breaks the moment a child is inserted, removed or
185
+ * reordered. The holders stay DIRECT children of the slot — core requires that
186
+ * (hierarchy reparenting and caret navigation compare `holder.parentElement`
187
+ * by identity), so decoration is attributes, never wrapper elements. Attributes
188
+ * the callback stops producing are removed on the next pass.
189
+ *
190
+ * Writing on a child's holder is inert by design: core's mutation filter drops
191
+ * a holder-targeted attribute record for the child block and suppresses it for
192
+ * the container. The guarantee stops at the holder and its
193
+ * `[data-blok-element-content]` wrapper — writing AT or BELOW a child's tool
194
+ * root DOES score as that child's edit.
195
+ */
50
196
  BlockChildren: Component;
51
197
  }
52
198
 
199
+ /**
200
+ * Second argument of {@link CreateVueBlockSpec.onMounted} and
201
+ * {@link CreateVueBlockSpec.onCreated} — everything the block cannot read off
202
+ * its own `BlockAPI`.
203
+ */
204
+ export interface VueBlockMountedContext {
205
+ /**
206
+ * Why this block instance was constructed: a CREATION origin (`user`, `api`,
207
+ * `convert`) means the author just made it, so seeding default children is
208
+ * correct; a RESTORE origin (`load`, `replay`, `paste`) means the document
209
+ * already says what the children are. `probe` is an off-tree instance built
210
+ * only to read a tool's default data — it must not touch the block tree at
211
+ * all. Defaults to `'api'` when the constructor was handed no origin.
212
+ */
213
+ origin: BlockOrigin;
214
+ /** The editor-level API (`api.blocks`, `api.caret`, `api.events`…). */
215
+ api: API;
216
+ }
217
+
53
218
  /** Spec for {@link createVueBlock}. Authored as a `.ts` render function (no SFC). */
54
219
  export interface CreateVueBlockSpec<Data = BlockToolData> {
55
220
  /** Tool type name (registered key). */
@@ -60,8 +225,89 @@ export interface CreateVueBlockSpec<Data = BlockToolData> {
60
225
  propSchema: PropSchema;
61
226
  /** Returns a render function (the `defineComponent` + `setup`-returns-`h` shape). */
62
227
  setup: (props: VueBlockRenderProps<Data>) => () => VNodeChild;
228
+ /**
229
+ * Static members of core's tool contract, forwarded verbatim onto the
230
+ * generated tool class — the single channel for everything core reads off the
231
+ * CLASS rather than the instance (`ownsChildren`, `keepsChildrenOnEnter`,
232
+ * `conversionConfig`, `pasteConfig`, `sanitize`, `shortcut`, `upgradeData`…).
233
+ * Without it the only way to declare one was to subclass the generated class.
234
+ *
235
+ * `keepsChildrenOnEnter` is the per-tool Enter POLICY: declare it and Enter on
236
+ * this container's empty LAST child creates the new line INSIDE the container
237
+ * instead of escaping to the container's parent (Blok's default, which is
238
+ * Notion's callout behaviour). Core cannot read that off the DOM — a callout
239
+ * renders the same `data-blok-nested-blocks` slot as a column yet wants the
240
+ * escape — so before it existed a layout container (a card, a `steps` block)
241
+ * had to hijack the editor-global `config.onEnter` and re-derive containment.
242
+ *
243
+ * `toolbox` and `isReadOnlySupported` are owned by the factory and cannot be
244
+ * overridden here (see {@link BlockToolStatics}).
245
+ */
246
+ statics?: BlockToolStatics;
247
+ /**
248
+ * The element the +/drag toolbar should vertically center on — core's
249
+ * `getToolbarAnchorElement` hook, resolved against this block's host element
250
+ * on every call (never cached, so it tracks re-renders).
251
+ *
252
+ * A container block whose own chrome is not editable needs it: with no anchor,
253
+ * core centers the toolbar on the first `[contenteditable]` under the host,
254
+ * which for a container is its FIRST CHILD BLOCK. Return `null`/`undefined`
255
+ * (or omit the field) to keep core's default.
256
+ * @param host - this block's mutation-free host element
257
+ * @param block - this block's per-block API
258
+ */
259
+ getToolbarAnchorElement?: (host: HTMLElement, block: BlockAPI) => HTMLElement | null | undefined;
63
260
  /** Optional lifecycle callbacks mapped from Blok's block hooks. */
64
261
  onRendered?: (block: BlockAPI) => void;
262
+ /**
263
+ * Fired ONCE per block instance, after the teleport's FIRST commit — the
264
+ * first moment this block's rendered DOM exists (and, for a container, the
265
+ * moment `BlockChildren` has adopted the child holders). `onRendered` cannot
266
+ * mean that: core calls `rendered()` in the same tick as `render()`, before
267
+ * Vue has drawn anything, which is why hosts ended up setting the caret twice
268
+ * around a `requestAnimationFrame`.
269
+ *
270
+ * It is also the create-vs-restore signal: `context.origin` says whether the
271
+ * author just made this block (`user`/`api`/`convert`) or the document is
272
+ * being re-materialised (`load`/`replay`/`paste`) — so a container can seed
273
+ * its default children here exactly once, without the "children are
274
+ * transiently empty during a replay" trap.
275
+ * @example
276
+ * ```ts
277
+ * onMounted: (block, { origin, api }) => {
278
+ * if (origin === 'user' && block.getChildren().length === 0) {
279
+ * api.blocks.insertInsideParent(block.id);
280
+ * }
281
+ * }
282
+ * ```
283
+ */
284
+ onMounted?: (block: BlockAPI, context: VueBlockMountedContext) => void;
285
+ /**
286
+ * The SEEDING hook: `onMounted`, narrowed to a genuine creation. Fired ONCE
287
+ * per block instance, after the teleport's first commit, and only when this
288
+ * instance is the author making a new block (`origin` of `user`, `api` or
289
+ * `convert`) — never for a `load`/`replay`/`paste` restore, and never for the
290
+ * off-tree `probe` instance core builds to read a tool's default data.
291
+ *
292
+ * That predicate is why the hook exists rather than leaving every block to
293
+ * read `context.origin` in `onMounted`: the intuitive `origin === 'user'` test
294
+ * is wrong. It drops `api.blocks.insert('steps')` and turn-into, so a
295
+ * container seeded that way comes up empty for every path except a keystroke.
296
+ * Core refused to ship that axis into its own `column`/`column_list`; this
297
+ * encodes the correct one once, here.
298
+ *
299
+ * The `context` is the same object {@link CreateVueBlockSpec.onMounted}
300
+ * receives, so a block that only seeds can read `origin` for finer decisions.
301
+ * @example
302
+ * ```ts
303
+ * onCreated: (block, { api }) => {
304
+ * if (block.getChildren().length === 0) {
305
+ * api.blocks.insertInsideParent(block.id);
306
+ * }
307
+ * }
308
+ * ```
309
+ */
310
+ onCreated?: (block: BlockAPI, context: VueBlockMountedContext) => void;
65
311
  onMoved?: (block: BlockAPI) => void;
66
312
  onRemoved?: (block: BlockAPI) => void;
67
313
  }
@@ -90,30 +336,53 @@ export function createVueBlock<Data = BlockToolData>(
90
336
  save(): BlockToolData;
91
337
  setData(newData: BlockToolData): Promise<boolean>;
92
338
  setReadOnly(state: boolean): void;
339
+ getToolbarAnchorElement(): HTMLElement | undefined;
93
340
  rendered(): void;
94
341
  moved(): void;
95
342
  removed(): void;
96
343
  destroy(): void;
97
- }) & {
344
+ }) & BlockToolStatics & {
98
345
  readonly __isBlokVueBlock: true;
99
346
  readonly toolbox: ToolboxConfig | undefined;
100
347
  readonly isReadOnlySupported: boolean;
101
348
  } {
102
- // One wrapped component definition per TYPE; each block instance gets its own
103
- // context via the `ctx` prop, so Vue mounts a distinct instance per entry.
104
- const WrappedComponent = defineComponent({
105
- name: `VueBlock(${spec.type})`,
106
- props: {
107
- ctx: { type: Object as PropType<VueBlockRenderProps<Data>>, required: true },
108
- },
109
- setup(props) {
110
- // props is shallow-reactive: `props.ctx` is the raw context object, so the
111
- // `data` ref inside it is NOT unwrapped (no reactive ref-unwrap gotcha).
112
- return spec.setup(props.ctx);
113
- },
114
- });
115
-
116
- return class VueBlockTool {
349
+ /**
350
+ * One wrapped component definition per BLOCK INSTANCE.
351
+ *
352
+ * Per-TYPE looks equivalent — each instance gets its own context through the
353
+ * `ctx` prop — but the portal host keys its `<Teleport>` by BLOCK ID, and core
354
+ * composes a replacement block (registering under that same id) BEFORE it
355
+ * destroys the block it replaces. With a shared type, Vue sees same key + same
356
+ * type and PATCHES the mounted instance instead of replacing it: `setup` has
357
+ * already run and its render closure captured the SUPERSEDED instance's `ctx`,
358
+ * so the live host renders the old block's data forever. A distinct type per
359
+ * instance makes Vue replace the instance, so the replacement's ctx renders.
360
+ * Within one instance the type is stable, so in-place prop updates still never
361
+ * remount.
362
+ */
363
+ const defineWrappedComponent = (): Component =>
364
+ defineComponent({
365
+ name: `VueBlock(${spec.type})`,
366
+ props: {
367
+ ctx: { type: Object as PropType<VueBlockRenderProps<Data>>, required: true },
368
+ /**
369
+ * Adapter-internal: the tool instance's post-commit notifier. Declared as
370
+ * a prop (not part of `ctx`) so it never reaches the authored `setup`.
371
+ */
372
+ notifyMounted: { type: Function as PropType<() => void>, default: undefined },
373
+ },
374
+ setup(props) {
375
+ // The block's DOM exists from here on — this is the first commit core's
376
+ // `rendered()` hook could not wait for.
377
+ onMounted(() => props.notifyMounted?.());
378
+
379
+ // props is shallow-reactive: `props.ctx` is the raw context object, so the
380
+ // `data` ref inside it is NOT unwrapped (no reactive ref-unwrap gotcha).
381
+ return spec.setup(props.ctx);
382
+ },
383
+ });
384
+
385
+ const VueBlockTool = class VueBlockTool {
117
386
  /** Marker so `useBlok` can detect vue-block tools and inject the registry. */
118
387
  public static readonly __isBlokVueBlock = true as const;
119
388
 
@@ -132,12 +401,20 @@ export function createVueBlock<Data = BlockToolData>(
132
401
  }
133
402
 
134
403
  private readonly blockApi: BlockAPI;
404
+ /** The editor-level API, handed to the block setup as `ctx.api`. */
405
+ private readonly api: API;
135
406
  private readonly registry: BlockPortalRegistry | undefined;
136
407
  private readonly pointerDrag: () => boolean;
137
408
  private readonly dataRef: ShallowRef<Readonly<Data>>;
138
409
  /** Reactive read-only flag handed to setup; flipped in place by setReadOnly. */
139
410
  private readonly readOnlyRef: ShallowRef<boolean>;
140
411
  private readonly childrenComponent: Component;
412
+ /** This instance's own wrapper type — see {@link defineWrappedComponent}. */
413
+ private readonly wrappedComponent: Component = defineWrappedComponent();
414
+ /** Why core built this instance — gates `onCreated`, handed to `onMounted`. */
415
+ private readonly origin: BlockOrigin;
416
+ /** True once the post-mount hooks fired; a re-mounted teleport must not re-fire them. */
417
+ private mountSignalled = false;
141
418
  private mirror: Readonly<Data>;
142
419
  /** Dedup baseline (Risk R3/R6): skip a redundant render of identical data. */
143
420
  private lastRendered: Readonly<Data>;
@@ -152,29 +429,49 @@ export function createVueBlock<Data = BlockToolData>(
152
429
 
153
430
  this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
154
431
 
432
+ this.api = options.api;
433
+
155
434
  // Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
156
435
  // (core silently drops a dispatchChange while a drag is active).
157
- const api = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
436
+ const drag = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
158
437
 
159
- this.pointerDrag = (): boolean => api?.blocks?.isPointerDragActive === true;
438
+ this.pointerDrag = (): boolean => drag?.blocks?.isPointerDragActive === true;
160
439
 
161
440
  this.mirror = fillDefaults<Data>(spec.propSchema, toRaw(options.data ?? {}) as Record<string, unknown>);
162
441
  this.lastRendered = this.mirror;
163
442
  this.dataRef = shallowRef(this.mirror);
164
443
  this.readOnlyRef = shallowRef(options.readOnly);
444
+ // Absent origin means a caller that predates the signal; 'api' is core's
445
+ // own default, so it is never mistaken for a user gesture.
446
+ this.origin = options.origin ?? 'api';
165
447
 
166
448
  const blockApi = this.blockApi;
449
+ const api = this.api;
167
450
 
168
451
  // Per-instance child slot: a childless ref'd div Blok owns; mountChildBlocks
169
452
  // appends the real child holders imperatively (Vue never reconciles them).
170
453
  this.childrenComponent = defineComponent({
171
454
  name: `VueBlockChildren(${spec.type})`,
172
- setup() {
455
+ props: {
456
+ childAttributes: {
457
+ type: Function as PropType<ChildAttributesFn>,
458
+ default: undefined,
459
+ },
460
+ },
461
+ setup(props) {
173
462
  const slot = ref<HTMLElement | null>(null);
463
+ // What the last decoration pass wrote, so a dropped key is cleaned up.
464
+ const stamped = new Map<string, StampedChild>();
174
465
  const mountKids = (): void => {
175
- if (slot.value !== null) {
176
- mountChildBlocks(slot.value, blockApi.getChildren());
466
+ if (slot.value === null) {
467
+ return;
177
468
  }
469
+
470
+ const children = blockApi.getChildren();
471
+
472
+ mountChildBlocks(slot.value, children);
473
+ applyChildAttributes(stamped, children, props.childAttributes);
474
+ emitChildrenMounted(api, blockApi.id, children);
178
475
  };
179
476
 
180
477
  onMounted(mountKids);
@@ -198,14 +495,15 @@ export function createVueBlock<Data = BlockToolData>(
198
495
  data: this.dataRef,
199
496
  commit: this.commit,
200
497
  block: this.blockApi,
498
+ api: this.api,
201
499
  readOnly: this.readOnlyRef,
202
500
  BlockChildren: this.childrenComponent,
203
501
  };
204
502
 
205
503
  this.registry?.register(this.blockApi.id, {
206
504
  hostEl: host,
207
- component: WrappedComponent,
208
- props: { ctx },
505
+ component: this.wrappedComponent,
506
+ props: { ctx, notifyMounted: this.notifyMounted },
209
507
  });
210
508
 
211
509
  // Returned synchronously; Vue flushes the teleport on the next tick.
@@ -216,6 +514,30 @@ export function createVueBlock<Data = BlockToolData>(
216
514
  spec.onRendered?.(this.blockApi);
217
515
  }
218
516
 
517
+ /**
518
+ * The wrapped component's post-commit callback: the first moment this
519
+ * block's DOM (and, for a container, its mounted child holders) exists.
520
+ * Fires the post-mount hooks once per block instance — a teleport that
521
+ * re-mounts must not repeat a creation signal.
522
+ */
523
+ private readonly notifyMounted = (): void => {
524
+ if (this.mountSignalled) {
525
+ return;
526
+ }
527
+
528
+ this.mountSignalled = true;
529
+
530
+ const context = { origin: this.origin, api: this.api };
531
+
532
+ spec.onMounted?.(this.blockApi, context);
533
+
534
+ // Creation-only, and after onMounted: a seeding hook must see whatever
535
+ // the mount hook already put in place.
536
+ if (CREATION_ORIGINS.has(this.origin)) {
537
+ spec.onCreated?.(this.blockApi, context);
538
+ }
539
+ };
540
+
219
541
  public save(): BlockToolData {
220
542
  // The complete, frozen, toRaw-clean mirror — never the DOM, never partial.
221
543
  return this.mirror;
@@ -253,19 +575,40 @@ export function createVueBlock<Data = BlockToolData>(
253
575
  this.readOnlyRef.value = state;
254
576
  }
255
577
 
578
+ /**
579
+ * Core's toolbar-anchor hook. Always defined so the delegation is one
580
+ * `typeof … === 'function'` probe away, and always resolved fresh against
581
+ * the LIVE host — the anchor element is Vue-rendered, so it does not exist
582
+ * yet when the tool is constructed and may be replaced on any re-render. A
583
+ * spec without a resolver returns undefined, which is exactly what core's
584
+ * default positioning already assumes.
585
+ */
586
+ public getToolbarAnchorElement(): HTMLElement | undefined {
587
+ const host = this.hostEl;
588
+
589
+ if (host === null || spec.getToolbarAnchorElement === undefined) {
590
+ return undefined;
591
+ }
592
+
593
+ return spec.getToolbarAnchorElement(host, this.blockApi) ?? undefined;
594
+ }
595
+
256
596
  public moved(): void {
257
597
  // No remount: Blok relocates the host element; the teleport follows it.
258
598
  spec.onMoved?.(this.blockApi);
259
599
  }
260
600
 
261
601
  public removed(): void {
262
- this.registry?.unregister(this.blockApi.id);
602
+ // Ownership-scoped: core composes a REPLACEMENT block (which registers
603
+ // under the SAME id) before it tears this one down, so an unqualified
604
+ // unregister here would delete the live entry and blank the block.
605
+ this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
263
606
  spec.onRemoved?.(this.blockApi);
264
607
  }
265
608
 
266
609
  public destroy(): void {
267
610
  // Idempotent with removed(); unregister is safe when already absent.
268
- this.registry?.unregister(this.blockApi.id);
611
+ this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
269
612
  }
270
613
 
271
614
  /**
@@ -323,4 +666,23 @@ export function createVueBlock<Data = BlockToolData>(
323
666
  requestAnimationFrame(retry);
324
667
  }
325
668
  };
669
+
670
+ // Forward the authored statics onto the generated class. `defineProperty`
671
+ // (not assignment) because `toolbox`/`isReadOnlySupported` are accessors and a
672
+ // plain write would throw in strict mode; the reserved list keeps a stray bag
673
+ // from taking those — or the adapter's own marker — over.
674
+ for (const [key, value] of Object.entries(spec.statics ?? {})) {
675
+ if (RESERVED_STATICS.includes(key)) {
676
+ continue;
677
+ }
678
+
679
+ Object.defineProperty(VueBlockTool, key, {
680
+ value,
681
+ writable: true,
682
+ enumerable: true,
683
+ configurable: true,
684
+ });
685
+ }
686
+
687
+ return VueBlockTool;
326
688
  }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export { useBlokReady } from './useBlokReady';
4
4
  export { BlokContent } from './BlokContent';
5
5
  export { BlokEditor } from './BlokEditor';
6
6
  export { provideBlok, useBlokDefaults, BLOK_DEFAULT_CONFIG } from './provide-blok';
7
+ export { useBlokInstance, BLOK_EDITOR_INSTANCE } from './blok-instance';
7
8
  export { createVueBlock } from './createVueBlock';
8
9
  export type { UseBlokConfig, BlokContentProps } from './types';
9
10
  export type { UseBlokReadyOptions } from './useBlokReady';
@@ -17,8 +18,12 @@ export type {
17
18
  MoveTarget,
18
19
  } from './blocks-snapshot';
19
20
  export type {
21
+ BlockToolStatics,
22
+ ChildAttributes,
23
+ ChildAttributesFn,
20
24
  CreateVueBlockSpec,
21
25
  PropSchema,
22
26
  PropSchemaEntry,
27
+ VueBlockMountedContext,
23
28
  VueBlockRenderProps,
24
29
  } from './createVueBlock';