@bloklabs/react 1.9.0 → 1.10.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bloklabs/react",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
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",
@@ -54,7 +54,7 @@
54
54
  "test": "yarn run -T vitest run"
55
55
  },
56
56
  "peerDependencies": {
57
- "@bloklabs/core": "^1.9.0",
57
+ "@bloklabs/core": "^1.10.1",
58
58
  "react": "^18.0.0 || ^19.0.0",
59
59
  "react-dom": "^18.0.0 || ^19.0.0"
60
60
  }
@@ -2,7 +2,9 @@ import { isValidElement, useEffect, useRef, type ComponentType, type ReactElemen
2
2
  import { flushSync } from 'react-dom';
3
3
  import { createRoot } from 'react-dom/client';
4
4
  import {
5
+ applyChildDecoration,
5
6
  BlockChildrenMounted,
7
+ createChildDecorationLedger,
6
8
  DATA_ATTR,
7
9
  deepEqual,
8
10
  fillDefaults,
@@ -47,55 +49,6 @@ const INTERNAL_CONFIG_KEYS: readonly string[] = [
47
49
  */
48
50
  export type BlockToolStatics = Omit<BlockToolConstructable, 'toolbox' | 'isReadOnlySupported'>;
49
51
 
50
- /** What the previous decoration pass wrote for one child, so it can be undone. */
51
- interface StampedChild {
52
- holder: HTMLElement;
53
- names: string[];
54
- }
55
-
56
- /**
57
- * Apply one pass of per-child holder attributes and clear the previous pass's
58
- * leftovers — including on a child that has since left the container, which
59
- * would otherwise keep a dead index forever.
60
- * @param stamped - the slot's ledger of what the last pass wrote (mutated)
61
- * @param children - the container's model children, in model order
62
- * @param decorate - the authored per-child decorator, if any
63
- */
64
- const applyChildAttributes = (
65
- stamped: Map<string, StampedChild>,
66
- children: BlockAPI[],
67
- decorate: BlockChildrenProps['childAttributes']
68
- ): void => {
69
- const next = new Map<string, StampedChild>();
70
-
71
- children.forEach((child, index) => {
72
- const names: string[] = [];
73
-
74
- for (const [name, value] of Object.entries(decorate?.(child, index) ?? {})) {
75
- if (value === null || value === undefined) {
76
- child.holder.removeAttribute(name);
77
- continue;
78
- }
79
-
80
- child.holder.setAttribute(name, String(value));
81
- names.push(name);
82
- }
83
-
84
- next.set(child.id, { holder: child.holder, names });
85
- });
86
-
87
- for (const [id, previous] of stamped) {
88
- const current = next.get(id);
89
-
90
- previous.names
91
- .filter(name => current === undefined || !current.names.includes(name))
92
- .forEach(name => previous.holder.removeAttribute(name));
93
- }
94
-
95
- stamped.clear();
96
- next.forEach((entry, id) => stamped.set(id, entry));
97
- };
98
-
99
52
  /**
100
53
  * Announce that a container's child holders have settled in its slot — the
101
54
  * signal a host waits on before putting the caret into a freshly inserted
@@ -187,6 +140,27 @@ export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
187
140
  config: Readonly<Partial<Config>>;
188
141
  /** Engine-owned child slot — render `<BlockChildren />` for a container block. */
189
142
  BlockChildren: ComponentType<BlockChildrenProps>;
143
+ /**
144
+ * Attach this to the element the +/drag toolbar should vertically center on:
145
+ * `<div ref={toolbarAnchorRef}>`. The React-native way to answer core's
146
+ * `getToolbarAnchorElement`, which is otherwise a `(host, block) => Element`
147
+ * hook resolved OUTSIDE the component tree — so pointing at an element you
148
+ * render meant inventing a data attribute and `querySelector`-ing for it from
149
+ * {@link CreateReactBlockSpec.getToolbarAnchorElement}.
150
+ *
151
+ * A container block whose own chrome is not editable needs an anchor: with
152
+ * none, core centers the toolbar on the first `[contenteditable]` under the
153
+ * host, which for a container is its FIRST CHILD BLOCK — parking the +/drag
154
+ * handles halfway down, beside content that has a toolbar of its own.
155
+ *
156
+ * The ref is stable across renders and outranks
157
+ * {@link CreateReactBlockSpec.getToolbarAnchorElement} while it holds a
158
+ * MOUNTED element; when the element unmounts (React calls the ref with null,
159
+ * or it is detached) the declared hook takes over again, so the toolbar is
160
+ * never positioned against a stale node. Leave it unattached to keep core's
161
+ * default.
162
+ */
163
+ toolbarAnchorRef: (element: HTMLElement | null) => void;
190
164
  }
191
165
 
192
166
  /**
@@ -222,6 +196,28 @@ export interface BlockChildrenProps {
222
196
  * @param index - the child's position in the container's model children
223
197
  */
224
198
  childAttributes?: (child: BlockAPI, index: number) => ChildAttributes;
199
+ /**
200
+ * The same per-child decoration, applied one level IN: on each child's
201
+ * `[data-blok-element-content]` wrapper instead of its holder. Core's
202
+ * child-holder decoration law blesses both, and a container needs both — the
203
+ * holder is the child's outer box (rails, indices, hover states), while the
204
+ * content wrapper is where the child's own text box begins, which is what a
205
+ * numbered rail or a connector line has to align to.
206
+ *
207
+ * Reach for it instead of walking core's wrapper chain from a holder hook
208
+ * (`[data-step] > [data-blok-element-content] > …`): those selectors encode
209
+ * engine DOM structure in host CSS and break the moment that structure changes.
210
+ * With a named hook the selector is one flat attribute.
211
+ *
212
+ * Same cleanup and same inertness as {@link BlockChildrenProps.childAttributes}
213
+ * — attributes the callback stops producing are removed on the next pass, and
214
+ * writes here are still not the child's edit. A child whose DOM has not
215
+ * committed yet (a portal-rendered block, on the first pass) has no wrapper to
216
+ * write to and is skipped, then stamped on the next pass.
217
+ * @param child - the child block's API
218
+ * @param index - the child's position in the container's model children
219
+ */
220
+ childContentAttributes?: (child: BlockAPI, index: number) => ChildAttributes;
225
221
  }
226
222
 
227
223
  /**
@@ -694,6 +690,17 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
694
690
  private hostEl: HTMLElement | null = null;
695
691
  /** True while a deferred dispatch is waiting for a pointer drag to end. */
696
692
  private pendingDispatch = false;
693
+ /** Element the component pointed `toolbarAnchorRef` at, if any. */
694
+ private anchorEl: HTMLElement | null = null;
695
+ /**
696
+ * The `toolbarAnchorRef` prop. A bound field so its identity is STABLE
697
+ * across renders — a fresh callback ref each render would make React detach
698
+ * (null) and re-attach it on every commit.
699
+ * @param element - the element React attached, or null on detach
700
+ */
701
+ private readonly toolbarAnchorRef = (element: HTMLElement | null): void => {
702
+ this.anchorEl = element;
703
+ };
697
704
 
698
705
  public constructor(options: BlockToolConstructorOptions) {
699
706
  this.blockApi = options.block;
@@ -736,10 +743,11 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
736
743
  // appends the real child holders imperatively (React never reconciles them).
737
744
  this.childrenComponent = function BlockChildren({
738
745
  childAttributes,
746
+ childContentAttributes,
739
747
  }: BlockChildrenProps): ReactElement {
740
748
  const slotRef = useRef<HTMLDivElement | null>(null);
741
749
  // What the last decoration pass wrote, so a dropped key is cleaned up.
742
- const stampedRef = useRef(new Map<string, StampedChild>());
750
+ const ledgerRef = useRef(createChildDecorationLedger());
743
751
 
744
752
  // No dep array: re-adopt children after EVERY render (mirrors the Vue
745
753
  // adapter's onMounted + onUpdated pair).
@@ -751,7 +759,8 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
751
759
  const children = blockApi.getChildren();
752
760
 
753
761
  mountChildBlocks(slotRef.current, children);
754
- applyChildAttributes(stampedRef.current, children, childAttributes);
762
+ applyChildDecoration(ledgerRef.current, children, { childAttributes,
763
+ childContentAttributes });
755
764
  emitChildrenMounted(api, blockApi.id, children);
756
765
  });
757
766
 
@@ -879,6 +888,19 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
879
888
  * default positioning already assumes.
880
889
  */
881
890
  public getToolbarAnchorElement(): HTMLElement | undefined {
891
+ /**
892
+ * A ref the component attached wins — it names the exact element, chosen
893
+ * from inside the tree. `isConnected` is the guard that matters: React
894
+ * nulls the ref on unmount, but a component that re-parents its anchor can
895
+ * leave a detached node here, and positioning the toolbar against one
896
+ * silently parks it at 0,0.
897
+ */
898
+ const fromRef = this.anchorEl;
899
+
900
+ if (fromRef !== null && fromRef.isConnected) {
901
+ return fromRef;
902
+ }
903
+
882
904
  const host = this.hostEl;
883
905
 
884
906
  if (host === null || spec.getToolbarAnchorElement === undefined) {
@@ -916,6 +938,7 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
916
938
  readOnly: this.readOnly,
917
939
  config: this.toolConfig,
918
940
  BlockChildren: this.childrenComponent,
941
+ toolbarAnchorRef: this.toolbarAnchorRef,
919
942
  };
920
943
 
921
944
  return props as unknown as Record<string, unknown>;
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export { useBlokInstance } from './blok-instance-context';
12
12
  export type { UseBlokConfig, BlokContentProps } from './types';
13
13
  export type { BlokEditorProps } from './BlokEditor';
14
14
  export { useBlocks } from './useBlocks';
15
+ export type { UseBlocksOptions } from './useBlocks';
15
16
  export { useBlokReady } from './useBlokReady';
16
17
  export type { UseBlokReadyOptions } from './useBlokReady';
17
18
  export { BlokView } from './BlokView';
package/src/useBlocks.ts CHANGED
@@ -2,12 +2,32 @@
2
2
  import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
3
3
 
4
4
  import type { Blok } from '@/types';
5
- import { createBlocksApiForEditor, EMPTY_API } from '@bloklabs/core/adapters';
5
+ import { changeTouchesSubtree, createBlocksApiForEditor, EMPTY_API } from '@bloklabs/core/adapters';
6
6
 
7
7
  import type { UseBlocksApi } from './blocks-snapshot';
8
8
 
9
9
  const BLOCK_CHANGED_EVENT = 'block changed';
10
10
 
11
+ /** Options for {@link useBlocks}. */
12
+ export interface UseBlocksOptions {
13
+ /**
14
+ * Re-render only for changes inside the subtree rooted at this block id (the
15
+ * block itself or any descendant). Omit — or pass `null` — for the
16
+ * document-wide default.
17
+ *
18
+ * This bounds REACTIVITY, not reads: the returned API still sees the whole
19
+ * tree, so a scoped consumer can still `getById` anything. Reach for it in a
20
+ * container block that renders only its own children — unscoped, such a block
21
+ * re-renders on every keystroke anywhere in the document, and a page of N
22
+ * containers turns one keystroke into N re-renders.
23
+ *
24
+ * A change whose block cannot be placed in the tree (a removal that emits
25
+ * after the block is gone) counts as in-scope: skipping it would leave a
26
+ * container rendering a child that no longer exists.
27
+ */
28
+ within?: string | null;
29
+ }
30
+
11
31
  /**
12
32
  * React hook exposing an id/parentId-relative, reactive view of the block tree.
13
33
  * Re-renders whenever the editor emits 'block changed' — including programmatic
@@ -42,8 +62,12 @@ const BLOCK_CHANGED_EVENT = 'block changed';
42
62
  * result (or the api handle) in a `useMemo`/`useEffect` dependency array
43
63
  * expecting it to change identity per mutation; depend on the node `id`s instead.
44
64
  * @param editor - the Blok instance from useBlok, or null before it is ready
65
+ * @param options - reactivity options; see {@link UseBlocksOptions.within} to
66
+ * scope re-renders to one block's subtree
45
67
  */
46
- export function useBlocks(editor: Blok | null): UseBlocksApi {
68
+ export function useBlocks(editor: Blok | null, options: UseBlocksOptions = {}): UseBlocksApi {
69
+ const within = options.within ?? null;
70
+
47
71
  // Monotonic version bumped on every 'block changed'; drives useSyncExternalStore.
48
72
  const versionRef = useRef(0);
49
73
 
@@ -65,7 +89,11 @@ export function useBlocks(editor: Blok | null): UseBlocksApi {
65
89
  return () => undefined;
66
90
  }
67
91
 
68
- const handler = (): void => {
92
+ const handler = (payload?: unknown): void => {
93
+ if (within !== null && !changeTouchesSubtree(editor, payload, within)) {
94
+ return;
95
+ }
96
+
69
97
  versionRef.current += 1;
70
98
  onStoreChange();
71
99
  };
@@ -74,7 +102,7 @@ export function useBlocks(editor: Blok | null): UseBlocksApi {
74
102
 
75
103
  return () => editor.off(BLOCK_CHANGED_EVENT, handler);
76
104
  },
77
- [editor]
105
+ [editor, within]
78
106
  );
79
107
 
80
108
  const getSnapshot = useCallback((): number => versionRef.current, []);
package/types/index.d.ts CHANGED
@@ -631,8 +631,33 @@ export interface UseBlocksApi {
631
631
  * expecting it to change identity per mutation; depend on the node `id`s instead.
632
632
  *
633
633
  * @param editor - the Blok instance from useBlok, or null before it is ready
634
+ * @param options - reactivity options; see {@link UseBlocksOptions.within}
634
635
  */
635
- export declare function useBlocks(editor: Blok | null): UseBlocksApi;
636
+ export declare function useBlocks(editor: Blok | null, options?: UseBlocksOptions): UseBlocksApi;
637
+
638
+ /** Options for {@link useBlocks}. */
639
+ export interface UseBlocksOptions {
640
+ /**
641
+ * Re-render only for changes inside the subtree rooted at this block id (the
642
+ * block itself or any descendant). Omit — or pass `null` — for the
643
+ * document-wide default.
644
+ *
645
+ * This bounds REACTIVITY, not reads: the returned API still sees the whole
646
+ * tree, so a scoped consumer can still `getById` anything. Reach for it in a
647
+ * container block that renders only its own children — unscoped, such a block
648
+ * re-renders on every keystroke anywhere in the document, and a page of N
649
+ * containers turns one keystroke into N re-renders.
650
+ *
651
+ * A change whose block cannot be placed in the tree (a removal that emits
652
+ * after the block is gone) counts as in-scope: skipping it would leave a
653
+ * container rendering a child that no longer exists.
654
+ *
655
+ * @example
656
+ * // A container block re-rendering only for its own subtree.
657
+ * const blocks = useBlocks(useBlokInstance(), { within: block.id });
658
+ */
659
+ within?: string | null;
660
+ }
636
661
 
637
662
  // ---------------------------------------------------------------------------
638
663
  // Scoped readiness (useBlokReady)
@@ -723,6 +748,25 @@ export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
723
748
  config: Readonly<Partial<Config>>;
724
749
  /** Engine-owned child slot — render `<BlockChildren />` for a container block. */
725
750
  BlockChildren: React.ComponentType<BlockChildrenProps>;
751
+ /**
752
+ * Attach this to the element the +/drag toolbar should vertically center on:
753
+ * `<div ref={toolbarAnchorRef}>`. The React-native way to answer core's
754
+ * `getToolbarAnchorElement`, which is otherwise a `(host, block) => Element`
755
+ * hook resolved OUTSIDE the component tree — so pointing at an element you
756
+ * render meant inventing a data attribute and `querySelector`-ing for it.
757
+ *
758
+ * A container block whose own chrome is not editable needs an anchor: with
759
+ * none, core centers the toolbar on the first `[contenteditable]` under the
760
+ * host, which for a container is its FIRST CHILD BLOCK — parking the +/drag
761
+ * handles halfway down, beside content that has a toolbar of its own.
762
+ *
763
+ * The ref is stable across renders and outranks the spec's
764
+ * `getToolbarAnchorElement` while it holds a MOUNTED element; once the element
765
+ * unmounts or detaches, the declared hook takes over again, so the toolbar is
766
+ * never positioned against a stale node. Leave it unattached to keep core's
767
+ * default positioning.
768
+ */
769
+ toolbarAnchorRef: (element: HTMLElement | null) => void;
726
770
  }
727
771
 
728
772
  /**
@@ -748,6 +792,24 @@ export interface BlockChildrenProps {
748
792
  * child's tool root DOES score as that child's edit.
749
793
  */
750
794
  childAttributes?: (child: BlockAPI, index: number) => ChildAttributes;
795
+ /**
796
+ * The same per-child decoration, applied one level IN: on each child's
797
+ * `[data-blok-element-content]` wrapper instead of its holder. Core's
798
+ * child-holder decoration law blesses both, and a container needs both — the
799
+ * holder is the child's outer box (rails, indices, hover states), while the
800
+ * content wrapper is where the child's own text box begins, which is what a
801
+ * numbered rail or a connector line has to align to.
802
+ *
803
+ * Reach for it instead of walking Blok's wrapper chain from a holder hook
804
+ * (`[data-step] > [data-blok-element-content] > …`): those selectors encode
805
+ * engine DOM structure in host CSS and break the moment that structure changes.
806
+ * With a named hook the selector is one flat attribute.
807
+ *
808
+ * Same cleanup and same inertness as `childAttributes`. A child whose DOM has
809
+ * not committed yet (a portal-rendered block, on the first pass) has no wrapper
810
+ * to write to and is stamped on the next pass.
811
+ */
812
+ childContentAttributes?: (child: BlockAPI, index: number) => ChildAttributes;
751
813
  }
752
814
 
753
815
  /**