@bloklabs/react 1.15.2 → 1.16.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.15.2",
3
+ "version": "1.16.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.15.2",
57
+ "@bloklabs/core": "^1.16.1",
58
58
  "react": "^18.0.0 || ^19.0.0",
59
59
  "react-dom": "^18.0.0 || ^19.0.0"
60
60
  }
package/src/BlokView.tsx CHANGED
@@ -26,6 +26,10 @@ export interface BlokViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'chi
26
26
  transformUrl?: BlocksToHtmlOptions['transformUrl'];
27
27
  /** Custom renderers for inline elements, keyed by lowercase tag name (equations, mentions). */
28
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'];
29
33
  /**
30
34
  * Render with the editor's presentational classes and per-block scaffolding
31
35
  * so the output matches a read-only editor render (default `true`). Needs
@@ -33,6 +37,8 @@ export interface BlokViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'chi
33
37
  * semantic markup.
34
38
  */
35
39
  classes?: BlocksToHtmlOptions['classes'];
40
+ /** Document direction: set as `dir` on the wrapper, and each block follows its own text. */
41
+ direction?: BlocksToHtmlOptions['direction'];
36
42
  }
37
43
 
38
44
  /**
@@ -59,7 +65,10 @@ export const BlokView = ({
59
65
  blockIds,
60
66
  transformUrl,
61
67
  inlineRenderers,
68
+ pageHref,
69
+ pageInfo,
62
70
  classes,
71
+ direction,
63
72
  ...divProps
64
73
  }: BlokViewProps): ReactNode => {
65
74
  /**
@@ -77,7 +86,10 @@ export const BlokView = ({
77
86
  blockIds,
78
87
  transformUrl,
79
88
  inlineRenderers,
89
+ pageHref,
90
+ pageInfo,
80
91
  classes: classes ?? true,
92
+ direction,
81
93
  });
82
94
 
83
95
  /**
@@ -90,5 +102,5 @@ export const BlokView = ({
90
102
  *
91
103
  * Written BEFORE the `divProps` spread so a caller can still override it.
92
104
  */
93
- return <div data-blok-interface="view" {...divProps}>{content}</div>;
105
+ return <div data-blok-interface="view" dir={direction} {...divProps}>{content}</div>;
94
106
  };
@@ -23,6 +23,7 @@ export const USE_BLOK_CONFIG_KEYS = [
23
23
  'tools',
24
24
  'data',
25
25
  'minHeight',
26
+ 'loader',
26
27
  'logLevel',
27
28
  'readOnly',
28
29
  'i18n',
@@ -33,12 +34,14 @@ export const USE_BLOK_CONFIG_KEYS = [
33
34
  'onChange',
34
35
  'onSave',
35
36
  'onError',
37
+ 'onImageFailure',
36
38
  'onEnter',
37
39
  'onSubmit',
38
40
  'onBeforeRender',
39
41
  'onAfterRender',
40
42
  'inlineToolbar',
41
43
  'inlineEmoji',
44
+ 'find',
42
45
  'tunes',
43
46
  'style',
44
47
  'theme',
@@ -51,10 +54,13 @@ export const USE_BLOK_CONFIG_KEYS = [
51
54
  'width',
52
55
  'migrations',
53
56
  'uploader',
57
+ 'media',
54
58
  'server',
55
59
  'ticket',
56
60
  'persistence',
57
61
  'collaboration',
62
+ 'documentId',
63
+ 'tabSync',
58
64
  'captureClicksBelowEditor',
59
65
  ] as const satisfies readonly (keyof UseBlokConfig)[];
60
66
 
@@ -64,5 +70,3 @@ export const USE_BLOK_CONFIG_KEYS = [
64
70
  */
65
71
  type _MissingConfigKey = Exclude<keyof UseBlokConfig, (typeof USE_BLOK_CONFIG_KEYS)[number]>;
66
72
  const _exhaustive: _MissingConfigKey extends never ? true : never = true;
67
-
68
- void _exhaustive;
@@ -10,6 +10,7 @@ import {
10
10
  fillDefaults,
11
11
  mountChildBlocks,
12
12
  type PropSchema,
13
+ withSlotlessDescendants,
13
14
  } from '@bloklabs/core/adapters';
14
15
  import {
15
16
  BLOK_PORTAL_REGISTRY_CONFIG_KEY,
@@ -330,6 +331,15 @@ export interface CreateReactBlockSpec<Data = BlockToolData, Config = Record<stri
330
331
  * @param block - this block's per-block API
331
332
  */
332
333
  getToolbarAnchorElement?: (host: HTMLElement, block: BlockAPI) => HTMLElement | null | undefined;
334
+ /**
335
+ * Enter (or Cmd/Ctrl+Enter) on this block while it is the keyboard
336
+ * navigation target — core's `onNavigationEnter` hook. Return true when the
337
+ * block handled it (e.g. opened a link); omit it, or return false, to let
338
+ * core put the caret in the block.
339
+ * @param event - the Enter keydown
340
+ * @param block - this block's per-block API
341
+ */
342
+ onNavigationEnter?: (event: KeyboardEvent, block: BlockAPI) => boolean;
333
343
  /** Optional lifecycle callbacks mapped from Blok's block hooks. */
334
344
  onRendered?: (block: BlockAPI) => void;
335
345
  /**
@@ -412,6 +422,7 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
412
422
  setData(newData: BlockToolData): Promise<boolean>;
413
423
  setReadOnly(state: boolean): void;
414
424
  getToolbarAnchorElement(): HTMLElement | undefined;
425
+ onNavigationEnter(event: KeyboardEvent): boolean;
415
426
  rendered(): void;
416
427
  moved(): void;
417
428
  removed(): void;
@@ -758,7 +769,9 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
758
769
 
759
770
  const children = blockApi.getChildren();
760
771
 
761
- mountChildBlocks(slotRef.current, children);
772
+ // A slotless child's own children live in this slot too. Decoration and
773
+ // the mounted event stay per DIRECT child.
774
+ mountChildBlocks(slotRef.current, withSlotlessDescendants(children, id => api.blocks.getChildren(id)));
762
775
  applyChildDecoration(ledgerRef.current, children, { childAttributes,
763
776
  childContentAttributes });
764
777
  emitChildrenMounted(api, blockApi.id, children);
@@ -910,6 +923,14 @@ export function createReactBlock<Data = BlockToolData, Config = Record<string, u
910
923
  return spec.getToolbarAnchorElement(host, this.blockApi) ?? undefined;
911
924
  }
912
925
 
926
+ /**
927
+ * Core's navigation-mode Enter hook. Only a real `true` from the spec
928
+ * counts as handled.
929
+ */
930
+ public onNavigationEnter(event: KeyboardEvent): boolean {
931
+ return spec.onNavigationEnter?.(event, this.blockApi) === true;
932
+ }
933
+
913
934
  public moved(): void {
914
935
  // No remount: Blok relocates the host element; the portal follows it.
915
936
  spec.onMoved?.(this.blockApi);
package/src/useBlok.ts CHANGED
@@ -186,8 +186,11 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
186
186
  // surfaced anywhere.
187
187
  const constructedCollaborationRef = useRef(config.collaboration);
188
188
  const renderChainRef = useRef<Promise<void>>(Promise.resolve());
189
- // One collaboration warning per hook instance (see the data effect).
190
- const collaborationWarnedRef = useRef(false);
189
+ // Editor the single collaboration warning has already been emitted for (see
190
+ // the data effect). Keyed by the editor, not a boolean, so the editor a `deps`
191
+ // change creates warns on its own first ignored `data` change instead of
192
+ // inheriting the previous editor's "already warned" flag and going silent.
193
+ const collaborationWarnedForRef = useRef<Blok | null>(null);
191
194
  // The content of the render currently queued/in-flight (distinct from
192
195
  // `lastRenderedDataRef`, which tracks the last SUCCESSFULLY rendered content).
193
196
  // Used to dedupe a re-queue of the same in-flight content without advancing the
@@ -716,22 +719,26 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
716
719
  const appliedI18nRef = useRef<UseBlokConfig['i18n']>(config.i18n);
717
720
 
718
721
  useEffect(() => {
719
- if (editor === null || i18n === undefined) {
722
+ const previous = appliedI18nRef.current;
723
+
724
+ if (editor === null || (i18n === undefined && previous?.direction === undefined)) {
720
725
  return;
721
726
  }
722
727
 
723
- if (deepEqual(i18n, appliedI18nRef.current)) {
728
+ if (deepEqual(i18n, previous)) {
724
729
  return;
725
730
  }
726
731
 
727
732
  appliedI18nRef.current = i18n;
728
733
 
729
- const { locale, messages, direction } = i18n;
734
+ const { locale, messages, direction } = i18n ?? {};
735
+ // A dropped direction must be cleared, or the editor keeps the old one.
736
+ const nextDirection = direction ?? (previous?.direction === undefined ? undefined : null);
730
737
 
731
738
  void editor.i18n.update({
732
739
  ...(locale === undefined ? {} : { locale }),
733
740
  ...(messages === undefined ? {} : { messages }),
734
- ...(direction === undefined ? {} : { direction }),
741
+ ...(nextDirection === undefined ? {} : { direction: nextDirection }),
735
742
  });
736
743
  }, [editor, i18n]);
737
744
 
@@ -829,8 +836,8 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
829
836
  const mountedCollaboration = constructedCollaborationRef.current;
830
837
 
831
838
  if (mountedCollaboration !== undefined) {
832
- if (!collaborationWarnedRef.current) {
833
- collaborationWarnedRef.current = true;
839
+ if (collaborationWarnedForRef.current !== editor) {
840
+ collaborationWarnedForRef.current = editor;
834
841
  console.warn(collaborationDataIgnoredMessage(mountedCollaboration.doc));
835
842
  }
836
843
 
@@ -44,6 +44,8 @@ export interface BlokEditorHandle {
44
44
  clear(): Promise<void>;
45
45
  /** Serialize the current content, or resolve to `null` before ready. */
46
46
  save(): Promise<OutputData | null>;
47
+ /** Ask before an in-app navigation (see `Blok.confirmLeave`). Resolves `true` before ready. */
48
+ confirmLeave(): Promise<boolean>;
47
49
  /**
48
50
  * Render new content in place. Resolves immediately (no-op) before ready.
49
51
  *
@@ -111,6 +113,8 @@ export function useBlokHandle(): BlokEditorHandle {
111
113
  },
112
114
  save: (): Promise<OutputData | null> =>
113
115
  instanceRef.current !== null ? instanceRef.current.save() : Promise.resolve(null),
116
+ confirmLeave: (): Promise<boolean> =>
117
+ instanceRef.current !== null ? instanceRef.current.confirmLeave() : Promise.resolve(true),
114
118
  render: (data: OutputData | LooseOutputData): Promise<void> => {
115
119
  const instance = instanceRef.current;
116
120
 
@@ -18,7 +18,7 @@ import { viewNodesToReact } from './view-nodes-to-react';
18
18
  * bindings map from remains `@experimental` — but consumers of this hook never
19
19
  * touch it directly.
20
20
  * @param data - saved document (strict or loose wire shape; nullish tolerated)
21
- * @param options - schema / renderers / inlineRenderers / unknown-block policy / toolAttributes / blockIds / transformUrl
21
+ * @param options - schema / renderers / inlineRenderers / unknown-block policy / toolAttributes / blockIds / transformUrl / pageHref / pageInfo
22
22
  */
23
23
  export const useBlokView = (
24
24
  data: OutputData | LooseOutputData | null | undefined,
@@ -31,7 +31,10 @@ export const useBlokView = (
31
31
  const blockIds = options?.blockIds;
32
32
  const transformUrl = options?.transformUrl;
33
33
  const inlineRenderers = options?.inlineRenderers;
34
+ const pageHref = options?.pageHref;
35
+ const pageInfo = options?.pageInfo;
34
36
  const classes = options?.classes;
37
+ const direction = options?.direction;
35
38
 
36
39
  return useMemo(() => {
37
40
  return createElement(
@@ -46,6 +49,8 @@ export const useBlokView = (
46
49
  blockIds,
47
50
  transformUrl,
48
51
  inlineRenderers,
52
+ pageHref,
53
+ pageInfo,
49
54
  /**
50
55
  * Opt-in, NOT defaulted on. Parity rendering wraps every block in the
51
56
  * core's holder → content scaffolding, which would contradict this
@@ -57,8 +62,9 @@ export const useBlokView = (
57
62
  * already owns a wrapper and its job IS to look like the editor.
58
63
  */
59
64
  classes,
65
+ direction,
60
66
  })
61
67
  )
62
68
  );
63
- }, [data, schema, renderers, onUnknownBlock, toolAttributes, blockIds, transformUrl, inlineRenderers, classes]);
69
+ }, [data, schema, renderers, onUnknownBlock, toolAttributes, blockIds, transformUrl, inlineRenderers, pageHref, pageInfo, classes, direction]);
64
70
  };
@@ -48,6 +48,18 @@ const ATTRIBUTE_NAME_MAP: Record<string, string> = {
48
48
  itemscope: 'itemScope',
49
49
  };
50
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
+
51
63
  /**
52
64
  * Convert a sanitized `style` attribute string to a React style object
53
65
  * (React only accepts object styles). Custom properties keep their raw name;
@@ -85,8 +97,9 @@ const styleStringToObject = (css: string): CSSProperties => {
85
97
  * Map one ViewNode attribute record to React props.
86
98
  * @param attrs - sanitized attributes from the view tree
87
99
  * @param key - sibling index used as the React key
100
+ * @param inSvg - whether the element sits in an `<svg>` subtree
88
101
  */
89
- const attrsToProps = (attrs: Record<string, string>, key: number): Record<string, unknown> => {
102
+ const attrsToProps = (attrs: Record<string, string>, key: number, inSvg: boolean): Record<string, unknown> => {
90
103
  const props: Record<string, unknown> = { key };
91
104
 
92
105
  for (const [name, value] of Object.entries(attrs)) {
@@ -110,7 +123,7 @@ const attrsToProps = (attrs: Record<string, string>, key: number): Record<string
110
123
  continue;
111
124
  }
112
125
 
113
- props[ATTRIBUTE_NAME_MAP[name] ?? name] = value;
126
+ props[ATTRIBUTE_NAME_MAP[name] ?? (inSvg ? svgPropName(name) : name)] = value;
114
127
  }
115
128
 
116
129
  return props;
@@ -120,19 +133,21 @@ const attrsToProps = (attrs: Record<string, string>, key: number): Record<string
120
133
  * Map a list of ViewNodes to React nodes. Element keys come from the sibling
121
134
  * index (the tree is static per render).
122
135
  * @param nodes - view tree siblings
136
+ * @param inSvg - whether the siblings sit in an `<svg>` subtree
123
137
  */
124
- export const viewNodesToReact = (nodes: ViewNode[]): ReactNode[] => {
138
+ export const viewNodesToReact = (nodes: ViewNode[], inSvg = false): ReactNode[] => {
125
139
  return nodes.map((node, index): ReactNode => {
126
140
  if ('text' in node) {
127
141
  return node.text;
128
142
  }
129
143
 
130
- const props = attrsToProps(node.attrs, index);
144
+ const svg = inSvg || node.tag === 'svg';
145
+ const props = attrsToProps(node.attrs, index, svg);
131
146
 
132
147
  if (VOID_ELEMENTS.has(node.tag) || node.children.length === 0) {
133
148
  return createElement(node.tag, props);
134
149
  }
135
150
 
136
- return createElement(node.tag, props, ...viewNodesToReact(node.children));
151
+ return createElement(node.tag, props, ...viewNodesToReact(node.children, svg));
137
152
  });
138
153
  };
package/types/index.d.ts CHANGED
@@ -192,6 +192,8 @@ export interface BlokEditorHandle {
192
192
  clear(): Promise<void>;
193
193
  /** Serialize the current content, or resolve to `null` before ready. */
194
194
  save(): Promise<OutputData | null>;
195
+ /** Ask before an in-app navigation (see `Blok.confirmLeave`). Resolves `true` before ready. */
196
+ confirmLeave(): Promise<boolean>;
195
197
  /**
196
198
  * Render new content in place. Resolves immediately (no-op) before ready. Safe
197
199
  * to mix with a controlled `data` prop: the rendered document becomes the
@@ -521,8 +523,12 @@ export interface UseBlocksApi {
521
523
  * the React-surface counterpart of core's `transactWithoutCapture`. Use for
522
524
  * silent auto-repair/normalization that a user's undo should never step
523
525
  * through. Mutations inside still emit reactively. Pre-ready it just runs `fn`.
526
+ *
527
+ * With `options`, the operations are data worked out from the `from` keys of
528
+ * block `derivedFrom` (a finished upload, for example): they join the undo
529
+ * entry that wrote those values, as in core.
524
530
  */
525
- transactWithoutCapture(fn: () => void): void;
531
+ transactWithoutCapture(fn: () => void, options?: { derivedFrom?: string; from?: readonly string[] }): void;
526
532
  /** The current block count. Reactive (re-reads on 'block changed'). Pre-ready: 0. */
527
533
  getBlocksCount(): number;
528
534
  /** The flat index of the block holding the caret, or -1 when none. Pre-ready: -1. */
@@ -908,6 +914,15 @@ export interface CreateReactBlockSpec<
908
914
  * `null`/`undefined` (or omit) to keep core's default.
909
915
  */
910
916
  getToolbarAnchorElement?: (host: HTMLElement, block: BlockAPI) => HTMLElement | null | undefined;
917
+ /**
918
+ * Enter (or Cmd/Ctrl+Enter) on this block while it is the keyboard
919
+ * navigation target — core's `onNavigationEnter` hook. Return true when the
920
+ * block handled it (e.g. opened a link); omit it, or return false, to let
921
+ * core put the caret in the block.
922
+ * @param event - the Enter keydown
923
+ * @param block - this block's per-block API
924
+ */
925
+ onNavigationEnter?: (event: KeyboardEvent, block: BlockAPI) => boolean;
911
926
  onRendered?: (block: BlockAPI) => void;
912
927
  /**
913
928
  * Fired ONCE per block instance, after the portal's FIRST commit — the first
@@ -1219,6 +1234,10 @@ export interface BlokViewProps extends Omit<React.HTMLAttributes<HTMLDivElement>
1219
1234
  transformUrl?: BlocksToHtmlOptions['transformUrl'];
1220
1235
  /** Custom renderers for inline elements, keyed by lowercase tag name (equations, mentions). */
1221
1236
  inlineRenderers?: BlocksToHtmlOptions['inlineRenderers'];
1237
+ /** Build the link for a `page` block from its `pageId`. A page card is a link only with this and an allowing {@link pageInfo}. */
1238
+ pageHref?: BlocksToHtmlOptions['pageHref'];
1239
+ /** Host metadata (title, icon, access) for a `page` block. Without it a page card shows a neutral "Page" label and no link. */
1240
+ pageInfo?: BlocksToHtmlOptions['pageInfo'];
1222
1241
  /**
1223
1242
  * Render with the editor's presentational classes and per-block scaffolding
1224
1243
  * so the output matches a read-only editor render (default `true`). Needs
@@ -1226,6 +1245,8 @@ export interface BlokViewProps extends Omit<React.HTMLAttributes<HTMLDivElement>
1226
1245
  * semantic markup.
1227
1246
  */
1228
1247
  classes?: BlocksToHtmlOptions['classes'];
1248
+ /** Document direction: set as `dir` on the wrapper, and each block follows its own text. */
1249
+ direction?: BlocksToHtmlOptions['direction'];
1229
1250
  }
1230
1251
 
1231
1252
  /**