@bloklabs/react 1.2.1 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +412 -139
- package/package.json +2 -2
- package/src/BlockPortalHost.tsx +58 -0
- package/src/BlokContent.tsx +13 -1
- package/src/BlokEditor.tsx +8 -0
- package/src/block-portal-registry.ts +145 -0
- package/src/createReactBlock.tsx +346 -0
- package/src/index.ts +15 -0
- package/src/live-tool-config.ts +154 -0
- package/src/registry-map.ts +21 -0
- package/src/useBlok.ts +135 -0
- package/types/index.d.ts +145 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live tool-config functions: the reason a consumer should never have to freeze
|
|
3
|
+
* callback identities (`useState(() => ...)`) or recreate the editor to get
|
|
4
|
+
* fresh closures into tool configs.
|
|
5
|
+
*
|
|
6
|
+
* Core snapshots each tool's config when a block instance is constructed
|
|
7
|
+
* (`ToolClass.settings` builds a merged copy), so a function passed in `tools`
|
|
8
|
+
* at editor construction would otherwise be pinned forever — consumers worked
|
|
9
|
+
* around it by freezing identities and smuggling live values through external
|
|
10
|
+
* stores. Instead, `useBlok` hands core STABLE wrapper functions that delegate
|
|
11
|
+
* to whatever closure the LATEST render passed at the same config path. New
|
|
12
|
+
* render → new closure → the wrapper calls it; the editor is never recreated.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type AnyFunction = (...args: unknown[]) => unknown;
|
|
16
|
+
|
|
17
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
18
|
+
if (typeof value !== 'object' || value === null) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
23
|
+
|
|
24
|
+
return proto === Object.prototype || proto === null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Walk `root` down `path`, returning undefined the moment the shape diverges. */
|
|
28
|
+
const resolveAtPath = (root: unknown, path: readonly (string | number)[]): unknown =>
|
|
29
|
+
path.reduce<unknown>((node, key) => {
|
|
30
|
+
if (Array.isArray(node)) {
|
|
31
|
+
return typeof key === 'number' ? node[key] : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (isPlainObject(node)) {
|
|
35
|
+
return node[key];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return undefined;
|
|
39
|
+
}, root);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the component-facing `config` for a react block from the LATEST render's
|
|
43
|
+
* raw config, preferring the stable live wrapper from `wrappedConfig` wherever
|
|
44
|
+
* both sides hold a function at the same path. The result carries the newest
|
|
45
|
+
* non-function VALUES while keeping function identities stable across renders
|
|
46
|
+
* (wrappers already delegate to the latest closure), so a deep-equal dedupe of
|
|
47
|
+
* successive results only fires when a value genuinely changed.
|
|
48
|
+
*/
|
|
49
|
+
export const buildLiveBlockConfig = (
|
|
50
|
+
wrappedConfig: unknown,
|
|
51
|
+
latestConfig: unknown
|
|
52
|
+
): Record<string, unknown> => {
|
|
53
|
+
const merge = (latest: unknown, wrapped: unknown): unknown => {
|
|
54
|
+
if (typeof latest === 'function') {
|
|
55
|
+
return typeof wrapped === 'function' ? wrapped : latest;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (Array.isArray(latest)) {
|
|
59
|
+
const wrappedArray = Array.isArray(wrapped) ? wrapped : [];
|
|
60
|
+
|
|
61
|
+
return latest.map((item, index) => merge(item, wrappedArray[index]));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (isPlainObject(latest)) {
|
|
65
|
+
const wrappedObject = isPlainObject(wrapped) ? wrapped : {};
|
|
66
|
+
const result: Record<string, unknown> = {};
|
|
67
|
+
|
|
68
|
+
for (const [key, value] of Object.entries(latest)) {
|
|
69
|
+
result[key] = merge(value, wrappedObject[key]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return latest;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const merged = merge(latestConfig, wrappedConfig);
|
|
79
|
+
|
|
80
|
+
return isPlainObject(merged) ? merged : {};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Return a copy of `tools` where every function inside a tool's `config` is
|
|
85
|
+
* replaced by a stable wrapper that calls the function found at the same path
|
|
86
|
+
* in `getLatestTools()` (falling back to the construction-time original when
|
|
87
|
+
* the latest config no longer carries one). Tool classes and non-function
|
|
88
|
+
* values pass through untouched; the consumer's objects are never mutated.
|
|
89
|
+
*/
|
|
90
|
+
export const bindLiveToolConfigFunctions = (tools: unknown, getLatestTools: () => unknown): unknown => {
|
|
91
|
+
if (!isPlainObject(tools)) {
|
|
92
|
+
return tools;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const result: Record<string, unknown> = {};
|
|
96
|
+
|
|
97
|
+
for (const [name, entry] of Object.entries(tools)) {
|
|
98
|
+
if (!isPlainObject(entry) || !isPlainObject(entry.config)) {
|
|
99
|
+
result[name] = entry;
|
|
100
|
+
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const seen = new WeakSet();
|
|
105
|
+
|
|
106
|
+
const wrapNode = (node: unknown, path: readonly (string | number)[]): unknown => {
|
|
107
|
+
if (typeof node === 'function') {
|
|
108
|
+
const original = node as AnyFunction;
|
|
109
|
+
|
|
110
|
+
return function liveConfigFunction(this: unknown, ...args: unknown[]): unknown {
|
|
111
|
+
const latestTools = getLatestTools();
|
|
112
|
+
const latestEntry = isPlainObject(latestTools) ? latestTools[name] : undefined;
|
|
113
|
+
const latestConfig = isPlainObject(latestEntry) ? latestEntry.config : undefined;
|
|
114
|
+
const latest = resolveAtPath(latestConfig, path);
|
|
115
|
+
const target = typeof latest === 'function' ? (latest as AnyFunction) : original;
|
|
116
|
+
|
|
117
|
+
// Preserve the call-site `this` (config functions may be invoked as
|
|
118
|
+
// methods of their config object).
|
|
119
|
+
return Reflect.apply(target, this, args);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (Array.isArray(node)) {
|
|
124
|
+
if (seen.has(node)) {
|
|
125
|
+
return node;
|
|
126
|
+
}
|
|
127
|
+
seen.add(node);
|
|
128
|
+
|
|
129
|
+
return node.map((item, index) => wrapNode(item, [...path, index]));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isPlainObject(node)) {
|
|
133
|
+
if (seen.has(node)) {
|
|
134
|
+
return node;
|
|
135
|
+
}
|
|
136
|
+
seen.add(node);
|
|
137
|
+
|
|
138
|
+
const wrapped: Record<string, unknown> = {};
|
|
139
|
+
|
|
140
|
+
for (const [key, value] of Object.entries(node)) {
|
|
141
|
+
wrapped[key] = wrapNode(value, [...path, key]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return wrapped;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return node;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
result[name] = { ...entry, config: wrapNode(entry.config, []) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return result;
|
|
154
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BlockPortalRegistry } from './block-portal-registry';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Associates each live Blok instance with its portal registry, keyed by the
|
|
5
|
+
* editor (mirrors the holder WeakMap). `useBlok` sets it at creation;
|
|
6
|
+
* `BlokContent` reads it to mount the shared `BlockPortalHost`. WeakMap → no
|
|
7
|
+
* leak on destroy.
|
|
8
|
+
*/
|
|
9
|
+
const registries = new WeakMap<WeakKey, BlockPortalRegistry>();
|
|
10
|
+
|
|
11
|
+
export function setRegistry(editor: WeakKey, registry: BlockPortalRegistry): void {
|
|
12
|
+
registries.set(editor, registry);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getRegistry(editor: WeakKey): BlockPortalRegistry | undefined {
|
|
16
|
+
return registries.get(editor);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function removeRegistry(editor: WeakKey): void {
|
|
20
|
+
registries.delete(editor);
|
|
21
|
+
}
|
package/src/useBlok.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { Blok as BlokRuntime } from '@bloklabs/core';
|
|
|
3
3
|
import { setHolder, removeHolder } from './holder-map';
|
|
4
4
|
import { deepEqual } from './deep-equal';
|
|
5
5
|
import { BlokDefaultsContext, mergeBlokDefaults } from './provide-blok';
|
|
6
|
+
import {
|
|
7
|
+
createBlockPortalRegistry,
|
|
8
|
+
BLOK_PORTAL_REGISTRY_CONFIG_KEY,
|
|
9
|
+
BLOK_TOOL_NAME_CONFIG_KEY,
|
|
10
|
+
type BlockPortalRegistry,
|
|
11
|
+
} from './block-portal-registry';
|
|
12
|
+
import { setRegistry, removeRegistry } from './registry-map';
|
|
13
|
+
import { bindLiveToolConfigFunctions, buildLiveBlockConfig } from './live-tool-config';
|
|
6
14
|
import { normalizeReadOnlyConfig } from '@bloklabs/core/adapters';
|
|
7
15
|
import type { Blok } from '@/types';
|
|
8
16
|
import type { UseBlokConfig } from './types';
|
|
@@ -18,6 +26,50 @@ interface EditorInstanceState {
|
|
|
18
26
|
depsToken: Record<string, unknown> | null;
|
|
19
27
|
}
|
|
20
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Inject the editor's portal registry into every `createReactBlock` tool's
|
|
31
|
+
* config (vanilla tools are left untouched), returning a NEW tools object so
|
|
32
|
+
* the consumer's config is never mutated. A react-block tool is constructed by
|
|
33
|
+
* CORE, outside any React render, so it cannot read context — this config
|
|
34
|
+
* bridge is how the tool reaches its editor-scoped registry.
|
|
35
|
+
*/
|
|
36
|
+
const isReactBlockEntry = (entry: unknown): boolean => {
|
|
37
|
+
const toolClass = typeof entry === 'function' ? entry : (entry as { class?: unknown })?.class;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
typeof toolClass === 'function' &&
|
|
41
|
+
(toolClass as { __isBlokReactBlock?: boolean }).__isBlokReactBlock === true
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): unknown => {
|
|
46
|
+
if (tools === null || typeof tools !== 'object') {
|
|
47
|
+
return tools;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const result: Record<string, unknown> = {};
|
|
51
|
+
|
|
52
|
+
for (const [name, entry] of Object.entries(tools as Record<string, unknown>)) {
|
|
53
|
+
if (!isReactBlockEntry(entry)) {
|
|
54
|
+
result[name] = entry;
|
|
55
|
+
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const base: Record<string, unknown> =
|
|
60
|
+
typeof entry === 'function' ? { class: entry } : { ...(entry as Record<string, unknown>) };
|
|
61
|
+
|
|
62
|
+
base.config = {
|
|
63
|
+
...((base.config as Record<string, unknown> | undefined) ?? {}),
|
|
64
|
+
[BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry,
|
|
65
|
+
[BLOK_TOOL_NAME_CONFIG_KEY]: name,
|
|
66
|
+
};
|
|
67
|
+
result[name] = base;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
};
|
|
72
|
+
|
|
21
73
|
/**
|
|
22
74
|
* React hook that manages a Blok editor instance lifecycle.
|
|
23
75
|
*
|
|
@@ -76,6 +128,12 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
76
128
|
// success baseline — so a failed render can't strand the baseline on content
|
|
77
129
|
// the editor never actually showed.
|
|
78
130
|
const pendingDataRef = useRef<UseBlokConfig['data']>(undefined);
|
|
131
|
+
// Live block-config channel (see the sync effect below): the current editor's
|
|
132
|
+
// portal registry, its construction-time wrapped tools (stable function
|
|
133
|
+
// wrappers), and the last config pushed per tool name (dedupe baseline).
|
|
134
|
+
const liveRegistryRef = useRef<BlockPortalRegistry | null>(null);
|
|
135
|
+
const wrappedToolsRef = useRef<unknown>(undefined);
|
|
136
|
+
const pushedBlockConfigsRef = useRef<Map<string, Record<string, unknown>>>(new Map());
|
|
79
137
|
|
|
80
138
|
// Main lifecycle effect
|
|
81
139
|
useEffect(() => {
|
|
@@ -115,6 +173,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
115
173
|
// Destroy leftover editor from a previous deps cycle
|
|
116
174
|
if (state.editor !== null && !state.isDestroyed) {
|
|
117
175
|
removeHolder(state.editor);
|
|
176
|
+
removeRegistry(state.editor);
|
|
118
177
|
try {
|
|
119
178
|
state.editor.destroy();
|
|
120
179
|
} catch {
|
|
@@ -136,8 +195,29 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
136
195
|
|
|
137
196
|
// Wrap callbacks via ref so they never go stale
|
|
138
197
|
const currentConfig = configRef.current;
|
|
198
|
+
|
|
199
|
+
// Per-editor portal registry for `createReactBlock` tools: injected into
|
|
200
|
+
// each react-block tool's config (so the core-constructed tool can reach
|
|
201
|
+
// it) and associated with the editor below (so BlokContent can mount the
|
|
202
|
+
// shared portal host). Tool-config FUNCTIONS are first re-bound to stable
|
|
203
|
+
// wrappers that always call the latest render's closure — so consumers
|
|
204
|
+
// never need to freeze identities or recreate the editor to update them.
|
|
205
|
+
const registry = createBlockPortalRegistry();
|
|
206
|
+
const wrappedTools =
|
|
207
|
+
currentConfig.tools === undefined
|
|
208
|
+
? undefined
|
|
209
|
+
: bindLiveToolConfigFunctions(currentConfig.tools, () => configRef.current.tools);
|
|
210
|
+
const liveTools = wrappedTools === undefined ? undefined : injectPortalRegistry(wrappedTools, registry);
|
|
211
|
+
|
|
212
|
+
// Reset the live block-config channel for this editor: the sync effect below
|
|
213
|
+
// reads these to push changed non-function config VALUES to mounted blocks.
|
|
214
|
+
liveRegistryRef.current = registry;
|
|
215
|
+
wrappedToolsRef.current = wrappedTools;
|
|
216
|
+
pushedBlockConfigsRef.current = new Map();
|
|
217
|
+
|
|
139
218
|
const blokConfig = {
|
|
140
219
|
...currentConfig,
|
|
220
|
+
...(liveTools === undefined ? {} : { tools: liveTools as UseBlokConfig['tools'] }),
|
|
141
221
|
holder,
|
|
142
222
|
onReady: (...args: Parameters<NonNullable<UseBlokConfig['onReady']>>): void => {
|
|
143
223
|
configRef.current.onReady?.(...args);
|
|
@@ -182,6 +262,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
182
262
|
// tell a genuine post-construction change from the construction value itself.
|
|
183
263
|
constructedDataRef.current = currentConfig.data;
|
|
184
264
|
setHolder(blok, holder);
|
|
265
|
+
setRegistry(blok, registry);
|
|
185
266
|
|
|
186
267
|
void blok.isReady
|
|
187
268
|
.then(() => {
|
|
@@ -193,6 +274,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
193
274
|
.catch(() => {
|
|
194
275
|
if (state.editor === blok && !state.isDestroyed) {
|
|
195
276
|
removeHolder(blok);
|
|
277
|
+
removeRegistry(blok);
|
|
196
278
|
try {
|
|
197
279
|
blok.destroy();
|
|
198
280
|
} catch {
|
|
@@ -211,6 +293,58 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
211
293
|
};
|
|
212
294
|
}, [depsToken]);
|
|
213
295
|
|
|
296
|
+
// Live tool-config VALUES for react blocks. Function slots are already live
|
|
297
|
+
// via the stable wrappers, but non-function values (permissions, URLs,
|
|
298
|
+
// locale…) were snapshotted at construction — which forced consumers to put
|
|
299
|
+
// them in `deps` and recreate the whole editor to change them. Instead, this
|
|
300
|
+
// effect runs on every render, rebuilds each react-block tool's
|
|
301
|
+
// component-facing config from the LATEST render's values (keeping wrapper
|
|
302
|
+
// identities for functions), and pushes it through the portal registry when
|
|
303
|
+
// it genuinely changed — mounted blocks re-render in place, future blocks
|
|
304
|
+
// start from the latest config.
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
const registry = liveRegistryRef.current;
|
|
307
|
+
const state = stateRef.current;
|
|
308
|
+
|
|
309
|
+
if (registry === null || state.editor === null || state.isDestroyed) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const latestTools = configRef.current.tools as Record<string, unknown> | undefined;
|
|
314
|
+
|
|
315
|
+
if (latestTools === null || typeof latestTools !== 'object') {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const wrappedTools = wrappedToolsRef.current as Record<string, unknown> | undefined;
|
|
320
|
+
|
|
321
|
+
for (const [name, entry] of Object.entries(latestTools)) {
|
|
322
|
+
if (!isReactBlockEntry(entry)) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const latestConfig =
|
|
327
|
+
typeof entry === 'function' ? {} : ((entry as { config?: unknown }).config ?? {});
|
|
328
|
+
const wrappedEntry = wrappedTools?.[name];
|
|
329
|
+
const wrappedConfig =
|
|
330
|
+
wrappedEntry !== null && typeof wrappedEntry === 'object'
|
|
331
|
+
? (wrappedEntry as { config?: unknown }).config
|
|
332
|
+
: undefined;
|
|
333
|
+
|
|
334
|
+
const candidate = buildLiveBlockConfig(wrappedConfig, latestConfig);
|
|
335
|
+
const pushed = pushedBlockConfigsRef.current.get(name);
|
|
336
|
+
|
|
337
|
+
if (pushed !== undefined && deepEqual(candidate, pushed)) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const frozen = Object.freeze(candidate);
|
|
342
|
+
|
|
343
|
+
pushedBlockConfigsRef.current.set(name, frozen);
|
|
344
|
+
registry.setToolConfig(name, frozen);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
214
348
|
// Reactive: readOnly (object form normalized so the effect dep is a stable boolean)
|
|
215
349
|
const readOnlyEnabled = normalizeReadOnlyConfig(config.readOnly).enabled;
|
|
216
350
|
useEffect(() => {
|
|
@@ -348,6 +482,7 @@ function deferDestroy(
|
|
|
348
482
|
state.destroyTimeout = setTimeout(() => {
|
|
349
483
|
if (state.editor !== null) {
|
|
350
484
|
removeHolder(state.editor);
|
|
485
|
+
removeRegistry(state.editor);
|
|
351
486
|
try {
|
|
352
487
|
state.editor.destroy();
|
|
353
488
|
} catch {
|
package/types/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BlokConfig, BlockToolData, OutputBlockData, OutputData } from '@bloklabs/core';
|
|
2
|
+
import type { BlockAPI, BlockToolConstructable, ToolboxConfig } from '@bloklabs/core';
|
|
2
3
|
import type { Blok, EditorWidth, BlockRenderedPayload, BlocksRenderedPayload } from '@bloklabs/core';
|
|
3
4
|
import type { BlockTuneData } from '@bloklabs/core';
|
|
4
5
|
import type { MarkdownImportConfig } from '@bloklabs/core/markdown';
|
|
@@ -38,7 +39,7 @@ export interface BlokContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
38
39
|
* React hook that creates and manages a Blok editor instance.
|
|
39
40
|
*
|
|
40
41
|
* @param config - Editor configuration (all BlokConfig props except `holder`)
|
|
41
|
-
* @param deps - Optional dependency array. When any dep changes, the editor is destroyed and recreated. Keep each value referentially stable (primitives or useMemo-stable objects) so the editor isn't recreated every render.
|
|
42
|
+
* @param deps - Optional dependency array. When any dep changes, the editor is destroyed and recreated. Keep each value referentially stable (primitives or useMemo-stable objects) so the editor isn't recreated every render. Reserve it for true identity changes (e.g. the document id): tool-config functions are re-bound to the latest render's closure automatically, and non-function config values of `createReactBlock` tools are pushed to mounted blocks in place — neither belongs in deps.
|
|
42
43
|
* @returns The Blok editor instance, or null during SSR / before initialization.
|
|
43
44
|
*
|
|
44
45
|
* @example
|
|
@@ -80,6 +81,8 @@ export interface BlokEditorProps
|
|
|
80
81
|
* When any value changes, the editor is destroyed and recreated. Keep each
|
|
81
82
|
* value referentially stable (primitives or useMemo-stable objects) — a dep
|
|
82
83
|
* whose identity changes every render recreates the editor each time.
|
|
84
|
+
* Reserve it for true identity changes (e.g. the document id): tool-config
|
|
85
|
+
* functions and `createReactBlock` config values stay live without it.
|
|
83
86
|
*/
|
|
84
87
|
deps?: React.DependencyList;
|
|
85
88
|
/** Test id forwarded to the editor container element (via data-testid). */
|
|
@@ -536,3 +539,144 @@ export interface UseBlocksApi {
|
|
|
536
539
|
* @param editor - the Blok instance from useBlok, or null before it is ready
|
|
537
540
|
*/
|
|
538
541
|
export declare function useBlocks(editor: Blok | null): UseBlocksApi;
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// Block authoring (createReactBlock + portal host)
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
/** One field of a {@link CreateReactBlockSpec.propSchema}. */
|
|
548
|
+
export interface PropSchemaEntry {
|
|
549
|
+
default: unknown;
|
|
550
|
+
values?: readonly unknown[];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Declarative data shape. Its keys are EXACTLY the keys `save()` returns to Yjs
|
|
555
|
+
* (a cleared field is written as its default, never dropped).
|
|
556
|
+
*/
|
|
557
|
+
export type PropSchema = Record<string, PropSchemaEntry>;
|
|
558
|
+
|
|
559
|
+
/** Props handed to a React block's `component` (the only data write path is `commit`). */
|
|
560
|
+
export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
|
|
561
|
+
/** Frozen snapshot of the block data. Re-rendered with a new value on change; never mutate. */
|
|
562
|
+
data: Readonly<Data>;
|
|
563
|
+
/**
|
|
564
|
+
* The ONLY data write path: merge a partial patch and sync once. Idempotent —
|
|
565
|
+
* a patch that changes nothing is a full no-op (no re-render, no change
|
|
566
|
+
* event), so effects may safely echo current values without a guard.
|
|
567
|
+
*/
|
|
568
|
+
commit: (patch: Partial<Data>) => void;
|
|
569
|
+
/** This block's per-block API. */
|
|
570
|
+
block: BlockAPI;
|
|
571
|
+
/**
|
|
572
|
+
* Read-only flag. Toggled IN PLACE by core's read-only switch (no remount,
|
|
573
|
+
* ephemeral state survives). Honor it — a block that ignores it stays
|
|
574
|
+
* interactive when the editor is read-only.
|
|
575
|
+
*/
|
|
576
|
+
readOnly: boolean;
|
|
577
|
+
/**
|
|
578
|
+
* The tool's `config` from the consumer's `tools` map (adapter-internal keys
|
|
579
|
+
* stripped) — the first-class channel for host props (permissions, CDN/upload
|
|
580
|
+
* URLs, locale…). LIVE under `useBlok`/`BlokEditor`: functions always call
|
|
581
|
+
* the latest render's closure, and changed non-function values are pushed to
|
|
582
|
+
* mounted blocks in place — config values never belong in `deps`.
|
|
583
|
+
*/
|
|
584
|
+
config: Readonly<Partial<Config>>;
|
|
585
|
+
/** Engine-owned child slot — render `<BlockChildren />` for a container block. */
|
|
586
|
+
BlockChildren: React.ComponentType;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Spec for {@link createReactBlock}. */
|
|
590
|
+
export interface CreateReactBlockSpec<
|
|
591
|
+
Data extends BlockToolData = BlockToolData,
|
|
592
|
+
Config = Record<string, unknown>
|
|
593
|
+
> {
|
|
594
|
+
type: string;
|
|
595
|
+
toolbox?: ToolboxConfig;
|
|
596
|
+
propSchema: PropSchema;
|
|
597
|
+
component: React.ComponentType<ReactBlockRenderProps<Data, Config>>;
|
|
598
|
+
onRendered?: (block: BlockAPI) => void;
|
|
599
|
+
onMoved?: (block: BlockAPI) => void;
|
|
600
|
+
onRemoved?: (block: BlockAPI) => void;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Author a first-party React block: returns a `BlockToolConstructable`
|
|
605
|
+
* registered exactly like a vanilla tool (`tools: { type: createReactBlock(...) }`).
|
|
606
|
+
* Every block renders through the editor's shared portal host INSIDE the React
|
|
607
|
+
* tree that renders `BlokContent`/`BlokEditor`, so app-level context (themes,
|
|
608
|
+
* design-system providers, stores) reaches block components with no bridge, and
|
|
609
|
+
* fresh render closures reach them with no editor recreation.
|
|
610
|
+
*
|
|
611
|
+
* @example
|
|
612
|
+
* ```tsx
|
|
613
|
+
* const Callout = createReactBlock<{ text: string }>({
|
|
614
|
+
* type: 'callout',
|
|
615
|
+
* propSchema: { text: { default: '' } },
|
|
616
|
+
* component: ({ data, commit }) => (
|
|
617
|
+
* <aside onClick={() => commit({ text: 'hi' })}>{data.text}</aside>
|
|
618
|
+
* ),
|
|
619
|
+
* });
|
|
620
|
+
* ```
|
|
621
|
+
*/
|
|
622
|
+
export declare function createReactBlock<
|
|
623
|
+
Data extends BlockToolData = BlockToolData,
|
|
624
|
+
Config = Record<string, unknown>
|
|
625
|
+
>(spec: CreateReactBlockSpec<Data, Config>): BlockToolConstructable;
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Tool-config key carrying the editor's portal registry into a
|
|
629
|
+
* `createReactBlock` tool. Injected automatically by `useBlok`; exposed for
|
|
630
|
+
* advanced setups that construct tools outside `useBlok`.
|
|
631
|
+
*/
|
|
632
|
+
export declare const BLOK_PORTAL_REGISTRY_CONFIG_KEY: '__blokPortalRegistry';
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Tool-config key carrying the name a react-block tool is registered under in
|
|
636
|
+
* the consumer's `tools` map. Injected automatically by `useBlok`; routes live
|
|
637
|
+
* tool-config updates to the tool's mounted blocks.
|
|
638
|
+
*/
|
|
639
|
+
export declare const BLOK_TOOL_NAME_CONFIG_KEY: '__blokToolName';
|
|
640
|
+
|
|
641
|
+
/** One mounted React block in the portal registry. */
|
|
642
|
+
export interface BlockPortalEntry {
|
|
643
|
+
hostEl: HTMLElement;
|
|
644
|
+
component: React.ComponentType<Record<string, unknown>>;
|
|
645
|
+
props: Record<string, unknown>;
|
|
646
|
+
/** Name the tool is registered under — routes per-tool live config updates. */
|
|
647
|
+
toolName?: string;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Per-editor registry of React blocks, shaped as an external store
|
|
652
|
+
* (`subscribe`/`getSnapshot`) consumed by `BlockPortalHost` via
|
|
653
|
+
* `useSyncExternalStore`. Snapshots are immutable and referentially stable
|
|
654
|
+
* between mutations.
|
|
655
|
+
*/
|
|
656
|
+
export interface BlockPortalRegistry {
|
|
657
|
+
register(id: string, entry: BlockPortalEntry): void;
|
|
658
|
+
unregister(id: string): void;
|
|
659
|
+
setProps(id: string, props: Record<string, unknown>): void;
|
|
660
|
+
/**
|
|
661
|
+
* Replace the `config` prop of every entry registered under `toolName`, and
|
|
662
|
+
* remember it so entries registered later start from the latest config.
|
|
663
|
+
*/
|
|
664
|
+
setToolConfig(toolName: string, config: Record<string, unknown>): void;
|
|
665
|
+
subscribe(listener: () => void): () => void;
|
|
666
|
+
getSnapshot(): ReadonlyMap<string, BlockPortalEntry>;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Create a fresh portal registry (one per editor instance). */
|
|
670
|
+
export declare function createBlockPortalRegistry(): BlockPortalRegistry;
|
|
671
|
+
|
|
672
|
+
/** Props for {@link BlockPortalHost}. */
|
|
673
|
+
export interface BlockPortalHostProps {
|
|
674
|
+
registry: BlockPortalRegistry;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* The single shared render tree that portals each registered React block into
|
|
679
|
+
* its Blok-owned host element. Mounted automatically by `BlokContent`; exposed
|
|
680
|
+
* for advanced setups that manage the registry themselves.
|
|
681
|
+
*/
|
|
682
|
+
export declare function BlockPortalHost(props: BlockPortalHostProps): React.ReactElement;
|