@bloklabs/react 1.2.1 → 1.2.2
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 +361 -140
- package/package.json +2 -2
- package/src/BlockPortalHost.tsx +58 -0
- package/src/BlokContent.tsx +13 -1
- package/src/BlokEditor.tsx +6 -0
- package/src/block-portal-registry.ts +105 -0
- package/src/createReactBlock.tsx +304 -0
- package/src/index.ts +14 -0
- package/src/live-tool-config.ts +112 -0
- package/src/registry-map.ts +21 -0
- package/src/useBlok.ts +67 -0
- package/types/index.d.ts +112 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Component, useSyncExternalStore, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import type { BlockPortalRegistry } from './block-portal-registry';
|
|
4
|
+
|
|
5
|
+
interface BoundaryState {
|
|
6
|
+
failed: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wraps a single block's render so a throw inside it can't tear down the whole
|
|
11
|
+
* editor's render tree (only the broken block goes blank).
|
|
12
|
+
*/
|
|
13
|
+
class BlockErrorBoundary extends Component<{ children: ReactNode }, BoundaryState> {
|
|
14
|
+
public state: BoundaryState = { failed: false };
|
|
15
|
+
|
|
16
|
+
public static getDerivedStateFromError(): BoundaryState {
|
|
17
|
+
return { failed: true };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public componentDidCatch(): void {
|
|
21
|
+
// Swallowed: one broken block must not unmount its siblings.
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public render(): ReactNode {
|
|
25
|
+
return this.state.failed ? null : this.props.children;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BlockPortalHostProps {
|
|
30
|
+
registry: BlockPortalRegistry;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The single shared render tree that portals each registered React block into
|
|
35
|
+
* its Blok-owned host element. Mounted once per editor (by `BlokContent`), it
|
|
36
|
+
* subscribes to the registry and emits one `createPortal` per entry — so all
|
|
37
|
+
* blocks share one reconciler and the surrounding app's React context
|
|
38
|
+
* (`createPortal` preserves the component tree's context, not the DOM target's).
|
|
39
|
+
*/
|
|
40
|
+
export function BlockPortalHost({ registry }: BlockPortalHostProps): ReactElement {
|
|
41
|
+
const entries = useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
{Array.from(entries.entries()).map(([id, entry]) => {
|
|
46
|
+
const BlockComponent = entry.component;
|
|
47
|
+
|
|
48
|
+
return createPortal(
|
|
49
|
+
<BlockErrorBoundary>
|
|
50
|
+
<BlockComponent {...entry.props} />
|
|
51
|
+
</BlockErrorBoundary>,
|
|
52
|
+
entry.hostEl,
|
|
53
|
+
id
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</>
|
|
57
|
+
);
|
|
58
|
+
}
|
package/src/BlokContent.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, forwardRef } from 'react';
|
|
2
2
|
import { getHolder } from './holder-map';
|
|
3
|
+
import { getRegistry } from './registry-map';
|
|
4
|
+
import { BlockPortalHost } from './BlockPortalHost';
|
|
3
5
|
import type { BlokContentProps } from './types';
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -41,6 +43,16 @@ export const BlokContent = forwardRef<HTMLDivElement, BlokContentProps>(
|
|
|
41
43
|
};
|
|
42
44
|
}, [editor]);
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
// The portal host renders the editor's `createReactBlock` tools INSIDE this
|
|
47
|
+
// component tree (via createPortal into each Blok-owned host element), so
|
|
48
|
+
// app-level React context flows into block tools with no bridge. It emits
|
|
49
|
+
// no DOM at this position, leaving the imperatively-adopted holder alone.
|
|
50
|
+
const registry = editor === null ? undefined : getRegistry(editor);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div ref={setRefs} {...divProps}>
|
|
54
|
+
{registry === undefined ? null : <BlockPortalHost registry={registry} />}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
45
57
|
}
|
|
46
58
|
);
|
package/src/BlokEditor.tsx
CHANGED
|
@@ -43,6 +43,12 @@ export interface BlokEditorProps
|
|
|
43
43
|
* When any value changes, the editor is destroyed and recreated. Keep each
|
|
44
44
|
* value referentially stable (primitives or useMemo-stable objects) — a dep
|
|
45
45
|
* whose identity changes every render recreates the editor each time.
|
|
46
|
+
*
|
|
47
|
+
* FUNCTIONS inside tool configs do NOT belong here: the adapter re-binds
|
|
48
|
+
* every tool-config function to the latest render's closure automatically,
|
|
49
|
+
* so an inline `uploadByFile`/callback with a fresh identity each render
|
|
50
|
+
* reaches the editor live, without recreation and without freezing its
|
|
51
|
+
* identity in a `useState` initializer.
|
|
46
52
|
*/
|
|
47
53
|
deps?: DependencyList;
|
|
48
54
|
/** Test id forwarded to the editor container element (via data-testid). */
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tool-config key carrying the editor's portal registry into a
|
|
5
|
+
* `createReactBlock` tool. The tool is constructed by CORE (outside any React
|
|
6
|
+
* render), so it cannot read context — `useBlok` injects the editor-scoped
|
|
7
|
+
* registry through each react-block tool's `config`, and the tool reads it here.
|
|
8
|
+
*/
|
|
9
|
+
export const BLOK_PORTAL_REGISTRY_CONFIG_KEY = '__blokPortalRegistry';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One mounted React block in the portal registry. `hostEl` is the Blok-owned
|
|
13
|
+
* `toolRenderedElement` (tagged `data-blok-mutation-free`) the component is
|
|
14
|
+
* portaled into; `props` is replaced immutably by `setProps` so the mounted
|
|
15
|
+
* component re-renders in place (no remount).
|
|
16
|
+
*/
|
|
17
|
+
export interface BlockPortalEntry {
|
|
18
|
+
hostEl: HTMLElement;
|
|
19
|
+
component: ComponentType<Record<string, unknown>>;
|
|
20
|
+
props: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Per-editor registry of React blocks. A single shared `BlockPortalHost`
|
|
25
|
+
* renders one `createPortal` per entry, so every block lives in ONE React tree
|
|
26
|
+
* (shared context, one reconciler) — the TipTap ReactNodeViewRenderer pattern,
|
|
27
|
+
* not a `createRoot` per block.
|
|
28
|
+
*
|
|
29
|
+
* Shaped as an external store (`subscribe`/`getSnapshot`) so the host can read
|
|
30
|
+
* it through `useSyncExternalStore`. The snapshot is immutable: every mutation
|
|
31
|
+
* replaces it, and `getSnapshot` is referentially stable between mutations.
|
|
32
|
+
*/
|
|
33
|
+
export interface BlockPortalRegistry {
|
|
34
|
+
/**
|
|
35
|
+
* Register (or replace) the entry for `id`. Idempotent: a second register
|
|
36
|
+
* for the same id replaces the entry rather than mounting a duplicate.
|
|
37
|
+
*/
|
|
38
|
+
register(id: string, entry: BlockPortalEntry): void;
|
|
39
|
+
/** Remove the entry for `id`. Safe (no-op) when absent. */
|
|
40
|
+
unregister(id: string): void;
|
|
41
|
+
/** Merge `props` into the entry's props (in-place update, no remount). */
|
|
42
|
+
setProps(id: string, props: Record<string, unknown>): void;
|
|
43
|
+
/** Subscribe to snapshot changes; returns the unsubscribe function. */
|
|
44
|
+
subscribe(listener: () => void): () => void;
|
|
45
|
+
/** Current immutable snapshot (stable identity until the next mutation). */
|
|
46
|
+
getSnapshot(): ReadonlyMap<string, BlockPortalEntry>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a fresh portal registry. One per editor instance (associated via the
|
|
51
|
+
* registry map, mirroring the holder WeakMap), so multiple editors on a page
|
|
52
|
+
* never cross-render each other's blocks.
|
|
53
|
+
*/
|
|
54
|
+
export const createBlockPortalRegistry = (): BlockPortalRegistry => {
|
|
55
|
+
const state = {
|
|
56
|
+
snapshot: new Map<string, BlockPortalEntry>() as ReadonlyMap<string, BlockPortalEntry>,
|
|
57
|
+
};
|
|
58
|
+
const listeners = new Set<() => void>();
|
|
59
|
+
|
|
60
|
+
const commit = (next: Map<string, BlockPortalEntry>): void => {
|
|
61
|
+
state.snapshot = next;
|
|
62
|
+
listeners.forEach(listener => listener());
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
register(id: string, entry: BlockPortalEntry): void {
|
|
67
|
+
const next = new Map(state.snapshot);
|
|
68
|
+
|
|
69
|
+
next.set(id, entry);
|
|
70
|
+
commit(next);
|
|
71
|
+
},
|
|
72
|
+
unregister(id: string): void {
|
|
73
|
+
if (!state.snapshot.has(id)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const next = new Map(state.snapshot);
|
|
78
|
+
|
|
79
|
+
next.delete(id);
|
|
80
|
+
commit(next);
|
|
81
|
+
},
|
|
82
|
+
setProps(id: string, props: Record<string, unknown>): void {
|
|
83
|
+
const current = state.snapshot.get(id);
|
|
84
|
+
|
|
85
|
+
if (current === undefined) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const next = new Map(state.snapshot);
|
|
90
|
+
|
|
91
|
+
next.set(id, { ...current, props: { ...current.props, ...props } });
|
|
92
|
+
commit(next);
|
|
93
|
+
},
|
|
94
|
+
subscribe(listener: () => void): () => void {
|
|
95
|
+
listeners.add(listener);
|
|
96
|
+
|
|
97
|
+
return (): void => {
|
|
98
|
+
listeners.delete(listener);
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
getSnapshot(): ReadonlyMap<string, BlockPortalEntry> {
|
|
102
|
+
return state.snapshot;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { useEffect, useRef, type ComponentType, type ReactElement } from 'react';
|
|
2
|
+
import { flushSync } from 'react-dom';
|
|
3
|
+
import { DATA_ATTR, deepEqual, fillDefaults, mountChildBlocks, type PropSchema } from '@bloklabs/core/adapters';
|
|
4
|
+
import {
|
|
5
|
+
BLOK_PORTAL_REGISTRY_CONFIG_KEY,
|
|
6
|
+
type BlockPortalRegistry,
|
|
7
|
+
} from './block-portal-registry';
|
|
8
|
+
import type { BlockAPI } from '@/types/api';
|
|
9
|
+
import type { BlockToolConstructorOptions, BlockToolData, ToolboxConfig } from '@/types/tools';
|
|
10
|
+
|
|
11
|
+
export type { PropSchema, PropSchemaEntry } from '@bloklabs/core/adapters';
|
|
12
|
+
|
|
13
|
+
/** Props handed to a React block's `component` (the only data write path is `commit`). */
|
|
14
|
+
export interface ReactBlockRenderProps<Data> {
|
|
15
|
+
/** Frozen snapshot of the block data. Re-rendered with a new value on change; never mutate. */
|
|
16
|
+
data: Readonly<Data>;
|
|
17
|
+
/** The ONLY data write path: merge a partial patch and sync once. */
|
|
18
|
+
commit: (patch: Partial<Data>) => void;
|
|
19
|
+
/** This block's per-block API (id, connection methods, dispatchChange…). */
|
|
20
|
+
block: BlockAPI;
|
|
21
|
+
/**
|
|
22
|
+
* Read-only flag. Render disabled/inert UI when true (e.g. drop
|
|
23
|
+
* `contentEditable`, hide controls). Toggled IN PLACE by core's read-only
|
|
24
|
+
* switch — the component re-renders without remounting, so ephemeral state
|
|
25
|
+
* survives. Honor it: a block that ignores `readOnly` stays interactive when
|
|
26
|
+
* the editor is read-only (same contract as a vanilla tool's `setReadOnly`).
|
|
27
|
+
*/
|
|
28
|
+
readOnly: boolean;
|
|
29
|
+
/** Engine-owned child slot — render `<BlockChildren />` for a container block. */
|
|
30
|
+
BlockChildren: ComponentType;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Spec for {@link createReactBlock}. */
|
|
34
|
+
export interface CreateReactBlockSpec<Data = BlockToolData> {
|
|
35
|
+
/** Tool type name (registered key). */
|
|
36
|
+
type: string;
|
|
37
|
+
/** Optional toolbox entry. */
|
|
38
|
+
toolbox?: ToolboxConfig;
|
|
39
|
+
/** Declarative defaults that also define the exact `save()` key set. */
|
|
40
|
+
propSchema: PropSchema;
|
|
41
|
+
/** The component rendered for each block instance. */
|
|
42
|
+
component: ComponentType<ReactBlockRenderProps<Data>>;
|
|
43
|
+
/** Optional lifecycle callbacks mapped from Blok's block hooks. */
|
|
44
|
+
onRendered?: (block: BlockAPI) => void;
|
|
45
|
+
onMoved?: (block: BlockAPI) => void;
|
|
46
|
+
onRemoved?: (block: BlockAPI) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Author a first-party React block. Returns a `BlockToolConstructable`
|
|
51
|
+
* registered exactly like a vanilla tool (`tools: { type: createReactBlock(...) }`).
|
|
52
|
+
*
|
|
53
|
+
* The factory owns the host element (`data-blok-mutation-free`), a frozen
|
|
54
|
+
* defaults-filled data mirror, and the entry props the component reads. It
|
|
55
|
+
* portals the component into the host via the editor's shared portal registry
|
|
56
|
+
* (the TipTap `ReactNodeViewRenderer` pattern) — every block renders inside the
|
|
57
|
+
* SAME React tree that renders `BlokContent`, so app-level context (themes,
|
|
58
|
+
* design-system providers, stores) flows into blocks with no bridge. Blok's
|
|
59
|
+
* block lifecycle maps to React:
|
|
60
|
+
* - `render()` creates the host and registers the portal entry (returned sync).
|
|
61
|
+
* - `setData()` dedups, pushes the new snapshot, and flushes React's commit
|
|
62
|
+
* before resolving `true` — defeating core's default remount.
|
|
63
|
+
* - `save()` returns the complete frozen mirror (never the DOM, never partial).
|
|
64
|
+
* - `commit()` merges a patch and fires `dispatchChange` exactly once.
|
|
65
|
+
* - `removed()`/`destroy()` unregister the portal (deterministic unmount).
|
|
66
|
+
*/
|
|
67
|
+
export function createReactBlock<Data = BlockToolData>(
|
|
68
|
+
spec: CreateReactBlockSpec<Data>
|
|
69
|
+
): (new (options: BlockToolConstructorOptions) => {
|
|
70
|
+
render(): HTMLElement;
|
|
71
|
+
save(): BlockToolData;
|
|
72
|
+
setData(newData: BlockToolData): Promise<boolean>;
|
|
73
|
+
setReadOnly(state: boolean): void;
|
|
74
|
+
rendered(): void;
|
|
75
|
+
moved(): void;
|
|
76
|
+
removed(): void;
|
|
77
|
+
destroy(): void;
|
|
78
|
+
}) & {
|
|
79
|
+
readonly __isBlokReactBlock: true;
|
|
80
|
+
readonly toolbox: ToolboxConfig | undefined;
|
|
81
|
+
readonly isReadOnlySupported: boolean;
|
|
82
|
+
} {
|
|
83
|
+
return class ReactBlockTool {
|
|
84
|
+
/** Marker so `useBlok` can detect react-block tools and inject the registry. */
|
|
85
|
+
public static readonly __isBlokReactBlock = true as const;
|
|
86
|
+
|
|
87
|
+
public static get toolbox(): ToolboxConfig | undefined {
|
|
88
|
+
return spec.toolbox;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* React blocks support read-only mode: `setReadOnly` pushes a new prop the
|
|
93
|
+
* component reads, so the block re-renders read-only IN PLACE. Without this
|
|
94
|
+
* flag core's ReadOnly module throws a critical error when read-only is
|
|
95
|
+
* enabled and a React block is present.
|
|
96
|
+
*/
|
|
97
|
+
public static get isReadOnlySupported(): boolean {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private readonly blockApi: BlockAPI;
|
|
102
|
+
private readonly registry: BlockPortalRegistry | undefined;
|
|
103
|
+
private readonly pointerDrag: () => boolean;
|
|
104
|
+
private readonly childrenComponent: ComponentType;
|
|
105
|
+
private mirror: Readonly<Data>;
|
|
106
|
+
/** Dedup baseline: skip a redundant render of identical data. */
|
|
107
|
+
private lastRendered: Readonly<Data>;
|
|
108
|
+
/** Read-only flag mirrored into the entry props by setReadOnly. */
|
|
109
|
+
private readOnly: boolean;
|
|
110
|
+
private hostEl: HTMLElement | null = null;
|
|
111
|
+
/** True while a deferred dispatch is waiting for a pointer drag to end. */
|
|
112
|
+
private pendingDispatch = false;
|
|
113
|
+
|
|
114
|
+
public constructor(options: BlockToolConstructorOptions) {
|
|
115
|
+
this.blockApi = options.block;
|
|
116
|
+
|
|
117
|
+
const config = (options.config ?? {}) as Record<string, unknown>;
|
|
118
|
+
|
|
119
|
+
this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
|
|
120
|
+
|
|
121
|
+
// Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
|
|
122
|
+
// (core silently drops a dispatchChange while a drag is active).
|
|
123
|
+
const api = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
|
|
124
|
+
|
|
125
|
+
this.pointerDrag = (): boolean => api?.blocks?.isPointerDragActive === true;
|
|
126
|
+
|
|
127
|
+
this.mirror = fillDefaults<Data>(spec.propSchema, (options.data ?? {}) as Record<string, unknown>);
|
|
128
|
+
this.lastRendered = this.mirror;
|
|
129
|
+
this.readOnly = options.readOnly;
|
|
130
|
+
|
|
131
|
+
const blockApi = this.blockApi;
|
|
132
|
+
|
|
133
|
+
// Per-instance child slot: a childless ref'd div Blok owns; mountChildBlocks
|
|
134
|
+
// appends the real child holders imperatively (React never reconciles them).
|
|
135
|
+
this.childrenComponent = function BlockChildren(): ReactElement {
|
|
136
|
+
const slotRef = useRef<HTMLDivElement | null>(null);
|
|
137
|
+
|
|
138
|
+
// No dep array: re-adopt children after EVERY render (mirrors the Vue
|
|
139
|
+
// adapter's onMounted + onUpdated pair).
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (slotRef.current !== null) {
|
|
142
|
+
mountChildBlocks(slotRef.current, blockApi.getChildren());
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return <div ref={slotRef} {...{ [DATA_ATTR.nestedBlocks]: '' }} />;
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public render(): HTMLElement {
|
|
151
|
+
const host = document.createElement('div');
|
|
152
|
+
|
|
153
|
+
// The Blok-owned host: React reconciles the chrome portaled INTO it, but
|
|
154
|
+
// this attribute makes core's MutationObserver ignore those mutations, so
|
|
155
|
+
// React's reconciliation never registers as a user edit.
|
|
156
|
+
host.setAttribute('data-blok-mutation-free', 'true');
|
|
157
|
+
this.hostEl = host;
|
|
158
|
+
|
|
159
|
+
this.registry?.register(this.blockApi.id, {
|
|
160
|
+
hostEl: host,
|
|
161
|
+
component: spec.component as unknown as ComponentType<Record<string, unknown>>,
|
|
162
|
+
props: this.buildProps(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Returned synchronously; React flushes the portal on its next commit.
|
|
166
|
+
return host;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
public rendered(): void {
|
|
170
|
+
spec.onRendered?.(this.blockApi);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
public save(): BlockToolData {
|
|
174
|
+
// The complete, frozen mirror — never the DOM, never partial.
|
|
175
|
+
return this.mirror as BlockToolData;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public async setData(newData: BlockToolData): Promise<boolean> {
|
|
179
|
+
const next = fillDefaults<Data>(spec.propSchema, newData ?? {});
|
|
180
|
+
|
|
181
|
+
// Dedup: identical data → skip the render entirely, but still return true
|
|
182
|
+
// so core keeps the block in place (no remount).
|
|
183
|
+
if (deepEqual(next, this.lastRendered)) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.mirror = next;
|
|
188
|
+
this.lastRendered = next;
|
|
189
|
+
|
|
190
|
+
// Flush React's commit before resolving. Core drives setData inside its
|
|
191
|
+
// RAF-extended Yjs-suppression window, so the re-render must land before
|
|
192
|
+
// suppression lifts — without ever remounting.
|
|
193
|
+
this.pushProps({ data: next });
|
|
194
|
+
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* In-place read-only toggle. Pushes a new `readOnly` prop the component
|
|
200
|
+
* reads, so the block re-renders read-only WITHOUT a remount (ephemeral
|
|
201
|
+
* component state survives). A regular prototype method (not an arrow
|
|
202
|
+
* field) so core's `supportsInPlaceReadOnly` — which probes the
|
|
203
|
+
* constructable's PROTOTYPE for `setReadOnly` — selects the in-place path.
|
|
204
|
+
*/
|
|
205
|
+
public setReadOnly(state: boolean): void {
|
|
206
|
+
this.readOnly = state;
|
|
207
|
+
this.pushProps({ readOnly: state });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
public moved(): void {
|
|
211
|
+
// No remount: Blok relocates the host element; the portal follows it.
|
|
212
|
+
spec.onMoved?.(this.blockApi);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
public removed(): void {
|
|
216
|
+
this.registry?.unregister(this.blockApi.id);
|
|
217
|
+
spec.onRemoved?.(this.blockApi);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
public destroy(): void {
|
|
221
|
+
// Idempotent with removed(); unregister is safe when already absent.
|
|
222
|
+
this.registry?.unregister(this.blockApi.id);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The full entry props handed to the component on (re-)register. */
|
|
226
|
+
private buildProps(): Record<string, unknown> {
|
|
227
|
+
const props: ReactBlockRenderProps<Data> = {
|
|
228
|
+
data: this.mirror,
|
|
229
|
+
commit: this.commit,
|
|
230
|
+
block: this.blockApi,
|
|
231
|
+
readOnly: this.readOnly,
|
|
232
|
+
BlockChildren: this.childrenComponent,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return props as unknown as Record<string, unknown>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Push a prop patch to the mounted component and flush React's commit. */
|
|
239
|
+
private pushProps(props: Record<string, unknown>): void {
|
|
240
|
+
const registry = this.registry;
|
|
241
|
+
|
|
242
|
+
if (registry === undefined) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// setData/setReadOnly arrive from core outside React's render phase, so a
|
|
247
|
+
// synchronous flush is safe and makes the DOM update deterministic.
|
|
248
|
+
flushSync(() => {
|
|
249
|
+
registry.setProps(this.blockApi.id, props);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The only data write path. Merges the patch into the frozen mirror, pushes
|
|
255
|
+
* the new snapshot, records the dedup baseline, and fires dispatchChange
|
|
256
|
+
* EXACTLY once — deferring it while a pointer drag is active (core would
|
|
257
|
+
* otherwise silently drop it).
|
|
258
|
+
*/
|
|
259
|
+
private readonly commit = (patch: Partial<Data>): void => {
|
|
260
|
+
const next = fillDefaults<Data>(spec.propSchema, {
|
|
261
|
+
...(this.mirror as Record<string, unknown>),
|
|
262
|
+
...(patch as Record<string, unknown>),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
this.mirror = next;
|
|
266
|
+
this.lastRendered = next;
|
|
267
|
+
// No flushSync here: commit is called from React event handlers (inside a
|
|
268
|
+
// render batch), where a sync flush is illegal — the normal async commit
|
|
269
|
+
// is fine because the change originates from the component itself.
|
|
270
|
+
this.registry?.setProps(this.blockApi.id, { data: next });
|
|
271
|
+
|
|
272
|
+
this.flushDispatch();
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** Dispatch the change, or retry on the next frame if a drag is in progress. */
|
|
276
|
+
private flushDispatch(): void {
|
|
277
|
+
if (!this.pointerDrag()) {
|
|
278
|
+
this.pendingDispatch = false;
|
|
279
|
+
this.blockApi.dispatchChange();
|
|
280
|
+
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (this.pendingDispatch) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.pendingDispatch = true;
|
|
289
|
+
|
|
290
|
+
const retry = (): void => {
|
|
291
|
+
if (this.pointerDrag()) {
|
|
292
|
+
requestAnimationFrame(retry);
|
|
293
|
+
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.pendingDispatch = false;
|
|
298
|
+
this.blockApi.dispatchChange();
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
requestAnimationFrame(retry);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,20 @@ 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 { createReactBlock } from './createReactBlock';
|
|
9
|
+
export type {
|
|
10
|
+
CreateReactBlockSpec,
|
|
11
|
+
ReactBlockRenderProps,
|
|
12
|
+
PropSchema,
|
|
13
|
+
PropSchemaEntry,
|
|
14
|
+
} from './createReactBlock';
|
|
15
|
+
export {
|
|
16
|
+
createBlockPortalRegistry,
|
|
17
|
+
BLOK_PORTAL_REGISTRY_CONFIG_KEY,
|
|
18
|
+
} from './block-portal-registry';
|
|
19
|
+
export type { BlockPortalRegistry, BlockPortalEntry } from './block-portal-registry';
|
|
20
|
+
export { BlockPortalHost } from './BlockPortalHost';
|
|
21
|
+
export type { BlockPortalHostProps } from './BlockPortalHost';
|
|
8
22
|
export type {
|
|
9
23
|
BlockNode,
|
|
10
24
|
CaretTarget,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live tool-config functions: the reason a consumer should never have to freeze
|
|
3
|
+
* callback identities (`useState(() => ...)`) or recreate the editor to get
|
|
4
|
+
* fresh closures into tool configs.
|
|
5
|
+
*
|
|
6
|
+
* Core snapshots each tool's config when a block instance is constructed
|
|
7
|
+
* (`ToolClass.settings` builds a merged copy), so a function passed in `tools`
|
|
8
|
+
* at editor construction would otherwise be pinned forever — consumers worked
|
|
9
|
+
* around it by freezing identities and smuggling live values through external
|
|
10
|
+
* stores. Instead, `useBlok` hands core STABLE wrapper functions that delegate
|
|
11
|
+
* to whatever closure the LATEST render passed at the same config path. New
|
|
12
|
+
* render → new closure → the wrapper calls it; the editor is never recreated.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type AnyFunction = (...args: unknown[]) => unknown;
|
|
16
|
+
|
|
17
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
18
|
+
if (typeof value !== 'object' || value === null) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
23
|
+
|
|
24
|
+
return proto === Object.prototype || proto === null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Walk `root` down `path`, returning undefined the moment the shape diverges. */
|
|
28
|
+
const resolveAtPath = (root: unknown, path: readonly (string | number)[]): unknown =>
|
|
29
|
+
path.reduce<unknown>((node, key) => {
|
|
30
|
+
if (Array.isArray(node)) {
|
|
31
|
+
return typeof key === 'number' ? node[key] : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (isPlainObject(node)) {
|
|
35
|
+
return node[key];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return undefined;
|
|
39
|
+
}, root);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Return a copy of `tools` where every function inside a tool's `config` is
|
|
43
|
+
* replaced by a stable wrapper that calls the function found at the same path
|
|
44
|
+
* in `getLatestTools()` (falling back to the construction-time original when
|
|
45
|
+
* the latest config no longer carries one). Tool classes and non-function
|
|
46
|
+
* values pass through untouched; the consumer's objects are never mutated.
|
|
47
|
+
*/
|
|
48
|
+
export const bindLiveToolConfigFunctions = (tools: unknown, getLatestTools: () => unknown): unknown => {
|
|
49
|
+
if (!isPlainObject(tools)) {
|
|
50
|
+
return tools;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result: Record<string, unknown> = {};
|
|
54
|
+
|
|
55
|
+
for (const [name, entry] of Object.entries(tools)) {
|
|
56
|
+
if (!isPlainObject(entry) || !isPlainObject(entry.config)) {
|
|
57
|
+
result[name] = entry;
|
|
58
|
+
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const seen = new WeakSet();
|
|
63
|
+
|
|
64
|
+
const wrapNode = (node: unknown, path: readonly (string | number)[]): unknown => {
|
|
65
|
+
if (typeof node === 'function') {
|
|
66
|
+
const original = node as AnyFunction;
|
|
67
|
+
|
|
68
|
+
return function liveConfigFunction(this: unknown, ...args: unknown[]): unknown {
|
|
69
|
+
const latestTools = getLatestTools();
|
|
70
|
+
const latestEntry = isPlainObject(latestTools) ? latestTools[name] : undefined;
|
|
71
|
+
const latestConfig = isPlainObject(latestEntry) ? latestEntry.config : undefined;
|
|
72
|
+
const latest = resolveAtPath(latestConfig, path);
|
|
73
|
+
const target = typeof latest === 'function' ? (latest as AnyFunction) : original;
|
|
74
|
+
|
|
75
|
+
// Preserve the call-site `this` (config functions may be invoked as
|
|
76
|
+
// methods of their config object).
|
|
77
|
+
return Reflect.apply(target, this, args);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (Array.isArray(node)) {
|
|
82
|
+
if (seen.has(node)) {
|
|
83
|
+
return node;
|
|
84
|
+
}
|
|
85
|
+
seen.add(node);
|
|
86
|
+
|
|
87
|
+
return node.map((item, index) => wrapNode(item, [...path, index]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (isPlainObject(node)) {
|
|
91
|
+
if (seen.has(node)) {
|
|
92
|
+
return node;
|
|
93
|
+
}
|
|
94
|
+
seen.add(node);
|
|
95
|
+
|
|
96
|
+
const wrapped: Record<string, unknown> = {};
|
|
97
|
+
|
|
98
|
+
for (const [key, value] of Object.entries(node)) {
|
|
99
|
+
wrapped[key] = wrapNode(value, [...path, key]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return wrapped;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return node;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
result[name] = { ...entry, config: wrapNode(entry.config, []) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result;
|
|
112
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BlockPortalRegistry } from './block-portal-registry';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Associates each live Blok instance with its portal registry, keyed by the
|
|
5
|
+
* editor (mirrors the holder WeakMap). `useBlok` sets it at creation;
|
|
6
|
+
* `BlokContent` reads it to mount the shared `BlockPortalHost`. WeakMap → no
|
|
7
|
+
* leak on destroy.
|
|
8
|
+
*/
|
|
9
|
+
const registries = new WeakMap<WeakKey, BlockPortalRegistry>();
|
|
10
|
+
|
|
11
|
+
export function setRegistry(editor: WeakKey, registry: BlockPortalRegistry): void {
|
|
12
|
+
registries.set(editor, registry);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getRegistry(editor: WeakKey): BlockPortalRegistry | undefined {
|
|
16
|
+
return registries.get(editor);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function removeRegistry(editor: WeakKey): void {
|
|
20
|
+
registries.delete(editor);
|
|
21
|
+
}
|