@bloklabs/react 1.16.1 → 2.1.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 +23 -0
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +365 -353
- package/package.json +3 -4
- package/types/index.d.ts +63 -4
- package/src/BlockPortalHost.tsx +0 -58
- package/src/BlokContent.tsx +0 -112
- package/src/BlokEditor.tsx +0 -201
- package/src/BlokView.tsx +0 -106
- package/src/block-portal-registry.ts +0 -161
- package/src/blocks-snapshot.ts +0 -27
- package/src/blok-instance-context.ts +0 -35
- package/src/config-keys.ts +0 -72
- package/src/content-baseline-map.ts +0 -38
- package/src/createReactBlock.tsx +0 -1062
- package/src/createReactInlineTool.tsx +0 -406
- package/src/deep-equal.ts +0 -7
- package/src/holder-map.ts +0 -17
- package/src/index.ts +0 -62
- package/src/live-tool-config.ts +0 -154
- package/src/provide-blok.tsx +0 -70
- package/src/registry-map.ts +0 -21
- package/src/types.ts +0 -38
- package/src/useBlocks.ts +0 -119
- package/src/useBlok.ts +0 -908
- package/src/useBlokHandle.ts +0 -141
- package/src/useBlokReady.ts +0 -60
- package/src/useBlokView.ts +0 -70
- package/src/view-nodes-to-react.ts +0 -153
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bloklabs/react",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "React block editor — official React bindings for Blok, the headless Notion-like rich text editor. Hooks and components that render blocks and save to JSON.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "JackUait",
|
|
@@ -43,8 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"files": [
|
|
45
45
|
"dist",
|
|
46
|
-
"types"
|
|
47
|
-
"src"
|
|
46
|
+
"types"
|
|
48
47
|
],
|
|
49
48
|
"publishConfig": {
|
|
50
49
|
"access": "public"
|
|
@@ -54,7 +53,7 @@
|
|
|
54
53
|
"test": "yarn run -T vitest run"
|
|
55
54
|
},
|
|
56
55
|
"peerDependencies": {
|
|
57
|
-
"@bloklabs/core": "^1.
|
|
56
|
+
"@bloklabs/core": "^2.1.0",
|
|
58
57
|
"react": "^18.0.0 || ^19.0.0",
|
|
59
58
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
60
59
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -107,6 +107,29 @@ export function useBlok(
|
|
|
107
107
|
*/
|
|
108
108
|
export declare const BlokContent: React.ForwardRefExoticComponent<BlokContentProps & React.RefAttributes<HTMLDivElement>>;
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Props for the BlokTitle component.
|
|
112
|
+
* Div attributes pass through. Children are not rendered: the div belongs to Blok.
|
|
113
|
+
*/
|
|
114
|
+
export type BlokTitleProps = {
|
|
115
|
+
/** The Blok editor instance, or null while it is not ready. */
|
|
116
|
+
editor: Blok | null;
|
|
117
|
+
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Places the editor's page title in a div of your own, through `editor.title.mount`.
|
|
121
|
+
* The editor needs `pageTitle` set: this component only moves the title.
|
|
122
|
+
* On unmount the title goes back above the first block.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```tsx
|
|
126
|
+
* const [editor, setEditor] = useState<Blok | null>(null);
|
|
127
|
+
* <BlokTitle editor={editor} />
|
|
128
|
+
* <BlokEditor ref={setEditor} pageTitle />
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export declare const BlokTitle: React.ForwardRefExoticComponent<BlokTitleProps & React.RefAttributes<HTMLDivElement>>;
|
|
132
|
+
|
|
110
133
|
/**
|
|
111
134
|
* Props for the BlokEditor component — all useBlok config (except `onReady`,
|
|
112
135
|
* re-typed to receive the ready instance) plus every standard HTML div attribute
|
|
@@ -311,6 +334,19 @@ export interface InsertSpec {
|
|
|
311
334
|
caret?: CaretTarget;
|
|
312
335
|
}
|
|
313
336
|
|
|
337
|
+
/** Spec for {@link UseBlocksApi.create}. */
|
|
338
|
+
export interface CreateSpec {
|
|
339
|
+
type?: string;
|
|
340
|
+
data?: BlockToolData;
|
|
341
|
+
/** `null` is the root. Omitted: the `before`/`after` sibling's parent, or the root. */
|
|
342
|
+
parentId?: string | null;
|
|
343
|
+
/** Defaults to `'end'`. */
|
|
344
|
+
position?: InsertPosition;
|
|
345
|
+
id?: string;
|
|
346
|
+
tunes?: { [name: string]: BlockTuneData };
|
|
347
|
+
focus?: boolean;
|
|
348
|
+
}
|
|
349
|
+
|
|
314
350
|
/**
|
|
315
351
|
* One node of a pre-built nested subtree for {@link UseBlocksApi.insertTree}.
|
|
316
352
|
*
|
|
@@ -421,7 +457,7 @@ export interface UseBlocksApi {
|
|
|
421
457
|
*
|
|
422
458
|
* `config` (optional {@link MarkdownImportConfig}) is forwarded to the
|
|
423
459
|
* converter so custom-tool consumers can map markdown nodes into their tools
|
|
424
|
-
* (`toolMap`/`onUnknownNode`)
|
|
460
|
+
* (`toolMap`/`onUnknownNode`) or toggle GFM.
|
|
425
461
|
*
|
|
426
462
|
* Returns ALL created {@link BlockNode}s in document order — including any the
|
|
427
463
|
* markdown nested internally (e.g. a table's cell children), not just the
|
|
@@ -441,9 +477,10 @@ export interface UseBlocksApi {
|
|
|
441
477
|
* additive {@link insertMarkdown}). Async: the serializer is lazy-loaded, like
|
|
442
478
|
* the importer. Markdown cannot express every block, so some structure is
|
|
443
479
|
* dropped (table `colspan`/`rowspan`, heading columns). Returns `''` for an
|
|
444
|
-
* empty document. Pre-ready: resolves to `''`.
|
|
480
|
+
* empty document. Pre-ready: resolves to `''`. `title: true` writes the
|
|
481
|
+
* page title first, as core's does.
|
|
445
482
|
*/
|
|
446
|
-
exportMarkdown(): Promise<string>;
|
|
483
|
+
exportMarkdown(options?: { title?: boolean }): Promise<string>;
|
|
447
484
|
/**
|
|
448
485
|
* Move `id` to a flat slot, as a single operation. No-op when `id` is unknown.
|
|
449
486
|
* For a relative `{ before|after }` target it is also a no-op when the ref is
|
|
@@ -598,6 +635,14 @@ export interface UseBlocksApi {
|
|
|
598
635
|
* dep array. Pre-ready: `null`.
|
|
599
636
|
*/
|
|
600
637
|
insertInsideParent(parentId: string, insertIndex: number, childData?: BlockToolData): BlockNode | null;
|
|
638
|
+
/**
|
|
639
|
+
* Add a block the way a toolbox pick does, for a host button such as "New
|
|
640
|
+
* page" — delegates to core's `blocks.create`. Goes to the end of the root
|
|
641
|
+
* unless `parentId`/`position` say otherwise. Resolves `null` for an unknown
|
|
642
|
+
* tool or a place that does not exist; rejects with the tool's
|
|
643
|
+
* `prepareInsert` error, inserting nothing. Pre-ready: `null`.
|
|
644
|
+
*/
|
|
645
|
+
create(spec?: CreateSpec): Promise<BlockNode | null>;
|
|
601
646
|
/**
|
|
602
647
|
* Replace the WHOLE document with blocks parsed from saved {@link OutputData}
|
|
603
648
|
* (the `save()` shape) — delegates to core's `blocks.render`. Unlike
|
|
@@ -923,6 +968,19 @@ export interface CreateReactBlockSpec<
|
|
|
923
968
|
* @param block - this block's per-block API
|
|
924
969
|
*/
|
|
925
970
|
onNavigationEnter?: (event: KeyboardEvent, block: BlockAPI) => boolean;
|
|
971
|
+
/**
|
|
972
|
+
* Core's `isReadOnly` hook: true when this block takes no edits from the
|
|
973
|
+
* user or an agent while the editor stays editable (no delete, move, merge,
|
|
974
|
+
* nesting or undo into it). Asked on each gesture; a throw counts as true.
|
|
975
|
+
* @param block - this block's per-block API
|
|
976
|
+
*/
|
|
977
|
+
isReadOnly?: (block: BlockAPI) => boolean;
|
|
978
|
+
/**
|
|
979
|
+
* Core's `isDataReadOnly` hook: true when only this block's own data is
|
|
980
|
+
* frozen and its children stay open. A throw counts as true.
|
|
981
|
+
* @param block - this block's per-block API
|
|
982
|
+
*/
|
|
983
|
+
isDataReadOnly?: (block: BlockAPI) => boolean;
|
|
926
984
|
onRendered?: (block: BlockAPI) => void;
|
|
927
985
|
/**
|
|
928
986
|
* Fired ONCE per block instance, after the portal's FIRST commit — the first
|
|
@@ -963,7 +1021,8 @@ export interface CreateReactBlockSpec<
|
|
|
963
1021
|
*/
|
|
964
1022
|
onCreated?: (block: BlockAPI, context: ReactBlockMountedContext) => void;
|
|
965
1023
|
onMoved?: (block: BlockAPI) => void;
|
|
966
|
-
|
|
1024
|
+
/** `removal.deleted` is true when a local edit deleted the block (see BlockTool.removed). */
|
|
1025
|
+
onRemoved?: (block: BlockAPI, removal?: { deleted: boolean }) => void;
|
|
967
1026
|
}
|
|
968
1027
|
|
|
969
1028
|
/**
|
package/src/BlockPortalHost.tsx
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, forwardRef } from 'react';
|
|
2
|
-
import { getHolder } from './holder-map';
|
|
3
|
-
import { getRegistry } from './registry-map';
|
|
4
|
-
import { BlockPortalHost } from './BlockPortalHost';
|
|
5
|
-
import { BlokInstanceContext } from './blok-instance-context';
|
|
6
|
-
import type { Blok } from '@/types';
|
|
7
|
-
import type { BlokContentProps } from './types';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Re-drive the boot-time URL-hash scroll now that the editor's holder is in the
|
|
11
|
-
* document. Reads the current hash, decodes it (falling back to the raw value on
|
|
12
|
-
* a malformed percent-sequence, which simply matches no block), and asks the
|
|
13
|
-
* editor to scroll — a one-shot drain of the navigation the constructor deferred
|
|
14
|
-
* because the holder was detached. No-op when the URL carries no hash.
|
|
15
|
-
* @param editor - the ready Blok instance whose holder just connected
|
|
16
|
-
*/
|
|
17
|
-
function drainDeferredHashScroll(editor: Blok): void {
|
|
18
|
-
if (typeof window === 'undefined') {
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const raw = window.location.hash.slice(1);
|
|
23
|
-
|
|
24
|
-
if (raw === '') {
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
editor.blocks.scrollToBlock?.(safeDecodeHashFragment(raw));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Decodes a URL hash fragment, falling back to the raw value on a malformed
|
|
33
|
-
* percent-sequence (e.g. `%ZZ`) — which simply matches no block downstream.
|
|
34
|
-
* @param raw - hash fragment without the leading `#`
|
|
35
|
-
*/
|
|
36
|
-
function safeDecodeHashFragment(raw: string): string {
|
|
37
|
-
try {
|
|
38
|
-
return decodeURIComponent(raw);
|
|
39
|
-
} catch {
|
|
40
|
-
return raw;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Component that provides the DOM mount point for a Blok editor.
|
|
46
|
-
* Renders a <div> and adopts the editor's detached holder DOM into it.
|
|
47
|
-
* When editor is null, renders an empty div (for layout stability).
|
|
48
|
-
* Passes through all standard HTML div attributes (className, style, etc.).
|
|
49
|
-
*/
|
|
50
|
-
export const BlokContent = forwardRef<HTMLDivElement, BlokContentProps>(
|
|
51
|
-
function BlokContent({ editor, ...divProps }, ref) {
|
|
52
|
-
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
53
|
-
|
|
54
|
-
const setRefs = (node: HTMLDivElement | null): void => {
|
|
55
|
-
containerRef.current = node;
|
|
56
|
-
if (typeof ref === 'function') {
|
|
57
|
-
ref(node);
|
|
58
|
-
} else if (ref !== null && ref !== undefined) {
|
|
59
|
-
const mutableRef = ref;
|
|
60
|
-
|
|
61
|
-
mutableRef.current = node;
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
if (editor === null || containerRef.current === null) {
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const holder = getHolder(editor);
|
|
71
|
-
|
|
72
|
-
if (holder === undefined) {
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
containerRef.current.appendChild(holder);
|
|
77
|
-
|
|
78
|
-
// The editor rendered its seeded content into this holder while it was
|
|
79
|
-
// still detached from the document, so the boot-time URL-hash scroll —
|
|
80
|
-
// which queries the LIVE document — found nothing and deferred. Now that
|
|
81
|
-
// the holder is connected, drain that deferred navigation once via the
|
|
82
|
-
// public scroll API (a no-op when there's no matching hash/block), so
|
|
83
|
-
// consumers don't have to hand-roll a DOM-polling hook.
|
|
84
|
-
drainDeferredHashScroll(editor);
|
|
85
|
-
|
|
86
|
-
return (): void => {
|
|
87
|
-
holder.remove();
|
|
88
|
-
};
|
|
89
|
-
}, [editor]);
|
|
90
|
-
|
|
91
|
-
// The portal host renders the editor's `createReactBlock` tools INSIDE this
|
|
92
|
-
// component tree (via createPortal into each Blok-owned host element), so
|
|
93
|
-
// app-level React context flows into block tools with no bridge. It emits
|
|
94
|
-
// no DOM at this position, leaving the imperatively-adopted holder alone.
|
|
95
|
-
//
|
|
96
|
-
// The instance provider rides along for the same reason: this is the one
|
|
97
|
-
// place that holds BOTH the live editor and the portal host, so publishing
|
|
98
|
-
// it here is what lets a block component call `useBlocks(useBlokInstance())`
|
|
99
|
-
// without the host prop-drilling the instance into every block.
|
|
100
|
-
const registry = editor === null ? undefined : getRegistry(editor);
|
|
101
|
-
|
|
102
|
-
return (
|
|
103
|
-
<div ref={setRefs} {...divProps}>
|
|
104
|
-
{registry === undefined ? null : (
|
|
105
|
-
<BlokInstanceContext.Provider value={editor}>
|
|
106
|
-
<BlockPortalHost registry={registry} />
|
|
107
|
-
</BlokInstanceContext.Provider>
|
|
108
|
-
)}
|
|
109
|
-
</div>
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
);
|
package/src/BlokEditor.tsx
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
forwardRef,
|
|
3
|
-
useImperativeHandle,
|
|
4
|
-
useEffect,
|
|
5
|
-
useRef,
|
|
6
|
-
type DependencyList,
|
|
7
|
-
type HTMLAttributes,
|
|
8
|
-
} from 'react';
|
|
9
|
-
import { useBlok } from './useBlok';
|
|
10
|
-
import { BlokContent } from './BlokContent';
|
|
11
|
-
import { USE_BLOK_CONFIG_KEYS } from './config-keys';
|
|
12
|
-
import type { Blok, BlockRenderedPayload, BlocksRenderedPayload } from '@/types';
|
|
13
|
-
import type { UseBlokConfig } from './types';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Props for the all-in-one BlokEditor component.
|
|
17
|
-
* Accepts every useBlok config prop (except `onReady`, re-typed below to receive
|
|
18
|
-
* the ready instance) AND every standard HTML div attribute, which is forwarded
|
|
19
|
-
* to the editor container element (mirrors `BlokContent`). `style` and `onChange`
|
|
20
|
-
* keep their editor-config meaning (they collide with div attributes of the same
|
|
21
|
-
* name), so the container is styled via `className` rather than an inline `style`.
|
|
22
|
-
*
|
|
23
|
-
* `data` is reactive (controlled-ish): it seeds the INITIAL content and, after
|
|
24
|
-
* mount, changing it to new *content* re-renders the editor via `render()` — no
|
|
25
|
-
* recreation. Updates are deep-equal–deduped (a new reference with identical
|
|
26
|
-
* content is a no-op, so it won't clobber the caret) and serialized (rapid
|
|
27
|
-
* changes can't overlap). For ad-hoc reloads you can still call
|
|
28
|
-
* `ref.current.render(newData)`.
|
|
29
|
-
*
|
|
30
|
-
* Pair `data` with `onSave` for a true controlled component: `onSave` is the
|
|
31
|
-
* output half, firing (debounced) with the full serialized `OutputData` on every
|
|
32
|
-
* content change — no manual `ref.current.save()` polling. Echoing that payload
|
|
33
|
-
* straight back via `onSave={setData}` is safe and caret-stable: the adapter
|
|
34
|
-
* records the editor's own emitted output as the content baseline, so the
|
|
35
|
-
* `onSave → setState → data` round-trip deep-equals that baseline and is deduped
|
|
36
|
-
* to a no-op (no re-render, no caret reset, no recursion). Genuine external `data`
|
|
37
|
-
* changes still re-render in place.
|
|
38
|
-
*/
|
|
39
|
-
export interface BlokEditorProps
|
|
40
|
-
extends Omit<UseBlokConfig, 'onReady'>,
|
|
41
|
-
// `onChange`/`onError`/`onSubmit` are Blok config callbacks here — drop the
|
|
42
|
-
// DOM element's same-named event handlers so the config signatures win.
|
|
43
|
-
Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'onChange' | 'onError' | 'onSubmit'> {
|
|
44
|
-
/**
|
|
45
|
-
* When any value changes, the editor is destroyed and recreated. Keep each
|
|
46
|
-
* value referentially stable (primitives or useMemo-stable objects) — a dep
|
|
47
|
-
* whose identity changes every render recreates the editor each time.
|
|
48
|
-
*
|
|
49
|
-
* Tool configs do NOT belong here — reserve deps for true identity changes
|
|
50
|
-
* (e.g. the document id). FUNCTIONS inside tool configs are re-bound to the
|
|
51
|
-
* latest render's closure automatically (an inline `uploadByFile` with a
|
|
52
|
-
* fresh identity each render reaches the editor live), and non-function
|
|
53
|
-
* config VALUES of `createReactBlock` tools (permissions, URLs, locale…) are
|
|
54
|
-
* pushed to mounted blocks in place when they change. Neither requires
|
|
55
|
-
* recreation or `useState` identity-freezing.
|
|
56
|
-
*/
|
|
57
|
-
deps?: DependencyList;
|
|
58
|
-
/** Test id forwarded to the editor container element (via data-testid). */
|
|
59
|
-
'data-testid'?: string;
|
|
60
|
-
/**
|
|
61
|
-
* Called with the live Blok instance once it is ready. Fires after the
|
|
62
|
-
* forwarded ref is committed, so `ref.current` is also populated at this point.
|
|
63
|
-
*
|
|
64
|
-
* Contract: fires exactly once per editor instance. The editor is recreated
|
|
65
|
-
* only when `deps` change or the component remounts; `data` changes —
|
|
66
|
-
* including transitions to and from empty content — re-render in place and
|
|
67
|
-
* never recreate the editor or re-fire `onReady`.
|
|
68
|
-
*/
|
|
69
|
-
onReady?: (editor: Blok) => void;
|
|
70
|
-
/**
|
|
71
|
-
* Library-neutral shorthand for the active locale — a plain BCP-47 string
|
|
72
|
-
* (`'en'`, `'ru-RU'`, `'nb'`) the host owns and updates. It is normalized and
|
|
73
|
-
* routed to `editor.i18n.update({ locale })` in place (caret/undo survive),
|
|
74
|
-
* so a host with its own language switcher never has to build an `i18n`
|
|
75
|
-
* config object or destroy the editor. Pair it with the re-exported
|
|
76
|
-
* `getDirection`/`normalizeLocale` to compute `dir` yourself.
|
|
77
|
-
*
|
|
78
|
-
* Shorthand for `i18n={{ locale }}`: if both are given, this wins. For
|
|
79
|
-
* message overrides or an explicit direction, use the full `i18n` prop.
|
|
80
|
-
*/
|
|
81
|
-
locale?: string;
|
|
82
|
-
/**
|
|
83
|
-
* Called after a batch render completes (core `blocks:rendered` event). The
|
|
84
|
-
* declarative analog of `ref.current.on('blocks:rendered', …)` — mirrors the
|
|
85
|
-
* Vue/Angular adapters' rendered-lifecycle outputs.
|
|
86
|
-
*/
|
|
87
|
-
onBlocksRendered?: (payload: BlocksRenderedPayload) => void;
|
|
88
|
-
/** Called for each block rendered into the DOM (core `block:rendered` event). */
|
|
89
|
-
onBlockRendered?: (payload: BlockRenderedPayload) => void;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Fast membership test for partitioning props into editor config vs. div attributes. */
|
|
93
|
-
const CONFIG_KEY_SET = new Set<string>(USE_BLOK_CONFIG_KEYS);
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The recommended way to embed Blok in React. Internally wires `useBlok` and
|
|
97
|
-
* `BlokContent`, and forwards a ref to the live `Blok` instance.
|
|
98
|
-
*
|
|
99
|
-
* Don't wrap this component in `styled()` or any HOC that reserves the `theme`
|
|
100
|
-
* prop — styled-components claims `theme` for its own `ThemeProvider`, so it
|
|
101
|
-
* never reaches the editor and theme sync silently breaks. Render it directly
|
|
102
|
-
* and style the container via `className`.
|
|
103
|
-
*
|
|
104
|
-
* @example
|
|
105
|
-
* ```tsx
|
|
106
|
-
* const ref = useRef<Blok | null>(null);
|
|
107
|
-
* <BlokEditor ref={ref} id="editor" tools={tools} data={data} theme={theme} onReady={(e) => e.focus()} />;
|
|
108
|
-
* ```
|
|
109
|
-
*/
|
|
110
|
-
export const BlokEditor = forwardRef<Blok | null, BlokEditorProps>(
|
|
111
|
-
function BlokEditor({ deps, onReady, locale, onBlocksRendered, onBlockRendered, ...rest }, ref) {
|
|
112
|
-
const config: Record<string, unknown> = {};
|
|
113
|
-
const divProps: Record<string, unknown> = {};
|
|
114
|
-
|
|
115
|
-
for (const key of Object.keys(rest)) {
|
|
116
|
-
const value = (rest as Record<string, unknown>)[key];
|
|
117
|
-
|
|
118
|
-
if (CONFIG_KEY_SET.has(key)) {
|
|
119
|
-
config[key] = value;
|
|
120
|
-
} else {
|
|
121
|
-
divProps[key] = value;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// `locale` is a neutral shorthand for `i18n.locale`. Fold it into the i18n
|
|
126
|
-
// config (winning over any locale already there) so the single reactive
|
|
127
|
-
// `i18n.update` path in `useBlok` carries it — no separate resolver. A fresh
|
|
128
|
-
// object each render is fine: that path is deep-equal–deduped.
|
|
129
|
-
if (locale !== undefined) {
|
|
130
|
-
const existingI18n = config.i18n as Record<string, unknown> | undefined;
|
|
131
|
-
|
|
132
|
-
config.i18n = { ...existingI18n, locale };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const editor = useBlok(config, deps);
|
|
136
|
-
|
|
137
|
-
useImperativeHandle<Blok | null, Blok | null>(ref, () => editor, [editor]);
|
|
138
|
-
|
|
139
|
-
// Own onReady here instead of forwarding it to the core config: the core
|
|
140
|
-
// onReady fires before useImperativeHandle commits the ref. This passive
|
|
141
|
-
// effect runs AFTER the commit, so both the instance argument and ref.current
|
|
142
|
-
// are reliably populated.
|
|
143
|
-
const onReadyRef = useRef(onReady);
|
|
144
|
-
onReadyRef.current = onReady;
|
|
145
|
-
// Hardening, not a bug fix: today this effect only re-runs when `editor`
|
|
146
|
-
// changes, but future React semantics (Fast Refresh, <Activity>) may re-run
|
|
147
|
-
// an effect with an unchanged value. Tracking the last-notified instance
|
|
148
|
-
// guarantees at-most-once per instance while keeping the per-instance
|
|
149
|
-
// re-fire on recreation (deps change / remount) intact.
|
|
150
|
-
const notifiedEditorRef = useRef<Blok | null>(null);
|
|
151
|
-
useEffect(() => {
|
|
152
|
-
if (editor !== null && notifiedEditorRef.current !== editor) {
|
|
153
|
-
notifiedEditorRef.current = editor;
|
|
154
|
-
onReadyRef.current?.(editor);
|
|
155
|
-
}
|
|
156
|
-
}, [editor]);
|
|
157
|
-
|
|
158
|
-
// Subscribe to the rendered-lifecycle events (the declarative analog of the
|
|
159
|
-
// Vue/Angular outputs). Latest handlers are read through refs so a new
|
|
160
|
-
// callback identity never resubscribes; presence booleans in the deps add or
|
|
161
|
-
// drop subscriptions when a handler appears/disappears.
|
|
162
|
-
const onBlocksRenderedRef = useRef(onBlocksRendered);
|
|
163
|
-
onBlocksRenderedRef.current = onBlocksRendered;
|
|
164
|
-
const onBlockRenderedRef = useRef(onBlockRendered);
|
|
165
|
-
onBlockRenderedRef.current = onBlockRendered;
|
|
166
|
-
|
|
167
|
-
const hasBlocksRendered = Boolean(onBlocksRendered);
|
|
168
|
-
const hasBlockRendered = Boolean(onBlockRendered);
|
|
169
|
-
useEffect(() => {
|
|
170
|
-
if (editor === null) {
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const subscriptions: Array<[string, (payload?: unknown) => void]> = [];
|
|
175
|
-
|
|
176
|
-
if (hasBlocksRendered) {
|
|
177
|
-
const handler = (payload?: unknown): void =>
|
|
178
|
-
onBlocksRenderedRef.current?.(payload as BlocksRenderedPayload);
|
|
179
|
-
|
|
180
|
-
editor.on('blocks:rendered', handler);
|
|
181
|
-
subscriptions.push(['blocks:rendered', handler]);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (hasBlockRendered) {
|
|
185
|
-
const handler = (payload?: unknown): void =>
|
|
186
|
-
onBlockRenderedRef.current?.(payload as BlockRenderedPayload);
|
|
187
|
-
|
|
188
|
-
editor.on('block:rendered', handler);
|
|
189
|
-
subscriptions.push(['block:rendered', handler]);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return (): void => {
|
|
193
|
-
for (const [name, handler] of subscriptions) {
|
|
194
|
-
editor.off(name, handler);
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
}, [editor, hasBlocksRendered, hasBlockRendered]);
|
|
198
|
-
|
|
199
|
-
return <BlokContent editor={editor} {...(divProps as HTMLAttributes<HTMLDivElement>)} />;
|
|
200
|
-
}
|
|
201
|
-
);
|
package/src/BlokView.tsx
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import type { HTMLAttributes, 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}. Beyond the render options below, any standard
|
|
9
|
-
* `<div>` attribute (`id`, `style`, `data-*`, `aria-*`, event handlers, …) is
|
|
10
|
-
* forwarded onto the single wrapper element.
|
|
11
|
-
*/
|
|
12
|
-
export interface BlokViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
13
|
-
/** Saved document to display (strict or loose wire shape; nullish tolerated). */
|
|
14
|
-
data: OutputData | LooseOutputData | null | undefined;
|
|
15
|
-
/** View schema from `defineBlokSchema` — display under the composition that produced the document. */
|
|
16
|
-
schema?: BlocksToHtmlOptions['schema'];
|
|
17
|
-
/** Custom per-tool renderers; win over built-ins. */
|
|
18
|
-
renderers?: BlocksToHtmlOptions['renderers'];
|
|
19
|
-
/** Unknown-tool policy (default 'skip'). */
|
|
20
|
-
onUnknownBlock?: BlocksToHtmlOptions['onUnknownBlock'];
|
|
21
|
-
/** Stamp `data-blok-tool="<type>"` on each block root as a styling hook (pairs with the opt-in view stylesheet). */
|
|
22
|
-
toolAttributes?: BlocksToHtmlOptions['toolAttributes'];
|
|
23
|
-
/** Stamp `data-blok-id="<id>"` on each block root (list items on their `<li>`) for "copy link to block" deep links. */
|
|
24
|
-
blockIds?: BlocksToHtmlOptions['blockIds'];
|
|
25
|
-
/** Pure URL rewrite hook for block URLs + inline anchors, run before the unsafe-scheme strip. */
|
|
26
|
-
transformUrl?: BlocksToHtmlOptions['transformUrl'];
|
|
27
|
-
/** Custom renderers for inline elements, keyed by lowercase tag name (equations, mentions). */
|
|
28
|
-
inlineRenderers?: BlocksToHtmlOptions['inlineRenderers'];
|
|
29
|
-
/** Build the link for a `page` block from its `pageId`. A page card is a link only with this and an allowing {@link pageInfo}. */
|
|
30
|
-
pageHref?: BlocksToHtmlOptions['pageHref'];
|
|
31
|
-
/** Host metadata (title, icon, access) for a `page` block. Without it a page card shows a neutral "Page" label and no link. */
|
|
32
|
-
pageInfo?: BlocksToHtmlOptions['pageInfo'];
|
|
33
|
-
/**
|
|
34
|
-
* Render with the editor's presentational classes and per-block scaffolding
|
|
35
|
-
* so the output matches a read-only editor render (default `true`). Needs
|
|
36
|
-
* `@bloklabs/core/view.css` imported to paint. Set `false` for unstyled
|
|
37
|
-
* semantic markup.
|
|
38
|
-
*/
|
|
39
|
-
classes?: BlocksToHtmlOptions['classes'];
|
|
40
|
-
/** Document direction: set as `dir` on the wrapper, and each block follows its own text. */
|
|
41
|
-
direction?: BlocksToHtmlOptions['direction'];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Display a saved Blok document synchronously — one `<div>` wrapper, no
|
|
46
|
-
* editor chrome, no async, no effects. Content is mapped from the sanitized
|
|
47
|
-
* view tree to real React elements (never `dangerouslySetInnerHTML`), so it
|
|
48
|
-
* renders identically under SSR.
|
|
49
|
-
*
|
|
50
|
-
* This is the intended read-only path — reach for it instead of mounting the
|
|
51
|
-
* full editing runtime (`BlokEditor readOnly`), which ships toolbar, history,
|
|
52
|
-
* and mutation machinery to every viewer. Pair with `defineBlokSchema` to
|
|
53
|
-
* display custom-tool content under the exact sanitization that produced it.
|
|
54
|
-
*
|
|
55
|
-
* The props API is semver-stable. Only the raw `ViewNode` tree that the
|
|
56
|
-
* bindings map from (via `blocksToViewNodes`) remains `@experimental` — using
|
|
57
|
-
* `BlokView`/`useBlokView` never exposes you to it.
|
|
58
|
-
*/
|
|
59
|
-
export const BlokView = ({
|
|
60
|
-
data,
|
|
61
|
-
schema,
|
|
62
|
-
renderers,
|
|
63
|
-
onUnknownBlock,
|
|
64
|
-
toolAttributes,
|
|
65
|
-
blockIds,
|
|
66
|
-
transformUrl,
|
|
67
|
-
inlineRenderers,
|
|
68
|
-
pageHref,
|
|
69
|
-
pageInfo,
|
|
70
|
-
classes,
|
|
71
|
-
direction,
|
|
72
|
-
...divProps
|
|
73
|
-
}: BlokViewProps): ReactNode => {
|
|
74
|
-
/**
|
|
75
|
-
* `classes: true` by default — this component's job is to look like a
|
|
76
|
-
* read-only editor render, and it already owns a wrapper element, so the
|
|
77
|
-
* per-block scaffolding parity needs is not a surprise here. (The bare
|
|
78
|
-
* `useBlokView` hook defaults it OFF, because its contract is to emit no
|
|
79
|
-
* wrapper at all.) Pass `classes={false}` for unstyled semantic markup.
|
|
80
|
-
*/
|
|
81
|
-
const content = useBlokView(data, {
|
|
82
|
-
schema,
|
|
83
|
-
renderers,
|
|
84
|
-
onUnknownBlock,
|
|
85
|
-
toolAttributes,
|
|
86
|
-
blockIds,
|
|
87
|
-
transformUrl,
|
|
88
|
-
inlineRenderers,
|
|
89
|
-
pageHref,
|
|
90
|
-
pageInfo,
|
|
91
|
-
classes: classes ?? true,
|
|
92
|
-
direction,
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* `data-blok-interface="view"` makes the wrapper a soft isolation root, which
|
|
97
|
-
* is what gives the rendered blocks the same appearance as a read-only
|
|
98
|
-
* editor: the scoped preflight and the token/colour layers key on the bare
|
|
99
|
-
* `[data-blok-interface]` attribute, so classes emitted outside it compute
|
|
100
|
-
* differently. The value is `view`, never `blok` — the latter carries
|
|
101
|
-
* `all: initial !important`, which would block host typography entirely.
|
|
102
|
-
*
|
|
103
|
-
* Written BEFORE the `divProps` spread so a caller can still override it.
|
|
104
|
-
*/
|
|
105
|
-
return <div data-blok-interface="view" dir={direction} {...divProps}>{content}</div>;
|
|
106
|
-
};
|