@bloklabs/vue 1.7.0 → 1.8.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.
package/src/useBlok.ts CHANGED
@@ -10,8 +10,9 @@ import {
10
10
  type Ref,
11
11
  } from 'vue';
12
12
  import { Blok as BlokRuntime } from '@bloklabs/core';
13
+ import { setContentBaseline, removeContentBaseline } from './content-baseline-map';
13
14
  import { setHolder, removeHolder } from './holder-map';
14
- import { deepEqual , normalizeReadOnlyConfig, toRenderableData } from '@bloklabs/core/adapters';
15
+ import { deepEqual, equalsOutputData, normalizeReadOnlyConfig, toRenderableData } from '@bloklabs/core/adapters';
15
16
  import { BLOK_DEFAULT_CONFIG, mergeBlokDefaults } from './provide-blok';
16
17
  import {
17
18
  createBlockPortalRegistry,
@@ -19,9 +20,29 @@ import {
19
20
  type BlockPortalRegistry,
20
21
  } from './block-portal-registry';
21
22
  import { setRegistry, removeRegistry } from './registry-map';
22
- import type { Blok, BlokConfig, LooseOutputData, OutputData } from '@/types';
23
+ import type { Blok, BlokConfig, LiveHandlers, LooseOutputData, OutputData } from '@/types';
23
24
  import type { UseBlokConfig } from './types';
24
25
 
26
+ /** Presence map for the live callback config (see the handler watcher below). */
27
+ type HandlerPresence = Record<keyof LiveHandlers, boolean>;
28
+
29
+ /**
30
+ * Which live callbacks the config currently carries. PRESENCE is load-bearing
31
+ * in core — an `onSubmit` turns Enter into serialize-and-submit, an `onSave`
32
+ * arms the change-observation pipeline — so it is tracked separately from the
33
+ * callbacks themselves (whose identity the adapter keeps stable).
34
+ * @param config - the merged per-render config
35
+ * @returns one boolean per live handler key
36
+ */
37
+ const readHandlerPresence = (config: UseBlokConfig): HandlerPresence => ({
38
+ onChange: config.onChange !== undefined,
39
+ onSave: config.onSave !== undefined,
40
+ onEnter: config.onEnter !== undefined,
41
+ onSubmit: config.onSubmit !== undefined,
42
+ onBeforeRender: config.onBeforeRender !== undefined,
43
+ onAfterRender: config.onAfterRender !== undefined,
44
+ });
45
+
25
46
  /**
26
47
  * Inject the editor's portal registry into every `createVueBlock` tool's config
27
48
  * (vanilla tools are left untouched), returning a NEW tools object so the
@@ -97,19 +118,60 @@ export function useBlok(
97
118
  // - `lastRenderedData` is the content the editor currently reflects, updated
98
119
  // when `data` is seeded/rendered AND when the editor emits its own serialized
99
120
  // output via the `onSave` wrapper — so a controlled `update:data -> data`
100
- // echo deep-equals this baseline and is deduped to a no-op (no caret reset).
121
+ // echo content-equals this baseline and is deduped to a no-op (no caret
122
+ // reset). `undefined` means "nothing recorded yet" — never an empty document.
101
123
  const state: {
102
124
  current: Blok | null;
103
125
  holder: HTMLDivElement | null;
104
126
  lastRenderedData: OutputData | LooseOutputData | null | undefined;
105
127
  seededEditor: Blok | null;
106
128
  renderChain: Promise<void>;
129
+ appliedHandlerPresence: HandlerPresence;
107
130
  } = {
108
131
  current: null,
109
132
  holder: null,
110
133
  lastRenderedData: mergedConfig().data,
111
134
  seededEditor: null,
112
135
  renderChain: Promise.resolve(),
136
+ appliedHandlerPresence: {
137
+ onChange: false,
138
+ onSave: false,
139
+ onEnter: false,
140
+ onSubmit: false,
141
+ onBeforeRender: false,
142
+ onAfterRender: false,
143
+ },
144
+ };
145
+
146
+ /**
147
+ * Stable wrappers for the live callback config. Each forwards to the LATEST
148
+ * config through `mergedConfig()`, so callback identity never goes stale and
149
+ * only PRESENCE has to be synced after mount (the watcher below).
150
+ */
151
+ const handlerWrappers: Required<LiveHandlers> = {
152
+ onChange: (...args: Parameters<NonNullable<UseBlokConfig['onChange']>>): void => {
153
+ mergedConfig().onChange?.(...args);
154
+ },
155
+ onSave: (...args: Parameters<NonNullable<UseBlokConfig['onSave']>>): void => {
156
+ // Record the editor's own serialized output as the dedup baseline BEFORE
157
+ // notifying the consumer, so a controlled `update:data -> data` echo
158
+ // content-equals it and never re-renders.
159
+ state.lastRenderedData = args[0];
160
+ mergedConfig().onSave?.(...args);
161
+ },
162
+ // Forward the return value: it is the "handled" signal the core acts on.
163
+ onEnter: (...args: Parameters<NonNullable<UseBlokConfig['onEnter']>>): boolean | void =>
164
+ mergedConfig().onEnter?.(...args),
165
+ onSubmit: (...args: Parameters<NonNullable<UseBlokConfig['onSubmit']>>): void => {
166
+ mergedConfig().onSubmit?.(...args);
167
+ },
168
+ onBeforeRender: (
169
+ ...args: Parameters<NonNullable<UseBlokConfig['onBeforeRender']>>
170
+ ): ReturnType<NonNullable<UseBlokConfig['onBeforeRender']>> =>
171
+ mergedConfig().onBeforeRender?.(...args) ?? args[0],
172
+ onAfterRender: (...args: Parameters<NonNullable<UseBlokConfig['onAfterRender']>>): void => {
173
+ mergedConfig().onAfterRender?.(...args);
174
+ },
113
175
  };
114
176
 
115
177
  /**
@@ -137,6 +199,7 @@ export function useBlok(
137
199
  if (state.current !== null) {
138
200
  removeHolder(state.current);
139
201
  removeRegistry(state.current);
202
+ removeContentBaseline(state.current);
140
203
  try {
141
204
  state.current.destroy();
142
205
  } catch {
@@ -165,22 +228,37 @@ export function useBlok(
165
228
  snapshot.tools = injectPortalRegistry(snapshot.tools, registry);
166
229
  }
167
230
 
168
- // Wrap onSave (when the consumer opted in): record the editor's own
169
- // serialized output as the dedup baseline BEFORE notifying the consumer, so a
170
- // controlled `update:data -> data` echo deep-equals it and never re-renders.
171
- // Reads the latest callback through the config getter so it never goes stale.
172
- if (typeof snapshot.onSave === 'function') {
173
- snapshot.onSave = (...args: Parameters<NonNullable<UseBlokConfig['onSave']>>): void => {
174
- state.lastRenderedData = args[0];
175
- mergedConfig().onSave?.(...args);
176
- };
177
- }
231
+ // Install the stable wrappers for the callbacks the consumer actually
232
+ // passed, and clear the rest: an absent handler must stay absent, because
233
+ // core reads PRESENCE as intent. What was installed is recorded so the
234
+ // watcher below can push later flips instead of recreating the editor.
235
+ const presence = readHandlerPresence(mergedConfig());
236
+
237
+ snapshot.onChange = presence.onChange ? handlerWrappers.onChange : undefined;
238
+ snapshot.onSave = presence.onSave ? handlerWrappers.onSave : undefined;
239
+ snapshot.onEnter = presence.onEnter ? handlerWrappers.onEnter : undefined;
240
+ snapshot.onSubmit = presence.onSubmit ? handlerWrappers.onSubmit : undefined;
241
+ snapshot.onBeforeRender = presence.onBeforeRender ? handlerWrappers.onBeforeRender : undefined;
242
+ snapshot.onAfterRender = presence.onAfterRender ? handlerWrappers.onAfterRender : undefined;
243
+
244
+ state.appliedHandlerPresence = presence;
178
245
 
179
246
  const blok = new BlokRuntime({ ...snapshot, holder });
180
247
 
181
248
  state.current = blok;
182
249
  setHolder(blok, holder);
183
250
  setRegistry(blok, registry);
251
+ // The out-of-band content channel: `<BlokEditor>`'s exposed `render()`
252
+ // changes the document without going through the reactive `data` watcher,
253
+ // so it reports the result here. Without it the watcher's baseline names a
254
+ // document the editor no longer shows, and a controlled `data` set back to
255
+ // that document is deduped away — leaving the editor on the imperative
256
+ // content while the host's state says otherwise.
257
+ setContentBaseline(blok, {
258
+ markRendered: (content): void => {
259
+ state.lastRenderedData = content;
260
+ },
261
+ });
184
262
 
185
263
  void blok.isReady
186
264
  .then(() => {
@@ -351,6 +429,63 @@ export function useBlok(
351
429
  { immediate: true }
352
430
  );
353
431
 
432
+ // Live callback config. Callback presence was frozen at construction and IS
433
+ // the semantics in core (`onSubmit` makes Enter serialize-and-submit instead
434
+ // of splitting the block; `onSave` arms the change pipeline), so a `@save`
435
+ // listener or an "Enter sends" toggle added after mount used to need an
436
+ // editor recreate — losing caret and undo history. Diff presence against what
437
+ // is installed and push genuine flips through the runtime `handlers.set` API,
438
+ // writing `undefined` for a callback that disappeared so the change is
439
+ // reversible. Identity is never diffed: the wrappers forward to the latest
440
+ // config already.
441
+ watch(
442
+ [editor, () => JSON.stringify(readHandlerPresence(mergedConfig()))],
443
+ ([ed]) => {
444
+ if (!ed) {
445
+ return;
446
+ }
447
+
448
+ const latest = readHandlerPresence(mergedConfig());
449
+ const applied = state.appliedHandlerPresence;
450
+ const diff: LiveHandlers = {};
451
+ const changed: { value: boolean } = { value: false };
452
+
453
+ const sync = <K extends keyof LiveHandlers>(key: K, write: (value: LiveHandlers[K]) => void): void => {
454
+ if (latest[key] === applied[key]) {
455
+ return;
456
+ }
457
+
458
+ applied[key] = latest[key];
459
+ changed.value = true;
460
+ write(latest[key] ? handlerWrappers[key] : undefined);
461
+ };
462
+
463
+ sync('onChange', (value) => {
464
+ diff.onChange = value;
465
+ });
466
+ sync('onSave', (value) => {
467
+ diff.onSave = value;
468
+ });
469
+ sync('onEnter', (value) => {
470
+ diff.onEnter = value;
471
+ });
472
+ sync('onSubmit', (value) => {
473
+ diff.onSubmit = value;
474
+ });
475
+ sync('onBeforeRender', (value) => {
476
+ diff.onBeforeRender = value;
477
+ });
478
+ sync('onAfterRender', (value) => {
479
+ diff.onAfterRender = value;
480
+ });
481
+
482
+ if (changed.value) {
483
+ ed.handlers.set(diff);
484
+ }
485
+ },
486
+ { immediate: true }
487
+ );
488
+
354
489
  // Reactive content. `data` seeds the editor at construction; afterwards a new
355
490
  // *content* value re-renders via the public render() API — deep-equal-deduped
356
491
  // against the baseline (so an unchanged reference, including the editor's own
@@ -371,7 +506,15 @@ export function useBlok(
371
506
  return;
372
507
  }
373
508
 
374
- if (deepEqual(data, state.lastRenderedData)) {
509
+ // Structural comparison (`equalsOutputData`, not a raw deep-equal): a host
510
+ // that persists the editor's own document and hands back a stripped copy —
511
+ // fresh `time`, dropped ids, no `lastEditedAt` stamp — is still echoing
512
+ // content the editor already shows, and re-rendering it would reset the
513
+ // caret for zero visual change. `undefined` means "nothing recorded yet",
514
+ // which is NOT an empty document, so it must never match.
515
+ const baseline = state.lastRenderedData;
516
+
517
+ if (baseline !== undefined && equalsOutputData(data, baseline)) {
375
518
  return;
376
519
  }
377
520
 
package/types/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  Blok,
5
5
  BlockAPI,
6
6
  BlockMutationEvent,
7
+ BlockOrigin,
7
8
  BlockToolConstructable,
8
9
  BlockToolData,
9
10
  BlokConfig,
@@ -40,6 +41,13 @@ import type { MarkdownImportConfig } from '@bloklabs/core/markdown';
40
41
  * forwarded — it only affects the initial locale resolution.
41
42
  * - `data` — re-renders via `editor.render(value)` when content changes
42
43
  * (deep-equal–deduped and serialized; seeds the initial content at creation)
44
+ * - `onChange`, `onSave`, `onEnter`, `onSubmit`, `onBeforeRender`,
45
+ * `onAfterRender` — calls `editor.handlers.set({ ... })` when a callback
46
+ * APPEARS or DISAPPEARS. Callback presence is load-bearing in core (an
47
+ * `onSubmit` turns Enter into serialize-and-submit; an `onSave` arms the
48
+ * change pipeline), so dropping the prop UNSETS the handler rather than
49
+ * leaving a stale one behind. Callback IDENTITY is always live — the adapter
50
+ * forwards to the latest render, so you never need to memoize them.
43
51
  *
44
52
  * All other config is consumed once at editor creation.
45
53
  */
@@ -117,7 +125,16 @@ export interface BlokEditorExposed {
117
125
  save(): Promise<OutputData> | undefined;
118
126
  /** Move the caret into the editor. */
119
127
  focus(atEnd?: boolean): void;
120
- /** Replace the editor content (undefined until ready). */
128
+ /**
129
+ * Replace the editor content (undefined until ready).
130
+ *
131
+ * Safe to mix with a controlled `data` prop: the rendered document becomes
132
+ * the adapter's content baseline once the call lands, so a later `data`
133
+ * change back to the previous document still re-renders instead of being
134
+ * deduped against a baseline the editor no longer reflects. Recorded only on
135
+ * the resolved path — a failed render leaves the editor on its previous
136
+ * content.
137
+ */
121
138
  render(data: OutputData): Promise<void> | undefined;
122
139
  }
123
140
 
@@ -157,6 +174,21 @@ export declare function provideBlok(defaults: Partial<UseBlokConfig>): void;
157
174
  */
158
175
  export declare function useBlokDefaults(): Partial<UseBlokConfig>;
159
176
 
177
+ /**
178
+ * Injection key carrying the LIVE editor instance down to everything
179
+ * `BlokContent` renders — including every `createVueBlock` block, because
180
+ * `<Teleport>` preserves the component render context.
181
+ */
182
+ export declare const BLOK_EDITOR_INSTANCE: InjectionKey<Readonly<ShallowRef<Blok | null>>>;
183
+
184
+ /**
185
+ * The live Blok instance a component is rendered inside, as a ref (null before
186
+ * it exists). Inside a `createVueBlock` block this is the block's OWN editor, so
187
+ * `useBlocks(useBlokInstance())` works with no host plumbing — and makes a
188
+ * container block reactive to its own child tree. Call it inside `setup`.
189
+ */
190
+ export declare function useBlokInstance(): Readonly<ShallowRef<Blok | null>>;
191
+
160
192
  /**
161
193
  * A plain, serializable view of one block in the tree. Snapshot-volatile: every
162
194
  * read allocates a fresh node and `contentIds` is derived per read — depend on
@@ -502,6 +534,23 @@ export interface PropSchemaEntry {
502
534
  */
503
535
  export type PropSchema = Record<string, PropSchemaEntry>;
504
536
 
537
+ /**
538
+ * One child's decoration: attribute name -> value. `null`/`undefined` removes
539
+ * the attribute; a boolean or number is stringified (so `false` writes
540
+ * `data-active="false"`, which CSS can select, rather than dropping the hook).
541
+ */
542
+ export type ChildAttributes = Record<string, string | number | boolean | null | undefined>;
543
+
544
+ /** The `BlockChildren` per-child decorator (see {@link VueBlockRenderProps.BlockChildren}). */
545
+ export type ChildAttributesFn = (child: BlockAPI, index: number) => ChildAttributes;
546
+
547
+ /**
548
+ * Every STATIC member of core's block-tool contract a Vue block may declare for
549
+ * itself. `toolbox` and `isReadOnlySupported` are excluded — the factory owns
550
+ * them.
551
+ */
552
+ export type BlockToolStatics = Omit<BlockToolConstructable, 'toolbox' | 'isReadOnlySupported'>;
553
+
505
554
  /** Context handed to a Vue block's `setup` (the only data write path is `commit`). */
506
555
  export interface VueBlockRenderProps<Data> {
507
556
  /** Reactive, frozen snapshot of the block data. Read `data.value`; never mutate. */
@@ -510,6 +559,12 @@ export interface VueBlockRenderProps<Data> {
510
559
  commit: (patch: Partial<Data>) => void;
511
560
  /** This block's per-block API. */
512
561
  block: BlockAPI;
562
+ /**
563
+ * The EDITOR-level API this block belongs to (`api.blocks`, `api.caret`…) —
564
+ * the same object a vanilla tool receives. For a reactive view of the tree
565
+ * pair `useBlocks` with `useBlokInstance()` instead; the api is not reactive.
566
+ */
567
+ api: API;
513
568
  /**
514
569
  * Reactive read-only flag. Read `readOnly.value` in render to disable editing.
515
570
  * Toggled IN PLACE by core's read-only switch (no remount, ephemeral state
@@ -517,7 +572,13 @@ export interface VueBlockRenderProps<Data> {
517
572
  * editor is read-only.
518
573
  */
519
574
  readOnly: Readonly<ShallowRef<boolean>>;
520
- /** Engine-owned child slot — render `h(BlockChildren)` for a container block. */
575
+ /**
576
+ * Engine-owned child slot — render `h(BlockChildren)` for a container block.
577
+ * Accepts one optional prop, `childAttributes: ChildAttributesFn`, applied to
578
+ * each child's HOLDER after mounting (named hooks instead of positional
579
+ * `:nth-child()` CSS). Holders stay DIRECT children of the slot; attributes
580
+ * the callback stops producing are removed on the next pass.
581
+ */
521
582
  BlockChildren: Component;
522
583
  }
523
584
 
@@ -527,11 +588,87 @@ export interface CreateVueBlockSpec<Data extends BlockToolData = BlockToolData>
527
588
  toolbox?: ToolboxConfig;
528
589
  propSchema: PropSchema;
529
590
  setup: (props: VueBlockRenderProps<Data>) => () => unknown;
591
+ /**
592
+ * Static members of core's tool contract, forwarded verbatim onto the
593
+ * generated tool class (`ownsChildren`, `keepsChildrenOnEnter`,
594
+ * `conversionConfig`, `pasteConfig`, `sanitize`, `shortcut`, `upgradeData`…).
595
+ *
596
+ * `keepsChildrenOnEnter` is the per-tool Enter policy: declare it and Enter on
597
+ * this container's empty LAST child creates the new line INSIDE it instead of
598
+ * escaping to the container's parent (Blok's default, which is Notion's
599
+ * callout behaviour). It is policy the DOM cannot express — a callout renders
600
+ * the same nested-blocks slot as a column yet wants the escape — so a layout
601
+ * container (a card, a `steps` block) had to hijack the editor-global
602
+ * `config.onEnter` before it existed.
603
+ */
604
+ statics?: BlockToolStatics;
605
+ /**
606
+ * The element the +/drag toolbar should vertically center on — core's
607
+ * `getToolbarAnchorElement` hook, resolved against the block's host on every
608
+ * call. Return `null`/`undefined` to keep core's default positioning.
609
+ */
610
+ getToolbarAnchorElement?: (host: HTMLElement, block: BlockAPI) => HTMLElement | null | undefined;
530
611
  onRendered?: (block: BlockAPI) => void;
612
+ /**
613
+ * Fired ONCE per block instance, after the teleport's FIRST commit — the
614
+ * first moment this block's rendered DOM exists (and, for a container, the
615
+ * moment `BlockChildren` has adopted the child holders). `onRendered` cannot
616
+ * mean that: core calls `rendered()` in the same tick as `render()`, before
617
+ * Vue has drawn anything.
618
+ *
619
+ * It is also the create-vs-restore signal: `context.origin` says whether the
620
+ * author just made this block (`user`/`api`/`convert`) or the document is
621
+ * being re-materialised (`load`/`replay`/`paste`), so a container can seed its
622
+ * default children here exactly once.
623
+ *
624
+ * To observe OTHER blocks settling — e.g. before placing the caret in a child
625
+ * you just inserted — listen for the `block:childrenMounted` editor event
626
+ * instead: `api.events.on('block:childrenMounted', ({ blockId, childIds }) => …)`.
627
+ */
628
+ onMounted?: (block: BlockAPI, context: VueBlockMountedContext) => void;
629
+ /**
630
+ * The SEEDING hook: `onMounted`, narrowed to a genuine creation. Fired ONCE
631
+ * per block instance, after the teleport's first commit, and only when the
632
+ * author just made this block (`origin` of `user`, `api` or `convert`) —
633
+ * never for a `load`/`replay`/`paste` restore, and never for the off-tree
634
+ * `probe` instance core builds to read a tool's default data.
635
+ *
636
+ * Prefer it over hand-testing `context.origin` in `onMounted`: the intuitive
637
+ * `origin === 'user'` check is wrong — it drops `api.blocks.insert(...)` and
638
+ * turn-into, so the container comes up empty for every path except a
639
+ * keystroke.
640
+ * @example
641
+ * ```ts
642
+ * onCreated: (block, { api }) => {
643
+ * if (block.getChildren().length === 0) {
644
+ * api.blocks.insertInsideParent(block.id);
645
+ * }
646
+ * }
647
+ * ```
648
+ */
649
+ onCreated?: (block: BlockAPI, context: VueBlockMountedContext) => void;
531
650
  onMoved?: (block: BlockAPI) => void;
532
651
  onRemoved?: (block: BlockAPI) => void;
533
652
  }
534
653
 
654
+ /**
655
+ * Second argument of {@link CreateVueBlockSpec.onMounted} and
656
+ * {@link CreateVueBlockSpec.onCreated}.
657
+ */
658
+ export interface VueBlockMountedContext {
659
+ /**
660
+ * Why this block instance was constructed. CREATION origins (`user`, `api`,
661
+ * `convert`) mean the author just made it, so seeding default children is
662
+ * correct; RESTORE origins (`load`, `replay`, `paste`) mean the document
663
+ * already says what the children are. `probe` is an off-tree instance built
664
+ * only to read a tool's default data and must not touch the block tree.
665
+ * Defaults to `'api'`.
666
+ */
667
+ origin: BlockOrigin;
668
+ /** The editor-level API (`api.blocks`, `api.caret`, `api.events`…). */
669
+ api: API;
670
+ }
671
+
535
672
  /**
536
673
  * Author a first-party Vue block: returns a `BlockToolConstructable` registered
537
674
  * exactly like a vanilla tool (`tools: { type: { class: createVueBlock(...) } }`).