@bloklabs/react 1.4.2 → 1.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bloklabs/react",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "React block editor — official React bindings for Blok, the headless Notion-like rich text editor. Hooks and components that render blocks and save to JSON.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "JackUait",
@@ -54,7 +54,7 @@
54
54
  "test": "yarn run -T vitest run"
55
55
  },
56
56
  "peerDependencies": {
57
- "@bloklabs/core": "^1.4.2",
57
+ "@bloklabs/core": "^1.4.3",
58
58
  "react": "^18.0.0 || ^19.0.0",
59
59
  "react-dom": "^18.0.0 || ^19.0.0"
60
60
  }
@@ -2,8 +2,44 @@ import { useEffect, useRef, forwardRef } from 'react';
2
2
  import { getHolder } from './holder-map';
3
3
  import { getRegistry } from './registry-map';
4
4
  import { BlockPortalHost } from './BlockPortalHost';
5
+ import type { Blok } from '@/types';
5
6
  import type { BlokContentProps } from './types';
6
7
 
8
+ /**
9
+ * Re-drive the boot-time URL-hash scroll now that the editor's holder is in the
10
+ * document. Reads the current hash, decodes it (falling back to the raw value on
11
+ * a malformed percent-sequence, which simply matches no block), and asks the
12
+ * editor to scroll — a one-shot drain of the navigation the constructor deferred
13
+ * because the holder was detached. No-op when the URL carries no hash.
14
+ * @param editor - the ready Blok instance whose holder just connected
15
+ */
16
+ function drainDeferredHashScroll(editor: Blok): void {
17
+ if (typeof window === 'undefined') {
18
+ return;
19
+ }
20
+
21
+ const raw = window.location.hash.slice(1);
22
+
23
+ if (raw === '') {
24
+ return;
25
+ }
26
+
27
+ editor.blocks.scrollToBlock?.(safeDecodeHashFragment(raw));
28
+ }
29
+
30
+ /**
31
+ * Decodes a URL hash fragment, falling back to the raw value on a malformed
32
+ * percent-sequence (e.g. `%ZZ`) — which simply matches no block downstream.
33
+ * @param raw - hash fragment without the leading `#`
34
+ */
35
+ function safeDecodeHashFragment(raw: string): string {
36
+ try {
37
+ return decodeURIComponent(raw);
38
+ } catch {
39
+ return raw;
40
+ }
41
+ }
42
+
7
43
  /**
8
44
  * Component that provides the DOM mount point for a Blok editor.
9
45
  * Renders a <div> and adopts the editor's detached holder DOM into it.
@@ -38,6 +74,14 @@ export const BlokContent = forwardRef<HTMLDivElement, BlokContentProps>(
38
74
 
39
75
  containerRef.current.appendChild(holder);
40
76
 
77
+ // The editor rendered its seeded content into this holder while it was
78
+ // still detached from the document, so the boot-time URL-hash scroll —
79
+ // which queries the LIVE document — found nothing and deferred. Now that
80
+ // the holder is connected, drain that deferred navigation once via the
81
+ // public scroll API (a no-op when there's no matching hash/block), so
82
+ // consumers don't have to hand-roll a DOM-polling hook.
83
+ drainDeferredHashScroll(editor);
84
+
41
85
  return (): void => {
42
86
  holder.remove();
43
87
  };
@@ -38,9 +38,9 @@ import type { UseBlokConfig } from './types';
38
38
  */
39
39
  export interface BlokEditorProps
40
40
  extends Omit<UseBlokConfig, 'onReady'>,
41
- // `onChange`/`onError` are Blok config callbacks here — drop the DOM
42
- // element's same-named event handlers so the config signatures win.
43
- Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'onChange' | 'onError'> {
41
+ // `onChange`/`onError`/`onSubmit` are Blok config callbacks here — drop the
42
+ // DOM element's same-named event handlers so the config signatures win.
43
+ Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'onChange' | 'onError' | 'onSubmit'> {
44
44
  /**
45
45
  * When any value changes, the editor is destroyed and recreated. Keep each
46
46
  * value referentially stable (primitives or useMemo-stable objects) — a dep
@@ -67,6 +67,18 @@ export interface BlokEditorProps
67
67
  * never recreate the editor or re-fire `onReady`.
68
68
  */
69
69
  onReady?: (editor: Blok) => void;
70
+ /**
71
+ * Library-neutral shorthand for the active locale — a plain BCP-47 string
72
+ * (`'en'`, `'ru-RU'`, `'nb'`) the host owns and updates. It is normalized and
73
+ * routed to `editor.i18n.update({ locale })` in place (caret/undo survive),
74
+ * so a host with its own language switcher never has to build an `i18n`
75
+ * config object or destroy the editor. Pair it with the re-exported
76
+ * `getDirection`/`normalizeLocale` to compute `dir` yourself.
77
+ *
78
+ * Shorthand for `i18n={{ locale }}`: if both are given, this wins. For
79
+ * message overrides or an explicit direction, use the full `i18n` prop.
80
+ */
81
+ locale?: string;
70
82
  /**
71
83
  * Called after a batch render completes (core `blocks:rendered` event). The
72
84
  * declarative analog of `ref.current.on('blocks:rendered', …)` — mirrors the
@@ -96,7 +108,7 @@ const CONFIG_KEY_SET = new Set<string>(USE_BLOK_CONFIG_KEYS);
96
108
  * ```
97
109
  */
98
110
  export const BlokEditor = forwardRef<Blok | null, BlokEditorProps>(
99
- function BlokEditor({ deps, onReady, onBlocksRendered, onBlockRendered, ...rest }, ref) {
111
+ function BlokEditor({ deps, onReady, locale, onBlocksRendered, onBlockRendered, ...rest }, ref) {
100
112
  const config: Record<string, unknown> = {};
101
113
  const divProps: Record<string, unknown> = {};
102
114
 
@@ -110,6 +122,16 @@ export const BlokEditor = forwardRef<Blok | null, BlokEditorProps>(
110
122
  }
111
123
  }
112
124
 
125
+ // `locale` is a neutral shorthand for `i18n.locale`. Fold it into the i18n
126
+ // config (winning over any locale already there) so the single reactive
127
+ // `i18n.update` path in `useBlok` carries it — no separate resolver. A fresh
128
+ // object each render is fine: that path is deep-equal–deduped.
129
+ if (locale !== undefined) {
130
+ const existingI18n = config.i18n as Record<string, unknown> | undefined;
131
+
132
+ config.i18n = { ...existingI18n, locale };
133
+ }
134
+
113
135
  const editor = useBlok(config, deps);
114
136
 
115
137
  useImperativeHandle<Blok | null, Blok | null>(ref, () => editor, [editor]);
package/src/BlokView.tsx CHANGED
@@ -1,13 +1,15 @@
1
- import type { ReactNode } from 'react';
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
2
  import type { BlocksToHtmlOptions } from '@bloklabs/core/view';
3
3
  import type { LooseOutputData, OutputData } from '@bloklabs/core';
4
4
 
5
5
  import { useBlokView } from './useBlokView';
6
6
 
7
7
  /**
8
- * Props for {@link BlokView}.
8
+ * Props for {@link BlokView}. Beyond the render options below, any standard
9
+ * `<div>` attribute (`id`, `style`, `data-*`, `aria-*`, event handlers, …) is
10
+ * forwarded onto the single wrapper element.
9
11
  */
10
- export interface BlokViewProps {
12
+ export interface BlokViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
11
13
  /** Saved document to display (strict or loose wire shape; nullish tolerated). */
12
14
  data: OutputData | LooseOutputData | null | undefined;
13
15
  /** View schema from `defineBlokSchema` — display under the composition that produced the document. */
@@ -16,22 +18,40 @@ export interface BlokViewProps {
16
18
  renderers?: BlocksToHtmlOptions['renderers'];
17
19
  /** Unknown-tool policy (default 'skip'). */
18
20
  onUnknownBlock?: BlocksToHtmlOptions['onUnknownBlock'];
19
- /** Class for the single wrapper div. */
20
- className?: string;
21
+ /** Stamp `data-blok-tool="<type>"` on each block root as a styling hook (pairs with the opt-in view stylesheet). */
22
+ toolAttributes?: BlocksToHtmlOptions['toolAttributes'];
23
+ /** Stamp `data-blok-id="<id>"` on each block root (list items on their `<li>`) for "copy link to block" deep links. */
24
+ blockIds?: BlocksToHtmlOptions['blockIds'];
25
+ /** Pure URL rewrite hook for block URLs + inline anchors, run before the unsafe-scheme strip. */
26
+ transformUrl?: BlocksToHtmlOptions['transformUrl'];
21
27
  }
22
28
 
23
29
  /**
24
30
  * Display a saved Blok document synchronously — one `<div>` wrapper, no
25
- * editor chrome, no ids, no async, no effects. Content is mapped from the
26
- * sanitized view tree to real React elements (never
27
- * `dangerouslySetInnerHTML`), so it renders identically under SSR.
31
+ * editor chrome, no async, no effects. Content is mapped from the sanitized
32
+ * view tree to real React elements (never `dangerouslySetInnerHTML`), so it
33
+ * renders identically under SSR.
28
34
  *
29
- * @experimental Built on the `@experimental` ViewNode tree of
30
- * `@bloklabs/core/view` — not frozen until a second framework adapter
31
- * consumes it.
35
+ * This is the intended read-only path — reach for it instead of mounting the
36
+ * full editing runtime (`BlokEditor readOnly`), which ships toolbar, history,
37
+ * and mutation machinery to every viewer. Pair with `defineBlokSchema` to
38
+ * display custom-tool content under the exact sanitization that produced it.
39
+ *
40
+ * The props API is semver-stable. Only the raw `ViewNode` tree that the
41
+ * bindings map from (via `blocksToViewNodes`) remains `@experimental` — using
42
+ * `BlokView`/`useBlokView` never exposes you to it.
32
43
  */
33
- export const BlokView = ({ data, schema, renderers, onUnknownBlock, className }: BlokViewProps): ReactNode => {
34
- const content = useBlokView(data, { schema, renderers, onUnknownBlock });
44
+ export const BlokView = ({
45
+ data,
46
+ schema,
47
+ renderers,
48
+ onUnknownBlock,
49
+ toolAttributes,
50
+ blockIds,
51
+ transformUrl,
52
+ ...divProps
53
+ }: BlokViewProps): ReactNode => {
54
+ const content = useBlokView(data, { schema, renderers, onUnknownBlock, toolAttributes, blockIds, transformUrl });
35
55
 
36
- return <div className={className}>{content}</div>;
56
+ return <div {...divProps}>{content}</div>;
37
57
  };
@@ -33,6 +33,7 @@ export const USE_BLOK_CONFIG_KEYS = [
33
33
  'onSave',
34
34
  'onError',
35
35
  'onEnter',
36
+ 'onSubmit',
36
37
  'onBeforeRender',
37
38
  'onAfterRender',
38
39
  'inlineToolbar',
@@ -61,8 +61,8 @@ export type ReactBlockViewProps<Data, Config = Record<string, unknown>> = Omit<
61
61
  'commit' | 'readOnly'
62
62
  >;
63
63
 
64
- /** A toolbox entry whose `icon` may be a React element instead of an SVG string. */
65
- export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon'> & {
64
+ /** A toolbox entry whose `icon`/`title` may be a React element instead of a string. */
65
+ export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon' | 'title'> & {
66
66
  /**
67
67
  * Toolbox icon. Core's toolbox chrome consumes markup strings, but React
68
68
  * authors may pass the same element they render in the block body — the
@@ -71,6 +71,17 @@ export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon'> & {
71
71
  * returned as-is and serialization retries on the next browser-side access.
72
72
  */
73
73
  icon?: string | ReactElement;
74
+ /**
75
+ * Toolbox label. A plain string is translated by core through the
76
+ * `toolNames.*` namespace as usual. A ReactElement instead renders live in
77
+ * the HOST's React tree (via the editor's shared BlockPortalHost), so the
78
+ * label tracks the host's OWN i18n — no mirroring of strings into Blok's
79
+ * dictionary. Pair a ReactElement title with `titleKey` so multilingual
80
+ * search still resolves an English term. Requires the editor to be created
81
+ * through `useBlok`/`BlokEditor` (which wires the portal registry); a
82
+ * ReactElement title is dropped when the tool is used with vanilla core.
83
+ */
84
+ title?: string | ReactElement;
74
85
  };
75
86
 
76
87
  /** Toolbox config for React blocks: single entry or several variants. */
@@ -133,6 +144,13 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
133
144
  }) & {
134
145
  readonly __isBlokReactBlock: true;
135
146
  readonly toolbox: ToolboxConfig | undefined;
147
+ /**
148
+ * Adapter-internal: builds a per-editor toolbox whose ReactElement titles are
149
+ * live portals registered in `registry`. Called by `useBlok`; returns
150
+ * undefined when no entry has a ReactElement title. Not part of the public
151
+ * tool contract.
152
+ */
153
+ __buildPortalToolbox(registry: BlockPortalRegistry, toolName: string): ToolboxConfig | undefined;
136
154
  readonly isReadOnlySupported: boolean;
137
155
  } {
138
156
  /**
@@ -162,35 +180,120 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
162
180
  return markup === '' ? null : markup;
163
181
  };
164
182
 
165
- const convertToolbox = (): { value: ToolboxConfig | undefined; complete: boolean } => {
166
- const status = { complete: true };
183
+ /**
184
+ * Convert one React toolbox entry to a core-valid entry: serialize a React
185
+ * icon to markup, and DROP a ReactElement title (core titles are
186
+ * string | HTMLElement only). A string title passes through untouched; the
187
+ * live element title is re-attached per-editor by `buildPortalToolbox`.
188
+ *
189
+ * `deferred` is true when icon serialization could not complete (SSR /
190
+ * mid-render) and must be retried; the entry is returned with its element
191
+ * icon left in place.
192
+ *
193
+ * @param entry - the authored React toolbox entry
194
+ */
195
+ const toCoreEntry = (
196
+ entry: ReactToolboxConfigEntry
197
+ ): { entry: ToolboxConfigEntry; deferred: boolean } => {
198
+ const { icon, title, ...rest } = entry;
199
+ const core: ToolboxConfigEntry = { ...(rest as ToolboxConfigEntry) };
200
+
201
+ if (typeof title === 'string') {
202
+ core.title = title;
203
+ }
167
204
 
168
- const toEntry = (entry: ReactToolboxConfigEntry): ToolboxConfigEntry => {
169
- if (!isValidElement(entry.icon)) {
170
- return entry as ToolboxConfigEntry;
205
+ if (!isValidElement(icon)) {
206
+ if (icon !== undefined) {
207
+ core.icon = icon;
171
208
  }
172
209
 
173
- const markup = renderElementToMarkup(entry.icon);
210
+ return { entry: core, deferred: false };
211
+ }
174
212
 
175
- if (markup === null) {
176
- // Leave the element in place (a consumer reading `toolbox` outside the
177
- // browser can still use it) and skip caching so a later browser-side
178
- // access serializes it properly.
179
- status.complete = false;
213
+ const markup = renderElementToMarkup(icon);
180
214
 
181
- return entry as ToolboxConfigEntry;
182
- }
215
+ if (markup === null) {
216
+ // Serialization deferred (SSR / mid-render): leave the element in place
217
+ // and skip caching so a later browser-side access serializes it.
218
+ (core as ReactToolboxConfigEntry).icon = icon;
183
219
 
184
- return { ...entry, icon: markup };
185
- };
220
+ return { entry: core, deferred: true };
221
+ }
222
+
223
+ core.icon = markup;
224
+
225
+ return { entry: core, deferred: false };
226
+ };
227
+
228
+ const convertToolbox = (): { value: ToolboxConfig | undefined; complete: boolean } => {
229
+ if (spec.toolbox === undefined) {
230
+ return { value: undefined, complete: true };
231
+ }
232
+
233
+ const entries = Array.isArray(spec.toolbox) ? spec.toolbox : [spec.toolbox];
234
+ const converted = entries.map((entry) => toCoreEntry(entry));
235
+ const complete = converted.every((result) => !result.deferred);
236
+ const coreEntries = converted.map((result) => result.entry);
237
+ const value = Array.isArray(spec.toolbox) ? coreEntries : coreEntries[0];
238
+
239
+ return { value, complete };
240
+ };
186
241
 
242
+ /**
243
+ * Build a per-editor toolbox in which every ReactElement title is a live
244
+ * portal: a Blok-owned mutation-free host element registered in the editor's
245
+ * `registry`, into which the ReactNode renders through the shared
246
+ * BlockPortalHost — so the label tracks the host's own i18n.
247
+ *
248
+ * Returns undefined when no entry has a ReactElement title (nothing to
249
+ * override; the static string/`toolNames.*` path already covers it).
250
+ *
251
+ * @param registry - the editor's portal registry (from `useBlok`)
252
+ * @param toolName - the name the tool is registered under
253
+ */
254
+ const buildPortalToolbox = (
255
+ registry: BlockPortalRegistry,
256
+ toolName: string
257
+ ): ToolboxConfig | undefined => {
187
258
  if (spec.toolbox === undefined) {
188
- return { value: undefined, complete: status.complete };
259
+ return undefined;
260
+ }
261
+
262
+ const entries = Array.isArray(spec.toolbox) ? spec.toolbox : [spec.toolbox];
263
+
264
+ // Nothing to override unless at least one entry has a ReactElement title;
265
+ // the static string/`toolNames.*` path already covers the rest.
266
+ if (!entries.some((entry) => isValidElement(entry.title))) {
267
+ return undefined;
189
268
  }
190
269
 
191
- const value = Array.isArray(spec.toolbox) ? spec.toolbox.map(toEntry) : toEntry(spec.toolbox);
270
+ return entries.map((entry, index): ToolboxConfigEntry => {
271
+ const { entry: core } = toCoreEntry(entry);
272
+
273
+ if (!isValidElement(entry.title)) {
274
+ return core;
275
+ }
276
+
277
+ const host = document.createElement('span');
278
+
279
+ host.setAttribute('data-blok-mutation-free', 'true');
192
280
 
193
- return { value, complete: status.complete };
281
+ const node = entry.title;
282
+ const TitleComponent: ComponentType<Record<string, unknown>> = (): ReactElement => <>{node}</>;
283
+
284
+ TitleComponent.displayName = `BlokToolboxTitle(${toolName})`;
285
+
286
+ // Unique within this per-editor registry; register() is idempotent, so a
287
+ // StrictMode double-build replaces rather than duplicates.
288
+ registry.register(`__blok-toolbox-title__:${toolName}:${index}`, {
289
+ hostEl: host,
290
+ component: TitleComponent,
291
+ props: {},
292
+ toolName,
293
+ });
294
+
295
+ return { ...core, titleEl: host };
296
+ });
194
297
  };
195
298
 
196
299
  /** Lazily-computed core-compatible toolbox (React element icons serialized once). */
@@ -247,6 +350,17 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
247
350
  return resolveToolbox();
248
351
  }
249
352
 
353
+ /**
354
+ * Adapter-internal (see the return-type doc): builds the per-editor toolbox
355
+ * override whose ReactElement titles are live portals in `registry`.
356
+ */
357
+ public static __buildPortalToolbox(
358
+ registry: BlockPortalRegistry,
359
+ toolName: string
360
+ ): ToolboxConfig | undefined {
361
+ return buildPortalToolbox(registry, toolName);
362
+ }
363
+
250
364
  /**
251
365
  * React blocks support read-only mode: `setReadOnly` pushes a new prop the
252
366
  * component reads, so the block re-renders read-only IN PLACE. Without this
@@ -158,9 +158,11 @@ export function createReactInlineTool<Config = Record<string, unknown>, State =
158
158
  ): (new (options: InlineToolConstructorOptions) => InlineTool) & {
159
159
  readonly __isBlokReactInlineTool: true;
160
160
  readonly isInline: true;
161
+ readonly type: string;
161
162
  readonly title: string | undefined;
162
- readonly titleKey: string | undefined;
163
+ readonly titleKey: string;
163
164
  readonly shortcut: string | undefined;
165
+ readonly mark: MarkSpec<State> | undefined;
164
166
  readonly sanitize: SanitizerConfig | undefined;
165
167
  readonly isReadOnlySupported: boolean;
166
168
  } {
@@ -169,18 +171,39 @@ export function createReactInlineTool<Config = Record<string, unknown>, State =
169
171
  public static readonly __isBlokReactInlineTool = true as const;
170
172
  public static readonly isInline = true as const;
171
173
 
174
+ /** The tool's type name — also the default translation key and mark tag context. */
175
+ public static get type(): string {
176
+ return spec.type;
177
+ }
178
+
172
179
  public static get title(): string | undefined {
173
180
  return spec.title;
174
181
  }
175
182
 
176
- public static get titleKey(): string | undefined {
177
- return spec.titleKey;
183
+ /**
184
+ * Translation key core i18n resolves as `toolNames.{titleKey}`. Defaults to
185
+ * the tool `type` so a custom mark localizes through the conventional
186
+ * `toolNames.{type}` key (as built-in tools do) instead of silently falling
187
+ * to the un-translated capitalized tool name. When the consumer supplies no
188
+ * matching i18n entry, core still falls through to that same label — the
189
+ * default is a no-op there and never a regression.
190
+ */
191
+ public static get titleKey(): string {
192
+ return spec.titleKey ?? spec.type;
178
193
  }
179
194
 
180
195
  public static get shortcut(): string | undefined {
181
196
  return spec.shortcut;
182
197
  }
183
198
 
199
+ /**
200
+ * The declared mark spec (if any). Exposed as a static so consumers can
201
+ * assert a tool's mark in unit tests without mocking the factory.
202
+ */
203
+ public static get mark(): MarkSpec<State> | undefined {
204
+ return spec.mark;
205
+ }
206
+
184
207
  public static get sanitize(): SanitizerConfig | undefined {
185
208
  if (spec.sanitize !== undefined) {
186
209
  return spec.sanitize;
package/src/index.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  export { useBlok } from './useBlok';
2
+ // Host-i18n bridge (#41): re-export the direction/normalization helpers so a
3
+ // host that owns its locale state can compute `dir` and validate tags without
4
+ // deep-importing core. `<BlokEditor locale>` pairs with these.
5
+ export { getDirection, normalizeLocale } from '@bloklabs/core/locales';
2
6
  export { BlokContent } from './BlokContent';
3
7
  export { BlokEditor } from './BlokEditor';
8
+ export { useBlokHandle } from './useBlokHandle';
9
+ export type { BlokEditorHandle } from './useBlokHandle';
4
10
  export { BlokProvider, useBlokDefaults } from './provide-blok';
5
11
  export type { UseBlokConfig, BlokContentProps } from './types';
6
12
  export type { BlokEditorProps } from './BlokEditor';
package/src/useBlok.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from './block-portal-registry';
12
12
  import { setRegistry, removeRegistry } from './registry-map';
13
13
  import { bindLiveToolConfigFunctions, buildLiveBlockConfig } from './live-tool-config';
14
- import { createEmittedEchoWindow, normalizeReadOnlyConfig } from '@bloklabs/core/adapters';
14
+ import { createEmittedEchoWindow, normalizeReadOnlyConfig, toRenderableData } from '@bloklabs/core/adapters';
15
15
  import type { Blok } from '@/types';
16
16
  import type { UseBlokConfig } from './types';
17
17
 
@@ -74,6 +74,24 @@ const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): un
74
74
  [BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry,
75
75
  [BLOK_TOOL_NAME_CONFIG_KEY]: name,
76
76
  };
77
+
78
+ // A React block tool whose toolbox title is a ReactElement needs a live
79
+ // portal that only the editor's registry can host. Build a per-editor
80
+ // toolbox override (element-title hosts registered now) and let core's
81
+ // per-tool `toolbox` setting carry it. Undefined = no ReactElement titles,
82
+ // so the static string/`toolNames.*` path is left untouched.
83
+ const buildPortalToolbox = (base.class as {
84
+ __buildPortalToolbox?: (r: BlockPortalRegistry, n: string) => unknown;
85
+ }).__buildPortalToolbox;
86
+
87
+ const portalToolbox = typeof buildPortalToolbox === 'function' && base.toolbox === undefined
88
+ ? buildPortalToolbox(registry, name)
89
+ : undefined;
90
+
91
+ if (portalToolbox !== undefined) {
92
+ base.toolbox = portalToolbox;
93
+ }
94
+
77
95
  result[name] = base;
78
96
  }
79
97
 
@@ -256,6 +274,16 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
256
274
  configRef.current.onEnter?.(...args),
257
275
  };
258
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
+
259
287
  // Only attach onSave when the consumer opted in: its mere presence makes the
260
288
  // core serialize on every change batch, so an absent prop must stay absent.
261
289
  // The wrapper reads through the ref so the latest callback is always used.
@@ -639,7 +667,9 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
639
667
  pendingDataRef.current = data;
640
668
  renderChainRef.current = renderChainRef.current
641
669
  .catch(() => undefined)
642
- .then(() => editor.render(data))
670
+ // `data` may be null here (a controlled "clear to empty"); render() throws
671
+ // on null, so normalize null → { blocks: [] } at the boundary.
672
+ .then(() => editor.render(toRenderableData(data)))
643
673
  .then(
644
674
  () => {
645
675
  lastRenderedDataRef.current = data;
@@ -0,0 +1,101 @@
1
+ import { useRef, useMemo, type RefCallback } from 'react';
2
+ import type { Blok, OutputData, LooseOutputData } from '@/types';
3
+
4
+ /**
5
+ * A typed, null-safe imperative handle for a Blok editor embedded via
6
+ * `<BlokEditor>`. Solves the "raw ref is `Blok | null` until ready" papercut:
7
+ * instead of every consumer re-declaring a `forwardRef`/`useImperativeHandle`
8
+ * with `?.` guards, `useBlokHandle()` returns this stable object whose methods
9
+ * safely no-op until the editor is ready.
10
+ *
11
+ * Attach it via `ref`: `<BlokEditor ref={handle.ref} … />`. The methods read the
12
+ * live instance through an internal ref, so they can be called from event
13
+ * handlers without re-subscribing, and they stay valid across re-renders.
14
+ *
15
+ * `current` / `isReady` are IMPERATIVE reads (they reflect the instance at call
16
+ * time and do NOT trigger a re-render when readiness flips). For render-reactive
17
+ * readiness use `useBlokReady`; drop to `current` when you need the full editor
18
+ * API beyond the handful of shortcuts below.
19
+ */
20
+ export interface BlokEditorHandle {
21
+ /**
22
+ * Ref callback to attach to `<BlokEditor ref={handle.ref} />`. Stable across
23
+ * renders. Stores the live instance (or null before ready / after unmount).
24
+ */
25
+ readonly ref: RefCallback<Blok | null>;
26
+ /** The live editor instance, or null before ready / after unmount. */
27
+ readonly current: Blok | null;
28
+ /** True once the editor is ready (the ref has been populated). */
29
+ readonly isReady: boolean;
30
+ /**
31
+ * Focus the editor. Returns whether focus landed; `false` (no-op) before ready.
32
+ * @param atEnd - place the caret at the end of the focused block
33
+ */
34
+ focus(atEnd?: boolean): boolean;
35
+ /** Clear all content. Resolves immediately (no-op) before ready. */
36
+ clear(): Promise<void>;
37
+ /** Serialize the current content, or resolve to `null` before ready. */
38
+ save(): Promise<OutputData | null>;
39
+ /**
40
+ * Render new content in place. Resolves immediately (no-op) before ready.
41
+ * @param data - the document to render
42
+ */
43
+ render(data: OutputData | LooseOutputData): Promise<void>;
44
+ /**
45
+ * Set or toggle read-only mode. Resolves to the resulting state; `false`
46
+ * (no-op) before ready.
47
+ * @param state - explicit state; omit to toggle
48
+ */
49
+ setReadOnly(state?: boolean): Promise<boolean>;
50
+ }
51
+
52
+ /**
53
+ * Returns a stable, null-safe {@link BlokEditorHandle} for a `<BlokEditor>`.
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * const blok = useBlokHandle();
58
+ * return (
59
+ * <>
60
+ * <BlokEditor ref={blok.ref} tools={tools} data={data} />
61
+ * <button onClick={() => blok.focus()}>Focus</button>
62
+ * <button onClick={() => blok.save().then(onSubmit)}>Send</button>
63
+ * </>
64
+ * );
65
+ * ```
66
+ */
67
+ export function useBlokHandle(): BlokEditorHandle {
68
+ const instanceRef = useRef<Blok | null>(null);
69
+
70
+ // Built once and kept stable: every method reads the live instance through
71
+ // `instanceRef`, so the handle identity (and its ref callback) never changes
72
+ // across renders even as the underlying editor is created or destroyed.
73
+ return useMemo<BlokEditorHandle>(() => ({
74
+ ref: (node): void => {
75
+ instanceRef.current = node;
76
+ },
77
+ get current(): Blok | null {
78
+ return instanceRef.current;
79
+ },
80
+ get isReady(): boolean {
81
+ return instanceRef.current !== null;
82
+ },
83
+ focus: (atEnd?: boolean): boolean => instanceRef.current?.focus(atEnd) ?? false,
84
+ clear: (): Promise<void> => instanceRef.current?.clear() ?? Promise.resolve(),
85
+ save: (): Promise<OutputData | null> =>
86
+ instanceRef.current !== null ? instanceRef.current.save() : Promise.resolve(null),
87
+ render: (data: OutputData | LooseOutputData): Promise<void> =>
88
+ instanceRef.current?.render(data) ?? Promise.resolve(),
89
+ setReadOnly: (state?: boolean): Promise<boolean> => {
90
+ const instance = instanceRef.current;
91
+
92
+ if (instance === null) {
93
+ return Promise.resolve(false);
94
+ }
95
+
96
+ // Resolve the toggle here (omitted `state` flips current) so we can use the
97
+ // non-deprecated `set()`, which requires an explicit target state.
98
+ return instance.readOnly.set(state ?? !instance.readOnly.isEnabled);
99
+ },
100
+ }), []);
101
+ }