@bloklabs/react 1.7.0 → 1.9.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 CHANGED
@@ -10,9 +10,15 @@ import {
10
10
  type BlockPortalRegistry,
11
11
  } from './block-portal-registry';
12
12
  import { setRegistry, removeRegistry } from './registry-map';
13
+ import { setContentBaseline, removeContentBaseline } from './content-baseline-map';
13
14
  import { bindLiveToolConfigFunctions, buildLiveBlockConfig } from './live-tool-config';
14
- import { createEmittedEchoWindow, normalizeReadOnlyConfig, toRenderableData } from '@bloklabs/core/adapters';
15
- import type { Blok } from '@/types';
15
+ import {
16
+ createEmittedEchoWindow,
17
+ equalsOutputData,
18
+ normalizeReadOnlyConfig,
19
+ toRenderableData,
20
+ } from '@bloklabs/core/adapters';
21
+ import type { Blok, LiveHandlers } from '@/types';
16
22
  import type { UseBlokConfig } from './types';
17
23
 
18
24
  interface EditorInstanceState {
@@ -135,12 +141,15 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
135
141
  const depsToken = useMemo(() => ({}), deps ?? []);
136
142
 
137
143
  // Tracks the data the editor currently reflects — set when content is seeded
138
- // or rendered (the reactive `data` effect below) AND when the editor emits its
139
- // own serialized content via `onSave`. The latter is what makes a controlled
140
- // `onSave -> setData -> data` round-trip a no-op: the echoed payload deep-equals
141
- // this baseline, so the data effect skips the redundant render() (which would
142
- // otherwise reset the caret). Declared here so the `onSave` wrapper below can
143
- // update it before the consumer's setState re-runs the data effect.
144
+ // or rendered (the reactive `data` effect below), when the editor emits its
145
+ // own serialized content via `onSave`, and when an IMPERATIVE call changed the
146
+ // content out of band (the content-baseline channel registered below). The
147
+ // `onSave` writer is what makes a controlled `onSave -> setData -> data`
148
+ // round-trip a no-op: the echoed payload content-equals this baseline, so the
149
+ // data effect skips the redundant render() (which would otherwise reset the
150
+ // caret). Declared here so the `onSave` wrapper below can update it before the
151
+ // consumer's setState re-runs the data effect. `undefined` means "nothing
152
+ // recorded yet" — never an empty document.
144
153
  const lastRenderedDataRef = useRef(config.data);
145
154
  // Window of recently emitted `onSave` payloads. The baseline above only
146
155
  // remembers the LAST one, but a host that persists on save and refetches can
@@ -172,6 +181,19 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
172
181
  // Last-applied tool-level `toolbox` setting per tool name (dedupe baseline for
173
182
  // the reactive toolbox effect below). Seeded from the construction-time tools.
174
183
  const appliedToolboxRef = useRef<Map<string, unknown>>(new Map());
184
+ // Live callback channel (see the handler-presence effect below): the current
185
+ // editor's stable per-handler wrappers (each forwards to the LATEST render's
186
+ // prop through `configRef`, so their identity never changes) and the presence
187
+ // actually installed on that editor — the dedupe baseline.
188
+ const handlerWrappersRef = useRef<LiveHandlers | null>(null);
189
+ const appliedHandlerPresenceRef = useRef<Record<keyof LiveHandlers, boolean>>({
190
+ onChange: false,
191
+ onSave: false,
192
+ onEnter: false,
193
+ onSubmit: false,
194
+ onBeforeRender: false,
195
+ onAfterRender: false,
196
+ });
175
197
 
176
198
  // Main lifecycle effect
177
199
  useEffect(() => {
@@ -212,6 +234,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
212
234
  if (state.editor !== null && !state.isDestroyed) {
213
235
  removeHolder(state.editor);
214
236
  removeRegistry(state.editor);
237
+ removeContentBaseline(state.editor);
215
238
  try {
216
239
  state.editor.destroy();
217
240
  } catch {
@@ -259,60 +282,69 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
259
282
  ])
260
283
  );
261
284
 
262
- const blokConfig = {
263
- ...currentConfig,
264
- ...(liveTools === undefined ? {} : { tools: liveTools as UseBlokConfig['tools'] }),
265
- holder,
266
- onReady: (...args: Parameters<NonNullable<UseBlokConfig['onReady']>>): void => {
267
- configRef.current.onReady?.(...args);
268
- },
285
+ // Stable per-editor wrappers for the live callback config. Each forwards to
286
+ // the LATEST render's prop through `configRef`, so a consumer never has to
287
+ // freeze callback identities — and, because identity is fixed for the
288
+ // editor's life, only PRESENCE has to be synced afterwards (below).
289
+ const handlerWrappers: LiveHandlers = {
269
290
  onChange: (...args: Parameters<NonNullable<UseBlokConfig['onChange']>>): void => {
270
291
  configRef.current.onChange?.(...args);
271
292
  },
272
- // Forward the return value: it is the "handled" signal the core acts on.
273
- onEnter: (...args: Parameters<NonNullable<UseBlokConfig['onEnter']>>): boolean | void =>
274
- configRef.current.onEnter?.(...args),
275
- };
276
-
277
- // Only attach onSubmit when the consumer opted in: its mere presence makes
278
- // Enter serialize-and-submit instead of splitting a block, so an absent prop
279
- // must stay absent. The wrapper reads through the ref so the latest callback
280
- // is always used without recreating the editor.
281
- if (currentConfig.onSubmit) {
282
- blokConfig.onSubmit = (...args: Parameters<NonNullable<UseBlokConfig['onSubmit']>>): void => {
283
- configRef.current.onSubmit?.(...args);
284
- };
285
- }
286
-
287
- // Only attach onSave when the consumer opted in: its mere presence makes the
288
- // core serialize on every change batch, so an absent prop must stay absent.
289
- // The wrapper reads through the ref so the latest callback is always used.
290
- if (currentConfig.onSave) {
291
- blokConfig.onSave = (...args: Parameters<NonNullable<UseBlokConfig['onSave']>>): void => {
293
+ onSave: (...args: Parameters<NonNullable<UseBlokConfig['onSave']>>): void => {
292
294
  // Record the editor's own serialized output as the rendered baseline so a
293
295
  // controlled consumer echoing it straight back into `data` is a no-op —
294
296
  // no redundant render(), no caret reset, no round-trip recursion.
295
297
  lastRenderedDataRef.current = args[0];
296
298
  echoWindowRef.current.record(args[0]);
297
299
  configRef.current.onSave?.(...args);
298
- };
299
- }
300
-
301
- // onBeforeRender / onAfterRender are opt-in (absent prop must stay absent so
302
- // the core skips them). When present, route through the ref so the latest
303
- // callback is always used without recreating the editor.
304
- if (currentConfig.onBeforeRender) {
305
- blokConfig.onBeforeRender = (
300
+ },
301
+ // Forward the return value: it is the "handled" signal the core acts on.
302
+ onEnter: (...args: Parameters<NonNullable<UseBlokConfig['onEnter']>>): boolean | void =>
303
+ configRef.current.onEnter?.(...args),
304
+ onSubmit: (...args: Parameters<NonNullable<UseBlokConfig['onSubmit']>>): void => {
305
+ configRef.current.onSubmit?.(...args);
306
+ },
307
+ onBeforeRender: (
306
308
  ...args: Parameters<NonNullable<UseBlokConfig['onBeforeRender']>>
307
309
  ): ReturnType<NonNullable<UseBlokConfig['onBeforeRender']>> =>
308
- configRef.current.onBeforeRender?.(...args) ?? args[0];
309
- }
310
-
311
- if (currentConfig.onAfterRender) {
312
- blokConfig.onAfterRender = (...args: Parameters<NonNullable<UseBlokConfig['onAfterRender']>>): void => {
310
+ configRef.current.onBeforeRender?.(...args) ?? args[0],
311
+ onAfterRender: (...args: Parameters<NonNullable<UseBlokConfig['onAfterRender']>>): void => {
313
312
  configRef.current.onAfterRender?.(...args);
314
- };
315
- }
313
+ },
314
+ };
315
+
316
+ handlerWrappersRef.current = handlerWrappers;
317
+
318
+ // Handler PRESENCE is load-bearing in core — an `onSubmit` turns Enter into
319
+ // serialize-and-submit, an `onSave` arms the change pipeline — so an absent
320
+ // prop must leave the key absent. Attach only the wrappers whose prop the
321
+ // consumer actually passed, and record what was installed so the effect
322
+ // below can push later presence flips instead of recreating the editor.
323
+ const handlerPresence: Record<keyof LiveHandlers, boolean> = {
324
+ onChange: currentConfig.onChange !== undefined,
325
+ onSave: currentConfig.onSave !== undefined,
326
+ onEnter: currentConfig.onEnter !== undefined,
327
+ onSubmit: currentConfig.onSubmit !== undefined,
328
+ onBeforeRender: currentConfig.onBeforeRender !== undefined,
329
+ onAfterRender: currentConfig.onAfterRender !== undefined,
330
+ };
331
+
332
+ appliedHandlerPresenceRef.current = handlerPresence;
333
+
334
+ const blokConfig = {
335
+ ...currentConfig,
336
+ ...(liveTools === undefined ? {} : { tools: liveTools as UseBlokConfig['tools'] }),
337
+ holder,
338
+ onReady: (...args: Parameters<NonNullable<UseBlokConfig['onReady']>>): void => {
339
+ configRef.current.onReady?.(...args);
340
+ },
341
+ ...(handlerPresence.onChange ? { onChange: handlerWrappers.onChange } : {}),
342
+ ...(handlerPresence.onSave ? { onSave: handlerWrappers.onSave } : {}),
343
+ ...(handlerPresence.onEnter ? { onEnter: handlerWrappers.onEnter } : {}),
344
+ ...(handlerPresence.onSubmit ? { onSubmit: handlerWrappers.onSubmit } : {}),
345
+ ...(handlerPresence.onBeforeRender ? { onBeforeRender: handlerWrappers.onBeforeRender } : {}),
346
+ ...(handlerPresence.onAfterRender ? { onAfterRender: handlerWrappers.onAfterRender } : {}),
347
+ };
316
348
 
317
349
  const blok = new BlokRuntime(blokConfig);
318
350
  state.editor = blok;
@@ -321,6 +353,19 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
321
353
  constructedDataRef.current = currentConfig.data;
322
354
  setHolder(blok, holder);
323
355
  setRegistry(blok, registry);
356
+ // Imperative-content channel: `useBlokHandle().clear()` / `.render()` change
357
+ // the content OUT OF BAND of the reactive `data` effect below. They report
358
+ // the new content here so the dedupe baseline and the emitted-echo window
359
+ // (both read by that effect) describe the editor as it actually is —
360
+ // otherwise restoring a document the editor itself emitted is dismissed as
361
+ // an echo and never rendered.
362
+ setContentBaseline(blok, {
363
+ markRendered: (content): void => {
364
+ echoWindowRef.current.clear();
365
+ lastRenderedDataRef.current = content;
366
+ pendingDataRef.current = undefined;
367
+ },
368
+ });
324
369
 
325
370
  void blok.isReady
326
371
  .then(() => {
@@ -333,6 +378,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
333
378
  if (state.editor === blok && !state.isDestroyed) {
334
379
  removeHolder(blok);
335
380
  removeRegistry(blok);
381
+ removeContentBaseline(blok);
336
382
  try {
337
383
  blok.destroy();
338
384
  } catch {
@@ -445,6 +491,74 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
445
491
  }
446
492
  });
447
493
 
494
+ // Reactive: onChange
495
+ // Reactive: onSave
496
+ // Reactive: onEnter
497
+ // Reactive: onSubmit
498
+ // Reactive: onBeforeRender
499
+ // Reactive: onAfterRender
500
+ //
501
+ // Callback PRESENCE was frozen at construction, and in core the presence of a
502
+ // handler IS the semantics: an `onSubmit` makes Enter serialize-and-submit
503
+ // instead of splitting the block, an `onSave` arms the whole change-observation
504
+ // pipeline. A host whose "Enter sends" toggle (or `onSave` subscription) lives
505
+ // in React state therefore had to bump `deps` and destroy/rebuild the editor,
506
+ // losing caret and undo history. Instead, diff each handler's presence against
507
+ // what is installed and push genuine flips through the runtime `handlers.set`
508
+ // API — passing `undefined` for a prop that disappeared, which is what makes
509
+ // the change reversible rather than a one-way latch. Identity never changes
510
+ // (the wrappers forward through `configRef`), so only presence is diffed.
511
+ // Applies once the editor is ready — module APIs attach at isReady — and the
512
+ // ready transition re-renders, so a flip made earlier is not lost.
513
+ useEffect(() => {
514
+ const state = stateRef.current;
515
+ const wrappers = handlerWrappersRef.current;
516
+
517
+ if (state.editor === null || state.isDestroyed || !state.isEditorReady || wrappers === null) {
518
+ return;
519
+ }
520
+
521
+ const latest = configRef.current;
522
+ const applied = appliedHandlerPresenceRef.current;
523
+ const diff: LiveHandlers = {};
524
+ const changed: { value: boolean } = { value: false };
525
+
526
+ const sync = <K extends keyof LiveHandlers>(key: K, write: (value: LiveHandlers[K]) => void): void => {
527
+ const present = latest[key] !== undefined;
528
+
529
+ if (present === applied[key]) {
530
+ return;
531
+ }
532
+
533
+ applied[key] = present;
534
+ changed.value = true;
535
+ write(present ? wrappers[key] : undefined);
536
+ };
537
+
538
+ sync('onChange', (value) => {
539
+ diff.onChange = value;
540
+ });
541
+ sync('onSave', (value) => {
542
+ diff.onSave = value;
543
+ });
544
+ sync('onEnter', (value) => {
545
+ diff.onEnter = value;
546
+ });
547
+ sync('onSubmit', (value) => {
548
+ diff.onSubmit = value;
549
+ });
550
+ sync('onBeforeRender', (value) => {
551
+ diff.onBeforeRender = value;
552
+ });
553
+ sync('onAfterRender', (value) => {
554
+ diff.onAfterRender = value;
555
+ });
556
+
557
+ if (changed.value) {
558
+ state.editor.handlers.set(diff);
559
+ }
560
+ });
561
+
448
562
  // Reactive: readOnly (object form normalized so the effect deps are stable booleans).
449
563
  // The object form additionally syncs `hideControls` through the options
450
564
  // parameter of `readOnly.set` — a plain boolean prop keeps the historical
@@ -597,11 +711,12 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
597
711
  //
598
712
  // `data` seeds the editor at construction. Afterwards, changing the prop to
599
713
  // new *content* re-renders the editor via the public render() API — no
600
- // recreation. Updates are deep-equal–deduped against the editor's current
601
- // content baseline (`lastRenderedDataRef`, declared above and also updated by
602
- // the `onSave` wrapper) so a new reference with the same content — including
603
- // the editor's own serialized output echoed back — is a no-op and won't
604
- // clobber the caret. Renders are serialized so rapid changes can't overlap.
714
+ // recreation. Updates are content-deduped against the editor's current
715
+ // baseline (`lastRenderedDataRef`, declared above and also updated by the
716
+ // `onSave` wrapper and the imperative content-baseline channel) so a new
717
+ // reference with the same content — including the editor's own serialized
718
+ // output echoed back, however the host reshaped it in transit — is a no-op and
719
+ // won't clobber the caret. Renders are serialized so rapid changes can't overlap.
605
720
  const { data } = config;
606
721
  useEffect(() => {
607
722
  if (editor === null || data === undefined) {
@@ -644,7 +759,20 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
644
759
  // Skip when the editor already reflects this content (last SUCCESSFUL render)
645
760
  // OR a render of the same content is already queued/in-flight — re-rendering
646
761
  // identical content would needlessly reset the caret.
647
- if (deepEqual(data, lastRenderedDataRef.current) || deepEqual(data, pendingDataRef.current)) {
762
+ //
763
+ // Compared with the SAME structural lens the echo window uses
764
+ // (`equalsOutputData`, not a raw deep-equal): a host that persists the
765
+ // editor's own document and hands back a stripped copy — fresh `time`,
766
+ // dropped ids, no `lastEditedAt` stamp — is still echoing content the editor
767
+ // already shows. `undefined` means "nothing recorded yet", which is NOT an
768
+ // empty document, so it must never match.
769
+ const baseline = lastRenderedDataRef.current;
770
+ const inFlight = pendingDataRef.current;
771
+
772
+ if (
773
+ (baseline !== undefined && equalsOutputData(data, baseline)) ||
774
+ (inFlight !== undefined && equalsOutputData(data, inFlight))
775
+ ) {
648
776
  return;
649
777
  }
650
778
 
@@ -701,6 +829,7 @@ function deferDestroy(
701
829
  if (state.editor !== null) {
702
830
  removeHolder(state.editor);
703
831
  removeRegistry(state.editor);
832
+ removeContentBaseline(state.editor);
704
833
  try {
705
834
  state.editor.destroy();
706
835
  } catch {
@@ -1,4 +1,6 @@
1
1
  import { useRef, useMemo, type RefCallback } from 'react';
2
+ import { EMPTY_OUTPUT_DATA } from '@bloklabs/core/adapters';
3
+ import { getContentBaseline } from './content-baseline-map';
2
4
  import type { Blok, OutputData, LooseOutputData } from '@/types';
3
5
 
4
6
  /**
@@ -32,12 +34,22 @@ export interface BlokEditorHandle {
32
34
  * @param atEnd - place the caret at the end of the focused block
33
35
  */
34
36
  focus(atEnd?: boolean): boolean;
35
- /** Clear all content. Resolves immediately (no-op) before ready. */
37
+ /**
38
+ * Clear all content. Resolves immediately (no-op) before ready.
39
+ *
40
+ * Safe to mix with a controlled `data` prop: the adapter's content baseline is
41
+ * updated once the clear lands, so setting `data` back to the document the
42
+ * editor itself emitted re-renders it instead of being dismissed as an echo.
43
+ */
36
44
  clear(): Promise<void>;
37
45
  /** Serialize the current content, or resolve to `null` before ready. */
38
46
  save(): Promise<OutputData | null>;
39
47
  /**
40
48
  * Render new content in place. Resolves immediately (no-op) before ready.
49
+ *
50
+ * Safe to mix with a controlled `data` prop: the rendered document becomes the
51
+ * adapter's content baseline, so a later `data` change back to the previous
52
+ * document still re-renders.
41
53
  * @param data - the document to render
42
54
  */
43
55
  render(data: OutputData | LooseOutputData): Promise<void>;
@@ -81,11 +93,35 @@ export function useBlokHandle(): BlokEditorHandle {
81
93
  return instanceRef.current !== null;
82
94
  },
83
95
  focus: (atEnd?: boolean): boolean => instanceRef.current?.focus(atEnd) ?? false,
84
- clear: (): Promise<void> => instanceRef.current?.clear() ?? Promise.resolve(),
96
+ // clear/render change the content OUT OF BAND of the controlled `data`
97
+ // effect, so they report the result to the adapter's content baseline (see
98
+ // content-baseline-map). Reported only once the call RESOLVES: a failed one
99
+ // leaves the editor on its previous content, and a baseline pointing at
100
+ // content the editor never showed would dedupe away the correcting update.
101
+ clear: (): Promise<void> => {
102
+ const instance = instanceRef.current;
103
+
104
+ if (instance === null) {
105
+ return Promise.resolve();
106
+ }
107
+
108
+ return instance.clear().then(() => {
109
+ getContentBaseline(instance)?.markRendered(EMPTY_OUTPUT_DATA);
110
+ });
111
+ },
85
112
  save: (): Promise<OutputData | null> =>
86
113
  instanceRef.current !== null ? instanceRef.current.save() : Promise.resolve(null),
87
- render: (data: OutputData | LooseOutputData): Promise<void> =>
88
- instanceRef.current?.render(data) ?? Promise.resolve(),
114
+ render: (data: OutputData | LooseOutputData): Promise<void> => {
115
+ const instance = instanceRef.current;
116
+
117
+ if (instance === null) {
118
+ return Promise.resolve();
119
+ }
120
+
121
+ return instance.render(data).then(() => {
122
+ getContentBaseline(instance)?.markRendered(data);
123
+ });
124
+ },
89
125
  setReadOnly: (state?: boolean): Promise<boolean> => {
90
126
  const instance = instanceRef.current;
91
127
 
@@ -18,7 +18,7 @@ import { viewNodesToReact } from './view-nodes-to-react';
18
18
  * bindings map from remains `@experimental` — but consumers of this hook never
19
19
  * touch it directly.
20
20
  * @param data - saved document (strict or loose wire shape; nullish tolerated)
21
- * @param options - schema / renderers / unknown-block policy / toolAttributes / blockIds / transformUrl
21
+ * @param options - schema / renderers / inlineRenderers / unknown-block policy / toolAttributes / blockIds / transformUrl
22
22
  */
23
23
  export const useBlokView = (
24
24
  data: OutputData | LooseOutputData | null | undefined,
@@ -30,6 +30,7 @@ export const useBlokView = (
30
30
  const toolAttributes = options?.toolAttributes;
31
31
  const blockIds = options?.blockIds;
32
32
  const transformUrl = options?.transformUrl;
33
+ const inlineRenderers = options?.inlineRenderers;
33
34
  const classes = options?.classes;
34
35
 
35
36
  return useMemo(() => {
@@ -44,6 +45,7 @@ export const useBlokView = (
44
45
  toolAttributes,
45
46
  blockIds,
46
47
  transformUrl,
48
+ inlineRenderers,
47
49
  /**
48
50
  * Opt-in, NOT defaulted on. Parity rendering wraps every block in the
49
51
  * core's holder → content scaffolding, which would contradict this
@@ -58,5 +60,5 @@ export const useBlokView = (
58
60
  })
59
61
  )
60
62
  );
61
- }, [data, schema, renderers, onUnknownBlock, toolAttributes, blockIds, transformUrl, classes]);
63
+ }, [data, schema, renderers, onUnknownBlock, toolAttributes, blockIds, transformUrl, inlineRenderers, classes]);
62
64
  };