@bloklabs/react 1.3.0 → 1.4.1

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,14 +1,34 @@
1
1
  {
2
2
  "name": "@bloklabs/react",
3
- "version": "1.3.0",
4
- "description": "React bindings for the Blok editor",
3
+ "version": "1.4.1",
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",
7
+ "homepage": "https://blokeditor.com",
7
8
  "repository": {
8
9
  "type": "git",
9
10
  "url": "git+https://github.com/JackUait/blok.git",
10
11
  "directory": "packages/react"
11
12
  },
13
+ "bugs": {
14
+ "url": "https://github.com/JackUait/blok/issues"
15
+ },
16
+ "keywords": [
17
+ "blok",
18
+ "react",
19
+ "react-component",
20
+ "block-editor",
21
+ "rich-text-editor",
22
+ "wysiwyg",
23
+ "editor",
24
+ "notion",
25
+ "notion-like",
26
+ "headless",
27
+ "block-based",
28
+ "contenteditable",
29
+ "json",
30
+ "typescript"
31
+ ],
12
32
  "type": "module",
13
33
  "sideEffects": false,
14
34
  "main": "./dist/index.cjs",
@@ -30,10 +50,11 @@
30
50
  "access": "public"
31
51
  },
32
52
  "scripts": {
33
- "build": "yarn run -T vite build --mode production"
53
+ "build": "yarn run -T vite build --mode production",
54
+ "test": "yarn run -T vitest run"
34
55
  },
35
56
  "peerDependencies": {
36
- "@bloklabs/core": "^1.3.0",
57
+ "@bloklabs/core": "^1.4.1",
37
58
  "react": "^18.0.0 || ^19.0.0",
38
59
  "react-dom": "^18.0.0 || ^19.0.0"
39
60
  }
@@ -38,7 +38,9 @@ import type { UseBlokConfig } from './types';
38
38
  */
39
39
  export interface BlokEditorProps
40
40
  extends Omit<UseBlokConfig, 'onReady'>,
41
- Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'onChange'> {
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'> {
42
44
  /**
43
45
  * When any value changes, the editor is destroyed and recreated. Keep each
44
46
  * value referentially stable (primitives or useMemo-stable objects) — a dep
@@ -0,0 +1,37 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { BlocksToHtmlOptions } from '@bloklabs/core/view';
3
+ import type { LooseOutputData, OutputData } from '@bloklabs/core';
4
+
5
+ import { useBlokView } from './useBlokView';
6
+
7
+ /**
8
+ * Props for {@link BlokView}.
9
+ */
10
+ export interface BlokViewProps {
11
+ /** Saved document to display (strict or loose wire shape; nullish tolerated). */
12
+ data: OutputData | LooseOutputData | null | undefined;
13
+ /** View schema from `defineBlokSchema` — display under the composition that produced the document. */
14
+ schema?: BlocksToHtmlOptions['schema'];
15
+ /** Custom per-tool renderers; win over built-ins. */
16
+ renderers?: BlocksToHtmlOptions['renderers'];
17
+ /** Unknown-tool policy (default 'skip'). */
18
+ onUnknownBlock?: BlocksToHtmlOptions['onUnknownBlock'];
19
+ /** Class for the single wrapper div. */
20
+ className?: string;
21
+ }
22
+
23
+ /**
24
+ * 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.
28
+ *
29
+ * @experimental Built on the `@experimental` ViewNode tree of
30
+ * `@bloklabs/core/view` — not frozen until a second framework adapter
31
+ * consumes it.
32
+ */
33
+ export const BlokView = ({ data, schema, renderers, onUnknownBlock, className }: BlokViewProps): ReactNode => {
34
+ const content = useBlokView(data, { schema, renderers, onUnknownBlock });
35
+
36
+ return <div className={className}>{content}</div>;
37
+ };
@@ -31,6 +31,7 @@ export const USE_BLOK_CONFIG_KEYS = [
31
31
  'onReady',
32
32
  'onChange',
33
33
  'onSave',
34
+ 'onError',
34
35
  'onEnter',
35
36
  'onBeforeRender',
36
37
  'onAfterRender',
@@ -1,10 +1,12 @@
1
- import type { ComponentType } from 'react';
1
+ import { createContext, useContext, useMemo, type ComponentType, type ReactElement } from 'react';
2
+ import { markSanitizerConfig } from '@bloklabs/core/adapters';
2
3
  import {
3
4
  BLOK_PORTAL_REGISTRY_CONFIG_KEY,
4
5
  BLOK_TOOL_NAME_CONFIG_KEY,
5
6
  type BlockPortalRegistry,
6
7
  } from './block-portal-registry';
7
- import type { SanitizerConfig } from '@/types';
8
+ import type { API, SanitizerConfig } from '@/types';
9
+ import type { MarkSnapshot, MarkSpec } from '@/types';
8
10
  import type { InlineTool, InlineToolConstructorOptions, MenuConfig } from '@/types/tools';
9
11
 
10
12
  /** Adapter-internal config keys, stripped before the config reaches the component. */
@@ -37,8 +39,57 @@ export interface ReactInlineToolRenderProps<Config = Record<string, unknown>> {
37
39
  config: Readonly<Partial<Config>>;
38
40
  }
39
41
 
42
+ /** The spec's mark operations, bound to the live selection via `api.marks`. */
43
+ export interface InlineToolMarkOps<State = void> {
44
+ /** Whether the whole selection currently carries the mark. */
45
+ has(): boolean;
46
+ /** Toggle the mark on the selection; returns true when now applied. */
47
+ toggle(state?: State): boolean;
48
+ /** Apply (or update in place) the mark on the selection. */
49
+ apply(state?: State): HTMLElement[];
50
+ /** Remove the mark from the selection. */
51
+ remove(): HTMLElement[];
52
+ /** Read the mark's current declared values at the selection. */
53
+ read(): MarkSnapshot | null;
54
+ }
55
+
56
+ /**
57
+ * Everything a React inline tool's component (and any component nested in it —
58
+ * swatches, popovers) can reach via {@link useInlineTool} without prop-drilling.
59
+ */
60
+ export interface InlineToolHandle<Config = Record<string, unknown>, State = void> {
61
+ /** Live active state, same value as the component's `active` prop. */
62
+ active: boolean;
63
+ /** The tool's config, same value as the component's `config` prop. */
64
+ config: Readonly<Partial<Config>>;
65
+ /** The editor API handed to the tool's constructor (undefined outside core). */
66
+ api: API | undefined;
67
+ /**
68
+ * Operations bound to the spec's `mark` — null when the spec declares no
69
+ * mark or the editor api provides no `marks` surface.
70
+ */
71
+ mark: InlineToolMarkOps<State> | null;
72
+ }
73
+
74
+ const InlineToolContext = createContext<InlineToolHandle | null>(null);
75
+
76
+ /**
77
+ * Reach the enclosing React inline tool from any component inside its
78
+ * portaled UI: live `active` state, the tool `config`, the editor `api`, and
79
+ * the spec's mark operations bound to the live selection.
80
+ */
81
+ export function useInlineTool<Config = Record<string, unknown>, State = void>(): InlineToolHandle<Config, State> {
82
+ const handle = useContext(InlineToolContext);
83
+
84
+ if (handle === null) {
85
+ throw new Error('useInlineTool() must be called from a component rendered by createReactInlineTool');
86
+ }
87
+
88
+ return handle as InlineToolHandle<Config, State>;
89
+ }
90
+
40
91
  /** Spec for {@link createReactInlineTool}. */
41
- export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
92
+ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>, State = void> {
42
93
  /** Tool type name — the MenuConfig item name (fallback when the tools-map key is unavailable). */
43
94
  type: string;
44
95
  /** Optional toolbar item title (shown in overflow/search contexts). */
@@ -52,13 +103,32 @@ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
52
103
  titleKey?: string;
53
104
  /** The component rendered as the tool's toolbar icon/UI. */
54
105
  component: ComponentType<ReactInlineToolRenderProps<Config>>;
55
- /** Applies/removes the formatting on the LIVE selection's range (captured at activation time). */
56
- surround?: (range: Range) => void;
57
- /** Reports whether the formatting is active for the given selection. */
58
- checkState?: (selection: Selection | null) => boolean;
106
+ /**
107
+ * Declarative description of the wrapper this tool produces. When present,
108
+ * `surround` (toggle), `checkState` (whole-range coverage) and `sanitize`
109
+ * are derived from it — range-aware splitting, adjacent-run semantics and
110
+ * the trailing-whitespace fix come from the editor's mark engine
111
+ * (`api.marks`) with no DOM plumbing in the tool.
112
+ */
113
+ mark?: MarkSpec<State>;
114
+ /**
115
+ * Applies/removes the formatting on the LIVE selection's range (captured at
116
+ * activation time). Receives the editor API as its second argument.
117
+ * Takes precedence over the `mark` derivation.
118
+ */
119
+ surround?: (range: Range, api: API | undefined) => void;
120
+ /**
121
+ * Reports whether the formatting is active for the given selection.
122
+ * Receives the editor API as its second argument.
123
+ * Takes precedence over the `mark` derivation.
124
+ */
125
+ checkState?: (selection: Selection | null, api: API | undefined) => boolean;
59
126
  /** Keyboard shortcut (e.g. `CMD+SHIFT+C`). */
60
127
  shortcut?: string;
61
- /** Inline sanitizer config declaring the markup this tool produces. */
128
+ /**
129
+ * Inline sanitizer config declaring the markup this tool produces.
130
+ * Defaults to the config derived from `mark` when one is declared.
131
+ */
62
132
  sanitize?: SanitizerConfig;
63
133
  /** Whether the tool stays available in read-only mode. */
64
134
  isReadOnlySupported?: boolean;
@@ -76,11 +146,15 @@ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
76
146
  * `destroy()` (called by the inline toolbar on close/teardown) unregisters the
77
147
  * portal so the component unmounts deterministically — no leaked React roots.
78
148
  *
149
+ * The editor `api` handed to the constructor is kept: it reaches `surround`/
150
+ * `checkState` as their second argument, powers the `mark` derivation, and is
151
+ * exposed to the portaled component tree via {@link useInlineTool}.
152
+ *
79
153
  * Without an injected registry (vanilla core, no `useBlok`), the tool still
80
154
  * renders a valid MenuConfig — the host element just stays empty.
81
155
  */
82
- export function createReactInlineTool<Config = Record<string, unknown>>(
83
- spec: CreateReactInlineToolSpec<Config>
156
+ export function createReactInlineTool<Config = Record<string, unknown>, State = void>(
157
+ spec: CreateReactInlineToolSpec<Config, State>
84
158
  ): (new (options: InlineToolConstructorOptions) => InlineTool) & {
85
159
  readonly __isBlokReactInlineTool: true;
86
160
  readonly isInline: true;
@@ -108,7 +182,11 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
108
182
  }
109
183
 
110
184
  public static get sanitize(): SanitizerConfig | undefined {
111
- return spec.sanitize;
185
+ if (spec.sanitize !== undefined) {
186
+ return spec.sanitize;
187
+ }
188
+
189
+ return spec.mark !== undefined ? markSanitizerConfig(spec.mark) : undefined;
112
190
  }
113
191
 
114
192
  public static get isReadOnlySupported(): boolean {
@@ -116,12 +194,20 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
116
194
  }
117
195
 
118
196
  private readonly registry: BlockPortalRegistry | undefined;
197
+ /** The editor API — kept (not discarded) so the tool has the same reach as a vanilla one. */
198
+ private readonly api: API | undefined;
119
199
  /** Name this tool is registered under in the consumer's `tools` map. */
120
200
  private readonly toolName: string;
121
201
  /** Unique portal-registry key — inline tools have no block id. */
122
202
  private readonly portalKey: string;
123
203
  /** The consumer's tool config with adapter-internal keys stripped. */
124
204
  private readonly toolConfig: Readonly<Partial<Config>>;
205
+ /**
206
+ * Stable provider component wrapping `spec.component` with the
207
+ * {@link useInlineTool} context. Created once per instance so prop pushes
208
+ * (setProps) re-render without remounting the consumer's component.
209
+ */
210
+ private readonly portalComponent: ComponentType<ReactInlineToolRenderProps<Config>>;
125
211
  /** Last active state pushed to the mounted component (change-dedup baseline). */
126
212
  private active = false;
127
213
  /** True while this instance owns a registered portal entry. */
@@ -130,6 +216,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
130
216
  public constructor(options: InlineToolConstructorOptions) {
131
217
  const config: Record<string, unknown> = options?.config ?? {};
132
218
 
219
+ this.api = options?.api;
133
220
  this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
134
221
 
135
222
  const toolName = config[BLOK_TOOL_NAME_CONFIG_KEY];
@@ -145,6 +232,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
145
232
  }
146
233
  }
147
234
  this.toolConfig = Object.freeze(publicConfig) as Readonly<Partial<Config>>;
235
+ this.portalComponent = this.createPortalComponent();
148
236
  }
149
237
 
150
238
  public render(): MenuConfig {
@@ -157,7 +245,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
157
245
  if (this.registry !== undefined) {
158
246
  this.registry.register(this.portalKey, {
159
247
  hostEl: host,
160
- component: spec.component as unknown as ComponentType<Record<string, unknown>>,
248
+ component: this.portalComponent as unknown as ComponentType<Record<string, unknown>>,
161
249
  props: { active: this.active, config: this.toolConfig },
162
250
  toolName: this.toolName,
163
251
  });
@@ -169,19 +257,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
169
257
  title: spec.title,
170
258
  icon: host,
171
259
  onActivate: (): void => {
172
- if (typeof spec.surround !== 'function') {
173
- return;
174
- }
175
-
176
- // Capture the LIVE selection at activation time (matching
177
- // wrapLegacyInlineTool) — never a range snapshotted at render.
178
- const selection = window.getSelection();
179
-
180
- if (!selection || selection.rangeCount === 0) {
181
- return;
182
- }
183
-
184
- spec.surround(selection.getRangeAt(0));
260
+ this.activate();
185
261
  },
186
262
  isActive: (): boolean => this.refreshActiveState(),
187
263
  };
@@ -202,15 +278,37 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
202
278
  this.registry?.unregister(this.portalKey);
203
279
  }
204
280
 
281
+ /**
282
+ * Run the tool's formatting action: an explicit `surround` on the LIVE
283
+ * selection's range (captured at activation time, matching
284
+ * wrapLegacyInlineTool — never a range snapshotted at render), or the
285
+ * mark derivation (`api.marks.toggle`) when only a `mark` is declared.
286
+ */
287
+ private activate(): void {
288
+ if (typeof spec.surround === 'function') {
289
+ const selection = window.getSelection();
290
+
291
+ if (!selection || selection.rangeCount === 0) {
292
+ return;
293
+ }
294
+
295
+ spec.surround(selection.getRangeAt(0), this.api);
296
+
297
+ return;
298
+ }
299
+
300
+ if (spec.mark !== undefined && this.api?.marks !== undefined) {
301
+ this.api.marks.toggle(spec.mark);
302
+ }
303
+ }
304
+
205
305
  /**
206
306
  * Recompute the active state from the live selection and push it to the
207
307
  * mounted component ONLY when it actually changed (no redundant
208
308
  * external-store notifications).
209
309
  */
210
310
  private refreshActiveState(): boolean {
211
- const active = typeof spec.checkState === 'function'
212
- ? Boolean(spec.checkState(window.getSelection()))
213
- : false;
311
+ const active = this.computeActiveState();
214
312
 
215
313
  if (active !== this.active) {
216
314
  this.active = active;
@@ -222,5 +320,64 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
222
320
 
223
321
  return active;
224
322
  }
323
+
324
+ /**
325
+ * Explicit `checkState` wins; otherwise the mark derivation answers
326
+ * "does the WHOLE selection carry the mark" via `api.marks.has`.
327
+ */
328
+ private computeActiveState(): boolean {
329
+ if (typeof spec.checkState === 'function') {
330
+ return Boolean(spec.checkState(window.getSelection(), this.api));
331
+ }
332
+
333
+ if (spec.mark !== undefined && this.api?.marks !== undefined) {
334
+ return this.api.marks.has(spec.mark);
335
+ }
336
+
337
+ return false;
338
+ }
339
+
340
+ /**
341
+ * Build the per-instance provider component: the consumer's component
342
+ * rendered inside the {@link useInlineTool} context, so nested
343
+ * swatches/popovers reach the tool without prop-drilling.
344
+ */
345
+ private createPortalComponent(): ComponentType<ReactInlineToolRenderProps<Config>> {
346
+ const api = this.api;
347
+ const mark = spec.mark;
348
+ const Component = spec.component;
349
+
350
+ const markOps: InlineToolMarkOps<State> | null = mark !== undefined && api?.marks !== undefined
351
+ ? {
352
+ has: (): boolean => api.marks.has(mark),
353
+ toggle: (state?: State): boolean => api.marks.toggle(mark, state),
354
+ apply: (state?: State): HTMLElement[] => api.marks.apply(mark, state),
355
+ remove: (): HTMLElement[] => api.marks.remove(mark),
356
+ read: (): MarkSnapshot | null => api.marks.read(mark),
357
+ }
358
+ : null;
359
+
360
+ const PortalComponent = (props: ReactInlineToolRenderProps<Config>): ReactElement => {
361
+ const handle = useMemo<InlineToolHandle>(
362
+ () => ({
363
+ active: props.active,
364
+ config: props.config,
365
+ api,
366
+ mark: markOps as InlineToolMarkOps | null,
367
+ }),
368
+ [props.active, props.config]
369
+ );
370
+
371
+ return (
372
+ <InlineToolContext.Provider value={handle}>
373
+ <Component active={props.active} config={props.config} />
374
+ </InlineToolContext.Provider>
375
+ );
376
+ };
377
+
378
+ PortalComponent.displayName = `BlokInlineTool(${this.toolName})`;
379
+
380
+ return PortalComponent;
381
+ }
225
382
  };
226
383
  }
package/src/index.ts CHANGED
@@ -5,6 +5,11 @@ export { BlokProvider, useBlokDefaults } from './provide-blok';
5
5
  export type { UseBlokConfig, BlokContentProps } from './types';
6
6
  export type { BlokEditorProps } from './BlokEditor';
7
7
  export { useBlocks } from './useBlocks';
8
+ export { useBlokReady } from './useBlokReady';
9
+ export type { UseBlokReadyOptions } from './useBlokReady';
10
+ export { BlokView } from './BlokView';
11
+ export type { BlokViewProps } from './BlokView';
12
+ export { useBlokView } from './useBlokView';
8
13
  export { createReactBlock } from './createReactBlock';
9
14
  export type {
10
15
  CreateReactBlockSpec,
@@ -15,10 +20,12 @@ export type {
15
20
  PropSchema,
16
21
  PropSchemaEntry,
17
22
  } from './createReactBlock';
18
- export { createReactInlineTool } from './createReactInlineTool';
23
+ export { createReactInlineTool, useInlineTool } from './createReactInlineTool';
19
24
  export type {
20
25
  CreateReactInlineToolSpec,
21
26
  ReactInlineToolRenderProps,
27
+ InlineToolHandle,
28
+ InlineToolMarkOps,
22
29
  } from './createReactInlineTool';
23
30
  export {
24
31
  createBlockPortalRegistry,
package/src/types.ts CHANGED
@@ -6,7 +6,11 @@ import type { BlokConfig, Blok, EditorWidth } from '@/types';
6
6
  * React-only reactive `width` prop.
7
7
  *
8
8
  * Reactive props (sync after mount without recreation):
9
- * - `readOnly` — calls `editor.readOnly.set(value)`
9
+ * - `readOnly` — calls `editor.readOnly.set(value)`; the object form also
10
+ * syncs `hideControls` via `editor.readOnly.set(enabled, { hideControls })`
11
+ * - `hideToolbar` — calls `editor.toolbar.setHidden(value)`
12
+ * - `inlineToolbar` — calls `editor.tools.setInlineToolbar(value)` (arrays
13
+ * are compared by content, so a re-created literal is a no-op)
10
14
  * - `autofocus` — calls `editor.focus()` when changed to true
11
15
  * - `theme` — calls `editor.theme.set(value)`
12
16
  * - `width` — calls `editor.width.set(value)`
package/src/useBlok.ts CHANGED
@@ -417,14 +417,51 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
417
417
  }
418
418
  });
419
419
 
420
- // Reactive: readOnly (object form normalized so the effect dep is a stable boolean)
421
- const readOnlyEnabled = normalizeReadOnlyConfig(config.readOnly).enabled;
420
+ // Reactive: readOnly (object form normalized so the effect deps are stable booleans).
421
+ // The object form additionally syncs `hideControls` through the options
422
+ // parameter of `readOnly.set` — a plain boolean prop keeps the historical
423
+ // boolean-only call so it never overwrites a construction-time object form.
424
+ const readOnlyIsObjectForm = typeof config.readOnly === 'object' && config.readOnly !== null;
425
+ const { enabled: readOnlyEnabled, hideControls: readOnlyHideControls } = normalizeReadOnlyConfig(config.readOnly);
422
426
  useEffect(() => {
423
427
  if (editor === null) {
424
428
  return;
425
429
  }
426
- void editor.readOnly.set(readOnlyEnabled);
427
- }, [editor, readOnlyEnabled]);
430
+
431
+ if (readOnlyIsObjectForm) {
432
+ void editor.readOnly.set(readOnlyEnabled, { hideControls: readOnlyHideControls });
433
+ } else {
434
+ void editor.readOnly.set(readOnlyEnabled);
435
+ }
436
+ }, [editor, readOnlyEnabled, readOnlyHideControls, readOnlyIsObjectForm]);
437
+
438
+ // Reactive: hideToolbar — `toolbar.setHidden` writes `config.hideToolbar`
439
+ // (read live by the toolbar's open guards) AND toggles the wrapper's
440
+ // toolbar-hidden attribute (the CSS hook that collapses the gutter), so a
441
+ // prop flip takes effect in place. Guarded on `undefined` so a consumer who
442
+ // never sets the prop gets no call at all (mirrors theme/width).
443
+ const { hideToolbar } = config;
444
+ useEffect(() => {
445
+ if (editor === null || hideToolbar === undefined) {
446
+ return;
447
+ }
448
+ editor.toolbar.setHidden(hideToolbar);
449
+ }, [editor, hideToolbar]);
450
+
451
+ // Reactive: inlineToolbar — `tools.setInlineToolbar` re-assigns every block
452
+ // tool's inline set and invalidates sanitize caches, so the change lands on
453
+ // the next selection without a re-render. The dep is a content serialization
454
+ // (boolean | string[]) so a re-created array literal with the same entries
455
+ // does not re-fire the setter.
456
+ const { inlineToolbar } = config;
457
+ const inlineToolbarKey = inlineToolbar === undefined ? undefined : JSON.stringify(inlineToolbar);
458
+ useEffect(() => {
459
+ if (editor === null || inlineToolbar === undefined) {
460
+ return;
461
+ }
462
+ editor.tools.setInlineToolbar(inlineToolbar);
463
+ // 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
464
+ }, [editor, inlineToolbarKey]);
428
465
 
429
466
  // Reactive: autofocus
430
467
  const { autofocus } = config;
@@ -480,6 +517,45 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
480
517
  editor.tokens.set(styleTokens);
481
518
  }, [editor, styleTokens]);
482
519
 
520
+ // Reactive: i18n
521
+ //
522
+ // `config.i18n` was read once during the editor's boot and never again, so a
523
+ // host driving a language switcher from React state had no correct option:
524
+ // accept a permanently stale UI, or bump `deps` and destroy the editor
525
+ // mid-typing. Push genuine changes through the runtime `i18n.update` API
526
+ // instead, deep-equal–deduped because `i18n` is almost always a fresh object
527
+ // literal each render (a `useMemo`d message map re-created on locale change
528
+ // is the whole point of the prop).
529
+ //
530
+ // `defaultLocale` is deliberately not forwarded: it only decides the
531
+ // fallback while resolving the INITIAL locale, so it is inert after mount.
532
+ const { i18n } = config;
533
+ /*
534
+ * Seeded with the mount-time value: construction already applied it, so the
535
+ * first effect run is a no-op and only genuine changes push.
536
+ */
537
+ const appliedI18nRef = useRef<UseBlokConfig['i18n']>(config.i18n);
538
+
539
+ useEffect(() => {
540
+ if (editor === null || i18n === undefined) {
541
+ return;
542
+ }
543
+
544
+ if (deepEqual(i18n, appliedI18nRef.current)) {
545
+ return;
546
+ }
547
+
548
+ appliedI18nRef.current = i18n;
549
+
550
+ const { locale, messages, direction } = i18n;
551
+
552
+ void editor.i18n.update({
553
+ ...(locale === undefined ? {} : { locale }),
554
+ ...(messages === undefined ? {} : { messages }),
555
+ ...(direction === undefined ? {} : { direction }),
556
+ });
557
+ }, [editor, i18n]);
558
+
483
559
  // Reactive: placeholder
484
560
  const { placeholder } = config;
485
561
  useEffect(() => {
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState, type RefObject } from 'react';
2
+ import { Blok as BlokRuntime } from '@bloklabs/core';
3
+
4
+ /** Options accepted by {@link useBlokReady}. */
5
+ export interface UseBlokReadyOptions {
6
+ /**
7
+ * Restrict the wait to editors mounted inside this element. Accepts the ref
8
+ * you already hold on the container (read on every notification, so a ref
9
+ * that attaches after the first render is picked up).
10
+ *
11
+ * Omit it to observe every editor on the page. Passing it while it still
12
+ * resolves to null reports NOT ready — an unresolved scope must never fall
13
+ * back to the page-global one.
14
+ */
15
+ within?: RefObject<Element | null> | Element | null;
16
+ /**
17
+ * `'ready'` (default) settles when each editor has finished booting.
18
+ * `'rendered'` also waits for its content to be in the DOM and re-arms on
19
+ * every post-boot re-render.
20
+ */
21
+ settleOn?: 'ready' | 'rendered';
22
+ }
23
+
24
+ /**
25
+ * Live readiness of the Blok editors in a DOM scope.
26
+ *
27
+ * Returns `false` until every editor inside `within` is settled, and follows
28
+ * the scope afterwards: editors mounted later re-close the gate, and with
29
+ * `settleOn: 'rendered'` so does each re-render. A scope holding no editors is
30
+ * ready, so callers need no "nothing to wait for" special case.
31
+ *
32
+ * The first render always reports `false` — the scope element is not attached
33
+ * yet, and over-waiting is safe while under-waiting is a bug.
34
+ * @param options - scope and readiness depth
35
+ */
36
+ export function useBlokReady(options: UseBlokReadyOptions = {}): boolean {
37
+ const { within, settleOn } = options;
38
+ const [ready, setReady] = useState(false);
39
+
40
+ useEffect(() => {
41
+ const read = (): void => {
42
+ const scope = within instanceof Element ? within : (within?.current ?? null);
43
+
44
+ // A scope that was asked for but has not resolved yet is NOT the global
45
+ // scope — reporting ready here would gate on the wrong set of editors.
46
+ setReady(within !== undefined && scope === null
47
+ ? false
48
+ : BlokRuntime.readyState({
49
+ within: scope,
50
+ settleOn,
51
+ }).ready);
52
+ };
53
+
54
+ read();
55
+
56
+ return BlokRuntime.subscribeReady(read);
57
+ }, [within, settleOn]);
58
+
59
+ return ready;
60
+ }
@@ -0,0 +1,38 @@
1
+ import { createElement, Fragment, useMemo } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { blocksToViewNodes } from '@bloklabs/core/view';
4
+ import type { BlocksToHtmlOptions } from '@bloklabs/core/view';
5
+ import type { LooseOutputData, OutputData } from '@bloklabs/core';
6
+
7
+ import { viewNodesToReact } from './view-nodes-to-react';
8
+
9
+ /**
10
+ * Render a saved Blok document to React elements with NO wrapper element —
11
+ * a Fragment of the block elements, for placement inside `<label>`s, table
12
+ * cells, and other slots where an extra `<div>` is invalid or unwanted.
13
+ *
14
+ * Synchronous and effect-free (SSR-safe); memoized on the data reference and
15
+ * the individual option values.
16
+ *
17
+ * @experimental Built on the `@experimental` ViewNode tree of
18
+ * `@bloklabs/core/view` — not frozen until a second framework adapter
19
+ * consumes it.
20
+ * @param data - saved document (strict or loose wire shape; nullish tolerated)
21
+ * @param options - schema / custom renderers / unknown-block policy
22
+ */
23
+ export const useBlokView = (
24
+ data: OutputData | LooseOutputData | null | undefined,
25
+ options?: BlocksToHtmlOptions
26
+ ): ReactNode => {
27
+ const schema = options?.schema;
28
+ const renderers = options?.renderers;
29
+ const onUnknownBlock = options?.onUnknownBlock;
30
+
31
+ return useMemo(() => {
32
+ return createElement(
33
+ Fragment,
34
+ null,
35
+ ...viewNodesToReact(blocksToViewNodes(data, { schema, renderers, onUnknownBlock }))
36
+ );
37
+ }, [data, schema, renderers, onUnknownBlock]);
38
+ };