@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.
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +426 -287
- package/package.json +2 -2
- package/src/BlokContent.tsx +11 -1
- package/src/block-portal-registry.ts +20 -4
- package/src/blok-instance-context.ts +35 -0
- package/src/config-keys.ts +1 -0
- package/src/content-baseline-map.ts +38 -0
- package/src/createReactBlock.tsx +431 -16
- package/src/index.ts +5 -0
- package/src/useBlok.ts +185 -56
- package/src/useBlokHandle.ts +40 -4
- package/types/index.d.ts +160 -5
package/src/createReactBlock.tsx
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
721
|
+
const drag = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
|
|
413
722
|
|
|
414
|
-
this.pointerDrag = (): boolean =>
|
|
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(
|
|
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
|
|
431
|
-
|
|
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
|
-
|
|
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,
|