@bloklabs/react 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * ViewNode → React element mapping for the synchronous view bindings.
3
+ * Real elements via `createElement` recursion — never `dangerouslySetInnerHTML`.
4
+ */
5
+ import { createElement } from 'react';
6
+ import type { CSSProperties, ReactNode } from 'react';
7
+ import type { ViewNode } from '@bloklabs/core/view';
8
+
9
+ /** HTML void elements — must never receive children. */
10
+ const VOID_ELEMENTS = new Set([
11
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
12
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
13
+ ]);
14
+
15
+ /**
16
+ * Presence-valued HTML attributes mapped to boolean React props. `checked`
17
+ * is handled separately (→ `defaultChecked`, so React does not treat the
18
+ * input as controlled).
19
+ */
20
+ const BOOLEAN_ATTRIBUTES = new Set([
21
+ 'disabled', 'open', 'controls', 'autoplay', 'loop', 'muted', 'required',
22
+ 'multiple', 'playsinline', 'reversed', 'hidden', 'default', 'novalidate',
23
+ 'allowfullscreen', 'itemscope',
24
+ ]);
25
+
26
+ /** HTML attribute name → React prop name (beyond the identity mapping). */
27
+ const ATTRIBUTE_NAME_MAP: Record<string, string> = {
28
+ class: 'className',
29
+ for: 'htmlFor',
30
+ colspan: 'colSpan',
31
+ rowspan: 'rowSpan',
32
+ tabindex: 'tabIndex',
33
+ readonly: 'readOnly',
34
+ srcset: 'srcSet',
35
+ srclang: 'srcLang',
36
+ datetime: 'dateTime',
37
+ autoplay: 'autoPlay',
38
+ playsinline: 'playsInline',
39
+ novalidate: 'noValidate',
40
+ allowfullscreen: 'allowFullScreen',
41
+ crossorigin: 'crossOrigin',
42
+ referrerpolicy: 'referrerPolicy',
43
+ contenteditable: 'contentEditable',
44
+ spellcheck: 'spellCheck',
45
+ maxlength: 'maxLength',
46
+ minlength: 'minLength',
47
+ usemap: 'useMap',
48
+ itemscope: 'itemScope',
49
+ };
50
+
51
+ /**
52
+ * Convert a sanitized `style` attribute string to a React style object
53
+ * (React only accepts object styles). Custom properties keep their raw name;
54
+ * everything else is camelCased.
55
+ * @param css - style attribute text
56
+ */
57
+ const styleStringToObject = (css: string): CSSProperties => {
58
+ const style: Record<string, string> = {};
59
+
60
+ for (const declaration of css.split(';')) {
61
+ const separator = declaration.indexOf(':');
62
+
63
+ if (separator === -1) {
64
+ continue;
65
+ }
66
+
67
+ const property = declaration.slice(0, separator).trim();
68
+ const value = declaration.slice(separator + 1).trim();
69
+
70
+ if (property === '' || value === '') {
71
+ continue;
72
+ }
73
+
74
+ const key = property.startsWith('--')
75
+ ? property
76
+ : property.toLowerCase().replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
77
+
78
+ style[key] = value;
79
+ }
80
+
81
+ return style;
82
+ };
83
+
84
+ /**
85
+ * Map one ViewNode attribute record to React props.
86
+ * @param attrs - sanitized attributes from the view tree
87
+ * @param key - sibling index used as the React key
88
+ */
89
+ const attrsToProps = (attrs: Record<string, string>, key: number): Record<string, unknown> => {
90
+ const props: Record<string, unknown> = { key };
91
+
92
+ for (const [name, value] of Object.entries(attrs)) {
93
+ if (name === 'style') {
94
+ props.style = styleStringToObject(value);
95
+ continue;
96
+ }
97
+
98
+ if (name === 'checked') {
99
+ props.defaultChecked = true;
100
+ continue;
101
+ }
102
+
103
+ if (name === 'value') {
104
+ props.defaultValue = value;
105
+ continue;
106
+ }
107
+
108
+ if (BOOLEAN_ATTRIBUTES.has(name)) {
109
+ props[ATTRIBUTE_NAME_MAP[name] ?? name] = true;
110
+ continue;
111
+ }
112
+
113
+ props[ATTRIBUTE_NAME_MAP[name] ?? name] = value;
114
+ }
115
+
116
+ return props;
117
+ };
118
+
119
+ /**
120
+ * Map a list of ViewNodes to React nodes. Element keys come from the sibling
121
+ * index (the tree is static per render).
122
+ * @param nodes - view tree siblings
123
+ */
124
+ export const viewNodesToReact = (nodes: ViewNode[]): ReactNode[] => {
125
+ return nodes.map((node, index): ReactNode => {
126
+ if ('text' in node) {
127
+ return node.text;
128
+ }
129
+
130
+ const props = attrsToProps(node.attrs, index);
131
+
132
+ if (VOID_ELEMENTS.has(node.tag) || node.children.length === 0) {
133
+ return createElement(node.tag, props);
134
+ }
135
+
136
+ return createElement(node.tag, props, ...viewNodesToReact(node.children));
137
+ });
138
+ };
package/types/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- import type { BlokConfig, BlockToolData, OutputBlockData, OutputData } from '@bloklabs/core';
1
+ import type { BlokConfig, BlockToolData, OutputBlockData, OutputData, LooseOutputData } from '@bloklabs/core';
2
+ import type { BlocksToHtmlOptions } from '@bloklabs/core/view';
2
3
  import type { BlockAPI, BlockToolConstructable, ToolboxConfigEntry } from '@bloklabs/core';
3
4
  import type { InlineToolConstructable, SanitizerConfig } from '@bloklabs/core';
5
+ import type { API, MarkSnapshot, MarkSpec } from '@bloklabs/core';
4
6
  import type { Blok, EditorWidth, BlockRenderedPayload, BlocksRenderedPayload } from '@bloklabs/core';
5
7
  import type { BlockTuneData } from '@bloklabs/core';
6
8
  import type { MarkdownImportConfig } from '@bloklabs/core/markdown';
@@ -11,11 +13,21 @@ import type React from 'react';
11
13
  * Accepts all BlokConfig properties except `holder`, plus a React-only `width` prop.
12
14
  *
13
15
  * Reactive props (sync after mount without recreation):
14
- * - `readOnly` — calls `editor.readOnly.set(value)`
16
+ * - `readOnly` — calls `editor.readOnly.set(value)`; the object form also
17
+ * syncs `hideControls` via `editor.readOnly.set(enabled, { hideControls })`
18
+ * - `hideToolbar` — calls `editor.toolbar.setHidden(value)`
19
+ * - `inlineToolbar` — calls `editor.tools.setInlineToolbar(value)` (arrays
20
+ * are compared by content, so a re-created literal is a no-op)
15
21
  * - `autofocus` — calls `editor.focus()` when changed to true
16
22
  * - `theme` — calls `editor.theme.set(value)`
17
23
  * - `width` — calls `editor.width.set(value)`
18
24
  * - `placeholder` — calls `editor.placeholder.set(value)`
25
+ * - `style.tokens` — calls `editor.tokens.set(value)` (deep-equal–deduped;
26
+ * replace semantics, so pass the complete token set on each change)
27
+ * - `i18n` — calls `editor.i18n.update({ locale, messages, direction })`
28
+ * (deep-equal–deduped; relabels the editor chrome in place, so caret, focus
29
+ * and undo history survive a language switch). `defaultLocale` is not
30
+ * forwarded — it only affects the initial locale resolution.
19
31
  * - `data` — re-renders via `editor.render(value)` when the content changes
20
32
  * (deep-equal–deduped and serialized; seeds the initial content at creation)
21
33
  *
@@ -541,6 +553,43 @@ export interface UseBlocksApi {
541
553
  */
542
554
  export declare function useBlocks(editor: Blok | null): UseBlocksApi;
543
555
 
556
+ // ---------------------------------------------------------------------------
557
+ // Scoped readiness (useBlokReady)
558
+ // ---------------------------------------------------------------------------
559
+
560
+ /** Options accepted by {@link useBlokReady}. */
561
+ export interface UseBlokReadyOptions {
562
+ /**
563
+ * Restrict the wait to editors mounted inside this element. Accepts the ref
564
+ * you already hold on the container; it is re-read on every readiness
565
+ * change, so a ref that attaches after the first render is picked up.
566
+ *
567
+ * Omit it to observe every editor on the page.
568
+ */
569
+ within?: React.RefObject<Element | null> | Element | null;
570
+ /**
571
+ * `'ready'` (default) settles when each editor has finished booting.
572
+ * `'rendered'` also waits for its content to be in the DOM and re-arms on
573
+ * every post-boot re-render.
574
+ */
575
+ settleOn?: 'ready' | 'rendered';
576
+ }
577
+
578
+ /**
579
+ * Live readiness of the Blok editors in a DOM scope.
580
+ *
581
+ * Returns `false` until every editor inside `within` is settled, and keeps
582
+ * following the scope afterwards: editors mounted later re-close the gate, and
583
+ * with `settleOn: 'rendered'` so does each re-render. A scope holding no
584
+ * editors is ready, so callers need no "nothing to wait for" special case.
585
+ *
586
+ * The first render always reports `false` — the scope element is not attached
587
+ * yet, and over-waiting is safe while under-waiting is a bug.
588
+ *
589
+ * @param options - scope and readiness depth
590
+ */
591
+ export declare function useBlokReady(options?: UseBlokReadyOptions): boolean;
592
+
544
593
  // ---------------------------------------------------------------------------
545
594
  // Block authoring (createReactBlock + portal host)
546
595
  // ---------------------------------------------------------------------------
@@ -675,8 +724,47 @@ export interface ReactInlineToolRenderProps<Config = Record<string, unknown>> {
675
724
  config: Readonly<Partial<Config>>;
676
725
  }
677
726
 
727
+ /** The spec's mark operations, bound to the live selection via `api.marks`. */
728
+ export interface InlineToolMarkOps<State = void> {
729
+ /** Whether the whole selection currently carries the mark. */
730
+ has(): boolean;
731
+ /** Toggle the mark on the selection; returns true when now applied. */
732
+ toggle(state?: State): boolean;
733
+ /** Apply (or update in place) the mark on the selection. */
734
+ apply(state?: State): HTMLElement[];
735
+ /** Remove the mark from the selection. */
736
+ remove(): HTMLElement[];
737
+ /** Read the mark's current declared values at the selection. */
738
+ read(): MarkSnapshot | null;
739
+ }
740
+
741
+ /**
742
+ * Everything a React inline tool's component (and any component nested in it —
743
+ * swatches, popovers) can reach via {@link useInlineTool} without prop-drilling.
744
+ */
745
+ export interface InlineToolHandle<Config = Record<string, unknown>, State = void> {
746
+ /** Live active state, same value as the component's `active` prop. */
747
+ active: boolean;
748
+ /** The tool's config, same value as the component's `config` prop. */
749
+ config: Readonly<Partial<Config>>;
750
+ /** The editor API handed to the tool's constructor (undefined outside core). */
751
+ api: API | undefined;
752
+ /**
753
+ * Operations bound to the spec's `mark` — null when the spec declares no
754
+ * mark or the editor api provides no `marks` surface.
755
+ */
756
+ mark: InlineToolMarkOps<State> | null;
757
+ }
758
+
759
+ /**
760
+ * Reach the enclosing React inline tool from any component inside its
761
+ * portaled UI: live `active` state, the tool `config`, the editor `api`, and
762
+ * the spec's mark operations bound to the live selection.
763
+ */
764
+ export declare function useInlineTool<Config = Record<string, unknown>, State = void>(): InlineToolHandle<Config, State>;
765
+
678
766
  /** Spec for {@link createReactInlineTool}. */
679
- export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
767
+ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>, State = void> {
680
768
  /** Tool type name — the MenuConfig item name (fallback when the tools-map key is unavailable). */
681
769
  type: string;
682
770
  /** Optional toolbar item title (shown in overflow/search contexts). */
@@ -690,13 +778,32 @@ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
690
778
  titleKey?: string;
691
779
  /** The component rendered as the tool's toolbar icon/UI. */
692
780
  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;
781
+ /**
782
+ * Declarative description of the wrapper this tool produces. When present,
783
+ * `surround` (toggle), `checkState` (whole-range coverage) and `sanitize`
784
+ * are derived from it — range-aware splitting, adjacent-run semantics and
785
+ * the trailing-whitespace fix come from the editor's mark engine
786
+ * (`api.marks`) with no DOM plumbing in the tool.
787
+ */
788
+ mark?: MarkSpec<State>;
789
+ /**
790
+ * Applies/removes the formatting on the LIVE selection's range (captured at
791
+ * activation time). Receives the editor API as its second argument.
792
+ * Takes precedence over the `mark` derivation.
793
+ */
794
+ surround?: (range: Range, api: API | undefined) => void;
795
+ /**
796
+ * Reports whether the formatting is active for the given selection.
797
+ * Receives the editor API as its second argument.
798
+ * Takes precedence over the `mark` derivation.
799
+ */
800
+ checkState?: (selection: Selection | null, api: API | undefined) => boolean;
697
801
  /** Keyboard shortcut (e.g. `CMD+SHIFT+C`). */
698
802
  shortcut?: string;
699
- /** Inline sanitizer config declaring the markup this tool produces. */
803
+ /**
804
+ * Inline sanitizer config declaring the markup this tool produces.
805
+ * Defaults to the config derived from `mark` when one is declared.
806
+ */
700
807
  sanitize?: SanitizerConfig;
701
808
  /** Whether the tool stays available in read-only mode. */
702
809
  isReadOnlySupported?: boolean;
@@ -718,13 +825,12 @@ export interface CreateReactInlineToolSpec<Config = Record<string, unknown>> {
718
825
  * type: 'descriptionColor',
719
826
  * title: 'Text color',
720
827
  * component: ({ active }) => <FontColorIcon active={active} />,
721
- * surround: (range) => applyColor(range),
722
- * checkState: (selection) => hasColor(selection),
828
+ * mark: { tag: 'span', className: 'hl-description' },
723
829
  * });
724
830
  * ```
725
831
  */
726
- export declare function createReactInlineTool<Config = Record<string, unknown>>(
727
- spec: CreateReactInlineToolSpec<Config>
832
+ export declare function createReactInlineTool<Config = Record<string, unknown>, State = void>(
833
+ spec: CreateReactInlineToolSpec<Config, State>
728
834
  ): InlineToolConstructable;
729
835
 
730
836
  /**
@@ -783,3 +889,49 @@ export interface BlockPortalHostProps {
783
889
  * for advanced setups that manage the registry themselves.
784
890
  */
785
891
  export declare function BlockPortalHost(props: BlockPortalHostProps): React.ReactElement;
892
+
893
+ /**
894
+ * Props for {@link BlokView}.
895
+ *
896
+ * @experimental Built on the `@experimental` view tree of
897
+ * `@bloklabs/core/view` — not frozen until a second framework adapter
898
+ * consumes it.
899
+ */
900
+ export interface BlokViewProps {
901
+ /** Saved document to display (strict or loose wire shape; nullish tolerated). */
902
+ data: OutputData | LooseOutputData | null | undefined;
903
+ /** View schema from `defineBlokSchema` — display under the composition that produced the document. */
904
+ schema?: BlocksToHtmlOptions['schema'];
905
+ /** Custom per-tool renderers; win over built-ins. */
906
+ renderers?: BlocksToHtmlOptions['renderers'];
907
+ /** Unknown-tool policy (default 'skip'). */
908
+ onUnknownBlock?: BlocksToHtmlOptions['onUnknownBlock'];
909
+ /** Class for the single wrapper div. */
910
+ className?: string;
911
+ }
912
+
913
+ /**
914
+ * Display a saved Blok document synchronously — one `<div>` wrapper, no
915
+ * editor chrome, no ids, no async, no effects. Content is mapped from the
916
+ * sanitized view tree to real React elements, so it renders identically
917
+ * under SSR.
918
+ *
919
+ * @experimental Not frozen until a second framework adapter consumes the
920
+ * underlying view tree.
921
+ */
922
+ export declare function BlokView(props: BlokViewProps): React.ReactNode;
923
+
924
+ /**
925
+ * Render a saved Blok document to React elements with NO wrapper element —
926
+ * a Fragment of the block elements, for placement inside labels, table
927
+ * cells, and other slots where an extra `<div>` is invalid or unwanted.
928
+ * Synchronous, effect-free (SSR-safe), memoized on the data reference and
929
+ * the individual option values.
930
+ *
931
+ * @experimental Not frozen until a second framework adapter consumes the
932
+ * underlying view tree.
933
+ */
934
+ export declare function useBlokView(
935
+ data: OutputData | LooseOutputData | null | undefined,
936
+ options?: BlocksToHtmlOptions
937
+ ): React.ReactNode;