@bloklabs/react 1.2.1 → 1.2.2

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/src/useBlok.ts CHANGED
@@ -3,6 +3,13 @@ import { Blok as BlokRuntime } from '@bloklabs/core';
3
3
  import { setHolder, removeHolder } from './holder-map';
4
4
  import { deepEqual } from './deep-equal';
5
5
  import { BlokDefaultsContext, mergeBlokDefaults } from './provide-blok';
6
+ import {
7
+ createBlockPortalRegistry,
8
+ BLOK_PORTAL_REGISTRY_CONFIG_KEY,
9
+ type BlockPortalRegistry,
10
+ } from './block-portal-registry';
11
+ import { setRegistry, removeRegistry } from './registry-map';
12
+ import { bindLiveToolConfigFunctions } from './live-tool-config';
6
13
  import { normalizeReadOnlyConfig } from '@bloklabs/core/adapters';
7
14
  import type { Blok } from '@/types';
8
15
  import type { UseBlokConfig } from './types';
@@ -18,6 +25,45 @@ interface EditorInstanceState {
18
25
  depsToken: Record<string, unknown> | null;
19
26
  }
20
27
 
28
+ /**
29
+ * Inject the editor's portal registry into every `createReactBlock` tool's
30
+ * config (vanilla tools are left untouched), returning a NEW tools object so
31
+ * the consumer's config is never mutated. A react-block tool is constructed by
32
+ * CORE, outside any React render, so it cannot read context — this config
33
+ * bridge is how the tool reaches its editor-scoped registry.
34
+ */
35
+ const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): unknown => {
36
+ if (tools === null || typeof tools !== 'object') {
37
+ return tools;
38
+ }
39
+
40
+ const result: Record<string, unknown> = {};
41
+
42
+ for (const [name, entry] of Object.entries(tools as Record<string, unknown>)) {
43
+ const toolClass = typeof entry === 'function' ? entry : (entry as { class?: unknown })?.class;
44
+ const isReactBlock =
45
+ typeof toolClass === 'function' &&
46
+ (toolClass as { __isBlokReactBlock?: boolean }).__isBlokReactBlock === true;
47
+
48
+ if (!isReactBlock) {
49
+ result[name] = entry;
50
+
51
+ continue;
52
+ }
53
+
54
+ const base: Record<string, unknown> =
55
+ typeof entry === 'function' ? { class: entry } : { ...(entry as Record<string, unknown>) };
56
+
57
+ base.config = {
58
+ ...((base.config as Record<string, unknown> | undefined) ?? {}),
59
+ [BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry,
60
+ };
61
+ result[name] = base;
62
+ }
63
+
64
+ return result;
65
+ };
66
+
21
67
  /**
22
68
  * React hook that manages a Blok editor instance lifecycle.
23
69
  *
@@ -115,6 +161,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
115
161
  // Destroy leftover editor from a previous deps cycle
116
162
  if (state.editor !== null && !state.isDestroyed) {
117
163
  removeHolder(state.editor);
164
+ removeRegistry(state.editor);
118
165
  try {
119
166
  state.editor.destroy();
120
167
  } catch {
@@ -136,8 +183,25 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
136
183
 
137
184
  // Wrap callbacks via ref so they never go stale
138
185
  const currentConfig = configRef.current;
186
+
187
+ // Per-editor portal registry for `createReactBlock` tools: injected into
188
+ // each react-block tool's config (so the core-constructed tool can reach
189
+ // it) and associated with the editor below (so BlokContent can mount the
190
+ // shared portal host). Tool-config FUNCTIONS are first re-bound to stable
191
+ // wrappers that always call the latest render's closure — so consumers
192
+ // never need to freeze identities or recreate the editor to update them.
193
+ const registry = createBlockPortalRegistry();
194
+ const liveTools =
195
+ currentConfig.tools === undefined
196
+ ? undefined
197
+ : injectPortalRegistry(
198
+ bindLiveToolConfigFunctions(currentConfig.tools, () => configRef.current.tools),
199
+ registry
200
+ );
201
+
139
202
  const blokConfig = {
140
203
  ...currentConfig,
204
+ ...(liveTools === undefined ? {} : { tools: liveTools as UseBlokConfig['tools'] }),
141
205
  holder,
142
206
  onReady: (...args: Parameters<NonNullable<UseBlokConfig['onReady']>>): void => {
143
207
  configRef.current.onReady?.(...args);
@@ -182,6 +246,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
182
246
  // tell a genuine post-construction change from the construction value itself.
183
247
  constructedDataRef.current = currentConfig.data;
184
248
  setHolder(blok, holder);
249
+ setRegistry(blok, registry);
185
250
 
186
251
  void blok.isReady
187
252
  .then(() => {
@@ -193,6 +258,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
193
258
  .catch(() => {
194
259
  if (state.editor === blok && !state.isDestroyed) {
195
260
  removeHolder(blok);
261
+ removeRegistry(blok);
196
262
  try {
197
263
  blok.destroy();
198
264
  } catch {
@@ -348,6 +414,7 @@ function deferDestroy(
348
414
  state.destroyTimeout = setTimeout(() => {
349
415
  if (state.editor !== null) {
350
416
  removeHolder(state.editor);
417
+ removeRegistry(state.editor);
351
418
  try {
352
419
  state.editor.destroy();
353
420
  } catch {
package/types/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { BlokConfig, BlockToolData, OutputBlockData, OutputData } from '@bloklabs/core';
2
+ import type { BlockAPI, BlockToolConstructable, ToolboxConfig } from '@bloklabs/core';
2
3
  import type { Blok, EditorWidth, BlockRenderedPayload, BlocksRenderedPayload } from '@bloklabs/core';
3
4
  import type { BlockTuneData } from '@bloklabs/core';
4
5
  import type { MarkdownImportConfig } from '@bloklabs/core/markdown';
@@ -536,3 +537,114 @@ export interface UseBlocksApi {
536
537
  * @param editor - the Blok instance from useBlok, or null before it is ready
537
538
  */
538
539
  export declare function useBlocks(editor: Blok | null): UseBlocksApi;
540
+
541
+ // ---------------------------------------------------------------------------
542
+ // Block authoring (createReactBlock + portal host)
543
+ // ---------------------------------------------------------------------------
544
+
545
+ /** One field of a {@link CreateReactBlockSpec.propSchema}. */
546
+ export interface PropSchemaEntry {
547
+ default: unknown;
548
+ values?: readonly unknown[];
549
+ }
550
+
551
+ /**
552
+ * Declarative data shape. Its keys are EXACTLY the keys `save()` returns to Yjs
553
+ * (a cleared field is written as its default, never dropped).
554
+ */
555
+ export type PropSchema = Record<string, PropSchemaEntry>;
556
+
557
+ /** Props handed to a React block's `component` (the only data write path is `commit`). */
558
+ export interface ReactBlockRenderProps<Data> {
559
+ /** Frozen snapshot of the block data. Re-rendered with a new value on change; never mutate. */
560
+ data: Readonly<Data>;
561
+ /** The ONLY data write path: merge a partial patch and sync once. */
562
+ commit: (patch: Partial<Data>) => void;
563
+ /** This block's per-block API. */
564
+ block: BlockAPI;
565
+ /**
566
+ * Read-only flag. Toggled IN PLACE by core's read-only switch (no remount,
567
+ * ephemeral state survives). Honor it — a block that ignores it stays
568
+ * interactive when the editor is read-only.
569
+ */
570
+ readOnly: boolean;
571
+ /** Engine-owned child slot — render `<BlockChildren />` for a container block. */
572
+ BlockChildren: React.ComponentType;
573
+ }
574
+
575
+ /** Spec for {@link createReactBlock}. */
576
+ export interface CreateReactBlockSpec<Data extends BlockToolData = BlockToolData> {
577
+ type: string;
578
+ toolbox?: ToolboxConfig;
579
+ propSchema: PropSchema;
580
+ component: React.ComponentType<ReactBlockRenderProps<Data>>;
581
+ onRendered?: (block: BlockAPI) => void;
582
+ onMoved?: (block: BlockAPI) => void;
583
+ onRemoved?: (block: BlockAPI) => void;
584
+ }
585
+
586
+ /**
587
+ * Author a first-party React block: returns a `BlockToolConstructable`
588
+ * registered exactly like a vanilla tool (`tools: { type: createReactBlock(...) }`).
589
+ * Every block renders through the editor's shared portal host INSIDE the React
590
+ * tree that renders `BlokContent`/`BlokEditor`, so app-level context (themes,
591
+ * design-system providers, stores) reaches block components with no bridge, and
592
+ * fresh render closures reach them with no editor recreation.
593
+ *
594
+ * @example
595
+ * ```tsx
596
+ * const Callout = createReactBlock<{ text: string }>({
597
+ * type: 'callout',
598
+ * propSchema: { text: { default: '' } },
599
+ * component: ({ data, commit }) => (
600
+ * <aside onClick={() => commit({ text: 'hi' })}>{data.text}</aside>
601
+ * ),
602
+ * });
603
+ * ```
604
+ */
605
+ export declare function createReactBlock<Data extends BlockToolData = BlockToolData>(
606
+ spec: CreateReactBlockSpec<Data>
607
+ ): BlockToolConstructable;
608
+
609
+ /**
610
+ * Tool-config key carrying the editor's portal registry into a
611
+ * `createReactBlock` tool. Injected automatically by `useBlok`; exposed for
612
+ * advanced setups that construct tools outside `useBlok`.
613
+ */
614
+ export declare const BLOK_PORTAL_REGISTRY_CONFIG_KEY: '__blokPortalRegistry';
615
+
616
+ /** One mounted React block in the portal registry. */
617
+ export interface BlockPortalEntry {
618
+ hostEl: HTMLElement;
619
+ component: React.ComponentType<Record<string, unknown>>;
620
+ props: Record<string, unknown>;
621
+ }
622
+
623
+ /**
624
+ * Per-editor registry of React blocks, shaped as an external store
625
+ * (`subscribe`/`getSnapshot`) consumed by `BlockPortalHost` via
626
+ * `useSyncExternalStore`. Snapshots are immutable and referentially stable
627
+ * between mutations.
628
+ */
629
+ export interface BlockPortalRegistry {
630
+ register(id: string, entry: BlockPortalEntry): void;
631
+ unregister(id: string): void;
632
+ setProps(id: string, props: Record<string, unknown>): void;
633
+ subscribe(listener: () => void): () => void;
634
+ getSnapshot(): ReadonlyMap<string, BlockPortalEntry>;
635
+ }
636
+
637
+ /** Create a fresh portal registry (one per editor instance). */
638
+ export declare function createBlockPortalRegistry(): BlockPortalRegistry;
639
+
640
+ /** Props for {@link BlockPortalHost}. */
641
+ export interface BlockPortalHostProps {
642
+ registry: BlockPortalRegistry;
643
+ }
644
+
645
+ /**
646
+ * The single shared render tree that portals each registered React block into
647
+ * its Blok-owned host element. Mounted automatically by `BlokContent`; exposed
648
+ * for advanced setups that manage the registry themselves.
649
+ */
650
+ export declare function BlockPortalHost(props: BlockPortalHostProps): React.ReactElement;