@bloklabs/react 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,1062 +0,0 @@
1
- import { isValidElement, useEffect, useRef, type ComponentType, type ReactElement } from 'react';
2
- import { flushSync } from 'react-dom';
3
- import { createRoot } from 'react-dom/client';
4
- import {
5
- applyChildDecoration,
6
- BlockChildrenMounted,
7
- createChildDecorationLedger,
8
- DATA_ATTR,
9
- deepEqual,
10
- fillDefaults,
11
- mountChildBlocks,
12
- type PropSchema,
13
- withSlotlessDescendants,
14
- } from '@bloklabs/core/adapters';
15
- import {
16
- BLOK_PORTAL_REGISTRY_CONFIG_KEY,
17
- BLOK_TOOL_NAME_CONFIG_KEY,
18
- type BlockPortalRegistry,
19
- } from './block-portal-registry';
20
- import type { API } from '@/types';
21
- import type { BlockAPI } from '@/types/api';
22
- import type {
23
- BlockOrigin,
24
- BlockToolConstructable,
25
- BlockToolConstructorOptions,
26
- BlockToolData,
27
- ToolboxConfig,
28
- ToolboxConfigEntry,
29
- } from '@/types/tools';
30
-
31
- export type { PropSchema, PropSchemaEntry } from '@bloklabs/core/adapters';
32
-
33
- /** Adapter-internal config keys, stripped before the config reaches the component. */
34
- const INTERNAL_CONFIG_KEYS: readonly string[] = [
35
- BLOK_PORTAL_REGISTRY_CONFIG_KEY,
36
- BLOK_TOOL_NAME_CONFIG_KEY,
37
- ];
38
-
39
- /**
40
- * Every STATIC member of core's block-tool contract a React block may declare
41
- * for itself — `ownsChildren`, `keepsChildrenOnEnter`, `conversionConfig`,
42
- * `pasteConfig`, `sanitize`, `shortcut`, `upgradeData`, and whatever core adds
43
- * next. Derived from
44
- * `BlockToolConstructable` rather than enumerated, so a new core static needs no
45
- * adapter change to become reachable.
46
- *
47
- * `toolbox` and `isReadOnlySupported` are excluded because the factory owns
48
- * them: `toolbox` is authored as {@link CreateReactBlockSpec.toolbox} (React
49
- * elements and all), and in-place read-only support is unconditional.
50
- */
51
- export type BlockToolStatics = Omit<BlockToolConstructable, 'toolbox' | 'isReadOnlySupported'>;
52
-
53
- /**
54
- * Announce that a container's child holders have settled in its slot — the
55
- * signal a host waits on before putting the caret into a freshly inserted
56
- * child, since the portal commits a frame after core's `rendered()`.
57
- *
58
- * Optional-chained: the editor always supplies `events`, but a host unit test
59
- * may hand a block a partial `api`, and an observability signal must never be
60
- * able to break a render.
61
- * @param api - the editor-level API
62
- * @param blockId - the container block's id
63
- * @param children - the container's model children, in model order
64
- */
65
- const emitChildrenMounted = (api: API, blockId: string, children: BlockAPI[]): void => {
66
- api?.events?.emit(BlockChildrenMounted, {
67
- blockId,
68
- childIds: children.map(child => child.id),
69
- });
70
- };
71
-
72
- /**
73
- * Origins that mean "the author just made this block" — the only ones that fire
74
- * {@link CreateReactBlockSpec.onCreated}. Written as an allow-list so a future
75
- * core origin fails CLOSED (no creation signal) instead of silently opting in.
76
- *
77
- * `undefined` is included because core always supplies an origin: an absent one
78
- * means a host hand-built the constructor options, which is an explicit
79
- * creation — the same reading core's own container tools apply.
80
- *
81
- * Note what is NOT the axis here: `origin === 'user'`. A container gated on that
82
- * alone would refuse to seed for `api.blocks.insert('steps')` and for a
83
- * turn-into, leaving an empty, unusable container.
84
- */
85
- const CREATION_ORIGINS: ReadonlySet<BlockOrigin | undefined> = new Set<BlockOrigin | undefined>([
86
- undefined,
87
- 'user',
88
- 'api',
89
- 'convert',
90
- ]);
91
-
92
- /** Statics the generated class owns; an authored `statics` bag can never take them over. */
93
- const RESERVED_STATICS: readonly string[] = [
94
- 'toolbox',
95
- 'isReadOnlySupported',
96
- '__isBlokReactBlock',
97
- '__buildPortalToolbox',
98
- ];
99
-
100
- /** Props handed to a React block's `component` (the only data write path is `commit`). */
101
- export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
102
- /** Frozen snapshot of the block data. Re-rendered with a new value on change; never mutate. */
103
- data: Readonly<Data>;
104
- /**
105
- * The ONLY data write path: merge a partial patch and sync once. Idempotency
106
- * is part of the public contract — a patch that changes nothing is a FULL
107
- * no-op (no re-render, no change event), so effects may echo current values
108
- * through `commit` on any render, including mount, without a guard.
109
- */
110
- commit: (patch: Partial<Data>) => void;
111
- /** This block's per-block API (id, connection methods, dispatchChange…). */
112
- block: BlockAPI;
113
- /**
114
- * The EDITOR-level API this block belongs to (`api.blocks`, `api.caret`,
115
- * `api.toolbar`…) — the same object a vanilla tool receives in its
116
- * constructor. Reach for it when a block has to drive the document around it
117
- * (insert a sibling, move the caret, open the toolbox) instead of routing
118
- * everything through `block.call()` string dispatch.
119
- *
120
- * For the reactive, id/parentId-relative view of the tree (and to re-render
121
- * when your own children change), pair `useBlocks` with `useBlokInstance()`
122
- * instead — the api handle itself is not reactive.
123
- */
124
- api: API;
125
- /**
126
- * Read-only flag. Render disabled/inert UI when true (e.g. drop
127
- * `contentEditable`, hide controls). Toggled IN PLACE by core's read-only
128
- * switch — the component re-renders without remounting, so ephemeral state
129
- * survives. Honor it: a block that ignores `readOnly` stays interactive when
130
- * the editor is read-only (same contract as a vanilla tool's `setReadOnly`).
131
- */
132
- readOnly: boolean;
133
- /**
134
- * The tool's `config` from the consumer's `tools` map (adapter-internal keys
135
- * stripped) — the first-class channel for host props (permissions, CDN/upload
136
- * URLs, locale…). No context provider needed. Under `useBlok`/`BlokEditor`
137
- * this prop is LIVE: functions always call the latest render's closure, and
138
- * changed non-function values are pushed to mounted blocks in place — so
139
- * config values never belong in `deps`.
140
- */
141
- config: Readonly<Partial<Config>>;
142
- /** Engine-owned child slot — render `<BlockChildren />` for a container block. */
143
- BlockChildren: ComponentType<BlockChildrenProps>;
144
- /**
145
- * Attach this to the element the +/drag toolbar should vertically center on:
146
- * `<div ref={toolbarAnchorRef}>`. The React-native way to answer core's
147
- * `getToolbarAnchorElement`, which is otherwise a `(host, block) => Element`
148
- * hook resolved OUTSIDE the component tree — so pointing at an element you
149
- * render meant inventing a data attribute and `querySelector`-ing for it from
150
- * {@link CreateReactBlockSpec.getToolbarAnchorElement}.
151
- *
152
- * A container block whose own chrome is not editable needs an anchor: with
153
- * none, core centers the toolbar on the first `[contenteditable]` under the
154
- * host, which for a container is its FIRST CHILD BLOCK — parking the +/drag
155
- * handles halfway down, beside content that has a toolbar of its own.
156
- *
157
- * The ref is stable across renders and outranks
158
- * {@link CreateReactBlockSpec.getToolbarAnchorElement} while it holds a
159
- * MOUNTED element; when the element unmounts (React calls the ref with null,
160
- * or it is detached) the declared hook takes over again, so the toolbar is
161
- * never positioned against a stale node. Leave it unattached to keep core's
162
- * default.
163
- */
164
- toolbarAnchorRef: (element: HTMLElement | null) => void;
165
- }
166
-
167
- /**
168
- * One child's decoration: attribute name → value. `null`/`undefined` removes the
169
- * attribute; a boolean or number is stringified (so `false` writes
170
- * `data-active="false"`, which CSS can select, rather than dropping the hook).
171
- */
172
- export type ChildAttributes = Record<string, string | number | boolean | null | undefined>;
173
-
174
- /** Props of the engine-owned {@link ReactBlockRenderProps.BlockChildren} slot. */
175
- export interface BlockChildrenProps {
176
- /**
177
- * Per-child decoration, applied to each child's HOLDER after the holders are
178
- * mounted. Named hooks (`data-step-index`, `data-active`…) replace positional
179
- * `:nth-child()` CSS over Blok's holders, which silently breaks the moment a
180
- * child is inserted, removed or reordered.
181
- *
182
- * The holders themselves stay DIRECT children of the slot — core requires that
183
- * (hierarchy reparenting looks for the next sibling whose
184
- * `holder.parentElement` IS the container, and caret navigation decides
185
- * "same container" by that identity), so decoration is expressed as attributes
186
- * rather than as wrapper elements. Attributes the callback stops producing are
187
- * removed on the next pass, so a reordered child never keeps a dead index.
188
- *
189
- * Writing on a child's holder is inert by design: core's mutation filter drops
190
- * a holder-targeted attribute record for the child block (the holder is the
191
- * child tool element's ANCESTOR, not its descendant) and suppresses it for the
192
- * container (whose own host is the nearest `data-blok-mutation-free` ancestor).
193
- * The guarantee stops at the holder and its `[data-blok-element-content]`
194
- * wrapper — writing AT or BELOW a child's tool root DOES score as that child's
195
- * edit.
196
- * @param child - the child block's API
197
- * @param index - the child's position in the container's model children
198
- */
199
- childAttributes?: (child: BlockAPI, index: number) => ChildAttributes;
200
- /**
201
- * The same per-child decoration, applied one level IN: on each child's
202
- * `[data-blok-element-content]` wrapper instead of its holder. Core's
203
- * child-holder decoration law blesses both, and a container needs both — the
204
- * holder is the child's outer box (rails, indices, hover states), while the
205
- * content wrapper is where the child's own text box begins, which is what a
206
- * numbered rail or a connector line has to align to.
207
- *
208
- * Reach for it instead of walking core's wrapper chain from a holder hook
209
- * (`[data-step] > [data-blok-element-content] > …`): those selectors encode
210
- * engine DOM structure in host CSS and break the moment that structure changes.
211
- * With a named hook the selector is one flat attribute.
212
- *
213
- * Same cleanup and same inertness as {@link BlockChildrenProps.childAttributes}
214
- * — attributes the callback stops producing are removed on the next pass, and
215
- * writes here are still not the child's edit. A child whose DOM has not
216
- * committed yet (a portal-rendered block, on the first pass) has no wrapper to
217
- * write to and is skipped, then stamped on the next pass.
218
- * @param child - the child block's API
219
- * @param index - the child's position in the container's model children
220
- */
221
- childContentAttributes?: (child: BlockAPI, index: number) => ChildAttributes;
222
- }
223
-
224
- /**
225
- * Props handed to a React block's `viewComponent` (the read-only renderer):
226
- * the entry props minus `commit` — a display renderer has no write path.
227
- */
228
- export type ReactBlockViewProps<Data, Config = Record<string, unknown>> = Omit<
229
- ReactBlockRenderProps<Data, Config>,
230
- 'commit' | 'readOnly'
231
- >;
232
-
233
- /** A toolbox entry whose `icon`/`title` may be a React element instead of a string. */
234
- export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon' | 'title'> & {
235
- /**
236
- * Toolbox icon. Core's toolbox chrome consumes markup strings, but React
237
- * authors may pass the same element they render in the block body — the
238
- * factory serializes it to markup once (lazily, cached) so icons are never
239
- * duplicated as parallel raw SVG strings. Without a DOM (SSR) the element is
240
- * returned as-is and serialization retries on the next browser-side access.
241
- */
242
- icon?: string | ReactElement;
243
- /**
244
- * Toolbox label. A plain string is translated by core through the
245
- * `toolNames.*` namespace as usual. A ReactElement instead renders live in
246
- * the HOST's React tree (via the editor's shared BlockPortalHost), so the
247
- * label tracks the host's OWN i18n — no mirroring of strings into Blok's
248
- * dictionary. Pair a ReactElement title with `titleKey` so multilingual
249
- * search still resolves an English term. Requires the editor to be created
250
- * through `useBlok`/`BlokEditor` (which wires the portal registry); a
251
- * ReactElement title is dropped when the tool is used with vanilla core.
252
- */
253
- title?: string | ReactElement;
254
- };
255
-
256
- /** Toolbox config for React blocks: single entry or several variants. */
257
- export type ReactToolboxConfig = ReactToolboxConfigEntry | ReactToolboxConfigEntry[];
258
-
259
- /**
260
- * Second argument of {@link CreateReactBlockSpec.onMounted} and
261
- * {@link CreateReactBlockSpec.onCreated} — everything the block cannot read off
262
- * its own `BlockAPI`.
263
- */
264
- export interface ReactBlockMountedContext {
265
- /**
266
- * Why this block instance was constructed: a CREATION origin (`user`, `api`,
267
- * `convert`) means the author just made it, so seeding default children is
268
- * correct; a RESTORE origin (`load`, `replay`, `paste`) means the document
269
- * already says what the children are. `probe` is an off-tree instance built
270
- * only to read a tool's default data — it must not touch the block tree at
271
- * all. Defaults to `'api'` when the constructor was handed no origin.
272
- */
273
- origin: BlockOrigin;
274
- /** The editor-level API (`api.blocks`, `api.caret`, `api.events`…). */
275
- api: API;
276
- }
277
-
278
- /** Spec for {@link createReactBlock}. */
279
- export interface CreateReactBlockSpec<Data = BlockToolData, Config = Record<string, unknown>> {
280
- /** Tool type name (registered key). */
281
- type: string;
282
- /** Optional toolbox entry; `icon` accepts a React element (see {@link ReactToolboxConfigEntry}). */
283
- toolbox?: ReactToolboxConfig;
284
- /** Declarative defaults that also define the exact `save()` key set. */
285
- propSchema: PropSchema;
286
- /** The component rendered for each block instance. */
287
- component: ComponentType<ReactBlockRenderProps<Data, Config>>;
288
- /**
289
- * Optional read-only renderer: when the editor is read-only, this component
290
- * is rendered INSTEAD of `component`, so blocks don't hand-plumb a
291
- * display-vs-edit ternary. It receives the entry props minus `commit` (no
292
- * write path) and minus `readOnly` (always true by definition). Toggling read-only
293
- * swaps renderers — ephemeral state does not survive the swap; omit
294
- * `viewComponent` to keep the single-component in-place toggle instead.
295
- */
296
- viewComponent?: ComponentType<ReactBlockViewProps<Data, Config>>;
297
- /**
298
- * Static members of core's tool contract, forwarded verbatim onto the
299
- * generated tool class — the single channel for everything core reads off the
300
- * CLASS rather than the instance (`ownsChildren`, `keepsChildrenOnEnter`,
301
- * `conversionConfig`, `pasteConfig`, `sanitize`, `shortcut`, `upgradeData`…).
302
- * Without it the only way to declare one was to subclass the generated class.
303
- *
304
- * `keepsChildrenOnEnter` is the per-tool Enter POLICY: declare it and Enter on
305
- * this container's empty LAST child creates the new line INSIDE the container
306
- * instead of escaping to the container's parent (Blok's default, which is
307
- * Notion's callout behaviour). Core cannot read that off the DOM — a callout
308
- * renders the same `data-blok-nested-blocks` slot as a column yet wants the
309
- * escape — so before it existed a layout container (a card, a `steps` block)
310
- * had to hijack the editor-global `config.onEnter` and re-derive containment.
311
- *
312
- * `toolbox` and `isReadOnlySupported` are owned by the factory and cannot be
313
- * overridden here (see {@link BlockToolStatics}).
314
- * @example
315
- * ```ts
316
- * statics: { ownsChildren: true, conversionConfig: { export: 'text', import: 'text' } }
317
- * ```
318
- */
319
- statics?: BlockToolStatics;
320
- /**
321
- * The element the +/drag toolbar should vertically center on — core's
322
- * `getToolbarAnchorElement` hook, resolved against this block's host element
323
- * on every call (never cached, so it tracks re-renders).
324
- *
325
- * A container block whose own chrome is not editable needs it: with no anchor,
326
- * core centers the toolbar on the first `[contenteditable]` it finds under the
327
- * host, which for a container is its FIRST CHILD BLOCK — parking the +/drag
328
- * handles halfway down the block, beside content that has a toolbar of its
329
- * own. Return `null`/`undefined` (or omit the field) to keep core's default.
330
- * @param host - this block's mutation-free host element
331
- * @param block - this block's per-block API
332
- */
333
- getToolbarAnchorElement?: (host: HTMLElement, block: BlockAPI) => HTMLElement | null | undefined;
334
- /**
335
- * Enter (or Cmd/Ctrl+Enter) on this block while it is the keyboard
336
- * navigation target — core's `onNavigationEnter` hook. Return true when the
337
- * block handled it (e.g. opened a link); omit it, or return false, to let
338
- * core put the caret in the block.
339
- * @param event - the Enter keydown
340
- * @param block - this block's per-block API
341
- */
342
- onNavigationEnter?: (event: KeyboardEvent, block: BlockAPI) => boolean;
343
- /** Optional lifecycle callbacks mapped from Blok's block hooks. */
344
- onRendered?: (block: BlockAPI) => void;
345
- /**
346
- * Fired ONCE per block instance, after the portal's FIRST commit — the first
347
- * moment this block's rendered DOM exists (and, for a container, the moment
348
- * `<BlockChildren />` has adopted the child holders). `onRendered` cannot
349
- * mean that: core calls `rendered()` in the same tick as `render()`, a commit
350
- * BEFORE React has drawn anything, which is why hosts ended up setting the
351
- * caret twice around a `requestAnimationFrame`.
352
- *
353
- * It is also the create-vs-restore signal: `context.origin` says whether the
354
- * author just made this block (`user`/`api`/`convert`) or the document is
355
- * being re-materialised (`load`/`replay`/`paste`) — so a container can seed
356
- * its default children here exactly once, without the "children are
357
- * transiently empty during a replay" trap.
358
- * @example
359
- * ```ts
360
- * onMounted: (block, { origin, api }) => {
361
- * if (origin === 'user' && block.getChildren().length === 0) {
362
- * api.blocks.insertInsideParent(block.id);
363
- * }
364
- * }
365
- * ```
366
- */
367
- onMounted?: (block: BlockAPI, context: ReactBlockMountedContext) => void;
368
- /**
369
- * The SEEDING hook: `onMounted`, narrowed to a genuine creation. Fired ONCE
370
- * per block instance, after the portal's first commit, and only when this
371
- * instance is the author making a new block (`origin` of `user`, `api` or
372
- * `convert`) — never for a `load`/`replay`/`paste` restore, and never for the
373
- * off-tree `probe` instance core builds to read a tool's default data.
374
- *
375
- * That predicate is why the hook exists rather than leaving every block to
376
- * read `context.origin` in `onMounted`: the intuitive `origin === 'user'` test
377
- * is wrong. It drops `api.blocks.insert('steps')` and turn-into, so a
378
- * container seeded that way comes up empty for every path except a keystroke.
379
- * Core refused to ship that axis into its own `column`/`column_list`; this
380
- * encodes the correct one once, here.
381
- *
382
- * The `context` is the same object {@link CreateReactBlockSpec.onMounted}
383
- * receives, so a block that only seeds can read `origin` for finer decisions
384
- * (e.g. seed a different default for a turn-into).
385
- * @example
386
- * ```ts
387
- * onCreated: (block, { api }) => {
388
- * if (block.getChildren().length === 0) {
389
- * api.blocks.insertInsideParent(block.id);
390
- * }
391
- * }
392
- * ```
393
- */
394
- onCreated?: (block: BlockAPI, context: ReactBlockMountedContext) => void;
395
- onMoved?: (block: BlockAPI) => void;
396
- onRemoved?: (block: BlockAPI) => void;
397
- }
398
-
399
- /**
400
- * Author a first-party React block. Returns a `BlockToolConstructable`
401
- * registered exactly like a vanilla tool (`tools: { type: createReactBlock(...) }`).
402
- *
403
- * The factory owns the host element (`data-blok-mutation-free`), a frozen
404
- * defaults-filled data mirror, and the entry props the component reads. It
405
- * portals the component into the host via the editor's shared portal registry
406
- * (the TipTap `ReactNodeViewRenderer` pattern) — every block renders inside the
407
- * SAME React tree that renders `BlokContent`, so app-level context (themes,
408
- * design-system providers, stores) flows into blocks with no bridge. Blok's
409
- * block lifecycle maps to React:
410
- * - `render()` creates the host and registers the portal entry (returned sync).
411
- * - `setData()` dedups, pushes the new snapshot, and flushes React's commit
412
- * before resolving `true` — defeating core's default remount.
413
- * - `save()` returns the complete frozen mirror (never the DOM, never partial).
414
- * - `commit()` merges a patch and fires `dispatchChange` exactly once.
415
- * - `removed()`/`destroy()` unregister the portal (deterministic unmount).
416
- */
417
- export function createReactBlock<Data = BlockToolData, Config = Record<string, unknown>>(
418
- spec: CreateReactBlockSpec<Data, Config>
419
- ): (new (options: BlockToolConstructorOptions) => {
420
- render(): HTMLElement;
421
- save(): BlockToolData;
422
- setData(newData: BlockToolData): Promise<boolean>;
423
- setReadOnly(state: boolean): void;
424
- getToolbarAnchorElement(): HTMLElement | undefined;
425
- onNavigationEnter(event: KeyboardEvent): boolean;
426
- rendered(): void;
427
- moved(): void;
428
- removed(): void;
429
- destroy(): void;
430
- }) & BlockToolStatics & {
431
- readonly __isBlokReactBlock: true;
432
- readonly toolbox: ToolboxConfig | undefined;
433
- /**
434
- * Adapter-internal: builds a per-editor toolbox whose ReactElement titles are
435
- * live portals registered in `registry`. Called by `useBlok`; returns
436
- * undefined when no entry has a ReactElement title. Not part of the public
437
- * tool contract.
438
- */
439
- __buildPortalToolbox(registry: BlockPortalRegistry, toolName: string): ToolboxConfig | undefined;
440
- readonly isReadOnlySupported: boolean;
441
- } {
442
- /**
443
- * Serialize a React element (an icon) to markup with a throwaway synchronous
444
- * root. Returns null when serialization cannot complete: without a DOM
445
- * (SSR — core never reads `toolbox` server-side, but a consumer may), or
446
- * when the flush was deferred because React was already rendering (flushSync
447
- * inside a render is a no-op with a warning, leaving the mount empty). A
448
- * null result is NOT cached, so the next access retries.
449
- */
450
- const renderElementToMarkup = (element: ReactElement): string | null => {
451
- if (typeof document === 'undefined') {
452
- return null;
453
- }
454
-
455
- const mount = document.createElement('div');
456
- const root = createRoot(mount);
457
-
458
- flushSync(() => {
459
- root.render(element);
460
- });
461
-
462
- const markup = mount.innerHTML;
463
-
464
- root.unmount();
465
-
466
- return markup === '' ? null : markup;
467
- };
468
-
469
- /**
470
- * Convert one React toolbox entry to a core-valid entry: serialize a React
471
- * icon to markup, and DROP a ReactElement title (core titles are
472
- * string | HTMLElement only). A string title passes through untouched; the
473
- * live element title is re-attached per-editor by `buildPortalToolbox`.
474
- *
475
- * `deferred` is true when icon serialization could not complete (SSR /
476
- * mid-render) and must be retried; the entry is returned with its element
477
- * icon left in place.
478
- *
479
- * @param entry - the authored React toolbox entry
480
- */
481
- const toCoreEntry = (
482
- entry: ReactToolboxConfigEntry
483
- ): { entry: ToolboxConfigEntry; deferred: boolean } => {
484
- const { icon, title, ...rest } = entry;
485
- const core: ToolboxConfigEntry = { ...(rest as ToolboxConfigEntry) };
486
-
487
- if (typeof title === 'string') {
488
- core.title = title;
489
- }
490
-
491
- if (!isValidElement(icon)) {
492
- if (icon !== undefined) {
493
- core.icon = icon;
494
- }
495
-
496
- return { entry: core, deferred: false };
497
- }
498
-
499
- const markup = renderElementToMarkup(icon);
500
-
501
- if (markup === null) {
502
- // Serialization deferred (SSR / mid-render): leave the element in place
503
- // and skip caching so a later browser-side access serializes it.
504
- (core as ReactToolboxConfigEntry).icon = icon;
505
-
506
- return { entry: core, deferred: true };
507
- }
508
-
509
- core.icon = markup;
510
-
511
- return { entry: core, deferred: false };
512
- };
513
-
514
- const convertToolbox = (): { value: ToolboxConfig | undefined; complete: boolean } => {
515
- if (spec.toolbox === undefined) {
516
- return { value: undefined, complete: true };
517
- }
518
-
519
- const entries = Array.isArray(spec.toolbox) ? spec.toolbox : [spec.toolbox];
520
- const converted = entries.map((entry) => toCoreEntry(entry));
521
- const complete = converted.every((result) => !result.deferred);
522
- const coreEntries = converted.map((result) => result.entry);
523
- const value = Array.isArray(spec.toolbox) ? coreEntries : coreEntries[0];
524
-
525
- return { value, complete };
526
- };
527
-
528
- /**
529
- * Build a per-editor toolbox in which every ReactElement title is a live
530
- * portal: a Blok-owned mutation-free host element registered in the editor's
531
- * `registry`, into which the ReactNode renders through the shared
532
- * BlockPortalHost — so the label tracks the host's own i18n.
533
- *
534
- * Returns undefined when no entry has a ReactElement title (nothing to
535
- * override; the static string/`toolNames.*` path already covers it).
536
- *
537
- * The override carries only what it uniquely owns. `useBlok` calls this from
538
- * a passive effect, i.e. inside React's commit phase, where `flushSync`
539
- * cannot flush — so an element ICON never serializes here. Such an icon is
540
- * OMITTED rather than shipped raw (core `insertAdjacentHTML`s it, writing the
541
- * literal `[object Object]`); core then merges this override OVER the static
542
- * toolbox, which serializes the same element successfully later, outside
543
- * React's commit. That merge is also why the authored SHAPE is preserved: a
544
- * single-object spec must stay a single object, or core replaces the tool
545
- * entry wholesale instead of merging and the entry ends up icon-less — which
546
- * silently drops it from the convert menu and block settings.
547
- *
548
- * @param registry - the editor's portal registry (from `useBlok`)
549
- * @param toolName - the name the tool is registered under
550
- */
551
- const buildPortalToolbox = (
552
- registry: BlockPortalRegistry,
553
- toolName: string
554
- ): ToolboxConfig | undefined => {
555
- if (spec.toolbox === undefined) {
556
- return undefined;
557
- }
558
-
559
- const entries = Array.isArray(spec.toolbox) ? spec.toolbox : [spec.toolbox];
560
-
561
- // Nothing to override unless at least one entry has a ReactElement title;
562
- // the static string/`toolNames.*` path already covers the rest.
563
- if (!entries.some((entry) => isValidElement(entry.title))) {
564
- return undefined;
565
- }
566
-
567
- const overrides = entries.map((entry, index): ToolboxConfigEntry => {
568
- const { entry: core, deferred } = toCoreEntry(entry);
569
-
570
- if (deferred) {
571
- // Omit the key entirely: an explicit `icon: undefined` would still
572
- // shadow the tool entry's icon in core's spread merge.
573
- delete core.icon;
574
- }
575
-
576
- if (!isValidElement(entry.title)) {
577
- return core;
578
- }
579
-
580
- const host = document.createElement('span');
581
-
582
- host.setAttribute('data-blok-mutation-free', 'true');
583
-
584
- const node = entry.title;
585
- const TitleComponent: ComponentType<Record<string, unknown>> = (): ReactElement => <>{node}</>;
586
-
587
- TitleComponent.displayName = `BlokToolboxTitle(${toolName})`;
588
-
589
- // Unique within this per-editor registry; register() is idempotent, so a
590
- // StrictMode double-build replaces rather than duplicates.
591
- registry.register(`__blok-toolbox-title__:${toolName}:${index}`, {
592
- hostEl: host,
593
- component: TitleComponent,
594
- props: {},
595
- toolName,
596
- });
597
-
598
- return { ...core, titleEl: host };
599
- });
600
-
601
- // Keep the authored shape (see the merge note above).
602
- return Array.isArray(spec.toolbox) ? overrides : overrides[0];
603
- };
604
-
605
- /** Lazily-computed core-compatible toolbox (React element icons serialized once). */
606
- const toolboxCache: { resolved: boolean; value: ToolboxConfig | undefined } = {
607
- resolved: false,
608
- value: undefined,
609
- };
610
-
611
- const resolveToolbox = (): ToolboxConfig | undefined => {
612
- if (toolboxCache.resolved) {
613
- return toolboxCache.value;
614
- }
615
-
616
- const { value, complete } = convertToolbox();
617
-
618
- // Only a fully-serialized toolbox is cached; an incomplete pass (no DOM,
619
- // deferred flush) is returned as-is and recomputed on the next access.
620
- if (complete) {
621
- toolboxCache.value = value;
622
- toolboxCache.resolved = true;
623
- }
624
-
625
- return value;
626
- };
627
-
628
- /**
629
- * The component actually registered with the portal: when the spec provides a
630
- * `viewComponent`, this chooser swaps to it while read-only (dropping the
631
- * write-path props); otherwise the edit component is registered directly and
632
- * keeps its in-place read-only toggle semantics.
633
- */
634
- const ReadOnlySwitch = (props: ReactBlockRenderProps<Data, Config>): ReactElement => {
635
- const ViewComponent = spec.viewComponent;
636
-
637
- if (props.readOnly && ViewComponent !== undefined) {
638
- const { commit: _commit, readOnly: _readOnly, ...viewProps } = props;
639
-
640
- return <ViewComponent {...(viewProps)} />;
641
- }
642
-
643
- const EditComponent = spec.component;
644
-
645
- return <EditComponent {...props} />;
646
- };
647
-
648
- const entryComponent: ComponentType<ReactBlockRenderProps<Data, Config>> =
649
- spec.viewComponent === undefined ? spec.component : ReadOnlySwitch;
650
-
651
- const ReactBlockTool = class ReactBlockTool {
652
- /** Marker so `useBlok` can detect react-block tools and inject the registry. */
653
- public static readonly __isBlokReactBlock = true as const;
654
-
655
- public static get toolbox(): ToolboxConfig | undefined {
656
- return resolveToolbox();
657
- }
658
-
659
- /**
660
- * Adapter-internal (see the return-type doc): builds the per-editor toolbox
661
- * override whose ReactElement titles are live portals in `registry`.
662
- */
663
- public static __buildPortalToolbox(
664
- registry: BlockPortalRegistry,
665
- toolName: string
666
- ): ToolboxConfig | undefined {
667
- return buildPortalToolbox(registry, toolName);
668
- }
669
-
670
- /**
671
- * React blocks support read-only mode: `setReadOnly` pushes a new prop the
672
- * component reads, so the block re-renders read-only IN PLACE. Without this
673
- * flag core's ReadOnly module throws a critical error when read-only is
674
- * enabled and a React block is present.
675
- */
676
- public static get isReadOnlySupported(): boolean {
677
- return true;
678
- }
679
-
680
- private readonly blockApi: BlockAPI;
681
- /** The editor-level API, handed to the component as the `api` prop. */
682
- private readonly api: API;
683
- private readonly registry: BlockPortalRegistry | undefined;
684
- /** Name this tool is registered under in the consumer's `tools` map. */
685
- private readonly toolName: string | undefined;
686
- /** The consumer's tool config with adapter-internal keys stripped. */
687
- private readonly toolConfig: Readonly<Partial<Config>>;
688
- private readonly pointerDrag: () => boolean;
689
- private readonly childrenComponent: ComponentType<BlockChildrenProps>;
690
- /** Why core built this instance — gates `onCreated`, handed to `onMounted`. */
691
- private readonly origin: BlockOrigin;
692
- /** The component registered with the portal (the post-mount gate, if any). */
693
- private readonly entryComponent: ComponentType<ReactBlockRenderProps<Data, Config>>;
694
- /** True once the post-mount hooks fired; keeps a StrictMode remount from re-firing them. */
695
- private mountSignalled = false;
696
- private mirror: Readonly<Data>;
697
- /** Dedup baseline: skip a redundant render of identical data. */
698
- private lastRendered: Readonly<Data>;
699
- /** Read-only flag mirrored into the entry props by setReadOnly. */
700
- private readOnly: boolean;
701
- private hostEl: HTMLElement | null = null;
702
- /** True while a deferred dispatch is waiting for a pointer drag to end. */
703
- private pendingDispatch = false;
704
- /** Element the component pointed `toolbarAnchorRef` at, if any. */
705
- private anchorEl: HTMLElement | null = null;
706
- /**
707
- * The `toolbarAnchorRef` prop. A bound field so its identity is STABLE
708
- * across renders — a fresh callback ref each render would make React detach
709
- * (null) and re-attach it on every commit.
710
- * @param element - the element React attached, or null on detach
711
- */
712
- private readonly toolbarAnchorRef = (element: HTMLElement | null): void => {
713
- this.anchorEl = element;
714
- };
715
-
716
- public constructor(options: BlockToolConstructorOptions) {
717
- this.blockApi = options.block;
718
-
719
- const config = (options.config ?? {}) as Record<string, unknown>;
720
-
721
- this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
722
- const toolName = config[BLOK_TOOL_NAME_CONFIG_KEY];
723
-
724
- this.toolName = typeof toolName === 'string' ? toolName : undefined;
725
-
726
- const publicConfig: Record<string, unknown> = {};
727
-
728
- for (const [key, value] of Object.entries(config)) {
729
- if (!INTERNAL_CONFIG_KEYS.includes(key)) {
730
- publicConfig[key] = value;
731
- }
732
- }
733
- this.toolConfig = Object.freeze(publicConfig) as Readonly<Partial<Config>>;
734
-
735
- this.api = options.api;
736
-
737
- // Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
738
- // (core silently drops a dispatchChange while a drag is active).
739
- const drag = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
740
-
741
- this.pointerDrag = (): boolean => drag?.blocks?.isPointerDragActive === true;
742
-
743
- this.mirror = fillDefaults<Data>(spec.propSchema, (options.data ?? {}) as Record<string, unknown>);
744
- this.lastRendered = this.mirror;
745
- this.readOnly = options.readOnly;
746
- // Absent origin means a caller that predates the signal; 'api' is core's
747
- // own default, so it is never mistaken for a user gesture.
748
- this.origin = options.origin ?? 'api';
749
-
750
- const blockApi = this.blockApi;
751
- const api = this.api;
752
-
753
- // Per-instance child slot: a childless ref'd div Blok owns; mountChildBlocks
754
- // appends the real child holders imperatively (React never reconciles them).
755
- this.childrenComponent = function BlockChildren({
756
- childAttributes,
757
- childContentAttributes,
758
- }: BlockChildrenProps): ReactElement {
759
- const slotRef = useRef<HTMLDivElement | null>(null);
760
- // What the last decoration pass wrote, so a dropped key is cleaned up.
761
- const ledgerRef = useRef(createChildDecorationLedger());
762
-
763
- // No dep array: re-adopt children after EVERY render (mirrors the Vue
764
- // adapter's onMounted + onUpdated pair).
765
- useEffect(() => {
766
- if (slotRef.current === null) {
767
- return;
768
- }
769
-
770
- const children = blockApi.getChildren();
771
-
772
- // A slotless child's own children live in this slot too. Decoration and
773
- // the mounted event stay per DIRECT child.
774
- mountChildBlocks(slotRef.current, withSlotlessDescendants(children, id => api.blocks.getChildren(id)));
775
- applyChildDecoration(ledgerRef.current, children, { childAttributes,
776
- childContentAttributes });
777
- emitChildrenMounted(api, blockApi.id, children);
778
- });
779
-
780
- return <div ref={slotRef} {...{ [DATA_ATTR.nestedBlocks]: '' }} />;
781
- };
782
-
783
- this.entryComponent = this.buildEntryComponent();
784
- }
785
-
786
- /**
787
- * The component actually registered with the portal. Without a post-mount
788
- * hook it is the shared per-TYPE component; with one it is a thin
789
- * per-INSTANCE gate whose mount effect fires the hooks after React's first
790
- * commit — children first, since React runs effects bottom-up, so the child
791
- * holders are already in the slot by then.
792
- */
793
- private buildEntryComponent(): ComponentType<ReactBlockRenderProps<Data, Config>> {
794
- const onMounted = spec.onMounted;
795
- const onCreated = spec.onCreated;
796
-
797
- if (onMounted === undefined && onCreated === undefined) {
798
- return entryComponent;
799
- }
800
-
801
- const Entry = entryComponent;
802
- const fire = (): void => {
803
- // Once per block instance: React StrictMode double-invokes mount
804
- // effects, and a second "the author just created me" signal would seed
805
- // a container's default children twice.
806
- if (this.mountSignalled) {
807
- return;
808
- }
809
-
810
- this.mountSignalled = true;
811
-
812
- const context = { origin: this.origin, api: this.api };
813
-
814
- onMounted?.(this.blockApi, context);
815
-
816
- // Creation-only, and after onMounted: a seeding hook must see whatever
817
- // the mount hook already put in place.
818
- if (CREATION_ORIGINS.has(this.origin)) {
819
- onCreated?.(this.blockApi, context);
820
- }
821
- };
822
-
823
- const MountGate = (props: ReactBlockRenderProps<Data, Config>): ReactElement => {
824
- useEffect(fire, []);
825
-
826
- return <Entry {...props} />;
827
- };
828
-
829
- MountGate.displayName = `BlokBlockMount(${spec.type})`;
830
-
831
- return MountGate;
832
- }
833
-
834
- public render(): HTMLElement {
835
- const host = document.createElement('div');
836
-
837
- // The Blok-owned host: React reconciles the chrome portaled INTO it, but
838
- // this attribute makes core's MutationObserver ignore those mutations, so
839
- // React's reconciliation never registers as a user edit.
840
- host.setAttribute('data-blok-mutation-free', 'true');
841
- this.hostEl = host;
842
-
843
- this.registry?.register(this.blockApi.id, {
844
- hostEl: host,
845
- component: this.entryComponent as unknown as ComponentType<Record<string, unknown>>,
846
- props: this.buildProps(),
847
- toolName: this.toolName,
848
- });
849
-
850
- // Returned synchronously; React flushes the portal on its next commit.
851
- return host;
852
- }
853
-
854
- public rendered(): void {
855
- spec.onRendered?.(this.blockApi);
856
- }
857
-
858
- public save(): BlockToolData {
859
- // The complete, frozen mirror — never the DOM, never partial.
860
- return this.mirror;
861
- }
862
-
863
- public async setData(newData: BlockToolData): Promise<boolean> {
864
- const next = fillDefaults<Data>(spec.propSchema, newData ?? {});
865
-
866
- // Dedup: identical data → skip the render entirely, but still return true
867
- // so core keeps the block in place (no remount).
868
- if (deepEqual(next, this.lastRendered)) {
869
- return true;
870
- }
871
-
872
- this.mirror = next;
873
- this.lastRendered = next;
874
-
875
- // Flush React's commit before resolving. Core drives setData inside its
876
- // RAF-extended Yjs-suppression window, so the re-render must land before
877
- // suppression lifts — without ever remounting.
878
- this.pushProps({ data: next });
879
-
880
- return true;
881
- }
882
-
883
- /**
884
- * In-place read-only toggle. Pushes a new `readOnly` prop the component
885
- * reads, so the block re-renders read-only WITHOUT a remount (ephemeral
886
- * component state survives). A regular prototype method (not an arrow
887
- * field) so core's `supportsInPlaceReadOnly` — which probes the
888
- * constructable's PROTOTYPE for `setReadOnly` — selects the in-place path.
889
- */
890
- public setReadOnly(state: boolean): void {
891
- this.readOnly = state;
892
- this.pushProps({ readOnly: state });
893
- }
894
-
895
- /**
896
- * Core's toolbar-anchor hook. Always defined so the delegation is one
897
- * `typeof … === 'function'` probe away, and always resolved fresh against
898
- * the LIVE host — the anchor element is React-rendered, so it does not exist
899
- * yet when the tool is constructed and may be replaced on any re-render. A
900
- * spec without a resolver returns undefined, which is exactly what core's
901
- * default positioning already assumes.
902
- */
903
- public getToolbarAnchorElement(): HTMLElement | undefined {
904
- /**
905
- * A ref the component attached wins — it names the exact element, chosen
906
- * from inside the tree. `isConnected` is the guard that matters: React
907
- * nulls the ref on unmount, but a component that re-parents its anchor can
908
- * leave a detached node here, and positioning the toolbar against one
909
- * silently parks it at 0,0.
910
- */
911
- const fromRef = this.anchorEl;
912
-
913
- if (fromRef !== null && fromRef.isConnected) {
914
- return fromRef;
915
- }
916
-
917
- const host = this.hostEl;
918
-
919
- if (host === null || spec.getToolbarAnchorElement === undefined) {
920
- return undefined;
921
- }
922
-
923
- return spec.getToolbarAnchorElement(host, this.blockApi) ?? undefined;
924
- }
925
-
926
- /**
927
- * Core's navigation-mode Enter hook. Only a real `true` from the spec
928
- * counts as handled.
929
- */
930
- public onNavigationEnter(event: KeyboardEvent): boolean {
931
- return spec.onNavigationEnter?.(event, this.blockApi) === true;
932
- }
933
-
934
- public moved(): void {
935
- // No remount: Blok relocates the host element; the portal follows it.
936
- spec.onMoved?.(this.blockApi);
937
- }
938
-
939
- public removed(): void {
940
- // Ownership-scoped: core composes a REPLACEMENT block (which registers
941
- // under the SAME id) before it tears this one down, so an unqualified
942
- // unregister here would delete the live entry and blank the block.
943
- this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
944
- spec.onRemoved?.(this.blockApi);
945
- }
946
-
947
- public destroy(): void {
948
- // Idempotent with removed(); unregister is safe when already absent.
949
- this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
950
- }
951
-
952
- /** The full entry props handed to the component on (re-)register. */
953
- private buildProps(): Record<string, unknown> {
954
- const props: ReactBlockRenderProps<Data, Config> = {
955
- data: this.mirror,
956
- commit: this.commit,
957
- block: this.blockApi,
958
- api: this.api,
959
- readOnly: this.readOnly,
960
- config: this.toolConfig,
961
- BlockChildren: this.childrenComponent,
962
- toolbarAnchorRef: this.toolbarAnchorRef,
963
- };
964
-
965
- return props as unknown as Record<string, unknown>;
966
- }
967
-
968
- /** Push a prop patch to the mounted component and flush React's commit. */
969
- private pushProps(props: Record<string, unknown>): void {
970
- const registry = this.registry;
971
-
972
- if (registry === undefined) {
973
- return;
974
- }
975
-
976
- // setData/setReadOnly arrive from core outside React's render phase, so a
977
- // synchronous flush is safe and makes the DOM update deterministic.
978
- flushSync(() => {
979
- registry.setProps(this.blockApi.id, props);
980
- });
981
- }
982
-
983
- /**
984
- * The only data write path. Merges the patch into the frozen mirror, pushes
985
- * the new snapshot, records the dedup baseline, and fires dispatchChange
986
- * EXACTLY once — deferring it while a pointer drag is active (core would
987
- * otherwise silently drop it).
988
- */
989
- private readonly commit = (patch: Partial<Data>): void => {
990
- const next = fillDefaults<Data>(spec.propSchema, {
991
- ...(this.mirror as Record<string, unknown>),
992
- ...(patch as Record<string, unknown>),
993
- });
994
-
995
- // Idempotent: a patch that changes nothing is a full no-op — no props
996
- // push, no dispatchChange. This is what makes an effect that echoes the
997
- // current value back through commit safe without a consumer-side guard
998
- // (an unguarded echo would otherwise loop commit → render → effect).
999
- if (deepEqual(next, this.mirror)) {
1000
- return;
1001
- }
1002
-
1003
- this.mirror = next;
1004
- this.lastRendered = next;
1005
- // No flushSync here: commit is called from React event handlers (inside a
1006
- // render batch), where a sync flush is illegal — the normal async commit
1007
- // is fine because the change originates from the component itself.
1008
- this.registry?.setProps(this.blockApi.id, { data: next });
1009
-
1010
- this.flushDispatch();
1011
- };
1012
-
1013
- /** Dispatch the change, or retry on the next frame if a drag is in progress. */
1014
- private flushDispatch(): void {
1015
- if (!this.pointerDrag()) {
1016
- this.pendingDispatch = false;
1017
- this.blockApi.dispatchChange();
1018
-
1019
- return;
1020
- }
1021
-
1022
- if (this.pendingDispatch) {
1023
- return;
1024
- }
1025
-
1026
- this.pendingDispatch = true;
1027
-
1028
- const retry = (): void => {
1029
- if (this.pointerDrag()) {
1030
- requestAnimationFrame(retry);
1031
-
1032
- return;
1033
- }
1034
-
1035
- this.pendingDispatch = false;
1036
- this.blockApi.dispatchChange();
1037
- };
1038
-
1039
- requestAnimationFrame(retry);
1040
- }
1041
- };
1042
-
1043
- // Forward the authored statics onto the generated class. `defineProperty`
1044
- // (not assignment) because `toolbox`/`isReadOnlySupported` are accessors on
1045
- // the prototype chain and a plain write would throw in strict mode; the
1046
- // reserved list keeps a stray bag from taking those — or the adapter's own
1047
- // markers — over.
1048
- for (const [key, value] of Object.entries(spec.statics ?? {})) {
1049
- if (RESERVED_STATICS.includes(key)) {
1050
- continue;
1051
- }
1052
-
1053
- Object.defineProperty(ReactBlockTool, key, {
1054
- value,
1055
- writable: true,
1056
- enumerable: true,
1057
- configurable: true,
1058
- });
1059
- }
1060
-
1061
- return ReactBlockTool;
1062
- }