@bloklabs/react 1.2.6 → 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 +485 -239
- package/package.json +25 -4
- package/src/BlokEditor.tsx +1 -1
- package/src/BlokView.tsx +37 -0
- package/src/createReactBlock.tsx +151 -8
- package/src/createReactInlineTool.tsx +197 -28
- package/src/index.ts +11 -1
- package/src/provide-blok.tsx +1 -1
- package/src/types.ts +5 -1
- package/src/useBlok.ts +182 -7
- 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 +204 -14
package/src/provide-blok.tsx
CHANGED
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
|
@@ -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,14 +375,93 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
358
375
|
}
|
|
359
376
|
});
|
|
360
377
|
|
|
361
|
-
//
|
|
362
|
-
|
|
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
|
+
|
|
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);
|
|
363
426
|
useEffect(() => {
|
|
364
427
|
if (editor === null) {
|
|
365
428
|
return;
|
|
366
429
|
}
|
|
367
|
-
|
|
368
|
-
|
|
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]);
|
|
369
465
|
|
|
370
466
|
// Reactive: autofocus
|
|
371
467
|
const { autofocus } = config;
|
|
@@ -394,6 +490,72 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
394
490
|
editor.width.set(width);
|
|
395
491
|
}, [editor, width]);
|
|
396
492
|
|
|
493
|
+
// Reactive: style.tokens
|
|
494
|
+
//
|
|
495
|
+
// Theme tokens were construction-only, so a host with a live light/dark
|
|
496
|
+
// toggle had to bump `deps` (destroying the editor, losing caret/history) or
|
|
497
|
+
// hand-write the global stylesheet Blok already injects. Push genuine changes
|
|
498
|
+
// through the runtime `tokens` API instead, deep-equal–deduped because
|
|
499
|
+
// `tokens` is almost always a fresh object literal each render.
|
|
500
|
+
const styleTokens = config.style?.tokens;
|
|
501
|
+
/*
|
|
502
|
+
* Seeded with the mount-time value: construction already injected those
|
|
503
|
+
* tokens, so the first effect run is a no-op and only genuine changes push.
|
|
504
|
+
*/
|
|
505
|
+
const appliedTokensRef = useRef<Record<string, string> | undefined>(config.style?.tokens);
|
|
506
|
+
|
|
507
|
+
useEffect(() => {
|
|
508
|
+
if (editor === null || styleTokens === undefined) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (deepEqual(styleTokens, appliedTokensRef.current)) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
appliedTokensRef.current = styleTokens;
|
|
517
|
+
editor.tokens.set(styleTokens);
|
|
518
|
+
}, [editor, styleTokens]);
|
|
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
|
+
|
|
397
559
|
// Reactive: placeholder
|
|
398
560
|
const { placeholder } = config;
|
|
399
561
|
useEffect(() => {
|
|
@@ -441,6 +603,8 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
441
603
|
// A render still pending on a PRIOR editor is moot for this fresh instance;
|
|
442
604
|
// clear it so its content can't dedupe a needed render on the new editor.
|
|
443
605
|
pendingDataRef.current = undefined;
|
|
606
|
+
// Payloads emitted by a PRIOR editor are likewise moot for this one.
|
|
607
|
+
echoWindowRef.current.clear();
|
|
444
608
|
|
|
445
609
|
if (deepEqual(data, constructedDataRef.current)) {
|
|
446
610
|
lastRenderedDataRef.current = data;
|
|
@@ -456,6 +620,17 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
456
620
|
return;
|
|
457
621
|
}
|
|
458
622
|
|
|
623
|
+
// Skip stale echoes: content the editor itself emitted via `onSave` recently,
|
|
624
|
+
// arriving back reshaped (fresh envelope, stripped ids) and/or late — after a
|
|
625
|
+
// newer save already replaced the baseline above.
|
|
626
|
+
if (echoWindowRef.current.matches(data)) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Genuinely external content takes over: earlier emitted payloads are moot,
|
|
631
|
+
// and keeping them could wrongly no-op a later deliberate revert to one.
|
|
632
|
+
echoWindowRef.current.clear();
|
|
633
|
+
|
|
459
634
|
// Mark the content in-flight, but DON'T advance the success baseline until the
|
|
460
635
|
// render actually resolves. A failed render leaves the editor on its last
|
|
461
636
|
// successful content, so the baseline must keep pointing there — otherwise a
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ViewNode → React element mapping for the synchronous view bindings.
|
|
3
|
+
* Real elements via `createElement` recursion — never `dangerouslySetInnerHTML`.
|
|
4
|
+
*/
|
|
5
|
+
import { createElement } from 'react';
|
|
6
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
7
|
+
import type { ViewNode } from '@bloklabs/core/view';
|
|
8
|
+
|
|
9
|
+
/** HTML void elements — must never receive children. */
|
|
10
|
+
const VOID_ELEMENTS = new Set([
|
|
11
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
12
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Presence-valued HTML attributes mapped to boolean React props. `checked`
|
|
17
|
+
* is handled separately (→ `defaultChecked`, so React does not treat the
|
|
18
|
+
* input as controlled).
|
|
19
|
+
*/
|
|
20
|
+
const BOOLEAN_ATTRIBUTES = new Set([
|
|
21
|
+
'disabled', 'open', 'controls', 'autoplay', 'loop', 'muted', 'required',
|
|
22
|
+
'multiple', 'playsinline', 'reversed', 'hidden', 'default', 'novalidate',
|
|
23
|
+
'allowfullscreen', 'itemscope',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** HTML attribute name → React prop name (beyond the identity mapping). */
|
|
27
|
+
const ATTRIBUTE_NAME_MAP: Record<string, string> = {
|
|
28
|
+
class: 'className',
|
|
29
|
+
for: 'htmlFor',
|
|
30
|
+
colspan: 'colSpan',
|
|
31
|
+
rowspan: 'rowSpan',
|
|
32
|
+
tabindex: 'tabIndex',
|
|
33
|
+
readonly: 'readOnly',
|
|
34
|
+
srcset: 'srcSet',
|
|
35
|
+
srclang: 'srcLang',
|
|
36
|
+
datetime: 'dateTime',
|
|
37
|
+
autoplay: 'autoPlay',
|
|
38
|
+
playsinline: 'playsInline',
|
|
39
|
+
novalidate: 'noValidate',
|
|
40
|
+
allowfullscreen: 'allowFullScreen',
|
|
41
|
+
crossorigin: 'crossOrigin',
|
|
42
|
+
referrerpolicy: 'referrerPolicy',
|
|
43
|
+
contenteditable: 'contentEditable',
|
|
44
|
+
spellcheck: 'spellCheck',
|
|
45
|
+
maxlength: 'maxLength',
|
|
46
|
+
minlength: 'minLength',
|
|
47
|
+
usemap: 'useMap',
|
|
48
|
+
itemscope: 'itemScope',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert a sanitized `style` attribute string to a React style object
|
|
53
|
+
* (React only accepts object styles). Custom properties keep their raw name;
|
|
54
|
+
* everything else is camelCased.
|
|
55
|
+
* @param css - style attribute text
|
|
56
|
+
*/
|
|
57
|
+
const styleStringToObject = (css: string): CSSProperties => {
|
|
58
|
+
const style: Record<string, string> = {};
|
|
59
|
+
|
|
60
|
+
for (const declaration of css.split(';')) {
|
|
61
|
+
const separator = declaration.indexOf(':');
|
|
62
|
+
|
|
63
|
+
if (separator === -1) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const property = declaration.slice(0, separator).trim();
|
|
68
|
+
const value = declaration.slice(separator + 1).trim();
|
|
69
|
+
|
|
70
|
+
if (property === '' || value === '') {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const key = property.startsWith('--')
|
|
75
|
+
? property
|
|
76
|
+
: property.toLowerCase().replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
77
|
+
|
|
78
|
+
style[key] = value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return style;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Map one ViewNode attribute record to React props.
|
|
86
|
+
* @param attrs - sanitized attributes from the view tree
|
|
87
|
+
* @param key - sibling index used as the React key
|
|
88
|
+
*/
|
|
89
|
+
const attrsToProps = (attrs: Record<string, string>, key: number): Record<string, unknown> => {
|
|
90
|
+
const props: Record<string, unknown> = { key };
|
|
91
|
+
|
|
92
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
93
|
+
if (name === 'style') {
|
|
94
|
+
props.style = styleStringToObject(value);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (name === 'checked') {
|
|
99
|
+
props.defaultChecked = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (name === 'value') {
|
|
104
|
+
props.defaultValue = value;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (BOOLEAN_ATTRIBUTES.has(name)) {
|
|
109
|
+
props[ATTRIBUTE_NAME_MAP[name] ?? name] = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
props[ATTRIBUTE_NAME_MAP[name] ?? name] = value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return props;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Map a list of ViewNodes to React nodes. Element keys come from the sibling
|
|
121
|
+
* index (the tree is static per render).
|
|
122
|
+
* @param nodes - view tree siblings
|
|
123
|
+
*/
|
|
124
|
+
export const viewNodesToReact = (nodes: ViewNode[]): ReactNode[] => {
|
|
125
|
+
return nodes.map((node, index): ReactNode => {
|
|
126
|
+
if ('text' in node) {
|
|
127
|
+
return node.text;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const props = attrsToProps(node.attrs, index);
|
|
131
|
+
|
|
132
|
+
if (VOID_ELEMENTS.has(node.tag) || node.children.length === 0) {
|
|
133
|
+
return createElement(node.tag, props);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return createElement(node.tag, props, ...viewNodesToReact(node.children));
|
|
137
|
+
});
|
|
138
|
+
};
|