@bloklabs/react 1.4.2 → 1.4.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/README.md +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +328 -213
- package/package.json +2 -2
- package/src/BlokContent.tsx +44 -0
- package/src/BlokEditor.tsx +26 -4
- package/src/BlokView.tsx +34 -14
- package/src/config-keys.ts +1 -0
- package/src/createReactBlock.tsx +134 -20
- package/src/createReactInlineTool.tsx +26 -3
- package/src/index.ts +6 -0
- package/src/useBlok.ts +32 -2
- package/src/useBlokHandle.ts +101 -0
- package/src/useBlokView.ts +11 -6
- package/types/index.d.ts +100 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bloklabs/react",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
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",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"test": "yarn run -T vitest run"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@bloklabs/core": "^1.4.
|
|
57
|
+
"@bloklabs/core": "^1.4.3",
|
|
58
58
|
"react": "^18.0.0 || ^19.0.0",
|
|
59
59
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
60
60
|
}
|
package/src/BlokContent.tsx
CHANGED
|
@@ -2,8 +2,44 @@ import { useEffect, useRef, forwardRef } from 'react';
|
|
|
2
2
|
import { getHolder } from './holder-map';
|
|
3
3
|
import { getRegistry } from './registry-map';
|
|
4
4
|
import { BlockPortalHost } from './BlockPortalHost';
|
|
5
|
+
import type { Blok } from '@/types';
|
|
5
6
|
import type { BlokContentProps } from './types';
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Re-drive the boot-time URL-hash scroll now that the editor's holder is in the
|
|
10
|
+
* document. Reads the current hash, decodes it (falling back to the raw value on
|
|
11
|
+
* a malformed percent-sequence, which simply matches no block), and asks the
|
|
12
|
+
* editor to scroll — a one-shot drain of the navigation the constructor deferred
|
|
13
|
+
* because the holder was detached. No-op when the URL carries no hash.
|
|
14
|
+
* @param editor - the ready Blok instance whose holder just connected
|
|
15
|
+
*/
|
|
16
|
+
function drainDeferredHashScroll(editor: Blok): void {
|
|
17
|
+
if (typeof window === 'undefined') {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const raw = window.location.hash.slice(1);
|
|
22
|
+
|
|
23
|
+
if (raw === '') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
editor.blocks.scrollToBlock?.(safeDecodeHashFragment(raw));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decodes a URL hash fragment, falling back to the raw value on a malformed
|
|
32
|
+
* percent-sequence (e.g. `%ZZ`) — which simply matches no block downstream.
|
|
33
|
+
* @param raw - hash fragment without the leading `#`
|
|
34
|
+
*/
|
|
35
|
+
function safeDecodeHashFragment(raw: string): string {
|
|
36
|
+
try {
|
|
37
|
+
return decodeURIComponent(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
7
43
|
/**
|
|
8
44
|
* Component that provides the DOM mount point for a Blok editor.
|
|
9
45
|
* Renders a <div> and adopts the editor's detached holder DOM into it.
|
|
@@ -38,6 +74,14 @@ export const BlokContent = forwardRef<HTMLDivElement, BlokContentProps>(
|
|
|
38
74
|
|
|
39
75
|
containerRef.current.appendChild(holder);
|
|
40
76
|
|
|
77
|
+
// The editor rendered its seeded content into this holder while it was
|
|
78
|
+
// still detached from the document, so the boot-time URL-hash scroll —
|
|
79
|
+
// which queries the LIVE document — found nothing and deferred. Now that
|
|
80
|
+
// the holder is connected, drain that deferred navigation once via the
|
|
81
|
+
// public scroll API (a no-op when there's no matching hash/block), so
|
|
82
|
+
// consumers don't have to hand-roll a DOM-polling hook.
|
|
83
|
+
drainDeferredHashScroll(editor);
|
|
84
|
+
|
|
41
85
|
return (): void => {
|
|
42
86
|
holder.remove();
|
|
43
87
|
};
|
package/src/BlokEditor.tsx
CHANGED
|
@@ -38,9 +38,9 @@ import type { UseBlokConfig } from './types';
|
|
|
38
38
|
*/
|
|
39
39
|
export interface BlokEditorProps
|
|
40
40
|
extends Omit<UseBlokConfig, 'onReady'>,
|
|
41
|
-
// `onChange`/`onError` are Blok config callbacks here — drop the
|
|
42
|
-
// element's same-named event handlers so the config signatures win.
|
|
43
|
-
Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'onChange' | 'onError'> {
|
|
41
|
+
// `onChange`/`onError`/`onSubmit` are Blok config callbacks here — drop the
|
|
42
|
+
// DOM element's same-named event handlers so the config signatures win.
|
|
43
|
+
Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'onChange' | 'onError' | 'onSubmit'> {
|
|
44
44
|
/**
|
|
45
45
|
* When any value changes, the editor is destroyed and recreated. Keep each
|
|
46
46
|
* value referentially stable (primitives or useMemo-stable objects) — a dep
|
|
@@ -67,6 +67,18 @@ export interface BlokEditorProps
|
|
|
67
67
|
* never recreate the editor or re-fire `onReady`.
|
|
68
68
|
*/
|
|
69
69
|
onReady?: (editor: Blok) => void;
|
|
70
|
+
/**
|
|
71
|
+
* Library-neutral shorthand for the active locale — a plain BCP-47 string
|
|
72
|
+
* (`'en'`, `'ru-RU'`, `'nb'`) the host owns and updates. It is normalized and
|
|
73
|
+
* routed to `editor.i18n.update({ locale })` in place (caret/undo survive),
|
|
74
|
+
* so a host with its own language switcher never has to build an `i18n`
|
|
75
|
+
* config object or destroy the editor. Pair it with the re-exported
|
|
76
|
+
* `getDirection`/`normalizeLocale` to compute `dir` yourself.
|
|
77
|
+
*
|
|
78
|
+
* Shorthand for `i18n={{ locale }}`: if both are given, this wins. For
|
|
79
|
+
* message overrides or an explicit direction, use the full `i18n` prop.
|
|
80
|
+
*/
|
|
81
|
+
locale?: string;
|
|
70
82
|
/**
|
|
71
83
|
* Called after a batch render completes (core `blocks:rendered` event). The
|
|
72
84
|
* declarative analog of `ref.current.on('blocks:rendered', …)` — mirrors the
|
|
@@ -96,7 +108,7 @@ const CONFIG_KEY_SET = new Set<string>(USE_BLOK_CONFIG_KEYS);
|
|
|
96
108
|
* ```
|
|
97
109
|
*/
|
|
98
110
|
export const BlokEditor = forwardRef<Blok | null, BlokEditorProps>(
|
|
99
|
-
function BlokEditor({ deps, onReady, onBlocksRendered, onBlockRendered, ...rest }, ref) {
|
|
111
|
+
function BlokEditor({ deps, onReady, locale, onBlocksRendered, onBlockRendered, ...rest }, ref) {
|
|
100
112
|
const config: Record<string, unknown> = {};
|
|
101
113
|
const divProps: Record<string, unknown> = {};
|
|
102
114
|
|
|
@@ -110,6 +122,16 @@ export const BlokEditor = forwardRef<Blok | null, BlokEditorProps>(
|
|
|
110
122
|
}
|
|
111
123
|
}
|
|
112
124
|
|
|
125
|
+
// `locale` is a neutral shorthand for `i18n.locale`. Fold it into the i18n
|
|
126
|
+
// config (winning over any locale already there) so the single reactive
|
|
127
|
+
// `i18n.update` path in `useBlok` carries it — no separate resolver. A fresh
|
|
128
|
+
// object each render is fine: that path is deep-equal–deduped.
|
|
129
|
+
if (locale !== undefined) {
|
|
130
|
+
const existingI18n = config.i18n as Record<string, unknown> | undefined;
|
|
131
|
+
|
|
132
|
+
config.i18n = { ...existingI18n, locale };
|
|
133
|
+
}
|
|
134
|
+
|
|
113
135
|
const editor = useBlok(config, deps);
|
|
114
136
|
|
|
115
137
|
useImperativeHandle<Blok | null, Blok | null>(ref, () => editor, [editor]);
|
package/src/BlokView.tsx
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import type { ReactNode } from 'react';
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
2
|
import type { BlocksToHtmlOptions } from '@bloklabs/core/view';
|
|
3
3
|
import type { LooseOutputData, OutputData } from '@bloklabs/core';
|
|
4
4
|
|
|
5
5
|
import { useBlokView } from './useBlokView';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Props for {@link BlokView}.
|
|
8
|
+
* Props for {@link BlokView}. Beyond the render options below, any standard
|
|
9
|
+
* `<div>` attribute (`id`, `style`, `data-*`, `aria-*`, event handlers, …) is
|
|
10
|
+
* forwarded onto the single wrapper element.
|
|
9
11
|
*/
|
|
10
|
-
export interface BlokViewProps {
|
|
12
|
+
export interface BlokViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
11
13
|
/** Saved document to display (strict or loose wire shape; nullish tolerated). */
|
|
12
14
|
data: OutputData | LooseOutputData | null | undefined;
|
|
13
15
|
/** View schema from `defineBlokSchema` — display under the composition that produced the document. */
|
|
@@ -16,22 +18,40 @@ export interface BlokViewProps {
|
|
|
16
18
|
renderers?: BlocksToHtmlOptions['renderers'];
|
|
17
19
|
/** Unknown-tool policy (default 'skip'). */
|
|
18
20
|
onUnknownBlock?: BlocksToHtmlOptions['onUnknownBlock'];
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
+
/** Stamp `data-blok-tool="<type>"` on each block root as a styling hook (pairs with the opt-in view stylesheet). */
|
|
22
|
+
toolAttributes?: BlocksToHtmlOptions['toolAttributes'];
|
|
23
|
+
/** Stamp `data-blok-id="<id>"` on each block root (list items on their `<li>`) for "copy link to block" deep links. */
|
|
24
|
+
blockIds?: BlocksToHtmlOptions['blockIds'];
|
|
25
|
+
/** Pure URL rewrite hook for block URLs + inline anchors, run before the unsafe-scheme strip. */
|
|
26
|
+
transformUrl?: BlocksToHtmlOptions['transformUrl'];
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
30
|
* Display a saved Blok document synchronously — one `<div>` wrapper, no
|
|
25
|
-
* editor chrome, no
|
|
26
|
-
*
|
|
27
|
-
*
|
|
31
|
+
* editor chrome, no async, no effects. Content is mapped from the sanitized
|
|
32
|
+
* view tree to real React elements (never `dangerouslySetInnerHTML`), so it
|
|
33
|
+
* renders identically under SSR.
|
|
28
34
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
35
|
+
* This is the intended read-only path — reach for it instead of mounting the
|
|
36
|
+
* full editing runtime (`BlokEditor readOnly`), which ships toolbar, history,
|
|
37
|
+
* and mutation machinery to every viewer. Pair with `defineBlokSchema` to
|
|
38
|
+
* display custom-tool content under the exact sanitization that produced it.
|
|
39
|
+
*
|
|
40
|
+
* The props API is semver-stable. Only the raw `ViewNode` tree that the
|
|
41
|
+
* bindings map from (via `blocksToViewNodes`) remains `@experimental` — using
|
|
42
|
+
* `BlokView`/`useBlokView` never exposes you to it.
|
|
32
43
|
*/
|
|
33
|
-
export const BlokView = ({
|
|
34
|
-
|
|
44
|
+
export const BlokView = ({
|
|
45
|
+
data,
|
|
46
|
+
schema,
|
|
47
|
+
renderers,
|
|
48
|
+
onUnknownBlock,
|
|
49
|
+
toolAttributes,
|
|
50
|
+
blockIds,
|
|
51
|
+
transformUrl,
|
|
52
|
+
...divProps
|
|
53
|
+
}: BlokViewProps): ReactNode => {
|
|
54
|
+
const content = useBlokView(data, { schema, renderers, onUnknownBlock, toolAttributes, blockIds, transformUrl });
|
|
35
55
|
|
|
36
|
-
return <div
|
|
56
|
+
return <div {...divProps}>{content}</div>;
|
|
37
57
|
};
|
package/src/config-keys.ts
CHANGED
package/src/createReactBlock.tsx
CHANGED
|
@@ -61,8 +61,8 @@ export type ReactBlockViewProps<Data, Config = Record<string, unknown>> = Omit<
|
|
|
61
61
|
'commit' | 'readOnly'
|
|
62
62
|
>;
|
|
63
63
|
|
|
64
|
-
/** A toolbox entry whose `icon` may be a React element instead of
|
|
65
|
-
export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon'> & {
|
|
64
|
+
/** A toolbox entry whose `icon`/`title` may be a React element instead of a string. */
|
|
65
|
+
export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon' | 'title'> & {
|
|
66
66
|
/**
|
|
67
67
|
* Toolbox icon. Core's toolbox chrome consumes markup strings, but React
|
|
68
68
|
* authors may pass the same element they render in the block body — the
|
|
@@ -71,6 +71,17 @@ export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon'> & {
|
|
|
71
71
|
* returned as-is and serialization retries on the next browser-side access.
|
|
72
72
|
*/
|
|
73
73
|
icon?: string | ReactElement;
|
|
74
|
+
/**
|
|
75
|
+
* Toolbox label. A plain string is translated by core through the
|
|
76
|
+
* `toolNames.*` namespace as usual. A ReactElement instead renders live in
|
|
77
|
+
* the HOST's React tree (via the editor's shared BlockPortalHost), so the
|
|
78
|
+
* label tracks the host's OWN i18n — no mirroring of strings into Blok's
|
|
79
|
+
* dictionary. Pair a ReactElement title with `titleKey` so multilingual
|
|
80
|
+
* search still resolves an English term. Requires the editor to be created
|
|
81
|
+
* through `useBlok`/`BlokEditor` (which wires the portal registry); a
|
|
82
|
+
* ReactElement title is dropped when the tool is used with vanilla core.
|
|
83
|
+
*/
|
|
84
|
+
title?: string | ReactElement;
|
|
74
85
|
};
|
|
75
86
|
|
|
76
87
|
/** Toolbox config for React blocks: single entry or several variants. */
|
|
@@ -133,6 +144,13 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
|
|
|
133
144
|
}) & {
|
|
134
145
|
readonly __isBlokReactBlock: true;
|
|
135
146
|
readonly toolbox: ToolboxConfig | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* Adapter-internal: builds a per-editor toolbox whose ReactElement titles are
|
|
149
|
+
* live portals registered in `registry`. Called by `useBlok`; returns
|
|
150
|
+
* undefined when no entry has a ReactElement title. Not part of the public
|
|
151
|
+
* tool contract.
|
|
152
|
+
*/
|
|
153
|
+
__buildPortalToolbox(registry: BlockPortalRegistry, toolName: string): ToolboxConfig | undefined;
|
|
136
154
|
readonly isReadOnlySupported: boolean;
|
|
137
155
|
} {
|
|
138
156
|
/**
|
|
@@ -162,35 +180,120 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
|
|
|
162
180
|
return markup === '' ? null : markup;
|
|
163
181
|
};
|
|
164
182
|
|
|
165
|
-
|
|
166
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Convert one React toolbox entry to a core-valid entry: serialize a React
|
|
185
|
+
* icon to markup, and DROP a ReactElement title (core titles are
|
|
186
|
+
* string | HTMLElement only). A string title passes through untouched; the
|
|
187
|
+
* live element title is re-attached per-editor by `buildPortalToolbox`.
|
|
188
|
+
*
|
|
189
|
+
* `deferred` is true when icon serialization could not complete (SSR /
|
|
190
|
+
* mid-render) and must be retried; the entry is returned with its element
|
|
191
|
+
* icon left in place.
|
|
192
|
+
*
|
|
193
|
+
* @param entry - the authored React toolbox entry
|
|
194
|
+
*/
|
|
195
|
+
const toCoreEntry = (
|
|
196
|
+
entry: ReactToolboxConfigEntry
|
|
197
|
+
): { entry: ToolboxConfigEntry; deferred: boolean } => {
|
|
198
|
+
const { icon, title, ...rest } = entry;
|
|
199
|
+
const core: ToolboxConfigEntry = { ...(rest as ToolboxConfigEntry) };
|
|
200
|
+
|
|
201
|
+
if (typeof title === 'string') {
|
|
202
|
+
core.title = title;
|
|
203
|
+
}
|
|
167
204
|
|
|
168
|
-
|
|
169
|
-
if (
|
|
170
|
-
|
|
205
|
+
if (!isValidElement(icon)) {
|
|
206
|
+
if (icon !== undefined) {
|
|
207
|
+
core.icon = icon;
|
|
171
208
|
}
|
|
172
209
|
|
|
173
|
-
|
|
210
|
+
return { entry: core, deferred: false };
|
|
211
|
+
}
|
|
174
212
|
|
|
175
|
-
|
|
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;
|
|
213
|
+
const markup = renderElementToMarkup(icon);
|
|
180
214
|
|
|
181
|
-
|
|
182
|
-
|
|
215
|
+
if (markup === null) {
|
|
216
|
+
// Serialization deferred (SSR / mid-render): leave the element in place
|
|
217
|
+
// and skip caching so a later browser-side access serializes it.
|
|
218
|
+
(core as ReactToolboxConfigEntry).icon = icon;
|
|
183
219
|
|
|
184
|
-
return {
|
|
185
|
-
}
|
|
220
|
+
return { entry: core, deferred: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
core.icon = markup;
|
|
224
|
+
|
|
225
|
+
return { entry: core, deferred: false };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const convertToolbox = (): { value: ToolboxConfig | undefined; complete: boolean } => {
|
|
229
|
+
if (spec.toolbox === undefined) {
|
|
230
|
+
return { value: undefined, complete: true };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const entries = Array.isArray(spec.toolbox) ? spec.toolbox : [spec.toolbox];
|
|
234
|
+
const converted = entries.map((entry) => toCoreEntry(entry));
|
|
235
|
+
const complete = converted.every((result) => !result.deferred);
|
|
236
|
+
const coreEntries = converted.map((result) => result.entry);
|
|
237
|
+
const value = Array.isArray(spec.toolbox) ? coreEntries : coreEntries[0];
|
|
238
|
+
|
|
239
|
+
return { value, complete };
|
|
240
|
+
};
|
|
186
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Build a per-editor toolbox in which every ReactElement title is a live
|
|
244
|
+
* portal: a Blok-owned mutation-free host element registered in the editor's
|
|
245
|
+
* `registry`, into which the ReactNode renders through the shared
|
|
246
|
+
* BlockPortalHost — so the label tracks the host's own i18n.
|
|
247
|
+
*
|
|
248
|
+
* Returns undefined when no entry has a ReactElement title (nothing to
|
|
249
|
+
* override; the static string/`toolNames.*` path already covers it).
|
|
250
|
+
*
|
|
251
|
+
* @param registry - the editor's portal registry (from `useBlok`)
|
|
252
|
+
* @param toolName - the name the tool is registered under
|
|
253
|
+
*/
|
|
254
|
+
const buildPortalToolbox = (
|
|
255
|
+
registry: BlockPortalRegistry,
|
|
256
|
+
toolName: string
|
|
257
|
+
): ToolboxConfig | undefined => {
|
|
187
258
|
if (spec.toolbox === undefined) {
|
|
188
|
-
return
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const entries = Array.isArray(spec.toolbox) ? spec.toolbox : [spec.toolbox];
|
|
263
|
+
|
|
264
|
+
// Nothing to override unless at least one entry has a ReactElement title;
|
|
265
|
+
// the static string/`toolNames.*` path already covers the rest.
|
|
266
|
+
if (!entries.some((entry) => isValidElement(entry.title))) {
|
|
267
|
+
return undefined;
|
|
189
268
|
}
|
|
190
269
|
|
|
191
|
-
|
|
270
|
+
return entries.map((entry, index): ToolboxConfigEntry => {
|
|
271
|
+
const { entry: core } = toCoreEntry(entry);
|
|
272
|
+
|
|
273
|
+
if (!isValidElement(entry.title)) {
|
|
274
|
+
return core;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const host = document.createElement('span');
|
|
278
|
+
|
|
279
|
+
host.setAttribute('data-blok-mutation-free', 'true');
|
|
192
280
|
|
|
193
|
-
|
|
281
|
+
const node = entry.title;
|
|
282
|
+
const TitleComponent: ComponentType<Record<string, unknown>> = (): ReactElement => <>{node}</>;
|
|
283
|
+
|
|
284
|
+
TitleComponent.displayName = `BlokToolboxTitle(${toolName})`;
|
|
285
|
+
|
|
286
|
+
// Unique within this per-editor registry; register() is idempotent, so a
|
|
287
|
+
// StrictMode double-build replaces rather than duplicates.
|
|
288
|
+
registry.register(`__blok-toolbox-title__:${toolName}:${index}`, {
|
|
289
|
+
hostEl: host,
|
|
290
|
+
component: TitleComponent,
|
|
291
|
+
props: {},
|
|
292
|
+
toolName,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return { ...core, titleEl: host };
|
|
296
|
+
});
|
|
194
297
|
};
|
|
195
298
|
|
|
196
299
|
/** Lazily-computed core-compatible toolbox (React element icons serialized once). */
|
|
@@ -247,6 +350,17 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
|
|
|
247
350
|
return resolveToolbox();
|
|
248
351
|
}
|
|
249
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Adapter-internal (see the return-type doc): builds the per-editor toolbox
|
|
355
|
+
* override whose ReactElement titles are live portals in `registry`.
|
|
356
|
+
*/
|
|
357
|
+
public static __buildPortalToolbox(
|
|
358
|
+
registry: BlockPortalRegistry,
|
|
359
|
+
toolName: string
|
|
360
|
+
): ToolboxConfig | undefined {
|
|
361
|
+
return buildPortalToolbox(registry, toolName);
|
|
362
|
+
}
|
|
363
|
+
|
|
250
364
|
/**
|
|
251
365
|
* React blocks support read-only mode: `setReadOnly` pushes a new prop the
|
|
252
366
|
* component reads, so the block re-renders read-only IN PLACE. Without this
|
|
@@ -158,9 +158,11 @@ export function createReactInlineTool<Config = Record<string, unknown>, State =
|
|
|
158
158
|
): (new (options: InlineToolConstructorOptions) => InlineTool) & {
|
|
159
159
|
readonly __isBlokReactInlineTool: true;
|
|
160
160
|
readonly isInline: true;
|
|
161
|
+
readonly type: string;
|
|
161
162
|
readonly title: string | undefined;
|
|
162
|
-
readonly titleKey: string
|
|
163
|
+
readonly titleKey: string;
|
|
163
164
|
readonly shortcut: string | undefined;
|
|
165
|
+
readonly mark: MarkSpec<State> | undefined;
|
|
164
166
|
readonly sanitize: SanitizerConfig | undefined;
|
|
165
167
|
readonly isReadOnlySupported: boolean;
|
|
166
168
|
} {
|
|
@@ -169,18 +171,39 @@ export function createReactInlineTool<Config = Record<string, unknown>, State =
|
|
|
169
171
|
public static readonly __isBlokReactInlineTool = true as const;
|
|
170
172
|
public static readonly isInline = true as const;
|
|
171
173
|
|
|
174
|
+
/** The tool's type name — also the default translation key and mark tag context. */
|
|
175
|
+
public static get type(): string {
|
|
176
|
+
return spec.type;
|
|
177
|
+
}
|
|
178
|
+
|
|
172
179
|
public static get title(): string | undefined {
|
|
173
180
|
return spec.title;
|
|
174
181
|
}
|
|
175
182
|
|
|
176
|
-
|
|
177
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Translation key core i18n resolves as `toolNames.{titleKey}`. Defaults to
|
|
185
|
+
* the tool `type` so a custom mark localizes through the conventional
|
|
186
|
+
* `toolNames.{type}` key (as built-in tools do) instead of silently falling
|
|
187
|
+
* to the un-translated capitalized tool name. When the consumer supplies no
|
|
188
|
+
* matching i18n entry, core still falls through to that same label — the
|
|
189
|
+
* default is a no-op there and never a regression.
|
|
190
|
+
*/
|
|
191
|
+
public static get titleKey(): string {
|
|
192
|
+
return spec.titleKey ?? spec.type;
|
|
178
193
|
}
|
|
179
194
|
|
|
180
195
|
public static get shortcut(): string | undefined {
|
|
181
196
|
return spec.shortcut;
|
|
182
197
|
}
|
|
183
198
|
|
|
199
|
+
/**
|
|
200
|
+
* The declared mark spec (if any). Exposed as a static so consumers can
|
|
201
|
+
* assert a tool's mark in unit tests without mocking the factory.
|
|
202
|
+
*/
|
|
203
|
+
public static get mark(): MarkSpec<State> | undefined {
|
|
204
|
+
return spec.mark;
|
|
205
|
+
}
|
|
206
|
+
|
|
184
207
|
public static get sanitize(): SanitizerConfig | undefined {
|
|
185
208
|
if (spec.sanitize !== undefined) {
|
|
186
209
|
return spec.sanitize;
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export { useBlok } from './useBlok';
|
|
2
|
+
// Host-i18n bridge (#41): re-export the direction/normalization helpers so a
|
|
3
|
+
// host that owns its locale state can compute `dir` and validate tags without
|
|
4
|
+
// deep-importing core. `<BlokEditor locale>` pairs with these.
|
|
5
|
+
export { getDirection, normalizeLocale } from '@bloklabs/core/locales';
|
|
2
6
|
export { BlokContent } from './BlokContent';
|
|
3
7
|
export { BlokEditor } from './BlokEditor';
|
|
8
|
+
export { useBlokHandle } from './useBlokHandle';
|
|
9
|
+
export type { BlokEditorHandle } from './useBlokHandle';
|
|
4
10
|
export { BlokProvider, useBlokDefaults } from './provide-blok';
|
|
5
11
|
export type { UseBlokConfig, BlokContentProps } from './types';
|
|
6
12
|
export type { BlokEditorProps } from './BlokEditor';
|
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 { createEmittedEchoWindow, normalizeReadOnlyConfig } from '@bloklabs/core/adapters';
|
|
14
|
+
import { createEmittedEchoWindow, normalizeReadOnlyConfig, toRenderableData } from '@bloklabs/core/adapters';
|
|
15
15
|
import type { Blok } from '@/types';
|
|
16
16
|
import type { UseBlokConfig } from './types';
|
|
17
17
|
|
|
@@ -74,6 +74,24 @@ const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): un
|
|
|
74
74
|
[BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry,
|
|
75
75
|
[BLOK_TOOL_NAME_CONFIG_KEY]: name,
|
|
76
76
|
};
|
|
77
|
+
|
|
78
|
+
// A React block tool whose toolbox title is a ReactElement needs a live
|
|
79
|
+
// portal that only the editor's registry can host. Build a per-editor
|
|
80
|
+
// toolbox override (element-title hosts registered now) and let core's
|
|
81
|
+
// per-tool `toolbox` setting carry it. Undefined = no ReactElement titles,
|
|
82
|
+
// so the static string/`toolNames.*` path is left untouched.
|
|
83
|
+
const buildPortalToolbox = (base.class as {
|
|
84
|
+
__buildPortalToolbox?: (r: BlockPortalRegistry, n: string) => unknown;
|
|
85
|
+
}).__buildPortalToolbox;
|
|
86
|
+
|
|
87
|
+
const portalToolbox = typeof buildPortalToolbox === 'function' && base.toolbox === undefined
|
|
88
|
+
? buildPortalToolbox(registry, name)
|
|
89
|
+
: undefined;
|
|
90
|
+
|
|
91
|
+
if (portalToolbox !== undefined) {
|
|
92
|
+
base.toolbox = portalToolbox;
|
|
93
|
+
}
|
|
94
|
+
|
|
77
95
|
result[name] = base;
|
|
78
96
|
}
|
|
79
97
|
|
|
@@ -256,6 +274,16 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
256
274
|
configRef.current.onEnter?.(...args),
|
|
257
275
|
};
|
|
258
276
|
|
|
277
|
+
// Only attach onSubmit when the consumer opted in: its mere presence makes
|
|
278
|
+
// Enter serialize-and-submit instead of splitting a block, so an absent prop
|
|
279
|
+
// must stay absent. The wrapper reads through the ref so the latest callback
|
|
280
|
+
// is always used without recreating the editor.
|
|
281
|
+
if (currentConfig.onSubmit) {
|
|
282
|
+
blokConfig.onSubmit = (...args: Parameters<NonNullable<UseBlokConfig['onSubmit']>>): void => {
|
|
283
|
+
configRef.current.onSubmit?.(...args);
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
259
287
|
// Only attach onSave when the consumer opted in: its mere presence makes the
|
|
260
288
|
// core serialize on every change batch, so an absent prop must stay absent.
|
|
261
289
|
// The wrapper reads through the ref so the latest callback is always used.
|
|
@@ -639,7 +667,9 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
639
667
|
pendingDataRef.current = data;
|
|
640
668
|
renderChainRef.current = renderChainRef.current
|
|
641
669
|
.catch(() => undefined)
|
|
642
|
-
|
|
670
|
+
// `data` may be null here (a controlled "clear to empty"); render() throws
|
|
671
|
+
// on null, so normalize null → { blocks: [] } at the boundary.
|
|
672
|
+
.then(() => editor.render(toRenderableData(data)))
|
|
643
673
|
.then(
|
|
644
674
|
() => {
|
|
645
675
|
lastRenderedDataRef.current = data;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useRef, useMemo, type RefCallback } from 'react';
|
|
2
|
+
import type { Blok, OutputData, LooseOutputData } from '@/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A typed, null-safe imperative handle for a Blok editor embedded via
|
|
6
|
+
* `<BlokEditor>`. Solves the "raw ref is `Blok | null` until ready" papercut:
|
|
7
|
+
* instead of every consumer re-declaring a `forwardRef`/`useImperativeHandle`
|
|
8
|
+
* with `?.` guards, `useBlokHandle()` returns this stable object whose methods
|
|
9
|
+
* safely no-op until the editor is ready.
|
|
10
|
+
*
|
|
11
|
+
* Attach it via `ref`: `<BlokEditor ref={handle.ref} … />`. The methods read the
|
|
12
|
+
* live instance through an internal ref, so they can be called from event
|
|
13
|
+
* handlers without re-subscribing, and they stay valid across re-renders.
|
|
14
|
+
*
|
|
15
|
+
* `current` / `isReady` are IMPERATIVE reads (they reflect the instance at call
|
|
16
|
+
* time and do NOT trigger a re-render when readiness flips). For render-reactive
|
|
17
|
+
* readiness use `useBlokReady`; drop to `current` when you need the full editor
|
|
18
|
+
* API beyond the handful of shortcuts below.
|
|
19
|
+
*/
|
|
20
|
+
export interface BlokEditorHandle {
|
|
21
|
+
/**
|
|
22
|
+
* Ref callback to attach to `<BlokEditor ref={handle.ref} />`. Stable across
|
|
23
|
+
* renders. Stores the live instance (or null before ready / after unmount).
|
|
24
|
+
*/
|
|
25
|
+
readonly ref: RefCallback<Blok | null>;
|
|
26
|
+
/** The live editor instance, or null before ready / after unmount. */
|
|
27
|
+
readonly current: Blok | null;
|
|
28
|
+
/** True once the editor is ready (the ref has been populated). */
|
|
29
|
+
readonly isReady: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Focus the editor. Returns whether focus landed; `false` (no-op) before ready.
|
|
32
|
+
* @param atEnd - place the caret at the end of the focused block
|
|
33
|
+
*/
|
|
34
|
+
focus(atEnd?: boolean): boolean;
|
|
35
|
+
/** Clear all content. Resolves immediately (no-op) before ready. */
|
|
36
|
+
clear(): Promise<void>;
|
|
37
|
+
/** Serialize the current content, or resolve to `null` before ready. */
|
|
38
|
+
save(): Promise<OutputData | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Render new content in place. Resolves immediately (no-op) before ready.
|
|
41
|
+
* @param data - the document to render
|
|
42
|
+
*/
|
|
43
|
+
render(data: OutputData | LooseOutputData): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Set or toggle read-only mode. Resolves to the resulting state; `false`
|
|
46
|
+
* (no-op) before ready.
|
|
47
|
+
* @param state - explicit state; omit to toggle
|
|
48
|
+
*/
|
|
49
|
+
setReadOnly(state?: boolean): Promise<boolean>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns a stable, null-safe {@link BlokEditorHandle} for a `<BlokEditor>`.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* const blok = useBlokHandle();
|
|
58
|
+
* return (
|
|
59
|
+
* <>
|
|
60
|
+
* <BlokEditor ref={blok.ref} tools={tools} data={data} />
|
|
61
|
+
* <button onClick={() => blok.focus()}>Focus</button>
|
|
62
|
+
* <button onClick={() => blok.save().then(onSubmit)}>Send</button>
|
|
63
|
+
* </>
|
|
64
|
+
* );
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function useBlokHandle(): BlokEditorHandle {
|
|
68
|
+
const instanceRef = useRef<Blok | null>(null);
|
|
69
|
+
|
|
70
|
+
// Built once and kept stable: every method reads the live instance through
|
|
71
|
+
// `instanceRef`, so the handle identity (and its ref callback) never changes
|
|
72
|
+
// across renders even as the underlying editor is created or destroyed.
|
|
73
|
+
return useMemo<BlokEditorHandle>(() => ({
|
|
74
|
+
ref: (node): void => {
|
|
75
|
+
instanceRef.current = node;
|
|
76
|
+
},
|
|
77
|
+
get current(): Blok | null {
|
|
78
|
+
return instanceRef.current;
|
|
79
|
+
},
|
|
80
|
+
get isReady(): boolean {
|
|
81
|
+
return instanceRef.current !== null;
|
|
82
|
+
},
|
|
83
|
+
focus: (atEnd?: boolean): boolean => instanceRef.current?.focus(atEnd) ?? false,
|
|
84
|
+
clear: (): Promise<void> => instanceRef.current?.clear() ?? Promise.resolve(),
|
|
85
|
+
save: (): Promise<OutputData | null> =>
|
|
86
|
+
instanceRef.current !== null ? instanceRef.current.save() : Promise.resolve(null),
|
|
87
|
+
render: (data: OutputData | LooseOutputData): Promise<void> =>
|
|
88
|
+
instanceRef.current?.render(data) ?? Promise.resolve(),
|
|
89
|
+
setReadOnly: (state?: boolean): Promise<boolean> => {
|
|
90
|
+
const instance = instanceRef.current;
|
|
91
|
+
|
|
92
|
+
if (instance === null) {
|
|
93
|
+
return Promise.resolve(false);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Resolve the toggle here (omitted `state` flips current) so we can use the
|
|
97
|
+
// non-deprecated `set()`, which requires an explicit target state.
|
|
98
|
+
return instance.readOnly.set(state ?? !instance.readOnly.isEnabled);
|
|
99
|
+
},
|
|
100
|
+
}), []);
|
|
101
|
+
}
|