@bloklabs/react 1.2.6 → 1.4.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/package.json CHANGED
@@ -1,14 +1,34 @@
1
1
  {
2
2
  "name": "@bloklabs/react",
3
- "version": "1.2.6",
4
- "description": "React bindings for the Blok editor",
3
+ "version": "1.4.0",
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.2.6",
57
+ "@bloklabs/core": "^1.4.0",
37
58
  "react": "^18.0.0 || ^19.0.0",
38
59
  "react-dom": "^18.0.0 || ^19.0.0"
39
60
  }
@@ -108,7 +108,7 @@ export const BlokEditor = forwardRef<Blok | null, BlokEditorProps>(
108
108
  }
109
109
  }
110
110
 
111
- const editor = useBlok(config as UseBlokConfig, deps);
111
+ const editor = useBlok(config, deps);
112
112
 
113
113
  useImperativeHandle<Blok | null, Blok | null>(ref, () => editor, [editor]);
114
114
 
@@ -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
+ };
@@ -1,5 +1,6 @@
1
- import { useEffect, useRef, type ComponentType, type ReactElement } from 'react';
1
+ import { isValidElement, useEffect, useRef, type ComponentType, type ReactElement } from 'react';
2
2
  import { flushSync } from 'react-dom';
3
+ import { createRoot } from 'react-dom/client';
3
4
  import { DATA_ATTR, deepEqual, fillDefaults, mountChildBlocks, type PropSchema } from '@bloklabs/core/adapters';
4
5
  import {
5
6
  BLOK_PORTAL_REGISTRY_CONFIG_KEY,
@@ -7,7 +8,7 @@ import {
7
8
  type BlockPortalRegistry,
8
9
  } from './block-portal-registry';
9
10
  import type { BlockAPI } from '@/types/api';
10
- import type { BlockToolConstructorOptions, BlockToolData, ToolboxConfig } from '@/types/tools';
11
+ import type { BlockToolConstructorOptions, BlockToolData, ToolboxConfig, ToolboxConfigEntry } from '@/types/tools';
11
12
 
12
13
  export type { PropSchema, PropSchemaEntry } from '@bloklabs/core/adapters';
13
14
 
@@ -21,7 +22,12 @@ const INTERNAL_CONFIG_KEYS: readonly string[] = [
21
22
  export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
22
23
  /** Frozen snapshot of the block data. Re-rendered with a new value on change; never mutate. */
23
24
  data: Readonly<Data>;
24
- /** The ONLY data write path: merge a partial patch and sync once. */
25
+ /**
26
+ * The ONLY data write path: merge a partial patch and sync once. Idempotency
27
+ * is part of the public contract — a patch that changes nothing is a FULL
28
+ * no-op (no re-render, no change event), so effects may echo current values
29
+ * through `commit` on any render, including mount, without a guard.
30
+ */
25
31
  commit: (patch: Partial<Data>) => void;
26
32
  /** This block's per-block API (id, connection methods, dispatchChange…). */
27
33
  block: BlockAPI;
@@ -46,16 +52,49 @@ export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
46
52
  BlockChildren: ComponentType;
47
53
  }
48
54
 
55
+ /**
56
+ * Props handed to a React block's `viewComponent` (the read-only renderer):
57
+ * the entry props minus `commit` — a display renderer has no write path.
58
+ */
59
+ export type ReactBlockViewProps<Data, Config = Record<string, unknown>> = Omit<
60
+ ReactBlockRenderProps<Data, Config>,
61
+ 'commit' | 'readOnly'
62
+ >;
63
+
64
+ /** A toolbox entry whose `icon` may be a React element instead of an SVG string. */
65
+ export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon'> & {
66
+ /**
67
+ * Toolbox icon. Core's toolbox chrome consumes markup strings, but React
68
+ * authors may pass the same element they render in the block body — the
69
+ * factory serializes it to markup once (lazily, cached) so icons are never
70
+ * duplicated as parallel raw SVG strings. Without a DOM (SSR) the element is
71
+ * returned as-is and serialization retries on the next browser-side access.
72
+ */
73
+ icon?: string | ReactElement;
74
+ };
75
+
76
+ /** Toolbox config for React blocks: single entry or several variants. */
77
+ export type ReactToolboxConfig = ReactToolboxConfigEntry | ReactToolboxConfigEntry[];
78
+
49
79
  /** Spec for {@link createReactBlock}. */
50
80
  export interface CreateReactBlockSpec<Data = BlockToolData, Config = Record<string, unknown>> {
51
81
  /** Tool type name (registered key). */
52
82
  type: string;
53
- /** Optional toolbox entry. */
54
- toolbox?: ToolboxConfig;
83
+ /** Optional toolbox entry; `icon` accepts a React element (see {@link ReactToolboxConfigEntry}). */
84
+ toolbox?: ReactToolboxConfig;
55
85
  /** Declarative defaults that also define the exact `save()` key set. */
56
86
  propSchema: PropSchema;
57
87
  /** The component rendered for each block instance. */
58
88
  component: ComponentType<ReactBlockRenderProps<Data, Config>>;
89
+ /**
90
+ * Optional read-only renderer: when the editor is read-only, this component
91
+ * is rendered INSTEAD of `component`, so blocks don't hand-plumb a
92
+ * display-vs-edit ternary. It receives the entry props minus `commit` (no
93
+ * write path) and minus `readOnly` (always true by definition). Toggling read-only
94
+ * swaps renderers — ephemeral state does not survive the swap; omit
95
+ * `viewComponent` to keep the single-component in-place toggle instead.
96
+ */
97
+ viewComponent?: ComponentType<ReactBlockViewProps<Data, Config>>;
59
98
  /** Optional lifecycle callbacks mapped from Blok's block hooks. */
60
99
  onRendered?: (block: BlockAPI) => void;
61
100
  onMoved?: (block: BlockAPI) => void;
@@ -96,12 +135,116 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
96
135
  readonly toolbox: ToolboxConfig | undefined;
97
136
  readonly isReadOnlySupported: boolean;
98
137
  } {
138
+ /**
139
+ * Serialize a React element (an icon) to markup with a throwaway synchronous
140
+ * root. Returns null when serialization cannot complete: without a DOM
141
+ * (SSR — core never reads `toolbox` server-side, but a consumer may), or
142
+ * when the flush was deferred because React was already rendering (flushSync
143
+ * inside a render is a no-op with a warning, leaving the mount empty). A
144
+ * null result is NOT cached, so the next access retries.
145
+ */
146
+ const renderElementToMarkup = (element: ReactElement): string | null => {
147
+ if (typeof document === 'undefined') {
148
+ return null;
149
+ }
150
+
151
+ const mount = document.createElement('div');
152
+ const root = createRoot(mount);
153
+
154
+ flushSync(() => {
155
+ root.render(element);
156
+ });
157
+
158
+ const markup = mount.innerHTML;
159
+
160
+ root.unmount();
161
+
162
+ return markup === '' ? null : markup;
163
+ };
164
+
165
+ const convertToolbox = (): { value: ToolboxConfig | undefined; complete: boolean } => {
166
+ const status = { complete: true };
167
+
168
+ const toEntry = (entry: ReactToolboxConfigEntry): ToolboxConfigEntry => {
169
+ if (!isValidElement(entry.icon)) {
170
+ return entry as ToolboxConfigEntry;
171
+ }
172
+
173
+ const markup = renderElementToMarkup(entry.icon);
174
+
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;
180
+
181
+ return entry as ToolboxConfigEntry;
182
+ }
183
+
184
+ return { ...entry, icon: markup };
185
+ };
186
+
187
+ if (spec.toolbox === undefined) {
188
+ return { value: undefined, complete: status.complete };
189
+ }
190
+
191
+ const value = Array.isArray(spec.toolbox) ? spec.toolbox.map(toEntry) : toEntry(spec.toolbox);
192
+
193
+ return { value, complete: status.complete };
194
+ };
195
+
196
+ /** Lazily-computed core-compatible toolbox (React element icons serialized once). */
197
+ const toolboxCache: { resolved: boolean; value: ToolboxConfig | undefined } = {
198
+ resolved: false,
199
+ value: undefined,
200
+ };
201
+
202
+ const resolveToolbox = (): ToolboxConfig | undefined => {
203
+ if (toolboxCache.resolved) {
204
+ return toolboxCache.value;
205
+ }
206
+
207
+ const { value, complete } = convertToolbox();
208
+
209
+ // Only a fully-serialized toolbox is cached; an incomplete pass (no DOM,
210
+ // deferred flush) is returned as-is and recomputed on the next access.
211
+ if (complete) {
212
+ toolboxCache.value = value;
213
+ toolboxCache.resolved = true;
214
+ }
215
+
216
+ return value;
217
+ };
218
+
219
+ /**
220
+ * The component actually registered with the portal: when the spec provides a
221
+ * `viewComponent`, this chooser swaps to it while read-only (dropping the
222
+ * write-path props); otherwise the edit component is registered directly and
223
+ * keeps its in-place read-only toggle semantics.
224
+ */
225
+ const ReadOnlySwitch = (props: ReactBlockRenderProps<Data, Config>): ReactElement => {
226
+ const ViewComponent = spec.viewComponent;
227
+
228
+ if (props.readOnly && ViewComponent !== undefined) {
229
+ const { commit: _commit, readOnly: _readOnly, ...viewProps } = props;
230
+
231
+ return <ViewComponent {...(viewProps)} />;
232
+ }
233
+
234
+ const EditComponent = spec.component;
235
+
236
+ return <EditComponent {...props} />;
237
+ };
238
+
239
+ const entryComponent: ComponentType<ReactBlockRenderProps<Data, Config>> =
240
+ spec.viewComponent === undefined ? spec.component : ReadOnlySwitch;
241
+
99
242
  return class ReactBlockTool {
100
243
  /** Marker so `useBlok` can detect react-block tools and inject the registry. */
101
244
  public static readonly __isBlokReactBlock = true as const;
102
245
 
103
246
  public static get toolbox(): ToolboxConfig | undefined {
104
- return spec.toolbox;
247
+ return resolveToolbox();
105
248
  }
106
249
 
107
250
  /**
@@ -190,7 +333,7 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
190
333
 
191
334
  this.registry?.register(this.blockApi.id, {
192
335
  hostEl: host,
193
- component: spec.component as unknown as ComponentType<Record<string, unknown>>,
336
+ component: entryComponent as unknown as ComponentType<Record<string, unknown>>,
194
337
  props: this.buildProps(),
195
338
  toolName: this.toolName,
196
339
  });
@@ -205,7 +348,7 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
205
348
 
206
349
  public save(): BlockToolData {
207
350
  // The complete, frozen mirror — never the DOM, never partial.
208
- return this.mirror as BlockToolData;
351
+ return this.mirror;
209
352
  }
210
353
 
211
354
  public async setData(newData: BlockToolData): Promise<boolean> {
@@ -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,21 +39,96 @@ 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). */
45
96
  title?: string;
97
+ /**
98
+ * Translation key for the toolbar label, resolved by core i18n as
99
+ * `toolNames.{titleKey}` (or used verbatim when it contains a dot). Without
100
+ * it a custom tool's label is only localizable through the legacy
101
+ * capitalized-tool-name fallback.
102
+ */
103
+ titleKey?: string;
46
104
  /** The component rendered as the tool's toolbar icon/UI. */
47
105
  component: ComponentType<ReactInlineToolRenderProps<Config>>;
48
- /** Applies/removes the formatting on the LIVE selection's range (captured at activation time). */
49
- surround?: (range: Range) => void;
50
- /** Reports whether the formatting is active for the given selection. */
51
- 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;
52
126
  /** Keyboard shortcut (e.g. `CMD+SHIFT+C`). */
53
127
  shortcut?: string;
54
- /** 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
+ */
55
132
  sanitize?: SanitizerConfig;
56
133
  /** Whether the tool stays available in read-only mode. */
57
134
  isReadOnlySupported?: boolean;
@@ -69,15 +146,20 @@ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
69
146
  * `destroy()` (called by the inline toolbar on close/teardown) unregisters the
70
147
  * portal so the component unmounts deterministically — no leaked React roots.
71
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
+ *
72
153
  * Without an injected registry (vanilla core, no `useBlok`), the tool still
73
154
  * renders a valid MenuConfig — the host element just stays empty.
74
155
  */
75
- export function createReactInlineTool<Config = Record<string, unknown>>(
76
- spec: CreateReactInlineToolSpec<Config>
156
+ export function createReactInlineTool<Config = Record<string, unknown>, State = void>(
157
+ spec: CreateReactInlineToolSpec<Config, State>
77
158
  ): (new (options: InlineToolConstructorOptions) => InlineTool) & {
78
159
  readonly __isBlokReactInlineTool: true;
79
160
  readonly isInline: true;
80
161
  readonly title: string | undefined;
162
+ readonly titleKey: string | undefined;
81
163
  readonly shortcut: string | undefined;
82
164
  readonly sanitize: SanitizerConfig | undefined;
83
165
  readonly isReadOnlySupported: boolean;
@@ -91,12 +173,20 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
91
173
  return spec.title;
92
174
  }
93
175
 
176
+ public static get titleKey(): string | undefined {
177
+ return spec.titleKey;
178
+ }
179
+
94
180
  public static get shortcut(): string | undefined {
95
181
  return spec.shortcut;
96
182
  }
97
183
 
98
184
  public static get sanitize(): SanitizerConfig | undefined {
99
- return spec.sanitize;
185
+ if (spec.sanitize !== undefined) {
186
+ return spec.sanitize;
187
+ }
188
+
189
+ return spec.mark !== undefined ? markSanitizerConfig(spec.mark) : undefined;
100
190
  }
101
191
 
102
192
  public static get isReadOnlySupported(): boolean {
@@ -104,12 +194,20 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
104
194
  }
105
195
 
106
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;
107
199
  /** Name this tool is registered under in the consumer's `tools` map. */
108
200
  private readonly toolName: string;
109
201
  /** Unique portal-registry key — inline tools have no block id. */
110
202
  private readonly portalKey: string;
111
203
  /** The consumer's tool config with adapter-internal keys stripped. */
112
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>>;
113
211
  /** Last active state pushed to the mounted component (change-dedup baseline). */
114
212
  private active = false;
115
213
  /** True while this instance owns a registered portal entry. */
@@ -118,6 +216,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
118
216
  public constructor(options: InlineToolConstructorOptions) {
119
217
  const config: Record<string, unknown> = options?.config ?? {};
120
218
 
219
+ this.api = options?.api;
121
220
  this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
122
221
 
123
222
  const toolName = config[BLOK_TOOL_NAME_CONFIG_KEY];
@@ -133,6 +232,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
133
232
  }
134
233
  }
135
234
  this.toolConfig = Object.freeze(publicConfig) as Readonly<Partial<Config>>;
235
+ this.portalComponent = this.createPortalComponent();
136
236
  }
137
237
 
138
238
  public render(): MenuConfig {
@@ -145,7 +245,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
145
245
  if (this.registry !== undefined) {
146
246
  this.registry.register(this.portalKey, {
147
247
  hostEl: host,
148
- component: spec.component as unknown as ComponentType<Record<string, unknown>>,
248
+ component: this.portalComponent as unknown as ComponentType<Record<string, unknown>>,
149
249
  props: { active: this.active, config: this.toolConfig },
150
250
  toolName: this.toolName,
151
251
  });
@@ -157,19 +257,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
157
257
  title: spec.title,
158
258
  icon: host,
159
259
  onActivate: (): void => {
160
- if (typeof spec.surround !== 'function') {
161
- return;
162
- }
163
-
164
- // Capture the LIVE selection at activation time (matching
165
- // wrapLegacyInlineTool) — never a range snapshotted at render.
166
- const selection = window.getSelection();
167
-
168
- if (!selection || selection.rangeCount === 0) {
169
- return;
170
- }
171
-
172
- spec.surround(selection.getRangeAt(0));
260
+ this.activate();
173
261
  },
174
262
  isActive: (): boolean => this.refreshActiveState(),
175
263
  };
@@ -190,15 +278,37 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
190
278
  this.registry?.unregister(this.portalKey);
191
279
  }
192
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
+
193
305
  /**
194
306
  * Recompute the active state from the live selection and push it to the
195
307
  * mounted component ONLY when it actually changed (no redundant
196
308
  * external-store notifications).
197
309
  */
198
310
  private refreshActiveState(): boolean {
199
- const active = typeof spec.checkState === 'function'
200
- ? Boolean(spec.checkState(window.getSelection()))
201
- : false;
311
+ const active = this.computeActiveState();
202
312
 
203
313
  if (active !== this.active) {
204
314
  this.active = active;
@@ -210,5 +320,64 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
210
320
 
211
321
  return active;
212
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
+ }
213
382
  };
214
383
  }
package/src/index.ts CHANGED
@@ -5,17 +5,27 @@ 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,
11
16
  ReactBlockRenderProps,
17
+ ReactBlockViewProps,
18
+ ReactToolboxConfig,
19
+ ReactToolboxConfigEntry,
12
20
  PropSchema,
13
21
  PropSchemaEntry,
14
22
  } from './createReactBlock';
15
- export { createReactInlineTool } from './createReactInlineTool';
23
+ export { createReactInlineTool, useInlineTool } from './createReactInlineTool';
16
24
  export type {
17
25
  CreateReactInlineToolSpec,
18
26
  ReactInlineToolRenderProps,
27
+ InlineToolHandle,
28
+ InlineToolMarkOps,
19
29
  } from './createReactInlineTool';
20
30
  export {
21
31
  createBlockPortalRegistry,