@bloklabs/react 1.16.1 → 2.0.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.
@@ -1,141 +0,0 @@
1
- import { useRef, useMemo, type RefCallback } from 'react';
2
- import { EMPTY_OUTPUT_DATA } from '@bloklabs/core/adapters';
3
- import { getContentBaseline } from './content-baseline-map';
4
- import type { Blok, OutputData, LooseOutputData } from '@/types';
5
-
6
- /**
7
- * A typed, null-safe imperative handle for a Blok editor embedded via
8
- * `<BlokEditor>`. Solves the "raw ref is `Blok | null` until ready" papercut:
9
- * instead of every consumer re-declaring a `forwardRef`/`useImperativeHandle`
10
- * with `?.` guards, `useBlokHandle()` returns this stable object whose methods
11
- * safely no-op until the editor is ready.
12
- *
13
- * Attach it via `ref`: `<BlokEditor ref={handle.ref} … />`. The methods read the
14
- * live instance through an internal ref, so they can be called from event
15
- * handlers without re-subscribing, and they stay valid across re-renders.
16
- *
17
- * `current` / `isReady` are IMPERATIVE reads (they reflect the instance at call
18
- * time and do NOT trigger a re-render when readiness flips). For render-reactive
19
- * readiness use `useBlokReady`; drop to `current` when you need the full editor
20
- * API beyond the handful of shortcuts below.
21
- */
22
- export interface BlokEditorHandle {
23
- /**
24
- * Ref callback to attach to `<BlokEditor ref={handle.ref} />`. Stable across
25
- * renders. Stores the live instance (or null before ready / after unmount).
26
- */
27
- readonly ref: RefCallback<Blok | null>;
28
- /** The live editor instance, or null before ready / after unmount. */
29
- readonly current: Blok | null;
30
- /** True once the editor is ready (the ref has been populated). */
31
- readonly isReady: boolean;
32
- /**
33
- * Focus the editor. Returns whether focus landed; `false` (no-op) before ready.
34
- * @param atEnd - place the caret at the end of the focused block
35
- */
36
- focus(atEnd?: boolean): boolean;
37
- /**
38
- * Clear all content. Resolves immediately (no-op) before ready.
39
- *
40
- * Safe to mix with a controlled `data` prop: the adapter's content baseline is
41
- * updated once the clear lands, so setting `data` back to the document the
42
- * editor itself emitted re-renders it instead of being dismissed as an echo.
43
- */
44
- clear(): Promise<void>;
45
- /** Serialize the current content, or resolve to `null` before ready. */
46
- save(): Promise<OutputData | null>;
47
- /** Ask before an in-app navigation (see `Blok.confirmLeave`). Resolves `true` before ready. */
48
- confirmLeave(): Promise<boolean>;
49
- /**
50
- * Render new content in place. Resolves immediately (no-op) before ready.
51
- *
52
- * Safe to mix with a controlled `data` prop: the rendered document becomes the
53
- * adapter's content baseline, so a later `data` change back to the previous
54
- * document still re-renders.
55
- * @param data - the document to render
56
- */
57
- render(data: OutputData | LooseOutputData): Promise<void>;
58
- /**
59
- * Set or toggle read-only mode. Resolves to the resulting state; `false`
60
- * (no-op) before ready.
61
- * @param state - explicit state; omit to toggle
62
- */
63
- setReadOnly(state?: boolean): Promise<boolean>;
64
- }
65
-
66
- /**
67
- * Returns a stable, null-safe {@link BlokEditorHandle} for a `<BlokEditor>`.
68
- *
69
- * @example
70
- * ```tsx
71
- * const blok = useBlokHandle();
72
- * return (
73
- * <>
74
- * <BlokEditor ref={blok.ref} tools={tools} data={data} />
75
- * <button onClick={() => blok.focus()}>Focus</button>
76
- * <button onClick={() => blok.save().then(onSubmit)}>Send</button>
77
- * </>
78
- * );
79
- * ```
80
- */
81
- export function useBlokHandle(): BlokEditorHandle {
82
- const instanceRef = useRef<Blok | null>(null);
83
-
84
- // Built once and kept stable: every method reads the live instance through
85
- // `instanceRef`, so the handle identity (and its ref callback) never changes
86
- // across renders even as the underlying editor is created or destroyed.
87
- return useMemo<BlokEditorHandle>(() => ({
88
- ref: (node): void => {
89
- instanceRef.current = node;
90
- },
91
- get current(): Blok | null {
92
- return instanceRef.current;
93
- },
94
- get isReady(): boolean {
95
- return instanceRef.current !== null;
96
- },
97
- focus: (atEnd?: boolean): boolean => instanceRef.current?.focus(atEnd) ?? false,
98
- // clear/render change the content OUT OF BAND of the controlled `data`
99
- // effect, so they report the result to the adapter's content baseline (see
100
- // content-baseline-map). Reported only once the call RESOLVES: a failed one
101
- // leaves the editor on its previous content, and a baseline pointing at
102
- // content the editor never showed would dedupe away the correcting update.
103
- clear: (): Promise<void> => {
104
- const instance = instanceRef.current;
105
-
106
- if (instance === null) {
107
- return Promise.resolve();
108
- }
109
-
110
- return instance.clear().then(() => {
111
- getContentBaseline(instance)?.markRendered(EMPTY_OUTPUT_DATA);
112
- });
113
- },
114
- save: (): Promise<OutputData | null> =>
115
- instanceRef.current !== null ? instanceRef.current.save() : Promise.resolve(null),
116
- confirmLeave: (): Promise<boolean> =>
117
- instanceRef.current !== null ? instanceRef.current.confirmLeave() : Promise.resolve(true),
118
- render: (data: OutputData | LooseOutputData): Promise<void> => {
119
- const instance = instanceRef.current;
120
-
121
- if (instance === null) {
122
- return Promise.resolve();
123
- }
124
-
125
- return instance.render(data).then(() => {
126
- getContentBaseline(instance)?.markRendered(data);
127
- });
128
- },
129
- setReadOnly: (state?: boolean): Promise<boolean> => {
130
- const instance = instanceRef.current;
131
-
132
- if (instance === null) {
133
- return Promise.resolve(false);
134
- }
135
-
136
- // Resolve the toggle here (omitted `state` flips current) so we can use the
137
- // non-deprecated `set()`, which requires an explicit target state.
138
- return instance.readOnly.set(state ?? !instance.readOnly.isEnabled);
139
- },
140
- }), []);
141
- }
@@ -1,60 +0,0 @@
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
- }
@@ -1,70 +0,0 @@
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
- * The props-based signature is semver-stable. The raw `ViewNode` tree the
18
- * bindings map from remains `@experimental` — but consumers of this hook never
19
- * touch it directly.
20
- * @param data - saved document (strict or loose wire shape; nullish tolerated)
21
- * @param options - schema / renderers / inlineRenderers / unknown-block policy / toolAttributes / blockIds / transformUrl / pageHref / pageInfo
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
- const toolAttributes = options?.toolAttributes;
31
- const blockIds = options?.blockIds;
32
- const transformUrl = options?.transformUrl;
33
- const inlineRenderers = options?.inlineRenderers;
34
- const pageHref = options?.pageHref;
35
- const pageInfo = options?.pageInfo;
36
- const classes = options?.classes;
37
- const direction = options?.direction;
38
-
39
- return useMemo(() => {
40
- return createElement(
41
- Fragment,
42
- null,
43
- ...viewNodesToReact(
44
- blocksToViewNodes(data, {
45
- schema,
46
- renderers,
47
- onUnknownBlock,
48
- toolAttributes,
49
- blockIds,
50
- transformUrl,
51
- inlineRenderers,
52
- pageHref,
53
- pageInfo,
54
- /**
55
- * Opt-in, NOT defaulted on. Parity rendering wraps every block in the
56
- * core's holder → content scaffolding, which would contradict this
57
- * hook's contract of emitting no wrapper element — the reason it
58
- * exists is placement inside `<label>`s, table cells and other slots
59
- * where extra elements are invalid or unwanted.
60
- *
61
- * `<BlokView>` passes `classes: true` for its own output, since it
62
- * already owns a wrapper and its job IS to look like the editor.
63
- */
64
- classes,
65
- direction,
66
- })
67
- )
68
- );
69
- }, [data, schema, renderers, onUnknownBlock, toolAttributes, blockIds, transformUrl, inlineRenderers, pageHref, pageInfo, classes, direction]);
70
- };
@@ -1,153 +0,0 @@
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
- * React names SVG attributes in camelCase (`stroke-width` → `strokeWidth`) and
53
- * warns on the raw form. Only inside `<svg>`: elsewhere a hyphenated name (a
54
- * custom element's attribute) must reach the DOM as written.
55
- * @param name - SVG attribute name
56
- */
57
- const svgPropName = (name: string): string => (
58
- name.startsWith('aria-') || name.startsWith('data-')
59
- ? name
60
- : name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
61
- );
62
-
63
- /**
64
- * Convert a sanitized `style` attribute string to a React style object
65
- * (React only accepts object styles). Custom properties keep their raw name;
66
- * everything else is camelCased.
67
- * @param css - style attribute text
68
- */
69
- const styleStringToObject = (css: string): CSSProperties => {
70
- const style: Record<string, string> = {};
71
-
72
- for (const declaration of css.split(';')) {
73
- const separator = declaration.indexOf(':');
74
-
75
- if (separator === -1) {
76
- continue;
77
- }
78
-
79
- const property = declaration.slice(0, separator).trim();
80
- const value = declaration.slice(separator + 1).trim();
81
-
82
- if (property === '' || value === '') {
83
- continue;
84
- }
85
-
86
- const key = property.startsWith('--')
87
- ? property
88
- : property.toLowerCase().replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
89
-
90
- style[key] = value;
91
- }
92
-
93
- return style;
94
- };
95
-
96
- /**
97
- * Map one ViewNode attribute record to React props.
98
- * @param attrs - sanitized attributes from the view tree
99
- * @param key - sibling index used as the React key
100
- * @param inSvg - whether the element sits in an `<svg>` subtree
101
- */
102
- const attrsToProps = (attrs: Record<string, string>, key: number, inSvg: boolean): Record<string, unknown> => {
103
- const props: Record<string, unknown> = { key };
104
-
105
- for (const [name, value] of Object.entries(attrs)) {
106
- if (name === 'style') {
107
- props.style = styleStringToObject(value);
108
- continue;
109
- }
110
-
111
- if (name === 'checked') {
112
- props.defaultChecked = true;
113
- continue;
114
- }
115
-
116
- if (name === 'value') {
117
- props.defaultValue = value;
118
- continue;
119
- }
120
-
121
- if (BOOLEAN_ATTRIBUTES.has(name)) {
122
- props[ATTRIBUTE_NAME_MAP[name] ?? name] = true;
123
- continue;
124
- }
125
-
126
- props[ATTRIBUTE_NAME_MAP[name] ?? (inSvg ? svgPropName(name) : name)] = value;
127
- }
128
-
129
- return props;
130
- };
131
-
132
- /**
133
- * Map a list of ViewNodes to React nodes. Element keys come from the sibling
134
- * index (the tree is static per render).
135
- * @param nodes - view tree siblings
136
- * @param inSvg - whether the siblings sit in an `<svg>` subtree
137
- */
138
- export const viewNodesToReact = (nodes: ViewNode[], inSvg = false): ReactNode[] => {
139
- return nodes.map((node, index): ReactNode => {
140
- if ('text' in node) {
141
- return node.text;
142
- }
143
-
144
- const svg = inSvg || node.tag === 'svg';
145
- const props = attrsToProps(node.attrs, index, svg);
146
-
147
- if (VOID_ELEMENTS.has(node.tag) || node.children.length === 0) {
148
- return createElement(node.tag, props);
149
- }
150
-
151
- return createElement(node.tag, props, ...viewNodesToReact(node.children, svg));
152
- });
153
- };