@bloklabs/react 1.2.5 → 1.3.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@bloklabs/react",
3
- "version": "1.2.5",
3
+ "version": "1.3.0",
4
4
  "description": "React bindings for the Blok editor",
5
5
  "license": "Apache-2.0",
6
6
  "author": "JackUait",
@@ -33,7 +33,7 @@
33
33
  "build": "yarn run -T vite build --mode production"
34
34
  },
35
35
  "peerDependencies": {
36
- "@bloklabs/core": "^1.2.5",
36
+ "@bloklabs/core": "^1.3.0",
37
37
  "react": "^18.0.0 || ^19.0.0",
38
38
  "react-dom": "^18.0.0 || ^19.0.0"
39
39
  }
@@ -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
 
@@ -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> {
@@ -0,0 +1,226 @@
1
+ import type { ComponentType } from 'react';
2
+ import {
3
+ BLOK_PORTAL_REGISTRY_CONFIG_KEY,
4
+ BLOK_TOOL_NAME_CONFIG_KEY,
5
+ type BlockPortalRegistry,
6
+ } from './block-portal-registry';
7
+ import type { SanitizerConfig } from '@/types';
8
+ import type { InlineTool, InlineToolConstructorOptions, MenuConfig } from '@/types/tools';
9
+
10
+ /** Adapter-internal config keys, stripped before the config reaches the component. */
11
+ const INTERNAL_CONFIG_KEYS: readonly string[] = [
12
+ BLOK_PORTAL_REGISTRY_CONFIG_KEY,
13
+ BLOK_TOOL_NAME_CONFIG_KEY,
14
+ ];
15
+
16
+ /**
17
+ * Unique portal-registry key per tool instance (inline tools have no block id
18
+ * to key on, and multiple instances of the same tool can coexist — e.g. the
19
+ * visible toolbar's instance plus a shortcut render-probe).
20
+ * @param toolName - name the tool is registered under
21
+ */
22
+ const generatePortalKey = (toolName: string): string =>
23
+ `__blok-inline-tool__:${toolName}:${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
24
+
25
+ /** Props handed to a React inline tool's `component` (its toolbar icon/UI). */
26
+ export interface ReactInlineToolRenderProps<Config = Record<string, unknown>> {
27
+ /**
28
+ * Whether the tool's formatting is active for the current selection —
29
+ * derived from the spec's `checkState` every time the toolbar queries it.
30
+ * Pushed IN PLACE (no remount), so the icon restyles live.
31
+ */
32
+ active: boolean;
33
+ /**
34
+ * The tool's `config` from the consumer's `tools` map (adapter-internal
35
+ * keys stripped) — the channel for host props (palette, locale…).
36
+ */
37
+ config: Readonly<Partial<Config>>;
38
+ }
39
+
40
+ /** Spec for {@link createReactInlineTool}. */
41
+ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
42
+ /** Tool type name — the MenuConfig item name (fallback when the tools-map key is unavailable). */
43
+ type: string;
44
+ /** Optional toolbar item title (shown in overflow/search contexts). */
45
+ title?: string;
46
+ /**
47
+ * Translation key for the toolbar label, resolved by core i18n as
48
+ * `toolNames.{titleKey}` (or used verbatim when it contains a dot). Without
49
+ * it a custom tool's label is only localizable through the legacy
50
+ * capitalized-tool-name fallback.
51
+ */
52
+ titleKey?: string;
53
+ /** The component rendered as the tool's toolbar icon/UI. */
54
+ 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;
59
+ /** Keyboard shortcut (e.g. `CMD+SHIFT+C`). */
60
+ shortcut?: string;
61
+ /** Inline sanitizer config declaring the markup this tool produces. */
62
+ sanitize?: SanitizerConfig;
63
+ /** Whether the tool stays available in read-only mode. */
64
+ isReadOnlySupported?: boolean;
65
+ }
66
+
67
+ /**
68
+ * Author a first-party React inline tool. Returns an inline-tool constructable
69
+ * registered exactly like a vanilla tool (`tools: { color: createReactInlineTool(...) }`).
70
+ *
71
+ * Mirrors the `createReactBlock` portal pattern: `render()` returns a
72
+ * `MenuConfig` whose `icon` is a Blok-owned host element
73
+ * (`data-blok-mutation-free`) the component is portaled into via the editor's
74
+ * shared `BlockPortalHost` tree — app-level context (themes, styled-components
75
+ * providers) reaches the icon with no consumer-managed `createRoot`, and
76
+ * `destroy()` (called by the inline toolbar on close/teardown) unregisters the
77
+ * portal so the component unmounts deterministically — no leaked React roots.
78
+ *
79
+ * Without an injected registry (vanilla core, no `useBlok`), the tool still
80
+ * renders a valid MenuConfig — the host element just stays empty.
81
+ */
82
+ export function createReactInlineTool<Config = Record<string, unknown>>(
83
+ spec: CreateReactInlineToolSpec<Config>
84
+ ): (new (options: InlineToolConstructorOptions) => InlineTool) & {
85
+ readonly __isBlokReactInlineTool: true;
86
+ readonly isInline: true;
87
+ readonly title: string | undefined;
88
+ readonly titleKey: string | undefined;
89
+ readonly shortcut: string | undefined;
90
+ readonly sanitize: SanitizerConfig | undefined;
91
+ readonly isReadOnlySupported: boolean;
92
+ } {
93
+ return class ReactInlineTool implements InlineTool {
94
+ /** Marker so `useBlok` can detect react inline tools and inject the registry. */
95
+ public static readonly __isBlokReactInlineTool = true as const;
96
+ public static readonly isInline = true as const;
97
+
98
+ public static get title(): string | undefined {
99
+ return spec.title;
100
+ }
101
+
102
+ public static get titleKey(): string | undefined {
103
+ return spec.titleKey;
104
+ }
105
+
106
+ public static get shortcut(): string | undefined {
107
+ return spec.shortcut;
108
+ }
109
+
110
+ public static get sanitize(): SanitizerConfig | undefined {
111
+ return spec.sanitize;
112
+ }
113
+
114
+ public static get isReadOnlySupported(): boolean {
115
+ return spec.isReadOnlySupported ?? false;
116
+ }
117
+
118
+ private readonly registry: BlockPortalRegistry | undefined;
119
+ /** Name this tool is registered under in the consumer's `tools` map. */
120
+ private readonly toolName: string;
121
+ /** Unique portal-registry key — inline tools have no block id. */
122
+ private readonly portalKey: string;
123
+ /** The consumer's tool config with adapter-internal keys stripped. */
124
+ private readonly toolConfig: Readonly<Partial<Config>>;
125
+ /** Last active state pushed to the mounted component (change-dedup baseline). */
126
+ private active = false;
127
+ /** True while this instance owns a registered portal entry. */
128
+ private registered = false;
129
+
130
+ public constructor(options: InlineToolConstructorOptions) {
131
+ const config: Record<string, unknown> = options?.config ?? {};
132
+
133
+ this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
134
+
135
+ const toolName = config[BLOK_TOOL_NAME_CONFIG_KEY];
136
+
137
+ this.toolName = typeof toolName === 'string' ? toolName : spec.type;
138
+ this.portalKey = generatePortalKey(this.toolName);
139
+
140
+ const publicConfig: Record<string, unknown> = {};
141
+
142
+ for (const [key, value] of Object.entries(config)) {
143
+ if (!INTERNAL_CONFIG_KEYS.includes(key)) {
144
+ publicConfig[key] = value;
145
+ }
146
+ }
147
+ this.toolConfig = Object.freeze(publicConfig) as Readonly<Partial<Config>>;
148
+ }
149
+
150
+ public render(): MenuConfig {
151
+ // The Blok-owned host: React reconciles the icon portaled INTO it, and
152
+ // this attribute makes core's MutationObserver ignore those mutations.
153
+ const host = document.createElement('div');
154
+
155
+ host.setAttribute('data-blok-mutation-free', 'true');
156
+
157
+ if (this.registry !== undefined) {
158
+ this.registry.register(this.portalKey, {
159
+ hostEl: host,
160
+ component: spec.component as unknown as ComponentType<Record<string, unknown>>,
161
+ props: { active: this.active, config: this.toolConfig },
162
+ toolName: this.toolName,
163
+ });
164
+ this.registered = true;
165
+ }
166
+
167
+ return {
168
+ name: this.toolName,
169
+ title: spec.title,
170
+ icon: host,
171
+ 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));
185
+ },
186
+ isActive: (): boolean => this.refreshActiveState(),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Idempotent teardown, called by the inline toolbar when this instance is
192
+ * dropped (toolbar close, editor destroy, or a throwaway render probe).
193
+ * Unregisters the portal entry so React unmounts the icon component —
194
+ * the unmount signal consumer-managed `createRoot` hacks never had.
195
+ */
196
+ public destroy(): void {
197
+ if (!this.registered) {
198
+ return;
199
+ }
200
+
201
+ this.registered = false;
202
+ this.registry?.unregister(this.portalKey);
203
+ }
204
+
205
+ /**
206
+ * Recompute the active state from the live selection and push it to the
207
+ * mounted component ONLY when it actually changed (no redundant
208
+ * external-store notifications).
209
+ */
210
+ private refreshActiveState(): boolean {
211
+ const active = typeof spec.checkState === 'function'
212
+ ? Boolean(spec.checkState(window.getSelection()))
213
+ : false;
214
+
215
+ if (active !== this.active) {
216
+ this.active = active;
217
+
218
+ if (this.registered) {
219
+ this.registry?.setProps(this.portalKey, { active });
220
+ }
221
+ }
222
+
223
+ return active;
224
+ }
225
+ };
226
+ }
package/src/index.ts CHANGED
@@ -9,9 +9,17 @@ export { createReactBlock } from './createReactBlock';
9
9
  export type {
10
10
  CreateReactBlockSpec,
11
11
  ReactBlockRenderProps,
12
+ ReactBlockViewProps,
13
+ ReactToolboxConfig,
14
+ ReactToolboxConfigEntry,
12
15
  PropSchema,
13
16
  PropSchemaEntry,
14
17
  } from './createReactBlock';
18
+ export { createReactInlineTool } from './createReactInlineTool';
19
+ export type {
20
+ CreateReactInlineToolSpec,
21
+ ReactInlineToolRenderProps,
22
+ } from './createReactInlineTool';
15
23
  export {
16
24
  createBlockPortalRegistry,
17
25
  BLOK_PORTAL_REGISTRY_CONFIG_KEY,
@@ -66,5 +66,5 @@ export function mergeBlokDefaults(
66
66
  merged.tools = { ...defaults.tools, ...config.tools };
67
67
  }
68
68
 
69
- return merged as UseBlokConfig;
69
+ return merged;
70
70
  }