@bloklabs/react 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.
@@ -1,14 +1,29 @@
1
1
  import { isValidElement, useEffect, useRef, type ComponentType, type ReactElement } from 'react';
2
2
  import { flushSync } from 'react-dom';
3
3
  import { createRoot } from 'react-dom/client';
4
- import { DATA_ATTR, deepEqual, fillDefaults, mountChildBlocks, type PropSchema } from '@bloklabs/core/adapters';
4
+ import {
5
+ BlockChildrenMounted,
6
+ DATA_ATTR,
7
+ deepEqual,
8
+ fillDefaults,
9
+ mountChildBlocks,
10
+ type PropSchema,
11
+ } from '@bloklabs/core/adapters';
5
12
  import {
6
13
  BLOK_PORTAL_REGISTRY_CONFIG_KEY,
7
14
  BLOK_TOOL_NAME_CONFIG_KEY,
8
15
  type BlockPortalRegistry,
9
16
  } from './block-portal-registry';
17
+ import type { API } from '@/types';
10
18
  import type { BlockAPI } from '@/types/api';
11
- import type { BlockToolConstructorOptions, BlockToolData, ToolboxConfig, ToolboxConfigEntry } from '@/types/tools';
19
+ import type {
20
+ BlockOrigin,
21
+ BlockToolConstructable,
22
+ BlockToolConstructorOptions,
23
+ BlockToolData,
24
+ ToolboxConfig,
25
+ ToolboxConfigEntry,
26
+ } from '@/types/tools';
12
27
 
13
28
  export type { PropSchema, PropSchemaEntry } from '@bloklabs/core/adapters';
14
29
 
@@ -18,6 +33,116 @@ const INTERNAL_CONFIG_KEYS: readonly string[] = [
18
33
  BLOK_TOOL_NAME_CONFIG_KEY,
19
34
  ];
20
35
 
36
+ /**
37
+ * Every STATIC member of core's block-tool contract a React block may declare
38
+ * for 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 CreateReactBlockSpec.toolbox} (React
46
+ * elements and all), and in-place read-only support is unconditional.
47
+ */
48
+ export type BlockToolStatics = Omit<BlockToolConstructable, 'toolbox' | 'isReadOnlySupported'>;
49
+
50
+ /** What the previous decoration pass wrote for one child, so it can be undone. */
51
+ interface StampedChild {
52
+ holder: HTMLElement;
53
+ names: string[];
54
+ }
55
+
56
+ /**
57
+ * Apply one pass of per-child holder attributes and clear the previous pass's
58
+ * leftovers — including on a child that has since left the container, which
59
+ * would otherwise keep a dead index forever.
60
+ * @param stamped - the slot's ledger of what the last pass wrote (mutated)
61
+ * @param children - the container's model children, in model order
62
+ * @param decorate - the authored per-child decorator, if any
63
+ */
64
+ const applyChildAttributes = (
65
+ stamped: Map<string, StampedChild>,
66
+ children: BlockAPI[],
67
+ decorate: BlockChildrenProps['childAttributes']
68
+ ): void => {
69
+ const next = new Map<string, StampedChild>();
70
+
71
+ children.forEach((child, index) => {
72
+ const names: string[] = [];
73
+
74
+ for (const [name, value] of Object.entries(decorate?.(child, index) ?? {})) {
75
+ if (value === null || value === undefined) {
76
+ child.holder.removeAttribute(name);
77
+ continue;
78
+ }
79
+
80
+ child.holder.setAttribute(name, String(value));
81
+ names.push(name);
82
+ }
83
+
84
+ next.set(child.id, { holder: child.holder, names });
85
+ });
86
+
87
+ for (const [id, previous] of stamped) {
88
+ const current = next.get(id);
89
+
90
+ previous.names
91
+ .filter(name => current === undefined || !current.names.includes(name))
92
+ .forEach(name => previous.holder.removeAttribute(name));
93
+ }
94
+
95
+ stamped.clear();
96
+ next.forEach((entry, id) => stamped.set(id, entry));
97
+ };
98
+
99
+ /**
100
+ * Announce that a container's child holders have settled in its slot — the
101
+ * signal a host waits on before putting the caret into a freshly inserted
102
+ * child, since the portal commits a frame after core's `rendered()`.
103
+ *
104
+ * Optional-chained: the editor always supplies `events`, but a host unit test
105
+ * may hand a block a partial `api`, and an observability signal must never be
106
+ * able to break a render.
107
+ * @param api - the editor-level API
108
+ * @param blockId - the container block's id
109
+ * @param children - the container's model children, in model order
110
+ */
111
+ const emitChildrenMounted = (api: API, blockId: string, children: BlockAPI[]): void => {
112
+ api?.events?.emit(BlockChildrenMounted, {
113
+ blockId,
114
+ childIds: children.map(child => child.id),
115
+ });
116
+ };
117
+
118
+ /**
119
+ * Origins that mean "the author just made this block" — the only ones that fire
120
+ * {@link CreateReactBlockSpec.onCreated}. Written as an allow-list so a future
121
+ * core origin fails CLOSED (no creation signal) instead of silently opting in.
122
+ *
123
+ * `undefined` is included because core always supplies an origin: an absent one
124
+ * means a host hand-built the constructor options, which is an explicit
125
+ * creation — the same reading core's own container tools apply.
126
+ *
127
+ * Note what is NOT the axis here: `origin === 'user'`. A container gated on that
128
+ * alone would refuse to seed for `api.blocks.insert('steps')` and for a
129
+ * turn-into, leaving an empty, unusable container.
130
+ */
131
+ const CREATION_ORIGINS: ReadonlySet<BlockOrigin | undefined> = new Set<BlockOrigin | undefined>([
132
+ undefined,
133
+ 'user',
134
+ 'api',
135
+ 'convert',
136
+ ]);
137
+
138
+ /** Statics the generated class owns; an authored `statics` bag can never take them over. */
139
+ const RESERVED_STATICS: readonly string[] = [
140
+ 'toolbox',
141
+ 'isReadOnlySupported',
142
+ '__isBlokReactBlock',
143
+ '__buildPortalToolbox',
144
+ ];
145
+
21
146
  /** Props handed to a React block's `component` (the only data write path is `commit`). */
22
147
  export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
23
148
  /** Frozen snapshot of the block data. Re-rendered with a new value on change; never mutate. */
@@ -31,6 +156,18 @@ export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
31
156
  commit: (patch: Partial<Data>) => void;
32
157
  /** This block's per-block API (id, connection methods, dispatchChange…). */
33
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
+ * (insert a sibling, move the caret, open the toolbox) instead of routing
164
+ * everything through `block.call()` string dispatch.
165
+ *
166
+ * For the reactive, id/parentId-relative view of the tree (and to re-render
167
+ * when your own children change), pair `useBlocks` with `useBlokInstance()`
168
+ * instead — the api handle itself is not reactive.
169
+ */
170
+ api: API;
34
171
  /**
35
172
  * Read-only flag. Render disabled/inert UI when true (e.g. drop
36
173
  * `contentEditable`, hide controls). Toggled IN PLACE by core's read-only
@@ -49,7 +186,42 @@ export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
49
186
  */
50
187
  config: Readonly<Partial<Config>>;
51
188
  /** Engine-owned child slot — render `<BlockChildren />` for a container block. */
52
- BlockChildren: ComponentType;
189
+ BlockChildren: ComponentType<BlockChildrenProps>;
190
+ }
191
+
192
+ /**
193
+ * One child's decoration: attribute name → value. `null`/`undefined` removes the
194
+ * attribute; a boolean or number is stringified (so `false` writes
195
+ * `data-active="false"`, which CSS can select, rather than dropping the hook).
196
+ */
197
+ export type ChildAttributes = Record<string, string | number | boolean | null | undefined>;
198
+
199
+ /** Props of the engine-owned {@link ReactBlockRenderProps.BlockChildren} slot. */
200
+ export interface BlockChildrenProps {
201
+ /**
202
+ * Per-child decoration, applied to each child's HOLDER after the holders are
203
+ * mounted. Named hooks (`data-step-index`, `data-active`…) replace positional
204
+ * `:nth-child()` CSS over Blok's holders, which silently breaks the moment a
205
+ * child is inserted, removed or reordered.
206
+ *
207
+ * The holders themselves stay DIRECT children of the slot — core requires that
208
+ * (hierarchy reparenting looks for the next sibling whose
209
+ * `holder.parentElement` IS the container, and caret navigation decides
210
+ * "same container" by that identity), so decoration is expressed as attributes
211
+ * rather than as wrapper elements. Attributes the callback stops producing are
212
+ * removed on the next pass, so a reordered child never keeps a dead index.
213
+ *
214
+ * Writing on a child's holder is inert by design: core's mutation filter drops
215
+ * a holder-targeted attribute record for the child block (the holder is the
216
+ * child tool element's ANCESTOR, not its descendant) and suppresses it for the
217
+ * container (whose own host is the nearest `data-blok-mutation-free` ancestor).
218
+ * The guarantee stops at the holder and its `[data-blok-element-content]`
219
+ * wrapper — writing AT or BELOW a child's tool root DOES score as that child's
220
+ * edit.
221
+ * @param child - the child block's API
222
+ * @param index - the child's position in the container's model children
223
+ */
224
+ childAttributes?: (child: BlockAPI, index: number) => ChildAttributes;
53
225
  }
54
226
 
55
227
  /**
@@ -87,6 +259,25 @@ export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon' | 'title'>
87
259
  /** Toolbox config for React blocks: single entry or several variants. */
88
260
  export type ReactToolboxConfig = ReactToolboxConfigEntry | ReactToolboxConfigEntry[];
89
261
 
262
+ /**
263
+ * Second argument of {@link CreateReactBlockSpec.onMounted} and
264
+ * {@link CreateReactBlockSpec.onCreated} — everything the block cannot read off
265
+ * its own `BlockAPI`.
266
+ */
267
+ export interface ReactBlockMountedContext {
268
+ /**
269
+ * Why this block instance was constructed: a CREATION origin (`user`, `api`,
270
+ * `convert`) means the author just made it, so seeding default children is
271
+ * correct; a RESTORE origin (`load`, `replay`, `paste`) means the document
272
+ * already says what the children are. `probe` is an off-tree instance built
273
+ * only to read a tool's default data — it must not touch the block tree at
274
+ * all. Defaults to `'api'` when the constructor was handed no origin.
275
+ */
276
+ origin: BlockOrigin;
277
+ /** The editor-level API (`api.blocks`, `api.caret`, `api.events`…). */
278
+ api: API;
279
+ }
280
+
90
281
  /** Spec for {@link createReactBlock}. */
91
282
  export interface CreateReactBlockSpec<Data = BlockToolData, Config = Record<string, unknown>> {
92
283
  /** Tool type name (registered key). */
@@ -106,8 +297,95 @@ export interface CreateReactBlockSpec<Data = BlockToolData, Config = Record<stri
106
297
  * `viewComponent` to keep the single-component in-place toggle instead.
107
298
  */
108
299
  viewComponent?: ComponentType<ReactBlockViewProps<Data, Config>>;
300
+ /**
301
+ * Static members of core's tool contract, forwarded verbatim onto the
302
+ * generated tool class — the single channel for everything core reads off the
303
+ * CLASS rather than the instance (`ownsChildren`, `keepsChildrenOnEnter`,
304
+ * `conversionConfig`, `pasteConfig`, `sanitize`, `shortcut`, `upgradeData`…).
305
+ * Without it the only way to declare one was to subclass the generated class.
306
+ *
307
+ * `keepsChildrenOnEnter` is the per-tool Enter POLICY: declare it and Enter on
308
+ * this container's empty LAST child creates the new line INSIDE the container
309
+ * instead of escaping to the container's parent (Blok's default, which is
310
+ * Notion's callout behaviour). Core cannot read that off the DOM — a callout
311
+ * renders the same `data-blok-nested-blocks` slot as a column yet wants the
312
+ * escape — so before it existed a layout container (a card, a `steps` block)
313
+ * had to hijack the editor-global `config.onEnter` and re-derive containment.
314
+ *
315
+ * `toolbox` and `isReadOnlySupported` are owned by the factory and cannot be
316
+ * overridden here (see {@link BlockToolStatics}).
317
+ * @example
318
+ * ```ts
319
+ * statics: { ownsChildren: true, conversionConfig: { export: 'text', import: 'text' } }
320
+ * ```
321
+ */
322
+ statics?: BlockToolStatics;
323
+ /**
324
+ * The element the +/drag toolbar should vertically center on — core's
325
+ * `getToolbarAnchorElement` hook, resolved against this block's host element
326
+ * on every call (never cached, so it tracks re-renders).
327
+ *
328
+ * A container block whose own chrome is not editable needs it: with no anchor,
329
+ * core centers the toolbar on the first `[contenteditable]` it finds under the
330
+ * host, which for a container is its FIRST CHILD BLOCK — parking the +/drag
331
+ * handles halfway down the block, beside content that has a toolbar of its
332
+ * own. Return `null`/`undefined` (or omit the field) to keep core's default.
333
+ * @param host - this block's mutation-free host element
334
+ * @param block - this block's per-block API
335
+ */
336
+ getToolbarAnchorElement?: (host: HTMLElement, block: BlockAPI) => HTMLElement | null | undefined;
109
337
  /** Optional lifecycle callbacks mapped from Blok's block hooks. */
110
338
  onRendered?: (block: BlockAPI) => void;
339
+ /**
340
+ * Fired ONCE per block instance, after the portal's FIRST commit — the first
341
+ * moment this block's rendered DOM exists (and, for a container, the moment
342
+ * `<BlockChildren />` has adopted the child holders). `onRendered` cannot
343
+ * mean that: core calls `rendered()` in the same tick as `render()`, a commit
344
+ * BEFORE React has drawn anything, which is why hosts ended up setting the
345
+ * caret twice around a `requestAnimationFrame`.
346
+ *
347
+ * It is also the create-vs-restore signal: `context.origin` says whether the
348
+ * author just made this block (`user`/`api`/`convert`) or the document is
349
+ * being re-materialised (`load`/`replay`/`paste`) — so a container can seed
350
+ * its default children here exactly once, without the "children are
351
+ * transiently empty during a replay" trap.
352
+ * @example
353
+ * ```ts
354
+ * onMounted: (block, { origin, api }) => {
355
+ * if (origin === 'user' && block.getChildren().length === 0) {
356
+ * api.blocks.insertInsideParent(block.id);
357
+ * }
358
+ * }
359
+ * ```
360
+ */
361
+ onMounted?: (block: BlockAPI, context: ReactBlockMountedContext) => void;
362
+ /**
363
+ * The SEEDING hook: `onMounted`, narrowed to a genuine creation. Fired ONCE
364
+ * per block instance, after the portal's first commit, and only when this
365
+ * instance is the author making a new block (`origin` of `user`, `api` or
366
+ * `convert`) — never for a `load`/`replay`/`paste` restore, and never for the
367
+ * off-tree `probe` instance core builds to read a tool's default data.
368
+ *
369
+ * That predicate is why the hook exists rather than leaving every block to
370
+ * read `context.origin` in `onMounted`: the intuitive `origin === 'user'` test
371
+ * is wrong. It drops `api.blocks.insert('steps')` and turn-into, so a
372
+ * container seeded that way comes up empty for every path except a keystroke.
373
+ * Core refused to ship that axis into its own `column`/`column_list`; this
374
+ * encodes the correct one once, here.
375
+ *
376
+ * The `context` is the same object {@link CreateReactBlockSpec.onMounted}
377
+ * receives, so a block that only seeds can read `origin` for finer decisions
378
+ * (e.g. seed a different default for a turn-into).
379
+ * @example
380
+ * ```ts
381
+ * onCreated: (block, { api }) => {
382
+ * if (block.getChildren().length === 0) {
383
+ * api.blocks.insertInsideParent(block.id);
384
+ * }
385
+ * }
386
+ * ```
387
+ */
388
+ onCreated?: (block: BlockAPI, context: ReactBlockMountedContext) => void;
111
389
  onMoved?: (block: BlockAPI) => void;
112
390
  onRemoved?: (block: BlockAPI) => void;
113
391
  }
@@ -137,11 +415,12 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
137
415
  save(): BlockToolData;
138
416
  setData(newData: BlockToolData): Promise<boolean>;
139
417
  setReadOnly(state: boolean): void;
418
+ getToolbarAnchorElement(): HTMLElement | undefined;
140
419
  rendered(): void;
141
420
  moved(): void;
142
421
  removed(): void;
143
422
  destroy(): void;
144
- }) & {
423
+ }) & BlockToolStatics & {
145
424
  readonly __isBlokReactBlock: true;
146
425
  readonly toolbox: ToolboxConfig | undefined;
147
426
  /**
@@ -248,6 +527,17 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
248
527
  * Returns undefined when no entry has a ReactElement title (nothing to
249
528
  * override; the static string/`toolNames.*` path already covers it).
250
529
  *
530
+ * The override carries only what it uniquely owns. `useBlok` calls this from
531
+ * a passive effect, i.e. inside React's commit phase, where `flushSync`
532
+ * cannot flush — so an element ICON never serializes here. Such an icon is
533
+ * OMITTED rather than shipped raw (core `insertAdjacentHTML`s it, writing the
534
+ * literal `[object Object]`); core then merges this override OVER the static
535
+ * toolbox, which serializes the same element successfully later, outside
536
+ * React's commit. That merge is also why the authored SHAPE is preserved: a
537
+ * single-object spec must stay a single object, or core replaces the tool
538
+ * entry wholesale instead of merging and the entry ends up icon-less — which
539
+ * silently drops it from the convert menu and block settings.
540
+ *
251
541
  * @param registry - the editor's portal registry (from `useBlok`)
252
542
  * @param toolName - the name the tool is registered under
253
543
  */
@@ -267,8 +557,14 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
267
557
  return undefined;
268
558
  }
269
559
 
270
- return entries.map((entry, index): ToolboxConfigEntry => {
271
- const { entry: core } = toCoreEntry(entry);
560
+ const overrides = entries.map((entry, index): ToolboxConfigEntry => {
561
+ const { entry: core, deferred } = toCoreEntry(entry);
562
+
563
+ if (deferred) {
564
+ // Omit the key entirely: an explicit `icon: undefined` would still
565
+ // shadow the tool entry's icon in core's spread merge.
566
+ delete core.icon;
567
+ }
272
568
 
273
569
  if (!isValidElement(entry.title)) {
274
570
  return core;
@@ -294,6 +590,9 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
294
590
 
295
591
  return { ...core, titleEl: host };
296
592
  });
593
+
594
+ // Keep the authored shape (see the merge note above).
595
+ return Array.isArray(spec.toolbox) ? overrides : overrides[0];
297
596
  };
298
597
 
299
598
  /** Lazily-computed core-compatible toolbox (React element icons serialized once). */
@@ -342,7 +641,7 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
342
641
  const entryComponent: ComponentType<ReactBlockRenderProps<Data, Config>> =
343
642
  spec.viewComponent === undefined ? spec.component : ReadOnlySwitch;
344
643
 
345
- return class ReactBlockTool {
644
+ const ReactBlockTool = class ReactBlockTool {
346
645
  /** Marker so `useBlok` can detect react-block tools and inject the registry. */
347
646
  public static readonly __isBlokReactBlock = true as const;
348
647
 
@@ -372,13 +671,21 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
372
671
  }
373
672
 
374
673
  private readonly blockApi: BlockAPI;
674
+ /** The editor-level API, handed to the component as the `api` prop. */
675
+ private readonly api: API;
375
676
  private readonly registry: BlockPortalRegistry | undefined;
376
677
  /** Name this tool is registered under in the consumer's `tools` map. */
377
678
  private readonly toolName: string | undefined;
378
679
  /** The consumer's tool config with adapter-internal keys stripped. */
379
680
  private readonly toolConfig: Readonly<Partial<Config>>;
380
681
  private readonly pointerDrag: () => boolean;
381
- private readonly childrenComponent: ComponentType;
682
+ private readonly childrenComponent: ComponentType<BlockChildrenProps>;
683
+ /** Why core built this instance — gates `onCreated`, handed to `onMounted`. */
684
+ private readonly origin: BlockOrigin;
685
+ /** The component registered with the portal (the post-mount gate, if any). */
686
+ private readonly entryComponent: ComponentType<ReactBlockRenderProps<Data, Config>>;
687
+ /** True once the post-mount hooks fired; keeps a StrictMode remount from re-firing them. */
688
+ private mountSignalled = false;
382
689
  private mirror: Readonly<Data>;
383
690
  /** Dedup baseline: skip a redundant render of identical data. */
384
691
  private lastRendered: Readonly<Data>;
@@ -407,33 +714,99 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
407
714
  }
408
715
  this.toolConfig = Object.freeze(publicConfig) as Readonly<Partial<Config>>;
409
716
 
717
+ this.api = options.api;
718
+
410
719
  // Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
411
720
  // (core silently drops a dispatchChange while a drag is active).
412
- const api = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
721
+ const drag = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
413
722
 
414
- this.pointerDrag = (): boolean => api?.blocks?.isPointerDragActive === true;
723
+ this.pointerDrag = (): boolean => drag?.blocks?.isPointerDragActive === true;
415
724
 
416
725
  this.mirror = fillDefaults<Data>(spec.propSchema, (options.data ?? {}) as Record<string, unknown>);
417
726
  this.lastRendered = this.mirror;
418
727
  this.readOnly = options.readOnly;
728
+ // Absent origin means a caller that predates the signal; 'api' is core's
729
+ // own default, so it is never mistaken for a user gesture.
730
+ this.origin = options.origin ?? 'api';
419
731
 
420
732
  const blockApi = this.blockApi;
733
+ const api = this.api;
421
734
 
422
735
  // Per-instance child slot: a childless ref'd div Blok owns; mountChildBlocks
423
736
  // appends the real child holders imperatively (React never reconciles them).
424
- this.childrenComponent = function BlockChildren(): ReactElement {
737
+ this.childrenComponent = function BlockChildren({
738
+ childAttributes,
739
+ }: BlockChildrenProps): ReactElement {
425
740
  const slotRef = useRef<HTMLDivElement | null>(null);
741
+ // What the last decoration pass wrote, so a dropped key is cleaned up.
742
+ const stampedRef = useRef(new Map<string, StampedChild>());
426
743
 
427
744
  // No dep array: re-adopt children after EVERY render (mirrors the Vue
428
745
  // adapter's onMounted + onUpdated pair).
429
746
  useEffect(() => {
430
- if (slotRef.current !== null) {
431
- mountChildBlocks(slotRef.current, blockApi.getChildren());
747
+ if (slotRef.current === null) {
748
+ return;
432
749
  }
750
+
751
+ const children = blockApi.getChildren();
752
+
753
+ mountChildBlocks(slotRef.current, children);
754
+ applyChildAttributes(stampedRef.current, children, childAttributes);
755
+ emitChildrenMounted(api, blockApi.id, children);
433
756
  });
434
757
 
435
758
  return <div ref={slotRef} {...{ [DATA_ATTR.nestedBlocks]: '' }} />;
436
759
  };
760
+
761
+ this.entryComponent = this.buildEntryComponent();
762
+ }
763
+
764
+ /**
765
+ * The component actually registered with the portal. Without a post-mount
766
+ * hook it is the shared per-TYPE component; with one it is a thin
767
+ * per-INSTANCE gate whose mount effect fires the hooks after React's first
768
+ * commit — children first, since React runs effects bottom-up, so the child
769
+ * holders are already in the slot by then.
770
+ */
771
+ private buildEntryComponent(): ComponentType<ReactBlockRenderProps<Data, Config>> {
772
+ const onMounted = spec.onMounted;
773
+ const onCreated = spec.onCreated;
774
+
775
+ if (onMounted === undefined && onCreated === undefined) {
776
+ return entryComponent;
777
+ }
778
+
779
+ const Entry = entryComponent;
780
+ const fire = (): void => {
781
+ // Once per block instance: React StrictMode double-invokes mount
782
+ // effects, and a second "the author just created me" signal would seed
783
+ // a container's default children twice.
784
+ if (this.mountSignalled) {
785
+ return;
786
+ }
787
+
788
+ this.mountSignalled = true;
789
+
790
+ const context = { origin: this.origin, api: this.api };
791
+
792
+ onMounted?.(this.blockApi, context);
793
+
794
+ // Creation-only, and after onMounted: a seeding hook must see whatever
795
+ // the mount hook already put in place.
796
+ if (CREATION_ORIGINS.has(this.origin)) {
797
+ onCreated?.(this.blockApi, context);
798
+ }
799
+ };
800
+
801
+ const MountGate = (props: ReactBlockRenderProps<Data, Config>): ReactElement => {
802
+ useEffect(fire, []);
803
+
804
+ return <Entry {...props} />;
805
+ };
806
+
807
+ MountGate.displayName = `BlokBlockMount(${spec.type})`;
808
+
809
+ return MountGate;
437
810
  }
438
811
 
439
812
  public render(): HTMLElement {
@@ -447,7 +820,7 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
447
820
 
448
821
  this.registry?.register(this.blockApi.id, {
449
822
  hostEl: host,
450
- component: entryComponent as unknown as ComponentType<Record<string, unknown>>,
823
+ component: this.entryComponent as unknown as ComponentType<Record<string, unknown>>,
451
824
  props: this.buildProps(),
452
825
  toolName: this.toolName,
453
826
  });
@@ -497,19 +870,40 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
497
870
  this.pushProps({ readOnly: state });
498
871
  }
499
872
 
873
+ /**
874
+ * Core's toolbar-anchor hook. Always defined so the delegation is one
875
+ * `typeof … === 'function'` probe away, and always resolved fresh against
876
+ * the LIVE host — the anchor element is React-rendered, so it does not exist
877
+ * yet when the tool is constructed and may be replaced on any re-render. A
878
+ * spec without a resolver returns undefined, which is exactly what core's
879
+ * default positioning already assumes.
880
+ */
881
+ public getToolbarAnchorElement(): HTMLElement | undefined {
882
+ const host = this.hostEl;
883
+
884
+ if (host === null || spec.getToolbarAnchorElement === undefined) {
885
+ return undefined;
886
+ }
887
+
888
+ return spec.getToolbarAnchorElement(host, this.blockApi) ?? undefined;
889
+ }
890
+
500
891
  public moved(): void {
501
892
  // No remount: Blok relocates the host element; the portal follows it.
502
893
  spec.onMoved?.(this.blockApi);
503
894
  }
504
895
 
505
896
  public removed(): void {
506
- this.registry?.unregister(this.blockApi.id);
897
+ // Ownership-scoped: core composes a REPLACEMENT block (which registers
898
+ // under the SAME id) before it tears this one down, so an unqualified
899
+ // unregister here would delete the live entry and blank the block.
900
+ this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
507
901
  spec.onRemoved?.(this.blockApi);
508
902
  }
509
903
 
510
904
  public destroy(): void {
511
905
  // Idempotent with removed(); unregister is safe when already absent.
512
- this.registry?.unregister(this.blockApi.id);
906
+ this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
513
907
  }
514
908
 
515
909
  /** The full entry props handed to the component on (re-)register. */
@@ -518,6 +912,7 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
518
912
  data: this.mirror,
519
913
  commit: this.commit,
520
914
  block: this.blockApi,
915
+ api: this.api,
521
916
  readOnly: this.readOnly,
522
917
  config: this.toolConfig,
523
918
  BlockChildren: this.childrenComponent,
@@ -600,4 +995,24 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
600
995
  requestAnimationFrame(retry);
601
996
  }
602
997
  };
998
+
999
+ // Forward the authored statics onto the generated class. `defineProperty`
1000
+ // (not assignment) because `toolbox`/`isReadOnlySupported` are accessors on
1001
+ // the prototype chain and a plain write would throw in strict mode; the
1002
+ // reserved list keeps a stray bag from taking those — or the adapter's own
1003
+ // markers — over.
1004
+ for (const [key, value] of Object.entries(spec.statics ?? {})) {
1005
+ if (RESERVED_STATICS.includes(key)) {
1006
+ continue;
1007
+ }
1008
+
1009
+ Object.defineProperty(ReactBlockTool, key, {
1010
+ value,
1011
+ writable: true,
1012
+ enumerable: true,
1013
+ configurable: true,
1014
+ });
1015
+ }
1016
+
1017
+ return ReactBlockTool;
603
1018
  }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export { BlokEditor } from './BlokEditor';
8
8
  export { useBlokHandle } from './useBlokHandle';
9
9
  export type { BlokEditorHandle } from './useBlokHandle';
10
10
  export { BlokProvider, useBlokDefaults } from './provide-blok';
11
+ export { useBlokInstance } from './blok-instance-context';
11
12
  export type { UseBlokConfig, BlokContentProps } from './types';
12
13
  export type { BlokEditorProps } from './BlokEditor';
13
14
  export { useBlocks } from './useBlocks';
@@ -18,7 +19,11 @@ export type { BlokViewProps } from './BlokView';
18
19
  export { useBlokView } from './useBlokView';
19
20
  export { createReactBlock } from './createReactBlock';
20
21
  export type {
22
+ BlockChildrenProps,
23
+ BlockToolStatics,
24
+ ChildAttributes,
21
25
  CreateReactBlockSpec,
26
+ ReactBlockMountedContext,
22
27
  ReactBlockRenderProps,
23
28
  ReactBlockViewProps,
24
29
  ReactToolboxConfig,