@bloklabs/react 1.2.6 → 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/dist/index.cjs +1 -1
- package/dist/index.mjs +277 -220
- package/package.json +2 -2
- package/src/BlokEditor.tsx +1 -1
- package/src/createReactBlock.tsx +151 -8
- package/src/createReactInlineTool.tsx +12 -0
- package/src/index.ts +3 -0
- package/src/provide-blok.tsx +1 -1
- package/src/useBlok.ts +102 -3
- package/types/index.d.ts +40 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bloklabs/react",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
}
|
package/src/BlokEditor.tsx
CHANGED
|
@@ -108,7 +108,7 @@ export const BlokEditor = forwardRef<Blok | null, BlokEditorProps>(
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
const editor = useBlok(config
|
|
111
|
+
const editor = useBlok(config, deps);
|
|
112
112
|
|
|
113
113
|
useImperativeHandle<Blok | null, Blok | null>(ref, () => editor, [editor]);
|
|
114
114
|
|
package/src/createReactBlock.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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?:
|
|
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
|
|
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:
|
|
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
|
|
351
|
+
return this.mirror;
|
|
209
352
|
}
|
|
210
353
|
|
|
211
354
|
public async setData(newData: BlockToolData): Promise<boolean> {
|
|
@@ -43,6 +43,13 @@ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
|
|
|
43
43
|
type: string;
|
|
44
44
|
/** Optional toolbar item title (shown in overflow/search contexts). */
|
|
45
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;
|
|
46
53
|
/** The component rendered as the tool's toolbar icon/UI. */
|
|
47
54
|
component: ComponentType<ReactInlineToolRenderProps<Config>>;
|
|
48
55
|
/** Applies/removes the formatting on the LIVE selection's range (captured at activation time). */
|
|
@@ -78,6 +85,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
|
|
|
78
85
|
readonly __isBlokReactInlineTool: true;
|
|
79
86
|
readonly isInline: true;
|
|
80
87
|
readonly title: string | undefined;
|
|
88
|
+
readonly titleKey: string | undefined;
|
|
81
89
|
readonly shortcut: string | undefined;
|
|
82
90
|
readonly sanitize: SanitizerConfig | undefined;
|
|
83
91
|
readonly isReadOnlySupported: boolean;
|
|
@@ -91,6 +99,10 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
|
|
|
91
99
|
return spec.title;
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
public static get titleKey(): string | undefined {
|
|
103
|
+
return spec.titleKey;
|
|
104
|
+
}
|
|
105
|
+
|
|
94
106
|
public static get shortcut(): string | undefined {
|
|
95
107
|
return spec.shortcut;
|
|
96
108
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,9 @@ 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';
|
package/src/provide-blok.tsx
CHANGED
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 { normalizeReadOnlyConfig } from '@bloklabs/core/adapters';
|
|
14
|
+
import { createEmittedEchoWindow, normalizeReadOnlyConfig } from '@bloklabs/core/adapters';
|
|
15
15
|
import type { Blok } from '@/types';
|
|
16
16
|
import type { UseBlokConfig } from './types';
|
|
17
17
|
|
|
@@ -70,7 +70,7 @@ const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): un
|
|
|
70
70
|
typeof entry === 'function' ? { class: entry } : { ...(entry as Record<string, unknown>) };
|
|
71
71
|
|
|
72
72
|
base.config = {
|
|
73
|
-
...((base.config
|
|
73
|
+
...((base.config) ?? {}),
|
|
74
74
|
[BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry,
|
|
75
75
|
[BLOK_TOOL_NAME_CONFIG_KEY]: name,
|
|
76
76
|
};
|
|
@@ -124,6 +124,13 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
124
124
|
// otherwise reset the caret). Declared here so the `onSave` wrapper below can
|
|
125
125
|
// update it before the consumer's setState re-runs the data effect.
|
|
126
126
|
const lastRenderedDataRef = useRef(config.data);
|
|
127
|
+
// Window of recently emitted `onSave` payloads. The baseline above only
|
|
128
|
+
// remembers the LAST one, but a host that persists on save and refetches can
|
|
129
|
+
// echo an EARLIER save after a newer one replaced the baseline (the user kept
|
|
130
|
+
// typing). Such a stale echo is still the editor's own output — re-rendering
|
|
131
|
+
// it would clobber the caret and the content typed since — so the data effect
|
|
132
|
+
// treats a match against ANY windowed payload as a no-op.
|
|
133
|
+
const echoWindowRef = useRef(createEmittedEchoWindow());
|
|
127
134
|
const seededEditorRef = useRef<Blok | null>(null);
|
|
128
135
|
// The `data` the live editor was actually constructed with. The seed gate in
|
|
129
136
|
// the reactive-`data` effect compares the current prop against THIS (not mere
|
|
@@ -144,6 +151,9 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
144
151
|
const liveRegistryRef = useRef<BlockPortalRegistry | null>(null);
|
|
145
152
|
const wrappedToolsRef = useRef<unknown>(undefined);
|
|
146
153
|
const pushedBlockConfigsRef = useRef<Map<string, Record<string, unknown>>>(new Map());
|
|
154
|
+
// Last-applied tool-level `toolbox` setting per tool name (dedupe baseline for
|
|
155
|
+
// the reactive toolbox effect below). Seeded from the construction-time tools.
|
|
156
|
+
const appliedToolboxRef = useRef<Map<string, unknown>>(new Map());
|
|
147
157
|
|
|
148
158
|
// Main lifecycle effect
|
|
149
159
|
useEffect(() => {
|
|
@@ -224,6 +234,12 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
224
234
|
liveRegistryRef.current = registry;
|
|
225
235
|
wrappedToolsRef.current = wrappedTools;
|
|
226
236
|
pushedBlockConfigsRef.current = new Map();
|
|
237
|
+
appliedToolboxRef.current = new Map(
|
|
238
|
+
Object.entries((currentConfig.tools ?? {}) as Record<string, unknown>).map(([name, entry]) => [
|
|
239
|
+
name,
|
|
240
|
+
entry !== null && typeof entry === 'object' ? (entry as { toolbox?: unknown }).toolbox : undefined,
|
|
241
|
+
])
|
|
242
|
+
);
|
|
227
243
|
|
|
228
244
|
const blokConfig = {
|
|
229
245
|
...currentConfig,
|
|
@@ -249,6 +265,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
249
265
|
// controlled consumer echoing it straight back into `data` is a no-op —
|
|
250
266
|
// no redundant render(), no caret reset, no round-trip recursion.
|
|
251
267
|
lastRenderedDataRef.current = args[0];
|
|
268
|
+
echoWindowRef.current.record(args[0]);
|
|
252
269
|
configRef.current.onSave?.(...args);
|
|
253
270
|
};
|
|
254
271
|
}
|
|
@@ -269,7 +286,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
269
286
|
};
|
|
270
287
|
}
|
|
271
288
|
|
|
272
|
-
const blok = new BlokRuntime(blokConfig)
|
|
289
|
+
const blok = new BlokRuntime(blokConfig);
|
|
273
290
|
state.editor = blok;
|
|
274
291
|
// Remember what this editor was seeded with so the reactive-`data` effect can
|
|
275
292
|
// tell a genuine post-construction change from the construction value itself.
|
|
@@ -358,6 +375,48 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
358
375
|
}
|
|
359
376
|
});
|
|
360
377
|
|
|
378
|
+
// Live tool-level `toolbox` setting. Toolbox membership was construction-only,
|
|
379
|
+
// which forced permission-style gating (`toolbox: false` for users who may not
|
|
380
|
+
// insert a tool) into `deps` — recreating the whole editor on a permission
|
|
381
|
+
// flip. Instead, diff each tool's `toolbox` setting against the last applied
|
|
382
|
+
// value on every render and push genuine changes through the runtime
|
|
383
|
+
// `tools.update` API (which reaches the live adapter and rebuilds the Toolbox
|
|
384
|
+
// UI). Applies only once the editor is ready — module APIs attach at isReady —
|
|
385
|
+
// and the ready transition re-renders, so a change made earlier is not lost.
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
const state = stateRef.current;
|
|
388
|
+
|
|
389
|
+
if (state.editor === null || state.isDestroyed || !state.isEditorReady) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const latestTools = configRef.current.tools as Record<string, unknown> | undefined;
|
|
394
|
+
|
|
395
|
+
if (latestTools === null || typeof latestTools !== 'object') {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
for (const [name, entry] of Object.entries(latestTools)) {
|
|
400
|
+
// Only tools the editor was constructed with can be updated at runtime.
|
|
401
|
+
if (!appliedToolboxRef.current.has(name)) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const latest =
|
|
406
|
+
entry !== null && typeof entry === 'object' ? (entry as { toolbox?: unknown }).toolbox : undefined;
|
|
407
|
+
const applied = appliedToolboxRef.current.get(name);
|
|
408
|
+
|
|
409
|
+
if (deepEqual(latest, applied)) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
appliedToolboxRef.current.set(name, latest);
|
|
414
|
+
(state.editor as unknown as {
|
|
415
|
+
tools?: { update: (toolName: string, config: Record<string, unknown>) => void };
|
|
416
|
+
}).tools?.update(name, { toolbox: latest });
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
361
420
|
// Reactive: readOnly (object form normalized so the effect dep is a stable boolean)
|
|
362
421
|
const readOnlyEnabled = normalizeReadOnlyConfig(config.readOnly).enabled;
|
|
363
422
|
useEffect(() => {
|
|
@@ -394,6 +453,33 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
394
453
|
editor.width.set(width);
|
|
395
454
|
}, [editor, width]);
|
|
396
455
|
|
|
456
|
+
// Reactive: style.tokens
|
|
457
|
+
//
|
|
458
|
+
// Theme tokens were construction-only, so a host with a live light/dark
|
|
459
|
+
// toggle had to bump `deps` (destroying the editor, losing caret/history) or
|
|
460
|
+
// hand-write the global stylesheet Blok already injects. Push genuine changes
|
|
461
|
+
// through the runtime `tokens` API instead, deep-equal–deduped because
|
|
462
|
+
// `tokens` is almost always a fresh object literal each render.
|
|
463
|
+
const styleTokens = config.style?.tokens;
|
|
464
|
+
/*
|
|
465
|
+
* Seeded with the mount-time value: construction already injected those
|
|
466
|
+
* tokens, so the first effect run is a no-op and only genuine changes push.
|
|
467
|
+
*/
|
|
468
|
+
const appliedTokensRef = useRef<Record<string, string> | undefined>(config.style?.tokens);
|
|
469
|
+
|
|
470
|
+
useEffect(() => {
|
|
471
|
+
if (editor === null || styleTokens === undefined) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (deepEqual(styleTokens, appliedTokensRef.current)) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
appliedTokensRef.current = styleTokens;
|
|
480
|
+
editor.tokens.set(styleTokens);
|
|
481
|
+
}, [editor, styleTokens]);
|
|
482
|
+
|
|
397
483
|
// Reactive: placeholder
|
|
398
484
|
const { placeholder } = config;
|
|
399
485
|
useEffect(() => {
|
|
@@ -441,6 +527,8 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
441
527
|
// A render still pending on a PRIOR editor is moot for this fresh instance;
|
|
442
528
|
// clear it so its content can't dedupe a needed render on the new editor.
|
|
443
529
|
pendingDataRef.current = undefined;
|
|
530
|
+
// Payloads emitted by a PRIOR editor are likewise moot for this one.
|
|
531
|
+
echoWindowRef.current.clear();
|
|
444
532
|
|
|
445
533
|
if (deepEqual(data, constructedDataRef.current)) {
|
|
446
534
|
lastRenderedDataRef.current = data;
|
|
@@ -456,6 +544,17 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
456
544
|
return;
|
|
457
545
|
}
|
|
458
546
|
|
|
547
|
+
// Skip stale echoes: content the editor itself emitted via `onSave` recently,
|
|
548
|
+
// arriving back reshaped (fresh envelope, stripped ids) and/or late — after a
|
|
549
|
+
// newer save already replaced the baseline above.
|
|
550
|
+
if (echoWindowRef.current.matches(data)) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Genuinely external content takes over: earlier emitted payloads are moot,
|
|
555
|
+
// and keeping them could wrongly no-op a later deliberate revert to one.
|
|
556
|
+
echoWindowRef.current.clear();
|
|
557
|
+
|
|
459
558
|
// Mark the content in-flight, but DON'T advance the success baseline until the
|
|
460
559
|
// render actually resolves. A failed render leaves the editor on its last
|
|
461
560
|
// successful content, so the baseline must keep pointing there — otherwise a
|
package/types/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BlokConfig, BlockToolData, OutputBlockData, OutputData } from '@bloklabs/core';
|
|
2
|
-
import type { BlockAPI, BlockToolConstructable,
|
|
2
|
+
import type { BlockAPI, BlockToolConstructable, ToolboxConfigEntry } from '@bloklabs/core';
|
|
3
3
|
import type { InlineToolConstructable, SanitizerConfig } from '@bloklabs/core';
|
|
4
4
|
import type { Blok, EditorWidth, BlockRenderedPayload, BlocksRenderedPayload } from '@bloklabs/core';
|
|
5
5
|
import type { BlockTuneData } from '@bloklabs/core';
|
|
@@ -587,15 +587,46 @@ export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
|
|
|
587
587
|
BlockChildren: React.ComponentType;
|
|
588
588
|
}
|
|
589
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Props handed to a React block's `viewComponent` (the read-only renderer):
|
|
592
|
+
* the entry props minus `commit` (no write path) and `readOnly` (always true).
|
|
593
|
+
*/
|
|
594
|
+
export type ReactBlockViewProps<Data, Config = Record<string, unknown>> = Omit<
|
|
595
|
+
ReactBlockRenderProps<Data, Config>,
|
|
596
|
+
'commit' | 'readOnly'
|
|
597
|
+
>;
|
|
598
|
+
|
|
599
|
+
/** A toolbox entry whose `icon` may be a React element instead of an SVG string. */
|
|
600
|
+
export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon'> & {
|
|
601
|
+
/**
|
|
602
|
+
* Toolbox icon. Accepts the markup string core consumes directly, or the same
|
|
603
|
+
* React element rendered in the block body — the factory serializes elements
|
|
604
|
+
* to markup once (lazily, cached), so icons are never duplicated as parallel
|
|
605
|
+
* raw SVG strings. Without a DOM (SSR) the element is returned as-is and
|
|
606
|
+
* serialization retries on the next browser-side access.
|
|
607
|
+
*/
|
|
608
|
+
icon?: string | React.ReactElement;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/** Toolbox config for React blocks: single entry or several variants. */
|
|
612
|
+
export type ReactToolboxConfig = ReactToolboxConfigEntry | ReactToolboxConfigEntry[];
|
|
613
|
+
|
|
590
614
|
/** Spec for {@link createReactBlock}. */
|
|
591
615
|
export interface CreateReactBlockSpec<
|
|
592
616
|
Data extends BlockToolData = BlockToolData,
|
|
593
617
|
Config = Record<string, unknown>
|
|
594
618
|
> {
|
|
595
619
|
type: string;
|
|
596
|
-
toolbox?:
|
|
620
|
+
toolbox?: ReactToolboxConfig;
|
|
597
621
|
propSchema: PropSchema;
|
|
598
622
|
component: React.ComponentType<ReactBlockRenderProps<Data, Config>>;
|
|
623
|
+
/**
|
|
624
|
+
* Optional read-only renderer: rendered INSTEAD of `component` while the
|
|
625
|
+
* editor is read-only, so blocks need no display-vs-edit ternary. Toggling
|
|
626
|
+
* read-only swaps renderers (ephemeral state does not survive the swap);
|
|
627
|
+
* omit it to keep the single-component in-place toggle.
|
|
628
|
+
*/
|
|
629
|
+
viewComponent?: React.ComponentType<ReactBlockViewProps<Data, Config>>;
|
|
599
630
|
onRendered?: (block: BlockAPI) => void;
|
|
600
631
|
onMoved?: (block: BlockAPI) => void;
|
|
601
632
|
onRemoved?: (block: BlockAPI) => void;
|
|
@@ -650,6 +681,13 @@ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
|
|
|
650
681
|
type: string;
|
|
651
682
|
/** Optional toolbar item title (shown in overflow/search contexts). */
|
|
652
683
|
title?: string;
|
|
684
|
+
/**
|
|
685
|
+
* Translation key for the toolbar label, resolved by core i18n as
|
|
686
|
+
* `toolNames.{titleKey}` (or used verbatim when it contains a dot). Without
|
|
687
|
+
* it a custom tool's label is only localizable through the legacy
|
|
688
|
+
* capitalized-tool-name fallback.
|
|
689
|
+
*/
|
|
690
|
+
titleKey?: string;
|
|
653
691
|
/** The component rendered as the tool's toolbar icon/UI. */
|
|
654
692
|
component: React.ComponentType<ReactInlineToolRenderProps<Config>>;
|
|
655
693
|
/** Applies/removes the formatting on the LIVE selection's range (captured at activation time). */
|