@bloklabs/react 1.16.0 → 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/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
- }