@bloklabs/react 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +433 -244
- package/package.json +25 -4
- package/src/BlokView.tsx +37 -0
- package/src/createReactInlineTool.tsx +185 -28
- package/src/index.ts +8 -1
- package/src/types.ts +5 -1
- package/src/useBlok.ts +80 -4
- package/src/useBlokReady.ts +60 -0
- package/src/useBlokView.ts +38 -0
- package/src/view-nodes-to-react.ts +138 -0
- package/types/index.d.ts +164 -12
package/package.json
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bloklabs/react",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "React bindings for the
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "React block editor — official React bindings for Blok, the headless Notion-like rich text editor. Hooks and components that render blocks and save to JSON.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "JackUait",
|
|
7
|
+
"homepage": "https://blokeditor.com",
|
|
7
8
|
"repository": {
|
|
8
9
|
"type": "git",
|
|
9
10
|
"url": "git+https://github.com/JackUait/blok.git",
|
|
10
11
|
"directory": "packages/react"
|
|
11
12
|
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/JackUait/blok/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"blok",
|
|
18
|
+
"react",
|
|
19
|
+
"react-component",
|
|
20
|
+
"block-editor",
|
|
21
|
+
"rich-text-editor",
|
|
22
|
+
"wysiwyg",
|
|
23
|
+
"editor",
|
|
24
|
+
"notion",
|
|
25
|
+
"notion-like",
|
|
26
|
+
"headless",
|
|
27
|
+
"block-based",
|
|
28
|
+
"contenteditable",
|
|
29
|
+
"json",
|
|
30
|
+
"typescript"
|
|
31
|
+
],
|
|
12
32
|
"type": "module",
|
|
13
33
|
"sideEffects": false,
|
|
14
34
|
"main": "./dist/index.cjs",
|
|
@@ -30,10 +50,11 @@
|
|
|
30
50
|
"access": "public"
|
|
31
51
|
},
|
|
32
52
|
"scripts": {
|
|
33
|
-
"build": "yarn run -T vite build --mode production"
|
|
53
|
+
"build": "yarn run -T vite build --mode production",
|
|
54
|
+
"test": "yarn run -T vitest run"
|
|
34
55
|
},
|
|
35
56
|
"peerDependencies": {
|
|
36
|
-
"@bloklabs/core": "^1.
|
|
57
|
+
"@bloklabs/core": "^1.4.0",
|
|
37
58
|
"react": "^18.0.0 || ^19.0.0",
|
|
38
59
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
39
60
|
}
|
package/src/BlokView.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { BlocksToHtmlOptions } from '@bloklabs/core/view';
|
|
3
|
+
import type { LooseOutputData, OutputData } from '@bloklabs/core';
|
|
4
|
+
|
|
5
|
+
import { useBlokView } from './useBlokView';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Props for {@link BlokView}.
|
|
9
|
+
*/
|
|
10
|
+
export interface BlokViewProps {
|
|
11
|
+
/** Saved document to display (strict or loose wire shape; nullish tolerated). */
|
|
12
|
+
data: OutputData | LooseOutputData | null | undefined;
|
|
13
|
+
/** View schema from `defineBlokSchema` — display under the composition that produced the document. */
|
|
14
|
+
schema?: BlocksToHtmlOptions['schema'];
|
|
15
|
+
/** Custom per-tool renderers; win over built-ins. */
|
|
16
|
+
renderers?: BlocksToHtmlOptions['renderers'];
|
|
17
|
+
/** Unknown-tool policy (default 'skip'). */
|
|
18
|
+
onUnknownBlock?: BlocksToHtmlOptions['onUnknownBlock'];
|
|
19
|
+
/** Class for the single wrapper div. */
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Display a saved Blok document synchronously — one `<div>` wrapper, no
|
|
25
|
+
* editor chrome, no ids, no async, no effects. Content is mapped from the
|
|
26
|
+
* sanitized view tree to real React elements (never
|
|
27
|
+
* `dangerouslySetInnerHTML`), so it renders identically under SSR.
|
|
28
|
+
*
|
|
29
|
+
* @experimental Built on the `@experimental` ViewNode tree of
|
|
30
|
+
* `@bloklabs/core/view` — not frozen until a second framework adapter
|
|
31
|
+
* consumes it.
|
|
32
|
+
*/
|
|
33
|
+
export const BlokView = ({ data, schema, renderers, onUnknownBlock, className }: BlokViewProps): ReactNode => {
|
|
34
|
+
const content = useBlokView(data, { schema, renderers, onUnknownBlock });
|
|
35
|
+
|
|
36
|
+
return <div className={className}>{content}</div>;
|
|
37
|
+
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { createContext, useContext, useMemo, type ComponentType, type ReactElement } from 'react';
|
|
2
|
+
import { markSanitizerConfig } from '@bloklabs/core/adapters';
|
|
2
3
|
import {
|
|
3
4
|
BLOK_PORTAL_REGISTRY_CONFIG_KEY,
|
|
4
5
|
BLOK_TOOL_NAME_CONFIG_KEY,
|
|
5
6
|
type BlockPortalRegistry,
|
|
6
7
|
} from './block-portal-registry';
|
|
7
|
-
import type { SanitizerConfig } from '@/types';
|
|
8
|
+
import type { API, SanitizerConfig } from '@/types';
|
|
9
|
+
import type { MarkSnapshot, MarkSpec } from '@/types';
|
|
8
10
|
import type { InlineTool, InlineToolConstructorOptions, MenuConfig } from '@/types/tools';
|
|
9
11
|
|
|
10
12
|
/** Adapter-internal config keys, stripped before the config reaches the component. */
|
|
@@ -37,8 +39,57 @@ export interface ReactInlineToolRenderProps<Config = Record<string, unknown>> {
|
|
|
37
39
|
config: Readonly<Partial<Config>>;
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/** The spec's mark operations, bound to the live selection via `api.marks`. */
|
|
43
|
+
export interface InlineToolMarkOps<State = void> {
|
|
44
|
+
/** Whether the whole selection currently carries the mark. */
|
|
45
|
+
has(): boolean;
|
|
46
|
+
/** Toggle the mark on the selection; returns true when now applied. */
|
|
47
|
+
toggle(state?: State): boolean;
|
|
48
|
+
/** Apply (or update in place) the mark on the selection. */
|
|
49
|
+
apply(state?: State): HTMLElement[];
|
|
50
|
+
/** Remove the mark from the selection. */
|
|
51
|
+
remove(): HTMLElement[];
|
|
52
|
+
/** Read the mark's current declared values at the selection. */
|
|
53
|
+
read(): MarkSnapshot | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Everything a React inline tool's component (and any component nested in it —
|
|
58
|
+
* swatches, popovers) can reach via {@link useInlineTool} without prop-drilling.
|
|
59
|
+
*/
|
|
60
|
+
export interface InlineToolHandle<Config = Record<string, unknown>, State = void> {
|
|
61
|
+
/** Live active state, same value as the component's `active` prop. */
|
|
62
|
+
active: boolean;
|
|
63
|
+
/** The tool's config, same value as the component's `config` prop. */
|
|
64
|
+
config: Readonly<Partial<Config>>;
|
|
65
|
+
/** The editor API handed to the tool's constructor (undefined outside core). */
|
|
66
|
+
api: API | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Operations bound to the spec's `mark` — null when the spec declares no
|
|
69
|
+
* mark or the editor api provides no `marks` surface.
|
|
70
|
+
*/
|
|
71
|
+
mark: InlineToolMarkOps<State> | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const InlineToolContext = createContext<InlineToolHandle | null>(null);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reach the enclosing React inline tool from any component inside its
|
|
78
|
+
* portaled UI: live `active` state, the tool `config`, the editor `api`, and
|
|
79
|
+
* the spec's mark operations bound to the live selection.
|
|
80
|
+
*/
|
|
81
|
+
export function useInlineTool<Config = Record<string, unknown>, State = void>(): InlineToolHandle<Config, State> {
|
|
82
|
+
const handle = useContext(InlineToolContext);
|
|
83
|
+
|
|
84
|
+
if (handle === null) {
|
|
85
|
+
throw new Error('useInlineTool() must be called from a component rendered by createReactInlineTool');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return handle as InlineToolHandle<Config, State>;
|
|
89
|
+
}
|
|
90
|
+
|
|
40
91
|
/** Spec for {@link createReactInlineTool}. */
|
|
41
|
-
export interface CreateReactInlineToolSpec<Config = Record<string, unknown
|
|
92
|
+
export interface CreateReactInlineToolSpec<Config = Record<string, unknown>, State = void> {
|
|
42
93
|
/** Tool type name — the MenuConfig item name (fallback when the tools-map key is unavailable). */
|
|
43
94
|
type: string;
|
|
44
95
|
/** Optional toolbar item title (shown in overflow/search contexts). */
|
|
@@ -52,13 +103,32 @@ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
|
|
|
52
103
|
titleKey?: string;
|
|
53
104
|
/** The component rendered as the tool's toolbar icon/UI. */
|
|
54
105
|
component: ComponentType<ReactInlineToolRenderProps<Config>>;
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Declarative description of the wrapper this tool produces. When present,
|
|
108
|
+
* `surround` (toggle), `checkState` (whole-range coverage) and `sanitize`
|
|
109
|
+
* are derived from it — range-aware splitting, adjacent-run semantics and
|
|
110
|
+
* the trailing-whitespace fix come from the editor's mark engine
|
|
111
|
+
* (`api.marks`) with no DOM plumbing in the tool.
|
|
112
|
+
*/
|
|
113
|
+
mark?: MarkSpec<State>;
|
|
114
|
+
/**
|
|
115
|
+
* Applies/removes the formatting on the LIVE selection's range (captured at
|
|
116
|
+
* activation time). Receives the editor API as its second argument.
|
|
117
|
+
* Takes precedence over the `mark` derivation.
|
|
118
|
+
*/
|
|
119
|
+
surround?: (range: Range, api: API | undefined) => void;
|
|
120
|
+
/**
|
|
121
|
+
* Reports whether the formatting is active for the given selection.
|
|
122
|
+
* Receives the editor API as its second argument.
|
|
123
|
+
* Takes precedence over the `mark` derivation.
|
|
124
|
+
*/
|
|
125
|
+
checkState?: (selection: Selection | null, api: API | undefined) => boolean;
|
|
59
126
|
/** Keyboard shortcut (e.g. `CMD+SHIFT+C`). */
|
|
60
127
|
shortcut?: string;
|
|
61
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Inline sanitizer config declaring the markup this tool produces.
|
|
130
|
+
* Defaults to the config derived from `mark` when one is declared.
|
|
131
|
+
*/
|
|
62
132
|
sanitize?: SanitizerConfig;
|
|
63
133
|
/** Whether the tool stays available in read-only mode. */
|
|
64
134
|
isReadOnlySupported?: boolean;
|
|
@@ -76,11 +146,15 @@ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
|
|
|
76
146
|
* `destroy()` (called by the inline toolbar on close/teardown) unregisters the
|
|
77
147
|
* portal so the component unmounts deterministically — no leaked React roots.
|
|
78
148
|
*
|
|
149
|
+
* The editor `api` handed to the constructor is kept: it reaches `surround`/
|
|
150
|
+
* `checkState` as their second argument, powers the `mark` derivation, and is
|
|
151
|
+
* exposed to the portaled component tree via {@link useInlineTool}.
|
|
152
|
+
*
|
|
79
153
|
* Without an injected registry (vanilla core, no `useBlok`), the tool still
|
|
80
154
|
* renders a valid MenuConfig — the host element just stays empty.
|
|
81
155
|
*/
|
|
82
|
-
export function createReactInlineTool<Config = Record<string, unknown
|
|
83
|
-
spec: CreateReactInlineToolSpec<Config>
|
|
156
|
+
export function createReactInlineTool<Config = Record<string, unknown>, State = void>(
|
|
157
|
+
spec: CreateReactInlineToolSpec<Config, State>
|
|
84
158
|
): (new (options: InlineToolConstructorOptions) => InlineTool) & {
|
|
85
159
|
readonly __isBlokReactInlineTool: true;
|
|
86
160
|
readonly isInline: true;
|
|
@@ -108,7 +182,11 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
|
|
|
108
182
|
}
|
|
109
183
|
|
|
110
184
|
public static get sanitize(): SanitizerConfig | undefined {
|
|
111
|
-
|
|
185
|
+
if (spec.sanitize !== undefined) {
|
|
186
|
+
return spec.sanitize;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return spec.mark !== undefined ? markSanitizerConfig(spec.mark) : undefined;
|
|
112
190
|
}
|
|
113
191
|
|
|
114
192
|
public static get isReadOnlySupported(): boolean {
|
|
@@ -116,12 +194,20 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
|
|
|
116
194
|
}
|
|
117
195
|
|
|
118
196
|
private readonly registry: BlockPortalRegistry | undefined;
|
|
197
|
+
/** The editor API — kept (not discarded) so the tool has the same reach as a vanilla one. */
|
|
198
|
+
private readonly api: API | undefined;
|
|
119
199
|
/** Name this tool is registered under in the consumer's `tools` map. */
|
|
120
200
|
private readonly toolName: string;
|
|
121
201
|
/** Unique portal-registry key — inline tools have no block id. */
|
|
122
202
|
private readonly portalKey: string;
|
|
123
203
|
/** The consumer's tool config with adapter-internal keys stripped. */
|
|
124
204
|
private readonly toolConfig: Readonly<Partial<Config>>;
|
|
205
|
+
/**
|
|
206
|
+
* Stable provider component wrapping `spec.component` with the
|
|
207
|
+
* {@link useInlineTool} context. Created once per instance so prop pushes
|
|
208
|
+
* (setProps) re-render without remounting the consumer's component.
|
|
209
|
+
*/
|
|
210
|
+
private readonly portalComponent: ComponentType<ReactInlineToolRenderProps<Config>>;
|
|
125
211
|
/** Last active state pushed to the mounted component (change-dedup baseline). */
|
|
126
212
|
private active = false;
|
|
127
213
|
/** True while this instance owns a registered portal entry. */
|
|
@@ -130,6 +216,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
|
|
|
130
216
|
public constructor(options: InlineToolConstructorOptions) {
|
|
131
217
|
const config: Record<string, unknown> = options?.config ?? {};
|
|
132
218
|
|
|
219
|
+
this.api = options?.api;
|
|
133
220
|
this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
|
|
134
221
|
|
|
135
222
|
const toolName = config[BLOK_TOOL_NAME_CONFIG_KEY];
|
|
@@ -145,6 +232,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
|
|
|
145
232
|
}
|
|
146
233
|
}
|
|
147
234
|
this.toolConfig = Object.freeze(publicConfig) as Readonly<Partial<Config>>;
|
|
235
|
+
this.portalComponent = this.createPortalComponent();
|
|
148
236
|
}
|
|
149
237
|
|
|
150
238
|
public render(): MenuConfig {
|
|
@@ -157,7 +245,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
|
|
|
157
245
|
if (this.registry !== undefined) {
|
|
158
246
|
this.registry.register(this.portalKey, {
|
|
159
247
|
hostEl: host,
|
|
160
|
-
component:
|
|
248
|
+
component: this.portalComponent as unknown as ComponentType<Record<string, unknown>>,
|
|
161
249
|
props: { active: this.active, config: this.toolConfig },
|
|
162
250
|
toolName: this.toolName,
|
|
163
251
|
});
|
|
@@ -169,19 +257,7 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
|
|
|
169
257
|
title: spec.title,
|
|
170
258
|
icon: host,
|
|
171
259
|
onActivate: (): void => {
|
|
172
|
-
|
|
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));
|
|
260
|
+
this.activate();
|
|
185
261
|
},
|
|
186
262
|
isActive: (): boolean => this.refreshActiveState(),
|
|
187
263
|
};
|
|
@@ -202,15 +278,37 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
|
|
|
202
278
|
this.registry?.unregister(this.portalKey);
|
|
203
279
|
}
|
|
204
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Run the tool's formatting action: an explicit `surround` on the LIVE
|
|
283
|
+
* selection's range (captured at activation time, matching
|
|
284
|
+
* wrapLegacyInlineTool — never a range snapshotted at render), or the
|
|
285
|
+
* mark derivation (`api.marks.toggle`) when only a `mark` is declared.
|
|
286
|
+
*/
|
|
287
|
+
private activate(): void {
|
|
288
|
+
if (typeof spec.surround === 'function') {
|
|
289
|
+
const selection = window.getSelection();
|
|
290
|
+
|
|
291
|
+
if (!selection || selection.rangeCount === 0) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
spec.surround(selection.getRangeAt(0), this.api);
|
|
296
|
+
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (spec.mark !== undefined && this.api?.marks !== undefined) {
|
|
301
|
+
this.api.marks.toggle(spec.mark);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
205
305
|
/**
|
|
206
306
|
* Recompute the active state from the live selection and push it to the
|
|
207
307
|
* mounted component ONLY when it actually changed (no redundant
|
|
208
308
|
* external-store notifications).
|
|
209
309
|
*/
|
|
210
310
|
private refreshActiveState(): boolean {
|
|
211
|
-
const active =
|
|
212
|
-
? Boolean(spec.checkState(window.getSelection()))
|
|
213
|
-
: false;
|
|
311
|
+
const active = this.computeActiveState();
|
|
214
312
|
|
|
215
313
|
if (active !== this.active) {
|
|
216
314
|
this.active = active;
|
|
@@ -222,5 +320,64 @@ export function createReactInlineTool<Config = Record<string, unknown>>(
|
|
|
222
320
|
|
|
223
321
|
return active;
|
|
224
322
|
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Explicit `checkState` wins; otherwise the mark derivation answers
|
|
326
|
+
* "does the WHOLE selection carry the mark" via `api.marks.has`.
|
|
327
|
+
*/
|
|
328
|
+
private computeActiveState(): boolean {
|
|
329
|
+
if (typeof spec.checkState === 'function') {
|
|
330
|
+
return Boolean(spec.checkState(window.getSelection(), this.api));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (spec.mark !== undefined && this.api?.marks !== undefined) {
|
|
334
|
+
return this.api.marks.has(spec.mark);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Build the per-instance provider component: the consumer's component
|
|
342
|
+
* rendered inside the {@link useInlineTool} context, so nested
|
|
343
|
+
* swatches/popovers reach the tool without prop-drilling.
|
|
344
|
+
*/
|
|
345
|
+
private createPortalComponent(): ComponentType<ReactInlineToolRenderProps<Config>> {
|
|
346
|
+
const api = this.api;
|
|
347
|
+
const mark = spec.mark;
|
|
348
|
+
const Component = spec.component;
|
|
349
|
+
|
|
350
|
+
const markOps: InlineToolMarkOps<State> | null = mark !== undefined && api?.marks !== undefined
|
|
351
|
+
? {
|
|
352
|
+
has: (): boolean => api.marks.has(mark),
|
|
353
|
+
toggle: (state?: State): boolean => api.marks.toggle(mark, state),
|
|
354
|
+
apply: (state?: State): HTMLElement[] => api.marks.apply(mark, state),
|
|
355
|
+
remove: (): HTMLElement[] => api.marks.remove(mark),
|
|
356
|
+
read: (): MarkSnapshot | null => api.marks.read(mark),
|
|
357
|
+
}
|
|
358
|
+
: null;
|
|
359
|
+
|
|
360
|
+
const PortalComponent = (props: ReactInlineToolRenderProps<Config>): ReactElement => {
|
|
361
|
+
const handle = useMemo<InlineToolHandle>(
|
|
362
|
+
() => ({
|
|
363
|
+
active: props.active,
|
|
364
|
+
config: props.config,
|
|
365
|
+
api,
|
|
366
|
+
mark: markOps as InlineToolMarkOps | null,
|
|
367
|
+
}),
|
|
368
|
+
[props.active, props.config]
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<InlineToolContext.Provider value={handle}>
|
|
373
|
+
<Component active={props.active} config={props.config} />
|
|
374
|
+
</InlineToolContext.Provider>
|
|
375
|
+
);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
PortalComponent.displayName = `BlokInlineTool(${this.toolName})`;
|
|
379
|
+
|
|
380
|
+
return PortalComponent;
|
|
381
|
+
}
|
|
225
382
|
};
|
|
226
383
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,11 @@ export { BlokProvider, useBlokDefaults } from './provide-blok';
|
|
|
5
5
|
export type { UseBlokConfig, BlokContentProps } from './types';
|
|
6
6
|
export type { BlokEditorProps } from './BlokEditor';
|
|
7
7
|
export { useBlocks } from './useBlocks';
|
|
8
|
+
export { useBlokReady } from './useBlokReady';
|
|
9
|
+
export type { UseBlokReadyOptions } from './useBlokReady';
|
|
10
|
+
export { BlokView } from './BlokView';
|
|
11
|
+
export type { BlokViewProps } from './BlokView';
|
|
12
|
+
export { useBlokView } from './useBlokView';
|
|
8
13
|
export { createReactBlock } from './createReactBlock';
|
|
9
14
|
export type {
|
|
10
15
|
CreateReactBlockSpec,
|
|
@@ -15,10 +20,12 @@ export type {
|
|
|
15
20
|
PropSchema,
|
|
16
21
|
PropSchemaEntry,
|
|
17
22
|
} from './createReactBlock';
|
|
18
|
-
export { createReactInlineTool } from './createReactInlineTool';
|
|
23
|
+
export { createReactInlineTool, useInlineTool } from './createReactInlineTool';
|
|
19
24
|
export type {
|
|
20
25
|
CreateReactInlineToolSpec,
|
|
21
26
|
ReactInlineToolRenderProps,
|
|
27
|
+
InlineToolHandle,
|
|
28
|
+
InlineToolMarkOps,
|
|
22
29
|
} from './createReactInlineTool';
|
|
23
30
|
export {
|
|
24
31
|
createBlockPortalRegistry,
|
package/src/types.ts
CHANGED
|
@@ -6,7 +6,11 @@ import type { BlokConfig, Blok, EditorWidth } from '@/types';
|
|
|
6
6
|
* React-only reactive `width` prop.
|
|
7
7
|
*
|
|
8
8
|
* Reactive props (sync after mount without recreation):
|
|
9
|
-
* - `readOnly` — calls `editor.readOnly.set(value)
|
|
9
|
+
* - `readOnly` — calls `editor.readOnly.set(value)`; the object form also
|
|
10
|
+
* syncs `hideControls` via `editor.readOnly.set(enabled, { hideControls })`
|
|
11
|
+
* - `hideToolbar` — calls `editor.toolbar.setHidden(value)`
|
|
12
|
+
* - `inlineToolbar` — calls `editor.tools.setInlineToolbar(value)` (arrays
|
|
13
|
+
* are compared by content, so a re-created literal is a no-op)
|
|
10
14
|
* - `autofocus` — calls `editor.focus()` when changed to true
|
|
11
15
|
* - `theme` — calls `editor.theme.set(value)`
|
|
12
16
|
* - `width` — calls `editor.width.set(value)`
|
package/src/useBlok.ts
CHANGED
|
@@ -417,14 +417,51 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
417
417
|
}
|
|
418
418
|
});
|
|
419
419
|
|
|
420
|
-
// Reactive: readOnly (object form normalized so the effect
|
|
421
|
-
|
|
420
|
+
// Reactive: readOnly (object form normalized so the effect deps are stable booleans).
|
|
421
|
+
// The object form additionally syncs `hideControls` through the options
|
|
422
|
+
// parameter of `readOnly.set` — a plain boolean prop keeps the historical
|
|
423
|
+
// boolean-only call so it never overwrites a construction-time object form.
|
|
424
|
+
const readOnlyIsObjectForm = typeof config.readOnly === 'object' && config.readOnly !== null;
|
|
425
|
+
const { enabled: readOnlyEnabled, hideControls: readOnlyHideControls } = normalizeReadOnlyConfig(config.readOnly);
|
|
422
426
|
useEffect(() => {
|
|
423
427
|
if (editor === null) {
|
|
424
428
|
return;
|
|
425
429
|
}
|
|
426
|
-
|
|
427
|
-
|
|
430
|
+
|
|
431
|
+
if (readOnlyIsObjectForm) {
|
|
432
|
+
void editor.readOnly.set(readOnlyEnabled, { hideControls: readOnlyHideControls });
|
|
433
|
+
} else {
|
|
434
|
+
void editor.readOnly.set(readOnlyEnabled);
|
|
435
|
+
}
|
|
436
|
+
}, [editor, readOnlyEnabled, readOnlyHideControls, readOnlyIsObjectForm]);
|
|
437
|
+
|
|
438
|
+
// Reactive: hideToolbar — `toolbar.setHidden` writes `config.hideToolbar`
|
|
439
|
+
// (read live by the toolbar's open guards) AND toggles the wrapper's
|
|
440
|
+
// toolbar-hidden attribute (the CSS hook that collapses the gutter), so a
|
|
441
|
+
// prop flip takes effect in place. Guarded on `undefined` so a consumer who
|
|
442
|
+
// never sets the prop gets no call at all (mirrors theme/width).
|
|
443
|
+
const { hideToolbar } = config;
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
if (editor === null || hideToolbar === undefined) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
editor.toolbar.setHidden(hideToolbar);
|
|
449
|
+
}, [editor, hideToolbar]);
|
|
450
|
+
|
|
451
|
+
// Reactive: inlineToolbar — `tools.setInlineToolbar` re-assigns every block
|
|
452
|
+
// tool's inline set and invalidates sanitize caches, so the change lands on
|
|
453
|
+
// the next selection without a re-render. The dep is a content serialization
|
|
454
|
+
// (boolean | string[]) so a re-created array literal with the same entries
|
|
455
|
+
// does not re-fire the setter.
|
|
456
|
+
const { inlineToolbar } = config;
|
|
457
|
+
const inlineToolbarKey = inlineToolbar === undefined ? undefined : JSON.stringify(inlineToolbar);
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
if (editor === null || inlineToolbar === undefined) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
editor.tools.setInlineToolbar(inlineToolbar);
|
|
463
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- inlineToolbar is deliberately depped by CONTENT (inlineToolbarKey) so a re-created array literal with the same entries does not thrash the setter
|
|
464
|
+
}, [editor, inlineToolbarKey]);
|
|
428
465
|
|
|
429
466
|
// Reactive: autofocus
|
|
430
467
|
const { autofocus } = config;
|
|
@@ -480,6 +517,45 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
480
517
|
editor.tokens.set(styleTokens);
|
|
481
518
|
}, [editor, styleTokens]);
|
|
482
519
|
|
|
520
|
+
// Reactive: i18n
|
|
521
|
+
//
|
|
522
|
+
// `config.i18n` was read once during the editor's boot and never again, so a
|
|
523
|
+
// host driving a language switcher from React state had no correct option:
|
|
524
|
+
// accept a permanently stale UI, or bump `deps` and destroy the editor
|
|
525
|
+
// mid-typing. Push genuine changes through the runtime `i18n.update` API
|
|
526
|
+
// instead, deep-equal–deduped because `i18n` is almost always a fresh object
|
|
527
|
+
// literal each render (a `useMemo`d message map re-created on locale change
|
|
528
|
+
// is the whole point of the prop).
|
|
529
|
+
//
|
|
530
|
+
// `defaultLocale` is deliberately not forwarded: it only decides the
|
|
531
|
+
// fallback while resolving the INITIAL locale, so it is inert after mount.
|
|
532
|
+
const { i18n } = config;
|
|
533
|
+
/*
|
|
534
|
+
* Seeded with the mount-time value: construction already applied it, so the
|
|
535
|
+
* first effect run is a no-op and only genuine changes push.
|
|
536
|
+
*/
|
|
537
|
+
const appliedI18nRef = useRef<UseBlokConfig['i18n']>(config.i18n);
|
|
538
|
+
|
|
539
|
+
useEffect(() => {
|
|
540
|
+
if (editor === null || i18n === undefined) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (deepEqual(i18n, appliedI18nRef.current)) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
appliedI18nRef.current = i18n;
|
|
549
|
+
|
|
550
|
+
const { locale, messages, direction } = i18n;
|
|
551
|
+
|
|
552
|
+
void editor.i18n.update({
|
|
553
|
+
...(locale === undefined ? {} : { locale }),
|
|
554
|
+
...(messages === undefined ? {} : { messages }),
|
|
555
|
+
...(direction === undefined ? {} : { direction }),
|
|
556
|
+
});
|
|
557
|
+
}, [editor, i18n]);
|
|
558
|
+
|
|
483
559
|
// Reactive: placeholder
|
|
484
560
|
const { placeholder } = config;
|
|
485
561
|
useEffect(() => {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useState, type RefObject } from 'react';
|
|
2
|
+
import { Blok as BlokRuntime } from '@bloklabs/core';
|
|
3
|
+
|
|
4
|
+
/** Options accepted by {@link useBlokReady}. */
|
|
5
|
+
export interface UseBlokReadyOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Restrict the wait to editors mounted inside this element. Accepts the ref
|
|
8
|
+
* you already hold on the container (read on every notification, so a ref
|
|
9
|
+
* that attaches after the first render is picked up).
|
|
10
|
+
*
|
|
11
|
+
* Omit it to observe every editor on the page. Passing it while it still
|
|
12
|
+
* resolves to null reports NOT ready — an unresolved scope must never fall
|
|
13
|
+
* back to the page-global one.
|
|
14
|
+
*/
|
|
15
|
+
within?: RefObject<Element | null> | Element | null;
|
|
16
|
+
/**
|
|
17
|
+
* `'ready'` (default) settles when each editor has finished booting.
|
|
18
|
+
* `'rendered'` also waits for its content to be in the DOM and re-arms on
|
|
19
|
+
* every post-boot re-render.
|
|
20
|
+
*/
|
|
21
|
+
settleOn?: 'ready' | 'rendered';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Live readiness of the Blok editors in a DOM scope.
|
|
26
|
+
*
|
|
27
|
+
* Returns `false` until every editor inside `within` is settled, and follows
|
|
28
|
+
* the scope afterwards: editors mounted later re-close the gate, and with
|
|
29
|
+
* `settleOn: 'rendered'` so does each re-render. A scope holding no editors is
|
|
30
|
+
* ready, so callers need no "nothing to wait for" special case.
|
|
31
|
+
*
|
|
32
|
+
* The first render always reports `false` — the scope element is not attached
|
|
33
|
+
* yet, and over-waiting is safe while under-waiting is a bug.
|
|
34
|
+
* @param options - scope and readiness depth
|
|
35
|
+
*/
|
|
36
|
+
export function useBlokReady(options: UseBlokReadyOptions = {}): boolean {
|
|
37
|
+
const { within, settleOn } = options;
|
|
38
|
+
const [ready, setReady] = useState(false);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const read = (): void => {
|
|
42
|
+
const scope = within instanceof Element ? within : (within?.current ?? null);
|
|
43
|
+
|
|
44
|
+
// A scope that was asked for but has not resolved yet is NOT the global
|
|
45
|
+
// scope — reporting ready here would gate on the wrong set of editors.
|
|
46
|
+
setReady(within !== undefined && scope === null
|
|
47
|
+
? false
|
|
48
|
+
: BlokRuntime.readyState({
|
|
49
|
+
within: scope,
|
|
50
|
+
settleOn,
|
|
51
|
+
}).ready);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
read();
|
|
55
|
+
|
|
56
|
+
return BlokRuntime.subscribeReady(read);
|
|
57
|
+
}, [within, settleOn]);
|
|
58
|
+
|
|
59
|
+
return ready;
|
|
60
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createElement, Fragment, useMemo } from 'react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { blocksToViewNodes } from '@bloklabs/core/view';
|
|
4
|
+
import type { BlocksToHtmlOptions } from '@bloklabs/core/view';
|
|
5
|
+
import type { LooseOutputData, OutputData } from '@bloklabs/core';
|
|
6
|
+
|
|
7
|
+
import { viewNodesToReact } from './view-nodes-to-react';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Render a saved Blok document to React elements with NO wrapper element —
|
|
11
|
+
* a Fragment of the block elements, for placement inside `<label>`s, table
|
|
12
|
+
* cells, and other slots where an extra `<div>` is invalid or unwanted.
|
|
13
|
+
*
|
|
14
|
+
* Synchronous and effect-free (SSR-safe); memoized on the data reference and
|
|
15
|
+
* the individual option values.
|
|
16
|
+
*
|
|
17
|
+
* @experimental Built on the `@experimental` ViewNode tree of
|
|
18
|
+
* `@bloklabs/core/view` — not frozen until a second framework adapter
|
|
19
|
+
* consumes it.
|
|
20
|
+
* @param data - saved document (strict or loose wire shape; nullish tolerated)
|
|
21
|
+
* @param options - schema / custom renderers / unknown-block policy
|
|
22
|
+
*/
|
|
23
|
+
export const useBlokView = (
|
|
24
|
+
data: OutputData | LooseOutputData | null | undefined,
|
|
25
|
+
options?: BlocksToHtmlOptions
|
|
26
|
+
): ReactNode => {
|
|
27
|
+
const schema = options?.schema;
|
|
28
|
+
const renderers = options?.renderers;
|
|
29
|
+
const onUnknownBlock = options?.onUnknownBlock;
|
|
30
|
+
|
|
31
|
+
return useMemo(() => {
|
|
32
|
+
return createElement(
|
|
33
|
+
Fragment,
|
|
34
|
+
null,
|
|
35
|
+
...viewNodesToReact(blocksToViewNodes(data, { schema, renderers, onUnknownBlock }))
|
|
36
|
+
);
|
|
37
|
+
}, [data, schema, renderers, onUnknownBlock]);
|
|
38
|
+
};
|