@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.
- package/README.md +23 -0
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +365 -353
- package/package.json +3 -4
- package/types/index.d.ts +63 -4
- package/src/BlockPortalHost.tsx +0 -58
- package/src/BlokContent.tsx +0 -112
- package/src/BlokEditor.tsx +0 -201
- package/src/BlokView.tsx +0 -106
- package/src/block-portal-registry.ts +0 -161
- package/src/blocks-snapshot.ts +0 -27
- package/src/blok-instance-context.ts +0 -35
- package/src/config-keys.ts +0 -72
- package/src/content-baseline-map.ts +0 -38
- package/src/createReactBlock.tsx +0 -1062
- package/src/createReactInlineTool.tsx +0 -406
- package/src/deep-equal.ts +0 -7
- package/src/holder-map.ts +0 -17
- package/src/index.ts +0 -62
- package/src/live-tool-config.ts +0 -154
- package/src/provide-blok.tsx +0 -70
- package/src/registry-map.ts +0 -21
- package/src/types.ts +0 -38
- package/src/useBlocks.ts +0 -119
- package/src/useBlok.ts +0 -908
- package/src/useBlokHandle.ts +0 -141
- package/src/useBlokReady.ts +0 -60
- package/src/useBlokView.ts +0 -70
- package/src/view-nodes-to-react.ts +0 -153
package/src/createReactBlock.tsx
DELETED
|
@@ -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
|
-
}
|