@bloklabs/react 1.2.5 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +333 -207
- package/package.json +2 -2
- package/src/BlokEditor.tsx +1 -1
- package/src/createReactBlock.tsx +151 -8
- package/src/createReactInlineTool.tsx +226 -0
- package/src/index.ts +8 -0
- package/src/provide-blok.tsx +1 -1
- package/src/useBlok.ts +113 -4
- package/types/index.d.ts +105 -2
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
|
|
|
@@ -42,6 +42,16 @@ const isReactBlockEntry = (entry: unknown): boolean => {
|
|
|
42
42
|
);
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
/** React inline tools (createReactInlineTool) need the same registry bridge. */
|
|
46
|
+
const isReactInlineToolEntry = (entry: unknown): boolean => {
|
|
47
|
+
const toolClass = typeof entry === 'function' ? entry : (entry as { class?: unknown })?.class;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
typeof toolClass === 'function' &&
|
|
51
|
+
(toolClass as { __isBlokReactInlineTool?: boolean }).__isBlokReactInlineTool === true
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
45
55
|
const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): unknown => {
|
|
46
56
|
if (tools === null || typeof tools !== 'object') {
|
|
47
57
|
return tools;
|
|
@@ -50,7 +60,7 @@ const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): un
|
|
|
50
60
|
const result: Record<string, unknown> = {};
|
|
51
61
|
|
|
52
62
|
for (const [name, entry] of Object.entries(tools as Record<string, unknown>)) {
|
|
53
|
-
if (!isReactBlockEntry(entry)) {
|
|
63
|
+
if (!isReactBlockEntry(entry) && !isReactInlineToolEntry(entry)) {
|
|
54
64
|
result[name] = entry;
|
|
55
65
|
|
|
56
66
|
continue;
|
|
@@ -60,7 +70,7 @@ const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): un
|
|
|
60
70
|
typeof entry === 'function' ? { class: entry } : { ...(entry as Record<string, unknown>) };
|
|
61
71
|
|
|
62
72
|
base.config = {
|
|
63
|
-
...((base.config
|
|
73
|
+
...((base.config) ?? {}),
|
|
64
74
|
[BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry,
|
|
65
75
|
[BLOK_TOOL_NAME_CONFIG_KEY]: name,
|
|
66
76
|
};
|
|
@@ -114,6 +124,13 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
114
124
|
// otherwise reset the caret). Declared here so the `onSave` wrapper below can
|
|
115
125
|
// update it before the consumer's setState re-runs the data effect.
|
|
116
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());
|
|
117
134
|
const seededEditorRef = useRef<Blok | null>(null);
|
|
118
135
|
// The `data` the live editor was actually constructed with. The seed gate in
|
|
119
136
|
// the reactive-`data` effect compares the current prop against THIS (not mere
|
|
@@ -134,6 +151,9 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
134
151
|
const liveRegistryRef = useRef<BlockPortalRegistry | null>(null);
|
|
135
152
|
const wrappedToolsRef = useRef<unknown>(undefined);
|
|
136
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());
|
|
137
157
|
|
|
138
158
|
// Main lifecycle effect
|
|
139
159
|
useEffect(() => {
|
|
@@ -214,6 +234,12 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
214
234
|
liveRegistryRef.current = registry;
|
|
215
235
|
wrappedToolsRef.current = wrappedTools;
|
|
216
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
|
+
);
|
|
217
243
|
|
|
218
244
|
const blokConfig = {
|
|
219
245
|
...currentConfig,
|
|
@@ -239,6 +265,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
239
265
|
// controlled consumer echoing it straight back into `data` is a no-op —
|
|
240
266
|
// no redundant render(), no caret reset, no round-trip recursion.
|
|
241
267
|
lastRenderedDataRef.current = args[0];
|
|
268
|
+
echoWindowRef.current.record(args[0]);
|
|
242
269
|
configRef.current.onSave?.(...args);
|
|
243
270
|
};
|
|
244
271
|
}
|
|
@@ -259,7 +286,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
259
286
|
};
|
|
260
287
|
}
|
|
261
288
|
|
|
262
|
-
const blok = new BlokRuntime(blokConfig)
|
|
289
|
+
const blok = new BlokRuntime(blokConfig);
|
|
263
290
|
state.editor = blok;
|
|
264
291
|
// Remember what this editor was seeded with so the reactive-`data` effect can
|
|
265
292
|
// tell a genuine post-construction change from the construction value itself.
|
|
@@ -348,6 +375,48 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
348
375
|
}
|
|
349
376
|
});
|
|
350
377
|
|
|
378
|
+
// Live tool-level `toolbox` setting. Toolbox membership was construction-only,
|
|
379
|
+
// which forced permission-style gating (`toolbox: false` for users who may not
|
|
380
|
+
// insert a tool) into `deps` — recreating the whole editor on a permission
|
|
381
|
+
// flip. Instead, diff each tool's `toolbox` setting against the last applied
|
|
382
|
+
// value on every render and push genuine changes through the runtime
|
|
383
|
+
// `tools.update` API (which reaches the live adapter and rebuilds the Toolbox
|
|
384
|
+
// UI). Applies only once the editor is ready — module APIs attach at isReady —
|
|
385
|
+
// and the ready transition re-renders, so a change made earlier is not lost.
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
const state = stateRef.current;
|
|
388
|
+
|
|
389
|
+
if (state.editor === null || state.isDestroyed || !state.isEditorReady) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const latestTools = configRef.current.tools as Record<string, unknown> | undefined;
|
|
394
|
+
|
|
395
|
+
if (latestTools === null || typeof latestTools !== 'object') {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
for (const [name, entry] of Object.entries(latestTools)) {
|
|
400
|
+
// Only tools the editor was constructed with can be updated at runtime.
|
|
401
|
+
if (!appliedToolboxRef.current.has(name)) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const latest =
|
|
406
|
+
entry !== null && typeof entry === 'object' ? (entry as { toolbox?: unknown }).toolbox : undefined;
|
|
407
|
+
const applied = appliedToolboxRef.current.get(name);
|
|
408
|
+
|
|
409
|
+
if (deepEqual(latest, applied)) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
appliedToolboxRef.current.set(name, latest);
|
|
414
|
+
(state.editor as unknown as {
|
|
415
|
+
tools?: { update: (toolName: string, config: Record<string, unknown>) => void };
|
|
416
|
+
}).tools?.update(name, { toolbox: latest });
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
351
420
|
// Reactive: readOnly (object form normalized so the effect dep is a stable boolean)
|
|
352
421
|
const readOnlyEnabled = normalizeReadOnlyConfig(config.readOnly).enabled;
|
|
353
422
|
useEffect(() => {
|
|
@@ -384,6 +453,33 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
384
453
|
editor.width.set(width);
|
|
385
454
|
}, [editor, width]);
|
|
386
455
|
|
|
456
|
+
// Reactive: style.tokens
|
|
457
|
+
//
|
|
458
|
+
// Theme tokens were construction-only, so a host with a live light/dark
|
|
459
|
+
// toggle had to bump `deps` (destroying the editor, losing caret/history) or
|
|
460
|
+
// hand-write the global stylesheet Blok already injects. Push genuine changes
|
|
461
|
+
// through the runtime `tokens` API instead, deep-equal–deduped because
|
|
462
|
+
// `tokens` is almost always a fresh object literal each render.
|
|
463
|
+
const styleTokens = config.style?.tokens;
|
|
464
|
+
/*
|
|
465
|
+
* Seeded with the mount-time value: construction already injected those
|
|
466
|
+
* tokens, so the first effect run is a no-op and only genuine changes push.
|
|
467
|
+
*/
|
|
468
|
+
const appliedTokensRef = useRef<Record<string, string> | undefined>(config.style?.tokens);
|
|
469
|
+
|
|
470
|
+
useEffect(() => {
|
|
471
|
+
if (editor === null || styleTokens === undefined) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (deepEqual(styleTokens, appliedTokensRef.current)) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
appliedTokensRef.current = styleTokens;
|
|
480
|
+
editor.tokens.set(styleTokens);
|
|
481
|
+
}, [editor, styleTokens]);
|
|
482
|
+
|
|
387
483
|
// Reactive: placeholder
|
|
388
484
|
const { placeholder } = config;
|
|
389
485
|
useEffect(() => {
|
|
@@ -431,6 +527,8 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
431
527
|
// A render still pending on a PRIOR editor is moot for this fresh instance;
|
|
432
528
|
// clear it so its content can't dedupe a needed render on the new editor.
|
|
433
529
|
pendingDataRef.current = undefined;
|
|
530
|
+
// Payloads emitted by a PRIOR editor are likewise moot for this one.
|
|
531
|
+
echoWindowRef.current.clear();
|
|
434
532
|
|
|
435
533
|
if (deepEqual(data, constructedDataRef.current)) {
|
|
436
534
|
lastRenderedDataRef.current = data;
|
|
@@ -446,6 +544,17 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
|
|
|
446
544
|
return;
|
|
447
545
|
}
|
|
448
546
|
|
|
547
|
+
// Skip stale echoes: content the editor itself emitted via `onSave` recently,
|
|
548
|
+
// arriving back reshaped (fresh envelope, stripped ids) and/or late — after a
|
|
549
|
+
// newer save already replaced the baseline above.
|
|
550
|
+
if (echoWindowRef.current.matches(data)) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Genuinely external content takes over: earlier emitted payloads are moot,
|
|
555
|
+
// and keeping them could wrongly no-op a later deliberate revert to one.
|
|
556
|
+
echoWindowRef.current.clear();
|
|
557
|
+
|
|
449
558
|
// Mark the content in-flight, but DON'T advance the success baseline until the
|
|
450
559
|
// render actually resolves. A failed render leaves the editor on its last
|
|
451
560
|
// successful content, so the baseline must keep pointing there — otherwise a
|
package/types/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BlokConfig, BlockToolData, OutputBlockData, OutputData } from '@bloklabs/core';
|
|
2
|
-
import type { BlockAPI, BlockToolConstructable,
|
|
2
|
+
import type { BlockAPI, BlockToolConstructable, ToolboxConfigEntry } from '@bloklabs/core';
|
|
3
|
+
import type { InlineToolConstructable, SanitizerConfig } from '@bloklabs/core';
|
|
3
4
|
import type { Blok, EditorWidth, BlockRenderedPayload, BlocksRenderedPayload } from '@bloklabs/core';
|
|
4
5
|
import type { BlockTuneData } from '@bloklabs/core';
|
|
5
6
|
import type { MarkdownImportConfig } from '@bloklabs/core/markdown';
|
|
@@ -586,15 +587,46 @@ export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
|
|
|
586
587
|
BlockChildren: React.ComponentType;
|
|
587
588
|
}
|
|
588
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Props handed to a React block's `viewComponent` (the read-only renderer):
|
|
592
|
+
* the entry props minus `commit` (no write path) and `readOnly` (always true).
|
|
593
|
+
*/
|
|
594
|
+
export type ReactBlockViewProps<Data, Config = Record<string, unknown>> = Omit<
|
|
595
|
+
ReactBlockRenderProps<Data, Config>,
|
|
596
|
+
'commit' | 'readOnly'
|
|
597
|
+
>;
|
|
598
|
+
|
|
599
|
+
/** A toolbox entry whose `icon` may be a React element instead of an SVG string. */
|
|
600
|
+
export type ReactToolboxConfigEntry = Omit<ToolboxConfigEntry, 'icon'> & {
|
|
601
|
+
/**
|
|
602
|
+
* Toolbox icon. Accepts the markup string core consumes directly, or the same
|
|
603
|
+
* React element rendered in the block body — the factory serializes elements
|
|
604
|
+
* to markup once (lazily, cached), so icons are never duplicated as parallel
|
|
605
|
+
* raw SVG strings. Without a DOM (SSR) the element is returned as-is and
|
|
606
|
+
* serialization retries on the next browser-side access.
|
|
607
|
+
*/
|
|
608
|
+
icon?: string | React.ReactElement;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/** Toolbox config for React blocks: single entry or several variants. */
|
|
612
|
+
export type ReactToolboxConfig = ReactToolboxConfigEntry | ReactToolboxConfigEntry[];
|
|
613
|
+
|
|
589
614
|
/** Spec for {@link createReactBlock}. */
|
|
590
615
|
export interface CreateReactBlockSpec<
|
|
591
616
|
Data extends BlockToolData = BlockToolData,
|
|
592
617
|
Config = Record<string, unknown>
|
|
593
618
|
> {
|
|
594
619
|
type: string;
|
|
595
|
-
toolbox?:
|
|
620
|
+
toolbox?: ReactToolboxConfig;
|
|
596
621
|
propSchema: PropSchema;
|
|
597
622
|
component: React.ComponentType<ReactBlockRenderProps<Data, Config>>;
|
|
623
|
+
/**
|
|
624
|
+
* Optional read-only renderer: rendered INSTEAD of `component` while the
|
|
625
|
+
* editor is read-only, so blocks need no display-vs-edit ternary. Toggling
|
|
626
|
+
* read-only swaps renderers (ephemeral state does not survive the swap);
|
|
627
|
+
* omit it to keep the single-component in-place toggle.
|
|
628
|
+
*/
|
|
629
|
+
viewComponent?: React.ComponentType<ReactBlockViewProps<Data, Config>>;
|
|
598
630
|
onRendered?: (block: BlockAPI) => void;
|
|
599
631
|
onMoved?: (block: BlockAPI) => void;
|
|
600
632
|
onRemoved?: (block: BlockAPI) => void;
|
|
@@ -624,6 +656,77 @@ export declare function createReactBlock<
|
|
|
624
656
|
Config = Record<string, unknown>
|
|
625
657
|
>(spec: CreateReactBlockSpec<Data, Config>): BlockToolConstructable;
|
|
626
658
|
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// Inline-tool authoring (createReactInlineTool)
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
/** Props handed to a React inline tool's `component` (its toolbar icon/UI). */
|
|
664
|
+
export interface ReactInlineToolRenderProps<Config = Record<string, unknown>> {
|
|
665
|
+
/**
|
|
666
|
+
* Whether the tool's formatting is active for the current selection —
|
|
667
|
+
* derived from the spec's `checkState` every time the toolbar queries it.
|
|
668
|
+
* Pushed IN PLACE (no remount), so the icon restyles live.
|
|
669
|
+
*/
|
|
670
|
+
active: boolean;
|
|
671
|
+
/**
|
|
672
|
+
* The tool's `config` from the consumer's `tools` map (adapter-internal
|
|
673
|
+
* keys stripped) — the channel for host props (palette, locale…).
|
|
674
|
+
*/
|
|
675
|
+
config: Readonly<Partial<Config>>;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Spec for {@link createReactInlineTool}. */
|
|
679
|
+
export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
|
|
680
|
+
/** Tool type name — the MenuConfig item name (fallback when the tools-map key is unavailable). */
|
|
681
|
+
type: string;
|
|
682
|
+
/** Optional toolbar item title (shown in overflow/search contexts). */
|
|
683
|
+
title?: string;
|
|
684
|
+
/**
|
|
685
|
+
* Translation key for the toolbar label, resolved by core i18n as
|
|
686
|
+
* `toolNames.{titleKey}` (or used verbatim when it contains a dot). Without
|
|
687
|
+
* it a custom tool's label is only localizable through the legacy
|
|
688
|
+
* capitalized-tool-name fallback.
|
|
689
|
+
*/
|
|
690
|
+
titleKey?: string;
|
|
691
|
+
/** The component rendered as the tool's toolbar icon/UI. */
|
|
692
|
+
component: React.ComponentType<ReactInlineToolRenderProps<Config>>;
|
|
693
|
+
/** Applies/removes the formatting on the LIVE selection's range (captured at activation time). */
|
|
694
|
+
surround?: (range: Range) => void;
|
|
695
|
+
/** Reports whether the formatting is active for the given selection. */
|
|
696
|
+
checkState?: (selection: Selection | null) => boolean;
|
|
697
|
+
/** Keyboard shortcut (e.g. `CMD+SHIFT+C`). */
|
|
698
|
+
shortcut?: string;
|
|
699
|
+
/** Inline sanitizer config declaring the markup this tool produces. */
|
|
700
|
+
sanitize?: SanitizerConfig;
|
|
701
|
+
/** Whether the tool stays available in read-only mode. */
|
|
702
|
+
isReadOnlySupported?: boolean;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Author a first-party React inline tool: returns an inline-tool
|
|
707
|
+
* constructable registered exactly like a vanilla tool
|
|
708
|
+
* (`tools: { color: createReactInlineTool(...) }`). The tool's toolbar icon is
|
|
709
|
+
* a React component portaled through the editor's shared portal host INSIDE
|
|
710
|
+
* the React tree that renders `BlokContent`/`BlokEditor` — app-level context
|
|
711
|
+
* (themes, styled-components providers) reaches the icon with no
|
|
712
|
+
* consumer-managed `createRoot`, and the inline toolbar's teardown unmounts it
|
|
713
|
+
* deterministically (no leaked React roots).
|
|
714
|
+
*
|
|
715
|
+
* @example
|
|
716
|
+
* ```tsx
|
|
717
|
+
* const DescriptionColor = createReactInlineTool({
|
|
718
|
+
* type: 'descriptionColor',
|
|
719
|
+
* title: 'Text color',
|
|
720
|
+
* component: ({ active }) => <FontColorIcon active={active} />,
|
|
721
|
+
* surround: (range) => applyColor(range),
|
|
722
|
+
* checkState: (selection) => hasColor(selection),
|
|
723
|
+
* });
|
|
724
|
+
* ```
|
|
725
|
+
*/
|
|
726
|
+
export declare function createReactInlineTool<Config = Record<string, unknown>>(
|
|
727
|
+
spec: CreateReactInlineToolSpec<Config>
|
|
728
|
+
): InlineToolConstructable;
|
|
729
|
+
|
|
627
730
|
/**
|
|
628
731
|
* Tool-config key carrying the editor's portal registry into a
|
|
629
732
|
* `createReactBlock` tool. Injected automatically by `useBlok`; exposed for
|