@bloklabs/react 1.16.1 → 2.1.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/useBlok.ts
DELETED
|
@@ -1,908 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useMemo, useContext, type DependencyList } from 'react';
|
|
2
|
-
import { Blok as BlokRuntime } from '@bloklabs/core';
|
|
3
|
-
import { setHolder, removeHolder } from './holder-map';
|
|
4
|
-
import { deepEqual } from './deep-equal';
|
|
5
|
-
import { BlokDefaultsContext, mergeBlokDefaults } from './provide-blok';
|
|
6
|
-
import {
|
|
7
|
-
createBlockPortalRegistry,
|
|
8
|
-
BLOK_PORTAL_REGISTRY_CONFIG_KEY,
|
|
9
|
-
BLOK_TOOL_NAME_CONFIG_KEY,
|
|
10
|
-
type BlockPortalRegistry,
|
|
11
|
-
} from './block-portal-registry';
|
|
12
|
-
import { setRegistry, removeRegistry } from './registry-map';
|
|
13
|
-
import { setContentBaseline, removeContentBaseline } from './content-baseline-map';
|
|
14
|
-
import { bindLiveToolConfigFunctions, buildLiveBlockConfig } from './live-tool-config';
|
|
15
|
-
import {
|
|
16
|
-
createEmittedEchoWindow,
|
|
17
|
-
equalsOutputData,
|
|
18
|
-
normalizeReadOnlyConfig,
|
|
19
|
-
toRenderableData,
|
|
20
|
-
} from '@bloklabs/core/adapters';
|
|
21
|
-
import type { Blok, LiveHandlers } from '@/types';
|
|
22
|
-
import type { UseBlokConfig } from './types';
|
|
23
|
-
|
|
24
|
-
interface EditorInstanceState {
|
|
25
|
-
editor: Blok | null;
|
|
26
|
-
holder: HTMLDivElement | null;
|
|
27
|
-
destroyTimeout: ReturnType<typeof setTimeout> | null;
|
|
28
|
-
isDestroyed: boolean;
|
|
29
|
-
/** True once `editor.isReady` has resolved and its API surface is attached. */
|
|
30
|
-
isEditorReady: boolean;
|
|
31
|
-
/** Opaque token identifying which deps cycle created this editor */
|
|
32
|
-
depsToken: Record<string, unknown> | null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Warning text for a controlled `data` change the adapter refuses to render
|
|
37
|
-
* under collaboration. Kept verbatim in all three adapters (they share no code).
|
|
38
|
-
* @param doc - the collaboration document id, for the reset endpoint in the text
|
|
39
|
-
* @returns the message to warn with, once per adapter instance
|
|
40
|
-
*/
|
|
41
|
-
const collaborationDataIgnoredMessage = (doc?: string): string =>
|
|
42
|
-
'[Blok] `data` is ignored while collaboration is on: the document lives on the sync service ' +
|
|
43
|
-
'and is shared with everyone editing it, so replacing it from this editor would overwrite their work. ' +
|
|
44
|
-
`To replace the whole document, call POST /sync/${doc ?? '{doc}'}/reset on your server — ` +
|
|
45
|
-
'it reloads the document from your own document endpoint and every open editor picks it up. ' +
|
|
46
|
-
'To change part of it, use the blocks API (insert/update/delete).';
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Inject the editor's portal registry into every `createReactBlock` tool's
|
|
50
|
-
* config (vanilla tools are left untouched), returning a NEW tools object so
|
|
51
|
-
* the consumer's config is never mutated. A react-block tool is constructed by
|
|
52
|
-
* CORE, outside any React render, so it cannot read context — this config
|
|
53
|
-
* bridge is how the tool reaches its editor-scoped registry.
|
|
54
|
-
*/
|
|
55
|
-
const isReactBlockEntry = (entry: unknown): boolean => {
|
|
56
|
-
const toolClass = typeof entry === 'function' ? entry : (entry as { class?: unknown })?.class;
|
|
57
|
-
|
|
58
|
-
return (
|
|
59
|
-
typeof toolClass === 'function' &&
|
|
60
|
-
(toolClass as { __isBlokReactBlock?: boolean }).__isBlokReactBlock === true
|
|
61
|
-
);
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
/** React inline tools (createReactInlineTool) need the same registry bridge. */
|
|
65
|
-
const isReactInlineToolEntry = (entry: unknown): boolean => {
|
|
66
|
-
const toolClass = typeof entry === 'function' ? entry : (entry as { class?: unknown })?.class;
|
|
67
|
-
|
|
68
|
-
return (
|
|
69
|
-
typeof toolClass === 'function' &&
|
|
70
|
-
(toolClass as { __isBlokReactInlineTool?: boolean }).__isBlokReactInlineTool === true
|
|
71
|
-
);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): unknown => {
|
|
75
|
-
if (tools === null || typeof tools !== 'object') {
|
|
76
|
-
return tools;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const result: Record<string, unknown> = {};
|
|
80
|
-
|
|
81
|
-
for (const [name, entry] of Object.entries(tools as Record<string, unknown>)) {
|
|
82
|
-
if (!isReactBlockEntry(entry) && !isReactInlineToolEntry(entry)) {
|
|
83
|
-
result[name] = entry;
|
|
84
|
-
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const base: Record<string, unknown> =
|
|
89
|
-
typeof entry === 'function' ? { class: entry } : { ...(entry as Record<string, unknown>) };
|
|
90
|
-
|
|
91
|
-
base.config = {
|
|
92
|
-
...((base.config) ?? {}),
|
|
93
|
-
[BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry,
|
|
94
|
-
[BLOK_TOOL_NAME_CONFIG_KEY]: name,
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
// A React block tool whose toolbox title is a ReactElement needs a live
|
|
98
|
-
// portal that only the editor's registry can host. Build a per-editor
|
|
99
|
-
// toolbox override (element-title hosts registered now) and let core's
|
|
100
|
-
// per-tool `toolbox` setting carry it. Undefined = no ReactElement titles,
|
|
101
|
-
// so the static string/`toolNames.*` path is left untouched.
|
|
102
|
-
const buildPortalToolbox = (base.class as {
|
|
103
|
-
__buildPortalToolbox?: (r: BlockPortalRegistry, n: string) => unknown;
|
|
104
|
-
}).__buildPortalToolbox;
|
|
105
|
-
|
|
106
|
-
const portalToolbox = typeof buildPortalToolbox === 'function' && base.toolbox === undefined
|
|
107
|
-
? buildPortalToolbox(registry, name)
|
|
108
|
-
: undefined;
|
|
109
|
-
|
|
110
|
-
if (portalToolbox !== undefined) {
|
|
111
|
-
base.toolbox = portalToolbox;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
result[name] = base;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return result;
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* React hook that manages a Blok editor instance lifecycle.
|
|
122
|
-
*
|
|
123
|
-
* Creates a detached holder div, instantiates Blok, and returns the instance
|
|
124
|
-
* once `isReady` resolves. Handles StrictMode double-mount by deferring
|
|
125
|
-
* destroy via `setTimeout(0)` and cancelling on remount.
|
|
126
|
-
*
|
|
127
|
-
* @param config - Blok configuration without `holder` (managed internally)
|
|
128
|
-
* @param deps - Optional dependency array; when values change, the editor is recreated
|
|
129
|
-
* @returns The Blok instance once ready, or null during initialization / SSR
|
|
130
|
-
*/
|
|
131
|
-
export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok | null {
|
|
132
|
-
// App-wide defaults from the nearest <BlokProvider> are merged UNDER the
|
|
133
|
-
// per-instance config (instance wins; tools registries merge). When no
|
|
134
|
-
// provider is present this returns `configInput` unchanged.
|
|
135
|
-
const defaults = useContext(BlokDefaultsContext);
|
|
136
|
-
const config = mergeBlokDefaults(defaults, configInput);
|
|
137
|
-
|
|
138
|
-
const [editor, setEditor] = useState<Blok | null>(null);
|
|
139
|
-
const configRef = useRef(config);
|
|
140
|
-
configRef.current = config;
|
|
141
|
-
|
|
142
|
-
const stateRef = useRef<EditorInstanceState>({
|
|
143
|
-
editor: null,
|
|
144
|
-
holder: null,
|
|
145
|
-
destroyTimeout: null,
|
|
146
|
-
isDestroyed: false,
|
|
147
|
-
isEditorReady: false,
|
|
148
|
-
depsToken: null,
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
// A new token is created only when deps change (useMemo compares deps).
|
|
152
|
-
// StrictMode re-runs see the SAME token. Deps changes produce a NEW token.
|
|
153
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
154
|
-
const depsToken = useMemo(() => ({}), deps ?? []);
|
|
155
|
-
|
|
156
|
-
// Tracks the data the editor currently reflects — set when content is seeded
|
|
157
|
-
// or rendered (the reactive `data` effect below), when the editor emits its
|
|
158
|
-
// own serialized content via `onSave`, and when an IMPERATIVE call changed the
|
|
159
|
-
// content out of band (the content-baseline channel registered below). The
|
|
160
|
-
// `onSave` writer is what makes a controlled `onSave -> setData -> data`
|
|
161
|
-
// round-trip a no-op: the echoed payload content-equals this baseline, so the
|
|
162
|
-
// data effect skips the redundant render() (which would otherwise reset the
|
|
163
|
-
// caret). Declared here so the `onSave` wrapper below can update it before the
|
|
164
|
-
// consumer's setState re-runs the data effect. `undefined` means "nothing
|
|
165
|
-
// recorded yet" — never an empty document.
|
|
166
|
-
const lastRenderedDataRef = useRef(config.data);
|
|
167
|
-
// Window of recently emitted `onSave` payloads. The baseline above only
|
|
168
|
-
// remembers the LAST one, but a host that persists on save and refetches can
|
|
169
|
-
// echo an EARLIER save after a newer one replaced the baseline (the user kept
|
|
170
|
-
// typing). Such a stale echo is still the editor's own output — re-rendering
|
|
171
|
-
// it would clobber the caret and the content typed since — so the data effect
|
|
172
|
-
// treats a match against ANY windowed payload as a no-op.
|
|
173
|
-
const echoWindowRef = useRef(createEmittedEchoWindow());
|
|
174
|
-
const seededEditorRef = useRef<Blok | null>(null);
|
|
175
|
-
// The `data` the live editor was actually constructed with. The seed gate in
|
|
176
|
-
// the reactive-`data` effect compares the current prop against THIS (not mere
|
|
177
|
-
// editor identity) so a prop that diverged from the construction value —
|
|
178
|
-
// undefined-at-mount then loaded, or changed before the editor finished
|
|
179
|
-
// initializing — still renders instead of being mistaken for the seed.
|
|
180
|
-
const constructedDataRef = useRef(config.data);
|
|
181
|
-
// The `collaboration` the live editor was CONSTRUCTED with. The key is
|
|
182
|
-
// mount-fixed, so dropping it from the prop (`collaboration: on ? {...} :
|
|
183
|
-
// undefined`) cannot turn a live session into a single-player editor —
|
|
184
|
-
// reading the current prop instead would let that change fall through into
|
|
185
|
-
// render(), which refuses under collaboration and rejects with nothing
|
|
186
|
-
// surfaced anywhere.
|
|
187
|
-
const constructedCollaborationRef = useRef(config.collaboration);
|
|
188
|
-
const renderChainRef = useRef<Promise<void>>(Promise.resolve());
|
|
189
|
-
// Editor the single collaboration warning has already been emitted for (see
|
|
190
|
-
// the data effect). Keyed by the editor, not a boolean, so the editor a `deps`
|
|
191
|
-
// change creates warns on its own first ignored `data` change instead of
|
|
192
|
-
// inheriting the previous editor's "already warned" flag and going silent.
|
|
193
|
-
const collaborationWarnedForRef = useRef<Blok | null>(null);
|
|
194
|
-
// The content of the render currently queued/in-flight (distinct from
|
|
195
|
-
// `lastRenderedDataRef`, which tracks the last SUCCESSFULLY rendered content).
|
|
196
|
-
// Used to dedupe a re-queue of the same in-flight content without advancing the
|
|
197
|
-
// success baseline — so a failed render can't strand the baseline on content
|
|
198
|
-
// the editor never actually showed.
|
|
199
|
-
const pendingDataRef = useRef<UseBlokConfig['data']>(undefined);
|
|
200
|
-
// Live block-config channel (see the sync effect below): the current editor's
|
|
201
|
-
// portal registry, its construction-time wrapped tools (stable function
|
|
202
|
-
// wrappers), and the last config pushed per tool name (dedupe baseline).
|
|
203
|
-
const liveRegistryRef = useRef<BlockPortalRegistry | null>(null);
|
|
204
|
-
const wrappedToolsRef = useRef<unknown>(undefined);
|
|
205
|
-
const pushedBlockConfigsRef = useRef<Map<string, Record<string, unknown>>>(new Map());
|
|
206
|
-
// Last-applied tool-level `toolbox` setting per tool name (dedupe baseline for
|
|
207
|
-
// the reactive toolbox effect below). Seeded from the construction-time tools.
|
|
208
|
-
const appliedToolboxRef = useRef<Map<string, unknown>>(new Map());
|
|
209
|
-
// Live callback channel (see the handler-presence effect below): the current
|
|
210
|
-
// editor's stable per-handler wrappers (each forwards to the LATEST render's
|
|
211
|
-
// prop through `configRef`, so their identity never changes) and the presence
|
|
212
|
-
// actually installed on that editor — the dedupe baseline.
|
|
213
|
-
const handlerWrappersRef = useRef<LiveHandlers | null>(null);
|
|
214
|
-
const appliedHandlerPresenceRef = useRef<Record<keyof LiveHandlers, boolean>>({
|
|
215
|
-
onChange: false,
|
|
216
|
-
onSave: false,
|
|
217
|
-
onEnter: false,
|
|
218
|
-
onSubmit: false,
|
|
219
|
-
onBeforeRender: false,
|
|
220
|
-
onAfterRender: false,
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
// Main lifecycle effect
|
|
224
|
-
useEffect(() => {
|
|
225
|
-
if (typeof window === 'undefined') {
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const state = stateRef.current;
|
|
230
|
-
|
|
231
|
-
// Cancel pending deferred destroy (StrictMode remount)
|
|
232
|
-
if (state.destroyTimeout !== null) {
|
|
233
|
-
clearTimeout(state.destroyTimeout);
|
|
234
|
-
state.destroyTimeout = null;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Reuse editor on StrictMode remount (same deps cycle). The construction
|
|
238
|
-
// effect below only calls setEditor(blok) once `blok.isReady` resolves and
|
|
239
|
-
// exportAPI() has attached the full instance API (readOnly, focus, etc.).
|
|
240
|
-
// StrictMode's synchronous cleanup+remount cycle happens well before that
|
|
241
|
-
// promise can settle, so publishing `state.editor` here unconditionally
|
|
242
|
-
// exposed a half-constructed instance to every reactive effect (e.g. the
|
|
243
|
-
// readOnly sync effect calling `.readOnly.set(...)` on an instance whose
|
|
244
|
-
// API surface wasn't attached yet, throwing). Only reuse it into state
|
|
245
|
-
// once it's actually known-ready — otherwise leave `editor` as-is and let
|
|
246
|
-
// the still-pending `isReady.then()` from the original construction (never
|
|
247
|
-
// torn down, since only its destroy was deferred) publish it for real.
|
|
248
|
-
if (state.editor !== null && !state.isDestroyed && state.depsToken === depsToken) {
|
|
249
|
-
if (state.isEditorReady) {
|
|
250
|
-
setEditor(state.editor);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return (): void => {
|
|
254
|
-
deferDestroy(state, setEditor);
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Destroy leftover editor from a previous deps cycle
|
|
259
|
-
if (state.editor !== null && !state.isDestroyed) {
|
|
260
|
-
removeHolder(state.editor);
|
|
261
|
-
removeRegistry(state.editor);
|
|
262
|
-
removeContentBaseline(state.editor);
|
|
263
|
-
try {
|
|
264
|
-
state.editor.destroy();
|
|
265
|
-
} catch {
|
|
266
|
-
// destroy may throw — still clean up state
|
|
267
|
-
}
|
|
268
|
-
state.editor = null;
|
|
269
|
-
state.holder = null;
|
|
270
|
-
state.isDestroyed = true;
|
|
271
|
-
state.isEditorReady = false;
|
|
272
|
-
setEditor(null);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Create detached holder
|
|
276
|
-
const holder = document.createElement('div');
|
|
277
|
-
state.holder = holder;
|
|
278
|
-
state.isDestroyed = false;
|
|
279
|
-
state.isEditorReady = false;
|
|
280
|
-
state.depsToken = depsToken;
|
|
281
|
-
|
|
282
|
-
// Wrap callbacks via ref so they never go stale
|
|
283
|
-
const currentConfig = configRef.current;
|
|
284
|
-
|
|
285
|
-
// Per-editor portal registry for `createReactBlock` tools: injected into
|
|
286
|
-
// each react-block tool's config (so the core-constructed tool can reach
|
|
287
|
-
// it) and associated with the editor below (so BlokContent can mount the
|
|
288
|
-
// shared portal host). Tool-config FUNCTIONS are first re-bound to stable
|
|
289
|
-
// wrappers that always call the latest render's closure — so consumers
|
|
290
|
-
// never need to freeze identities or recreate the editor to update them.
|
|
291
|
-
const registry = createBlockPortalRegistry();
|
|
292
|
-
const wrappedTools =
|
|
293
|
-
currentConfig.tools === undefined
|
|
294
|
-
? undefined
|
|
295
|
-
: bindLiveToolConfigFunctions(currentConfig.tools, () => configRef.current.tools);
|
|
296
|
-
const liveTools = wrappedTools === undefined ? undefined : injectPortalRegistry(wrappedTools, registry);
|
|
297
|
-
|
|
298
|
-
// Reset the live block-config channel for this editor: the sync effect below
|
|
299
|
-
// reads these to push changed non-function config VALUES to mounted blocks.
|
|
300
|
-
liveRegistryRef.current = registry;
|
|
301
|
-
wrappedToolsRef.current = wrappedTools;
|
|
302
|
-
pushedBlockConfigsRef.current = new Map();
|
|
303
|
-
appliedToolboxRef.current = new Map(
|
|
304
|
-
Object.entries((currentConfig.tools ?? {}) as Record<string, unknown>).map(([name, entry]) => [
|
|
305
|
-
name,
|
|
306
|
-
entry !== null && typeof entry === 'object' ? (entry as { toolbox?: unknown }).toolbox : undefined,
|
|
307
|
-
])
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
// Stable per-editor wrappers for the live callback config. Each forwards to
|
|
311
|
-
// the LATEST render's prop through `configRef`, so a consumer never has to
|
|
312
|
-
// freeze callback identities — and, because identity is fixed for the
|
|
313
|
-
// editor's life, only PRESENCE has to be synced afterwards (below).
|
|
314
|
-
const handlerWrappers: LiveHandlers = {
|
|
315
|
-
onChange: (...args: Parameters<NonNullable<UseBlokConfig['onChange']>>): void => {
|
|
316
|
-
configRef.current.onChange?.(...args);
|
|
317
|
-
},
|
|
318
|
-
onSave: (...args: Parameters<NonNullable<UseBlokConfig['onSave']>>): void => {
|
|
319
|
-
// Record the editor's own serialized output as the rendered baseline so a
|
|
320
|
-
// controlled consumer echoing it straight back into `data` is a no-op —
|
|
321
|
-
// no redundant render(), no caret reset, no round-trip recursion.
|
|
322
|
-
lastRenderedDataRef.current = args[0];
|
|
323
|
-
echoWindowRef.current.record(args[0]);
|
|
324
|
-
configRef.current.onSave?.(...args);
|
|
325
|
-
},
|
|
326
|
-
// Forward the return value: it is the "handled" signal the core acts on.
|
|
327
|
-
onEnter: (...args: Parameters<NonNullable<UseBlokConfig['onEnter']>>): boolean | void =>
|
|
328
|
-
configRef.current.onEnter?.(...args),
|
|
329
|
-
onSubmit: (...args: Parameters<NonNullable<UseBlokConfig['onSubmit']>>): void => {
|
|
330
|
-
configRef.current.onSubmit?.(...args);
|
|
331
|
-
},
|
|
332
|
-
onBeforeRender: (
|
|
333
|
-
...args: Parameters<NonNullable<UseBlokConfig['onBeforeRender']>>
|
|
334
|
-
): ReturnType<NonNullable<UseBlokConfig['onBeforeRender']>> =>
|
|
335
|
-
configRef.current.onBeforeRender?.(...args) ?? args[0],
|
|
336
|
-
onAfterRender: (...args: Parameters<NonNullable<UseBlokConfig['onAfterRender']>>): void => {
|
|
337
|
-
configRef.current.onAfterRender?.(...args);
|
|
338
|
-
},
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
handlerWrappersRef.current = handlerWrappers;
|
|
342
|
-
|
|
343
|
-
// Handler PRESENCE is load-bearing in core — an `onSubmit` turns Enter into
|
|
344
|
-
// serialize-and-submit, an `onSave` arms the change pipeline — so an absent
|
|
345
|
-
// prop must leave the key absent. Attach only the wrappers whose prop the
|
|
346
|
-
// consumer actually passed, and record what was installed so the effect
|
|
347
|
-
// below can push later presence flips instead of recreating the editor.
|
|
348
|
-
const handlerPresence: Record<keyof LiveHandlers, boolean> = {
|
|
349
|
-
onChange: currentConfig.onChange !== undefined,
|
|
350
|
-
onSave: currentConfig.onSave !== undefined,
|
|
351
|
-
onEnter: currentConfig.onEnter !== undefined,
|
|
352
|
-
onSubmit: currentConfig.onSubmit !== undefined,
|
|
353
|
-
onBeforeRender: currentConfig.onBeforeRender !== undefined,
|
|
354
|
-
onAfterRender: currentConfig.onAfterRender !== undefined,
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
appliedHandlerPresenceRef.current = handlerPresence;
|
|
358
|
-
|
|
359
|
-
const blokConfig = {
|
|
360
|
-
...currentConfig,
|
|
361
|
-
...(liveTools === undefined ? {} : { tools: liveTools as UseBlokConfig['tools'] }),
|
|
362
|
-
holder,
|
|
363
|
-
onReady: (...args: Parameters<NonNullable<UseBlokConfig['onReady']>>): void => {
|
|
364
|
-
configRef.current.onReady?.(...args);
|
|
365
|
-
},
|
|
366
|
-
...(handlerPresence.onChange ? { onChange: handlerWrappers.onChange } : {}),
|
|
367
|
-
...(handlerPresence.onSave ? { onSave: handlerWrappers.onSave } : {}),
|
|
368
|
-
...(handlerPresence.onEnter ? { onEnter: handlerWrappers.onEnter } : {}),
|
|
369
|
-
...(handlerPresence.onSubmit ? { onSubmit: handlerWrappers.onSubmit } : {}),
|
|
370
|
-
...(handlerPresence.onBeforeRender ? { onBeforeRender: handlerWrappers.onBeforeRender } : {}),
|
|
371
|
-
...(handlerPresence.onAfterRender ? { onAfterRender: handlerWrappers.onAfterRender } : {}),
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
const blok = new BlokRuntime(blokConfig);
|
|
375
|
-
state.editor = blok;
|
|
376
|
-
// Remember what this editor was seeded with so the reactive-`data` effect can
|
|
377
|
-
// tell a genuine post-construction change from the construction value itself.
|
|
378
|
-
constructedDataRef.current = currentConfig.data;
|
|
379
|
-
constructedCollaborationRef.current = currentConfig.collaboration;
|
|
380
|
-
setHolder(blok, holder);
|
|
381
|
-
setRegistry(blok, registry);
|
|
382
|
-
// Imperative-content channel: `useBlokHandle().clear()` / `.render()` change
|
|
383
|
-
// the content OUT OF BAND of the reactive `data` effect below. They report
|
|
384
|
-
// the new content here so the dedupe baseline and the emitted-echo window
|
|
385
|
-
// (both read by that effect) describe the editor as it actually is —
|
|
386
|
-
// otherwise restoring a document the editor itself emitted is dismissed as
|
|
387
|
-
// an echo and never rendered.
|
|
388
|
-
setContentBaseline(blok, {
|
|
389
|
-
markRendered: (content): void => {
|
|
390
|
-
echoWindowRef.current.clear();
|
|
391
|
-
lastRenderedDataRef.current = content;
|
|
392
|
-
pendingDataRef.current = undefined;
|
|
393
|
-
},
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
void blok.isReady
|
|
397
|
-
.then(() => {
|
|
398
|
-
if (state.editor === blok && !state.isDestroyed) {
|
|
399
|
-
state.isEditorReady = true;
|
|
400
|
-
setEditor(blok);
|
|
401
|
-
}
|
|
402
|
-
})
|
|
403
|
-
.catch(() => {
|
|
404
|
-
if (state.editor === blok && !state.isDestroyed) {
|
|
405
|
-
removeHolder(blok);
|
|
406
|
-
removeRegistry(blok);
|
|
407
|
-
removeContentBaseline(blok);
|
|
408
|
-
try {
|
|
409
|
-
blok.destroy();
|
|
410
|
-
} catch {
|
|
411
|
-
// destroy may also throw — still clean up state
|
|
412
|
-
}
|
|
413
|
-
state.editor = null;
|
|
414
|
-
state.holder = null;
|
|
415
|
-
state.isDestroyed = true;
|
|
416
|
-
state.isEditorReady = false;
|
|
417
|
-
setEditor(null);
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
return (): void => {
|
|
422
|
-
deferDestroy(state, setEditor);
|
|
423
|
-
};
|
|
424
|
-
}, [depsToken]);
|
|
425
|
-
|
|
426
|
-
// Live tool-config VALUES for react blocks. Function slots are already live
|
|
427
|
-
// via the stable wrappers, but non-function values (permissions, URLs,
|
|
428
|
-
// locale…) were snapshotted at construction — which forced consumers to put
|
|
429
|
-
// them in `deps` and recreate the whole editor to change them. Instead, this
|
|
430
|
-
// effect runs on every render, rebuilds each react-block tool's
|
|
431
|
-
// component-facing config from the LATEST render's values (keeping wrapper
|
|
432
|
-
// identities for functions), and pushes it through the portal registry when
|
|
433
|
-
// it genuinely changed — mounted blocks re-render in place, future blocks
|
|
434
|
-
// start from the latest config.
|
|
435
|
-
useEffect(() => {
|
|
436
|
-
const registry = liveRegistryRef.current;
|
|
437
|
-
const state = stateRef.current;
|
|
438
|
-
|
|
439
|
-
if (registry === null || state.editor === null || state.isDestroyed) {
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const latestTools = configRef.current.tools as Record<string, unknown> | undefined;
|
|
444
|
-
|
|
445
|
-
if (latestTools === null || typeof latestTools !== 'object') {
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const wrappedTools = wrappedToolsRef.current as Record<string, unknown> | undefined;
|
|
450
|
-
|
|
451
|
-
for (const [name, entry] of Object.entries(latestTools)) {
|
|
452
|
-
if (!isReactBlockEntry(entry)) {
|
|
453
|
-
continue;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
const latestConfig =
|
|
457
|
-
typeof entry === 'function' ? {} : ((entry as { config?: unknown }).config ?? {});
|
|
458
|
-
const wrappedEntry = wrappedTools?.[name];
|
|
459
|
-
const wrappedConfig =
|
|
460
|
-
wrappedEntry !== null && typeof wrappedEntry === 'object'
|
|
461
|
-
? (wrappedEntry as { config?: unknown }).config
|
|
462
|
-
: undefined;
|
|
463
|
-
|
|
464
|
-
const candidate = buildLiveBlockConfig(wrappedConfig, latestConfig);
|
|
465
|
-
const pushed = pushedBlockConfigsRef.current.get(name);
|
|
466
|
-
|
|
467
|
-
if (pushed !== undefined && deepEqual(candidate, pushed)) {
|
|
468
|
-
continue;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
const frozen = Object.freeze(candidate);
|
|
472
|
-
|
|
473
|
-
pushedBlockConfigsRef.current.set(name, frozen);
|
|
474
|
-
registry.setToolConfig(name, frozen);
|
|
475
|
-
}
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
// Live tool-level `toolbox` setting. Toolbox membership was construction-only,
|
|
479
|
-
// which forced permission-style gating (`toolbox: false` for users who may not
|
|
480
|
-
// insert a tool) into `deps` — recreating the whole editor on a permission
|
|
481
|
-
// flip. Instead, diff each tool's `toolbox` setting against the last applied
|
|
482
|
-
// value on every render and push genuine changes through the runtime
|
|
483
|
-
// `tools.update` API (which reaches the live adapter and rebuilds the Toolbox
|
|
484
|
-
// UI). Applies only once the editor is ready — module APIs attach at isReady —
|
|
485
|
-
// and the ready transition re-renders, so a change made earlier is not lost.
|
|
486
|
-
useEffect(() => {
|
|
487
|
-
const state = stateRef.current;
|
|
488
|
-
|
|
489
|
-
if (state.editor === null || state.isDestroyed || !state.isEditorReady) {
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
const latestTools = configRef.current.tools as Record<string, unknown> | undefined;
|
|
494
|
-
|
|
495
|
-
if (latestTools === null || typeof latestTools !== 'object') {
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
for (const [name, entry] of Object.entries(latestTools)) {
|
|
500
|
-
// Only tools the editor was constructed with can be updated at runtime.
|
|
501
|
-
if (!appliedToolboxRef.current.has(name)) {
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const latest =
|
|
506
|
-
entry !== null && typeof entry === 'object' ? (entry as { toolbox?: unknown }).toolbox : undefined;
|
|
507
|
-
const applied = appliedToolboxRef.current.get(name);
|
|
508
|
-
|
|
509
|
-
if (deepEqual(latest, applied)) {
|
|
510
|
-
continue;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
appliedToolboxRef.current.set(name, latest);
|
|
514
|
-
(state.editor as unknown as {
|
|
515
|
-
tools?: { update: (toolName: string, config: Record<string, unknown>) => void };
|
|
516
|
-
}).tools?.update(name, { toolbox: latest });
|
|
517
|
-
}
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
// Reactive: onChange
|
|
521
|
-
// Reactive: onSave
|
|
522
|
-
// Reactive: onEnter
|
|
523
|
-
// Reactive: onSubmit
|
|
524
|
-
// Reactive: onBeforeRender
|
|
525
|
-
// Reactive: onAfterRender
|
|
526
|
-
//
|
|
527
|
-
// Callback PRESENCE was frozen at construction, and in core the presence of a
|
|
528
|
-
// handler IS the semantics: an `onSubmit` makes Enter serialize-and-submit
|
|
529
|
-
// instead of splitting the block, an `onSave` arms the whole change-observation
|
|
530
|
-
// pipeline. A host whose "Enter sends" toggle (or `onSave` subscription) lives
|
|
531
|
-
// in React state therefore had to bump `deps` and destroy/rebuild the editor,
|
|
532
|
-
// losing caret and undo history. Instead, diff each handler's presence against
|
|
533
|
-
// what is installed and push genuine flips through the runtime `handlers.set`
|
|
534
|
-
// API — passing `undefined` for a prop that disappeared, which is what makes
|
|
535
|
-
// the change reversible rather than a one-way latch. Identity never changes
|
|
536
|
-
// (the wrappers forward through `configRef`), so only presence is diffed.
|
|
537
|
-
// Applies once the editor is ready — module APIs attach at isReady — and the
|
|
538
|
-
// ready transition re-renders, so a flip made earlier is not lost.
|
|
539
|
-
useEffect(() => {
|
|
540
|
-
const state = stateRef.current;
|
|
541
|
-
const wrappers = handlerWrappersRef.current;
|
|
542
|
-
|
|
543
|
-
if (state.editor === null || state.isDestroyed || !state.isEditorReady || wrappers === null) {
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const latest = configRef.current;
|
|
548
|
-
const applied = appliedHandlerPresenceRef.current;
|
|
549
|
-
const diff: LiveHandlers = {};
|
|
550
|
-
const changed: { value: boolean } = { value: false };
|
|
551
|
-
|
|
552
|
-
const sync = <K extends keyof LiveHandlers>(key: K, write: (value: LiveHandlers[K]) => void): void => {
|
|
553
|
-
const present = latest[key] !== undefined;
|
|
554
|
-
|
|
555
|
-
if (present === applied[key]) {
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
applied[key] = present;
|
|
560
|
-
changed.value = true;
|
|
561
|
-
write(present ? wrappers[key] : undefined);
|
|
562
|
-
};
|
|
563
|
-
|
|
564
|
-
sync('onChange', (value) => {
|
|
565
|
-
diff.onChange = value;
|
|
566
|
-
});
|
|
567
|
-
sync('onSave', (value) => {
|
|
568
|
-
diff.onSave = value;
|
|
569
|
-
});
|
|
570
|
-
sync('onEnter', (value) => {
|
|
571
|
-
diff.onEnter = value;
|
|
572
|
-
});
|
|
573
|
-
sync('onSubmit', (value) => {
|
|
574
|
-
diff.onSubmit = value;
|
|
575
|
-
});
|
|
576
|
-
sync('onBeforeRender', (value) => {
|
|
577
|
-
diff.onBeforeRender = value;
|
|
578
|
-
});
|
|
579
|
-
sync('onAfterRender', (value) => {
|
|
580
|
-
diff.onAfterRender = value;
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
if (changed.value) {
|
|
584
|
-
state.editor.handlers.set(diff);
|
|
585
|
-
}
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
// Reactive: readOnly (object form normalized so the effect deps are stable booleans).
|
|
589
|
-
// The object form additionally syncs `hideControls` through the options
|
|
590
|
-
// parameter of `readOnly.set` — a plain boolean prop keeps the historical
|
|
591
|
-
// boolean-only call so it never overwrites a construction-time object form.
|
|
592
|
-
const readOnlyIsObjectForm = typeof config.readOnly === 'object' && config.readOnly !== null;
|
|
593
|
-
const { enabled: readOnlyEnabled, hideControls: readOnlyHideControls } = normalizeReadOnlyConfig(config.readOnly);
|
|
594
|
-
useEffect(() => {
|
|
595
|
-
if (editor === null) {
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
if (readOnlyIsObjectForm) {
|
|
600
|
-
void editor.readOnly.set(readOnlyEnabled, { hideControls: readOnlyHideControls });
|
|
601
|
-
} else {
|
|
602
|
-
void editor.readOnly.set(readOnlyEnabled);
|
|
603
|
-
}
|
|
604
|
-
}, [editor, readOnlyEnabled, readOnlyHideControls, readOnlyIsObjectForm]);
|
|
605
|
-
|
|
606
|
-
// Reactive: hideToolbar — `toolbar.setHidden` writes `config.hideToolbar`
|
|
607
|
-
// (read live by the toolbar's open guards) AND toggles the wrapper's
|
|
608
|
-
// toolbar-hidden attribute (the CSS hook that collapses the gutter), so a
|
|
609
|
-
// prop flip takes effect in place. Guarded on `undefined` so a consumer who
|
|
610
|
-
// never sets the prop gets no call at all (mirrors theme/width).
|
|
611
|
-
const { hideToolbar } = config;
|
|
612
|
-
useEffect(() => {
|
|
613
|
-
if (editor === null || hideToolbar === undefined) {
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
editor.toolbar.setHidden(hideToolbar);
|
|
617
|
-
}, [editor, hideToolbar]);
|
|
618
|
-
|
|
619
|
-
// Reactive: toolbarPosition — `toolbar.setPosition` writes
|
|
620
|
-
// `config.toolbarPosition` (read live by the layout paths CSS cannot mirror)
|
|
621
|
-
// AND re-stamps the wrapper's toolbar-position attribute (the CSS hook that
|
|
622
|
-
// swaps the gutter and the actions bar), so a prop flip moves the controls in
|
|
623
|
-
// place. Guarded on `undefined` so a consumer who never sets the prop gets no
|
|
624
|
-
// call at all (mirrors hideToolbar).
|
|
625
|
-
const { toolbarPosition } = config;
|
|
626
|
-
useEffect(() => {
|
|
627
|
-
if (editor === null || toolbarPosition === undefined) {
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
editor.toolbar.setPosition(toolbarPosition);
|
|
631
|
-
}, [editor, toolbarPosition]);
|
|
632
|
-
|
|
633
|
-
// Reactive: inlineToolbar — `tools.setInlineToolbar` re-assigns every block
|
|
634
|
-
// tool's inline set and invalidates sanitize caches, so the change lands on
|
|
635
|
-
// the next selection without a re-render. The dep is a content serialization
|
|
636
|
-
// (boolean | string[]) so a re-created array literal with the same entries
|
|
637
|
-
// does not re-fire the setter.
|
|
638
|
-
const { inlineToolbar } = config;
|
|
639
|
-
const inlineToolbarKey = inlineToolbar === undefined ? undefined : JSON.stringify(inlineToolbar);
|
|
640
|
-
useEffect(() => {
|
|
641
|
-
if (editor === null || inlineToolbar === undefined) {
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
editor.tools.setInlineToolbar(inlineToolbar);
|
|
645
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- inlineToolbar is deliberately depped by CONTENT (inlineToolbarKey) so a re-created array literal with the same entries does not thrash the setter
|
|
646
|
-
}, [editor, inlineToolbarKey]);
|
|
647
|
-
|
|
648
|
-
// Reactive: autofocus
|
|
649
|
-
const { autofocus } = config;
|
|
650
|
-
useEffect(() => {
|
|
651
|
-
if (editor === null || !autofocus) {
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
editor.focus();
|
|
655
|
-
}, [editor, autofocus]);
|
|
656
|
-
|
|
657
|
-
// Reactive: theme
|
|
658
|
-
const { theme } = config;
|
|
659
|
-
useEffect(() => {
|
|
660
|
-
if (editor === null || theme === undefined) {
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
editor.theme.set(theme);
|
|
664
|
-
}, [editor, theme]);
|
|
665
|
-
|
|
666
|
-
// Reactive: width
|
|
667
|
-
const { width } = config;
|
|
668
|
-
useEffect(() => {
|
|
669
|
-
if (editor === null || width === undefined) {
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
672
|
-
editor.width.set(width);
|
|
673
|
-
}, [editor, width]);
|
|
674
|
-
|
|
675
|
-
// Reactive: style.tokens
|
|
676
|
-
//
|
|
677
|
-
// Theme tokens were construction-only, so a host with a live light/dark
|
|
678
|
-
// toggle had to bump `deps` (destroying the editor, losing caret/history) or
|
|
679
|
-
// hand-write the global stylesheet Blok already injects. Push genuine changes
|
|
680
|
-
// through the runtime `tokens` API instead, deep-equal–deduped because
|
|
681
|
-
// `tokens` is almost always a fresh object literal each render.
|
|
682
|
-
const styleTokens = config.style?.tokens;
|
|
683
|
-
/*
|
|
684
|
-
* Seeded with the mount-time value: construction already injected those
|
|
685
|
-
* tokens, so the first effect run is a no-op and only genuine changes push.
|
|
686
|
-
*/
|
|
687
|
-
const appliedTokensRef = useRef<Record<string, string> | undefined>(config.style?.tokens);
|
|
688
|
-
|
|
689
|
-
useEffect(() => {
|
|
690
|
-
if (editor === null || styleTokens === undefined) {
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
if (deepEqual(styleTokens, appliedTokensRef.current)) {
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
appliedTokensRef.current = styleTokens;
|
|
699
|
-
editor.tokens.set(styleTokens);
|
|
700
|
-
}, [editor, styleTokens]);
|
|
701
|
-
|
|
702
|
-
// Reactive: i18n
|
|
703
|
-
//
|
|
704
|
-
// `config.i18n` was read once during the editor's boot and never again, so a
|
|
705
|
-
// host driving a language switcher from React state had no correct option:
|
|
706
|
-
// accept a permanently stale UI, or bump `deps` and destroy the editor
|
|
707
|
-
// mid-typing. Push genuine changes through the runtime `i18n.update` API
|
|
708
|
-
// instead, deep-equal–deduped because `i18n` is almost always a fresh object
|
|
709
|
-
// literal each render (a `useMemo`d message map re-created on locale change
|
|
710
|
-
// is the whole point of the prop).
|
|
711
|
-
//
|
|
712
|
-
// `defaultLocale` is deliberately not forwarded: it only decides the
|
|
713
|
-
// fallback while resolving the INITIAL locale, so it is inert after mount.
|
|
714
|
-
const { i18n } = config;
|
|
715
|
-
/*
|
|
716
|
-
* Seeded with the mount-time value: construction already applied it, so the
|
|
717
|
-
* first effect run is a no-op and only genuine changes push.
|
|
718
|
-
*/
|
|
719
|
-
const appliedI18nRef = useRef<UseBlokConfig['i18n']>(config.i18n);
|
|
720
|
-
|
|
721
|
-
useEffect(() => {
|
|
722
|
-
const previous = appliedI18nRef.current;
|
|
723
|
-
|
|
724
|
-
if (editor === null || (i18n === undefined && previous?.direction === undefined)) {
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
if (deepEqual(i18n, previous)) {
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
appliedI18nRef.current = i18n;
|
|
733
|
-
|
|
734
|
-
const { locale, messages, direction } = i18n ?? {};
|
|
735
|
-
// A dropped direction must be cleared, or the editor keeps the old one.
|
|
736
|
-
const nextDirection = direction ?? (previous?.direction === undefined ? undefined : null);
|
|
737
|
-
|
|
738
|
-
void editor.i18n.update({
|
|
739
|
-
...(locale === undefined ? {} : { locale }),
|
|
740
|
-
...(messages === undefined ? {} : { messages }),
|
|
741
|
-
...(nextDirection === undefined ? {} : { direction: nextDirection }),
|
|
742
|
-
});
|
|
743
|
-
}, [editor, i18n]);
|
|
744
|
-
|
|
745
|
-
// Reactive: placeholder
|
|
746
|
-
const { placeholder } = config;
|
|
747
|
-
useEffect(() => {
|
|
748
|
-
if (editor === null || placeholder === undefined) {
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
editor.placeholder.set(placeholder);
|
|
752
|
-
}, [editor, placeholder]);
|
|
753
|
-
|
|
754
|
-
// Reactive: data (controlled content)
|
|
755
|
-
//
|
|
756
|
-
// `data` seeds the editor at construction. Afterwards, changing the prop to
|
|
757
|
-
// new *content* re-renders the editor via the public render() API — no
|
|
758
|
-
// recreation. Updates are content-deduped against the editor's current
|
|
759
|
-
// baseline (`lastRenderedDataRef`, declared above and also updated by the
|
|
760
|
-
// `onSave` wrapper and the imperative content-baseline channel) so a new
|
|
761
|
-
// reference with the same content — including the editor's own serialized
|
|
762
|
-
// output echoed back, however the host reshaped it in transit — is a no-op and
|
|
763
|
-
// won't clobber the caret. Renders are serialized so rapid changes can't overlap.
|
|
764
|
-
const { data } = config;
|
|
765
|
-
useEffect(() => {
|
|
766
|
-
if (editor === null || data === undefined) {
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// The lifecycle effect runs BEFORE this one, so a commit that changes `deps`
|
|
771
|
-
// AND `data` together has already swapped `state.editor` to the freshly
|
|
772
|
-
// constructed instance (and may have destroyed the old one) by the time this
|
|
773
|
-
// effect fires with the PREVIOUS render's `editor` closure. Rendering through
|
|
774
|
-
// that stale handle would target a destroyed editor — and the new instance is
|
|
775
|
-
// already seeded with the new `data` at construction. Skip unless this closure
|
|
776
|
-
// still owns the live editor, mirroring the isReady guard above.
|
|
777
|
-
const state = stateRef.current;
|
|
778
|
-
|
|
779
|
-
if (state.editor !== editor || state.isDestroyed) {
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// First time observing this editor: it was seeded at construction with
|
|
784
|
-
// `constructedDataRef`. When the current prop still matches that seed, just
|
|
785
|
-
// record the baseline and skip the redundant render. When it has diverged
|
|
786
|
-
// (undefined-at-mount then loaded, or changed before the editor finished
|
|
787
|
-
// initializing), fall through and render so the editor reflects the prop.
|
|
788
|
-
if (seededEditorRef.current !== editor) {
|
|
789
|
-
seededEditorRef.current = editor;
|
|
790
|
-
// A render still pending on a PRIOR editor is moot for this fresh instance;
|
|
791
|
-
// clear it so its content can't dedupe a needed render on the new editor.
|
|
792
|
-
pendingDataRef.current = undefined;
|
|
793
|
-
// Payloads emitted by a PRIOR editor are likewise moot for this one.
|
|
794
|
-
echoWindowRef.current.clear();
|
|
795
|
-
|
|
796
|
-
if (deepEqual(data, constructedDataRef.current)) {
|
|
797
|
-
lastRenderedDataRef.current = data;
|
|
798
|
-
|
|
799
|
-
return;
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// Skip when the editor already reflects this content (last SUCCESSFUL render)
|
|
804
|
-
// OR a render of the same content is already queued/in-flight — re-rendering
|
|
805
|
-
// identical content would needlessly reset the caret.
|
|
806
|
-
//
|
|
807
|
-
// Compared with the SAME structural lens the echo window uses
|
|
808
|
-
// (`equalsOutputData`, not a raw deep-equal): a host that persists the
|
|
809
|
-
// editor's own document and hands back a stripped copy — fresh `time`,
|
|
810
|
-
// dropped ids, no `lastEditedAt` stamp — is still echoing content the editor
|
|
811
|
-
// already shows. `undefined` means "nothing recorded yet", which is NOT an
|
|
812
|
-
// empty document, so it must never match.
|
|
813
|
-
const baseline = lastRenderedDataRef.current;
|
|
814
|
-
const inFlight = pendingDataRef.current;
|
|
815
|
-
|
|
816
|
-
if (
|
|
817
|
-
(baseline !== undefined && equalsOutputData(data, baseline)) ||
|
|
818
|
-
(inFlight !== undefined && equalsOutputData(data, inFlight))
|
|
819
|
-
) {
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
// Skip stale echoes: content the editor itself emitted via `onSave` recently,
|
|
824
|
-
// arriving back reshaped (fresh envelope, stripped ids) and/or late — after a
|
|
825
|
-
// newer save already replaced the baseline above.
|
|
826
|
-
if (echoWindowRef.current.matches(data)) {
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// Under collaboration the document belongs to the sync service, and render()
|
|
831
|
-
// refuses a wholesale replace — so don't attempt one. The prop's premise
|
|
832
|
-
// ("this value IS the document") does not hold here. Decided against the
|
|
833
|
-
// MOUNTED config, never the current prop: `collaboration` is mount-fixed,
|
|
834
|
-
// so a host that drops the key is still driving a collaboration session and
|
|
835
|
-
// deserves the warning rather than a silently rejected render.
|
|
836
|
-
const mountedCollaboration = constructedCollaborationRef.current;
|
|
837
|
-
|
|
838
|
-
if (mountedCollaboration !== undefined) {
|
|
839
|
-
if (collaborationWarnedForRef.current !== editor) {
|
|
840
|
-
collaborationWarnedForRef.current = editor;
|
|
841
|
-
console.warn(collaborationDataIgnoredMessage(mountedCollaboration.doc));
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
// Genuinely external content takes over: earlier emitted payloads are moot,
|
|
848
|
-
// and keeping them could wrongly no-op a later deliberate revert to one.
|
|
849
|
-
echoWindowRef.current.clear();
|
|
850
|
-
|
|
851
|
-
// Mark the content in-flight, but DON'T advance the success baseline until the
|
|
852
|
-
// render actually resolves. A failed render leaves the editor on its last
|
|
853
|
-
// successful content, so the baseline must keep pointing there — otherwise a
|
|
854
|
-
// run of consecutive failures (e.g. successive malformed `data`) would strand
|
|
855
|
-
// the baseline on a never-rendered payload and wrongly dedupe a later retry.
|
|
856
|
-
pendingDataRef.current = data;
|
|
857
|
-
renderChainRef.current = renderChainRef.current
|
|
858
|
-
.catch(() => undefined)
|
|
859
|
-
// `data` may be null here (a controlled "clear to empty"); render() throws
|
|
860
|
-
// on null, so normalize null → { blocks: [] } at the boundary.
|
|
861
|
-
.then(() => editor.render(toRenderableData(data)))
|
|
862
|
-
.then(
|
|
863
|
-
() => {
|
|
864
|
-
lastRenderedDataRef.current = data;
|
|
865
|
-
},
|
|
866
|
-
() => {
|
|
867
|
-
// render failed: keep the last successful baseline so a later identical
|
|
868
|
-
// or corrected `data` re-renders instead of being deduped. Owning the
|
|
869
|
-
// rejection here also keeps it off the unhandled-rejection path.
|
|
870
|
-
}
|
|
871
|
-
)
|
|
872
|
-
.then(() => {
|
|
873
|
-
// Clear the in-flight marker once settled, unless a newer render has since
|
|
874
|
-
// claimed it (then that newer render owns the clear).
|
|
875
|
-
if (pendingDataRef.current === data) {
|
|
876
|
-
pendingDataRef.current = undefined;
|
|
877
|
-
}
|
|
878
|
-
});
|
|
879
|
-
}, [editor, data]);
|
|
880
|
-
|
|
881
|
-
return editor;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
function deferDestroy(
|
|
885
|
-
state: EditorInstanceState,
|
|
886
|
-
setEditorState: React.Dispatch<React.SetStateAction<Blok | null>>
|
|
887
|
-
): void {
|
|
888
|
-
/* eslint-disable no-param-reassign -- intentional mutation of shared state ref */
|
|
889
|
-
state.destroyTimeout = setTimeout(() => {
|
|
890
|
-
if (state.editor !== null) {
|
|
891
|
-
removeHolder(state.editor);
|
|
892
|
-
removeRegistry(state.editor);
|
|
893
|
-
removeContentBaseline(state.editor);
|
|
894
|
-
try {
|
|
895
|
-
state.editor.destroy();
|
|
896
|
-
} catch {
|
|
897
|
-
// destroy may throw — still clean up state
|
|
898
|
-
}
|
|
899
|
-
state.editor = null;
|
|
900
|
-
state.holder = null;
|
|
901
|
-
state.isDestroyed = true;
|
|
902
|
-
state.isEditorReady = false;
|
|
903
|
-
state.destroyTimeout = null;
|
|
904
|
-
setEditorState(null);
|
|
905
|
-
}
|
|
906
|
-
}, 0);
|
|
907
|
-
/* eslint-enable no-param-reassign */
|
|
908
|
-
}
|