@bloklabs/angular 1.1.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.
@@ -0,0 +1,2735 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, Injector, createComponent, inject, ElementRef, NgZone, PLATFORM_ID, EnvironmentInjector, ApplicationRef, ErrorHandler, EventEmitter, signal, afterNextRender, Output, Input, Directive, computed, effect, forwardRef, ViewChild, ChangeDetectionStrategy, Component, DestroyRef } from '@angular/core';
3
+ import { NG_VALUE_ACCESSOR } from '@angular/forms';
4
+ import { isPlatformBrowser } from '@angular/common';
5
+ import { Blok } from '@bloklabs/core';
6
+
7
+ /**
8
+ * DI token holding app-wide Blok defaults (shared tools registry, default theme,
9
+ * i18n). Merged under per-instance inputs by `BlokEditorComponent`.
10
+ */
11
+ const BLOK_DEFAULT_CONFIG = new InjectionToken('BLOK_DEFAULT_CONFIG');
12
+ /**
13
+ * Standalone provider registering app-wide Blok defaults.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * bootstrapApplication(AppComponent, {
18
+ * providers: [provideBlok({ theme: 'dark', tools: sharedTools })],
19
+ * });
20
+ * ```
21
+ */
22
+ function provideBlok(defaults) {
23
+ return makeEnvironmentProviders([{ provide: BLOK_DEFAULT_CONFIG, useValue: defaults }]);
24
+ }
25
+
26
+ // packages/angular/src/block-context.ts
27
+ /** DI token carrying the per-block render context into the authored component. */
28
+ const BLOK_BLOCK_CONTEXT = new InjectionToken('BLOK_BLOCK_CONTEXT');
29
+
30
+ // packages/angular/src/block-portal-registry.ts
31
+ /**
32
+ * Tool-config key carrying the editor's portal registry into a
33
+ * `createAngularBlock` tool. The tool is constructed by CORE (outside any Angular
34
+ * injection context), so it cannot `inject()` — the directive injects the
35
+ * editor-scoped registry through each Angular-block tool's `config`, and the tool
36
+ * reads it back from there.
37
+ */
38
+ const BLOK_PORTAL_REGISTRY_CONFIG_KEY = '__blokAngularPortalRegistry';
39
+ /**
40
+ * Create a fresh portal registry bound to a live Angular environment. One per
41
+ * editor instance (associated via the registry map). The injectors/appRef are
42
+ * passed in (captured by the directive in an injection context) because the
43
+ * registry itself is built outside one.
44
+ */
45
+ const createBlockPortalRegistry = (envInjector, appRef, errorHandler) => {
46
+ const mounted = new Map();
47
+ // Change detection runs outside NgZone here; a throwing author component must
48
+ // degrade to a blank holder, not break core's block insertion.
49
+ const safe = (fn) => {
50
+ try {
51
+ fn();
52
+ }
53
+ catch (error) {
54
+ errorHandler.handleError(error);
55
+ }
56
+ };
57
+ const teardown = (id) => {
58
+ const entry = mounted.get(id);
59
+ if (entry === undefined) {
60
+ return;
61
+ }
62
+ const { ref, injector } = entry;
63
+ // Capture host before deletion so we can clear it below.
64
+ const hostEl = ref.location.nativeElement;
65
+ mounted.delete(id);
66
+ appRef.detachView(ref.hostView);
67
+ ref.destroy();
68
+ // `Injector.create({ parent })` returns an injector that is NOT torn down by
69
+ // `ref.destroy()`. We must destroy it explicitly to prevent one small injector
70
+ // accumulating per block over a long editor session.
71
+ const destroyable = injector;
72
+ destroyable.destroy?.();
73
+ // createComponent({ hostElement }) renders INTO an external div. Angular's
74
+ // destroy() tears down the component instance and CD but does NOT clear the
75
+ // host's DOM children — we must do it explicitly.
76
+ hostEl.replaceChildren();
77
+ };
78
+ return {
79
+ register(id, entry) {
80
+ // Idempotent: replace any prior mount for this id.
81
+ teardown(id);
82
+ // The context is provided through an ELEMENT injector (a node injector),
83
+ // so it is destroyed automatically with the component view — no leaked
84
+ // EnvironmentInjector. The shared app envInjector is the parent, so author
85
+ // blocks still see app-level providers (HttpClient, etc.).
86
+ const elementInjector = Injector.create({
87
+ providers: [{ provide: BLOK_BLOCK_CONTEXT, useValue: entry.context }],
88
+ parent: envInjector,
89
+ });
90
+ safe(() => {
91
+ const ref = createComponent(entry.component, {
92
+ environmentInjector: envInjector,
93
+ elementInjector,
94
+ hostElement: entry.hostEl,
95
+ });
96
+ appRef.attachView(ref.hostView);
97
+ mounted.set(id, { ref, injector: elementInjector });
98
+ // createComponent does not auto-run CD; render synchronously into the
99
+ // (still-detached) host before core inserts it into the document.
100
+ ref.changeDetectorRef.detectChanges();
101
+ });
102
+ },
103
+ unregister(id) {
104
+ teardown(id);
105
+ },
106
+ flush(id) {
107
+ const entry = mounted.get(id);
108
+ if (entry === undefined) {
109
+ return;
110
+ }
111
+ safe(() => entry.ref.changeDetectorRef.detectChanges());
112
+ },
113
+ destroyAll() {
114
+ for (const id of Array.from(mounted.keys())) {
115
+ teardown(id);
116
+ }
117
+ },
118
+ };
119
+ };
120
+
121
+ /**
122
+ * Associates each live Blok instance with its portal registry. `BlokContentDirective`
123
+ * sets it at creation so consumers (and future composables) can reach an editor's
124
+ * registry. WeakMap → no leak when the editor is destroyed.
125
+ */
126
+ const registries = new WeakMap();
127
+ function setRegistry(editor, registry) {
128
+ registries.set(editor, registry);
129
+ }
130
+ function getRegistry(editor) {
131
+ return registries.get(editor);
132
+ }
133
+ function removeRegistry(editor) {
134
+ registries.delete(editor);
135
+ }
136
+
137
+ /**
138
+ * Escape-hatch directive and lifecycle engine for Blok (mirrors React's
139
+ * `useBlok` + `BlokContent`). Constructs a Blok instance into its own host
140
+ * element and tears it down on destroy.
141
+ *
142
+ * The editor renders directly into the directive's host element — so the host
143
+ * IS the holder. This sidesteps the fact that Blok core exposes no public
144
+ * `holder` accessor: the adapter never has to read it back, it owns it.
145
+ *
146
+ * `BlokEditorComponent` applies this directive to its internal `<div>` and reads
147
+ * the instance back; consumers can also use it directly for full control:
148
+ * `<div blokContent [config]="cfg" (ready)="onReady($event)"></div>`.
149
+ *
150
+ * Implementation note: classic `@Input()`/`@Output()` are used (not signal
151
+ * `input()`/`output()`) because the repo's Vitest+Analog harness compiles the
152
+ * adapter via JIT, which does not register signal-based members. `instance`
153
+ * remains a plain `signal` (runtime API, JIT-safe) for reactive consumption.
154
+ */
155
+ class BlokContentDirective {
156
+ host = inject(ElementRef);
157
+ ngZone = inject(NgZone);
158
+ isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
159
+ /**
160
+ * App-wide defaults from `provideBlok()`, merged UNDER the bound `[config]` so
161
+ * the escape-hatch path honors them just like `<blok-editor>` (which also
162
+ * merges, idempotently). Mirrors React's `useBlok` merging context defaults.
163
+ */
164
+ defaults = inject(BLOK_DEFAULT_CONFIG, { optional: true }) ?? {};
165
+ // Captured in the injection context (field initializers) so the registry can
166
+ // mount author components via createComponent — core constructs tools outside
167
+ // Angular DI and cannot inject these itself.
168
+ envInjector = inject(EnvironmentInjector);
169
+ appRef = inject(ApplicationRef);
170
+ errorHandler = inject(ErrorHandler);
171
+ /** Construction-time Blok config (everything except `holder`, which the directive owns). */
172
+ config = {};
173
+ /** Changing this input's identity (after the first build) destroys + recreates the editor. */
174
+ set recreateKey(value) {
175
+ const changed = this.built && value !== this.currentKey;
176
+ this.currentKey = value;
177
+ if (changed) {
178
+ this.current?.destroy();
179
+ this.build();
180
+ }
181
+ }
182
+ /** Emits the live Blok instance once it is ready, after `instance` is populated. */
183
+ ready = new EventEmitter();
184
+ /** The live Blok instance, or null before `isReady` resolves / after destroy. */
185
+ instance = signal(null, ...(ngDevMode ? [{ debugName: "instance" }] : []));
186
+ /** The editor created by the most recent construction; used as the staleness key. */
187
+ current = null;
188
+ destroyed = false;
189
+ built = false;
190
+ currentKey;
191
+ /** The portal registry for the current editor (Angular-block mounting). */
192
+ registry;
193
+ constructor() {
194
+ // afterNextRender is browser-only (skipped during SSR) and runs after the
195
+ // host element exists and inputs are bound.
196
+ afterNextRender(() => {
197
+ // afterNextRender is browser-only by contract, but guard explicitly so the
198
+ // editor is never constructed during SSR / on a non-browser platform.
199
+ if (this.destroyed || !this.isBrowser) {
200
+ return;
201
+ }
202
+ this.build();
203
+ this.built = true;
204
+ });
205
+ }
206
+ /** Construct a Blok into the host element and publish it once ready. */
207
+ build() {
208
+ this.destroyed = false;
209
+ this.instance.set(null);
210
+ // Tear down any registry from a superseded editor (recreate path) before
211
+ // building a new one.
212
+ this.registry?.destroyAll();
213
+ // Merge provideBlok defaults under the bound config (instance wins; tools
214
+ // registries compose across both layers rather than replacing).
215
+ const merged = { ...this.defaults, ...this.config };
216
+ if (this.defaults.tools !== undefined || this.config.tools !== undefined) {
217
+ merged.tools = { ...this.defaults.tools, ...this.config.tools };
218
+ }
219
+ // Create the editor-scoped portal registry and thread it into every
220
+ // Angular-block tool's config (vanilla tools pass through untouched).
221
+ const registry = createBlockPortalRegistry(this.envInjector, this.appRef, this.errorHandler);
222
+ this.registry = registry;
223
+ merged.tools = injectPortalRegistry(merged.tools, registry);
224
+ // Construct outside Angular's zone so the editor's internal DOM churn never
225
+ // schedules change detection.
226
+ const blok = this.ngZone.runOutsideAngular(() => new Blok({
227
+ ...merged,
228
+ holder: this.host.nativeElement,
229
+ }));
230
+ this.current = blok;
231
+ setRegistry(blok, registry);
232
+ void blok.isReady.then(() => this.ngZone.run(() => {
233
+ // Guard on the constructor-returned reference (not isReady's resolved
234
+ // value): a superseded or torn-down editor must not publish itself.
235
+ if (this.current !== blok || this.destroyed) {
236
+ return;
237
+ }
238
+ this.instance.set(blok); // assign FIRST…
239
+ this.ready.emit(blok); // …THEN emit, so consumers see a populated instance.
240
+ }));
241
+ }
242
+ ngOnDestroy() {
243
+ this.destroyed = true;
244
+ this.registry?.destroyAll();
245
+ if (this.current !== null) {
246
+ removeRegistry(this.current);
247
+ }
248
+ this.current?.destroy();
249
+ this.current = null;
250
+ this.registry = undefined;
251
+ this.instance.set(null);
252
+ }
253
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: BlokContentDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
254
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.25", type: BlokContentDirective, isStandalone: true, selector: "[blokContent]", inputs: { config: "config", recreateKey: "recreateKey" }, outputs: { ready: "ready" }, ngImport: i0 });
255
+ }
256
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: BlokContentDirective, decorators: [{
257
+ type: Directive,
258
+ args: [{
259
+ selector: '[blokContent]',
260
+ standalone: true,
261
+ }]
262
+ }], ctorParameters: () => [], propDecorators: { config: [{
263
+ type: Input
264
+ }], recreateKey: [{
265
+ type: Input
266
+ }], ready: [{
267
+ type: Output
268
+ }] } });
269
+ /**
270
+ * Return a NEW tools map (never mutate the consumer's config) where each
271
+ * Angular-block tool carries the portal registry under
272
+ * BLOK_PORTAL_REGISTRY_CONFIG_KEY in its `config`. Vanilla tools are returned
273
+ * unchanged. Handles both entry shapes: a bare constructor and `{ class, config }`.
274
+ */
275
+ function injectPortalRegistry(tools, registry) {
276
+ if (tools === undefined) {
277
+ return tools;
278
+ }
279
+ const result = {};
280
+ for (const [name, entry] of Object.entries(tools)) {
281
+ const asObject = entry;
282
+ const toolClass = (typeof entry === 'function' ? entry : asObject?.class);
283
+ if (toolClass?.__isBlokAngularBlock === true) {
284
+ const base = typeof entry === 'function' ? { class: entry } : { ...asObject };
285
+ result[name] = {
286
+ ...base,
287
+ config: { ...(base.config ?? {}), [BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry },
288
+ };
289
+ }
290
+ else {
291
+ result[name] = entry;
292
+ }
293
+ }
294
+ return result;
295
+ }
296
+
297
+ /**
298
+ * Thrown when a block tool cannot be resolved during a creation/conversion
299
+ * operation (insert, insertMany/composeBlock, composeBlockData, convert).
300
+ *
301
+ * A typed error lets adapters (React/Vue/Angular useBlocks) distinguish a
302
+ * genuinely-unknown tool — an EXPECTED, recoverable outcome they surface as a
303
+ * `null`/`[]` no-op — from a real bug, WITHOUT brittle `message.includes('not
304
+ * found')` substring matching (which both mis-catches unrelated errors whose
305
+ * message happens to contain "not found" and breaks under message localization).
306
+ */
307
+ class ToolNotFoundError extends Error {
308
+ toolName;
309
+ /**
310
+ * @param toolName - the tool type that could not be resolved
311
+ * @param message - optional override; defaults to a descriptive message
312
+ */
313
+ constructor(toolName, message) {
314
+ super(message ?? `Block Tool «${toolName}» not found.`);
315
+ this.toolName = toolName;
316
+ this.name = 'ToolNotFoundError';
317
+ }
318
+ }
319
+
320
+ /**
321
+ * ID generation utilities
322
+ */
323
+ // nanoid replaced with inline crypto implementation for the Angular adapter bundle.
324
+ const _ID_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
325
+ const _nanoid = (n) => {
326
+ const buf = crypto.getRandomValues(new Uint8Array(n));
327
+ return Array.from(buf, (b) => _ID_CHARS[b % 64]).join('');
328
+ };
329
+ const nanoid = _nanoid;
330
+ /**
331
+ * Constants for ID generation
332
+ */
333
+ const ID_RANDOM_MULTIPLIER = 100_000_000; // 1e8
334
+ const HEXADECIMAL_RADIX = 16;
335
+ /**
336
+ * Create a block id
337
+ * @returns unique block ID
338
+ */
339
+ const generateBlockId = () => {
340
+ const idLen = 10;
341
+ return nanoid(idLen);
342
+ };
343
+ /**
344
+ * Nanoid-compatible block ID pattern: exactly 10 URL-safe characters (A-Z, a-z, 0-9, _, -)
345
+ */
346
+ const BLOCK_ID_PATTERN = /^[A-Za-z0-9_-]{10}$/;
347
+ /**
348
+ * Returns true if the given string is a valid block ID (nanoid format).
349
+ * @param id - string to check
350
+ */
351
+ const isValidBlockId = (id) => BLOCK_ID_PATTERN.test(id);
352
+ /**
353
+ * Returns random generated identifier
354
+ * @param prefix - identifier prefix
355
+ * @returns unique identifier with prefix
356
+ */
357
+ const generateId = (prefix = '') => {
358
+ return `${prefix}${(Math.floor(Math.random() * ID_RANDOM_MULTIPLIER)).toString(HEXADECIMAL_RADIX)}`;
359
+ };
360
+
361
+ /**
362
+ * Enumerate the editor's blocks once into BlockNode records, in flat order,
363
+ * deriving each node's contentIds from the children that name it as parent.
364
+ */
365
+ const snapshotNodes = (reader) => {
366
+ const count = reader.getBlocksCount();
367
+ const flat = Array.from({ length: count }, (_, i) => reader.getBlockByIndex(i))
368
+ .filter((b) => b !== undefined)
369
+ .map((b) => ({ id: b.id, type: b.name, parentId: b.parentId }));
370
+ const childrenByParent = new Map();
371
+ for (const b of flat) {
372
+ if (b.parentId === null) {
373
+ continue;
374
+ }
375
+ const bucket = childrenByParent.get(b.parentId) ?? [];
376
+ bucket.push(b.id);
377
+ childrenByParent.set(b.parentId, bucket);
378
+ }
379
+ return flat.map((b) => ({
380
+ id: b.id,
381
+ type: b.type,
382
+ parentId: b.parentId,
383
+ contentIds: childrenByParent.get(b.id) ?? [],
384
+ }));
385
+ };
386
+ /** Flat indices of a parent's direct children, ascending. Empty if none. */
387
+ const childFlatIndices = (reader, parentId) => {
388
+ const count = reader.getBlocksCount();
389
+ return Array.from({ length: count }, (_, i) => i).filter((i) => reader.getBlockByIndex(i)?.parentId === parentId);
390
+ };
391
+ /** Map of block id -> parentId, in flat order. */
392
+ const parentMap = (reader) => {
393
+ const count = reader.getBlocksCount();
394
+ const entries = Array.from({ length: count }, (_, i) => reader.getBlockByIndex(i))
395
+ .filter((b) => b !== undefined)
396
+ .map((b) => [b.id, b.parentId]);
397
+ return new Map(entries);
398
+ };
399
+ /** True if `id` descends from `ancestorId` via the parentId chain. */
400
+ const isDescendantOf = (parentOf, id, ancestorId) => {
401
+ const parent = parentOf.get(id) ?? null;
402
+ if (parent === null) {
403
+ return false;
404
+ }
405
+ return parent === ancestorId || isDescendantOf(parentOf, parent, ancestorId);
406
+ };
407
+ /**
408
+ * The flat index of the last block in the contiguous subtree rooted at `index`.
409
+ * In flat (DFS) order a block's descendants sit immediately after it, so this
410
+ * walks forward while blocks remain descendants of the block at `index`.
411
+ * Returns `index` itself when the block has no descendants.
412
+ */
413
+ const subtreeEndIndex = (reader, index) => {
414
+ const root = reader.getBlockByIndex(index);
415
+ if (root === undefined) {
416
+ return index;
417
+ }
418
+ const parentOf = parentMap(reader);
419
+ const count = reader.getBlocksCount();
420
+ // Walk forward from index+1 while blocks remain descendants (DFS contiguity).
421
+ // The subtree ends just before the first non-descendant, or at the last block
422
+ // when every follower is a descendant.
423
+ const followers = Array.from({ length: count - (index + 1) }, (_, k) => index + 1 + k);
424
+ const breakAt = followers.find((i) => {
425
+ const b = reader.getBlockByIndex(i);
426
+ return b === undefined || !isDescendantOf(parentOf, b.id, root.id);
427
+ });
428
+ return breakAt === undefined ? count - 1 : breakAt - 1;
429
+ };
430
+ /**
431
+ * The flat index at which a new block should be inserted.
432
+ *
433
+ * `replace` flips the meaning of a before/after `position`: under replace the
434
+ * ref IS the block being overwritten (a "turn into"), not a sibling anchor, so
435
+ * it resolves to the ref's OWN flat slot regardless of which parent it lives in
436
+ * — bypassing the sibling-relative parent guard that would otherwise redirect a
437
+ * nested target to the requested parent's end and replace the wrong block.
438
+ */
439
+ const resolveInsertIndex = (reader, parentId, position, replace = false) => {
440
+ if (typeof position === 'object') {
441
+ const ref = 'before' in position ? position.before : position.after;
442
+ const refIndex = reader.getBlockIndex(ref);
443
+ if (replace && refIndex !== undefined) {
444
+ return refIndex;
445
+ }
446
+ const refParent = refIndex === undefined ? undefined : reader.getBlockByIndex(refIndex)?.parentId ?? null;
447
+ // before/after is sibling-relative: the ref must be a child of the requested
448
+ // parent. When the ref is missing or lives in a DIFFERENT parent, the
449
+ // parentId constraint wins — fall back to appending at the end of the
450
+ // requested parent rather than splicing into an unrelated subtree (the
451
+ // cross-parent mis-place) or silently landing at the document end.
452
+ if (refIndex === undefined || refParent !== parentId) {
453
+ return resolveInsertIndex(reader, parentId, 'end');
454
+ }
455
+ // 'after' must clear the ref's entire subtree, not just the ref node, or the
456
+ // new block splits the ref's descendants in flat (DFS) order.
457
+ return 'before' in position ? refIndex : subtreeEndIndex(reader, refIndex) + 1;
458
+ }
459
+ if (parentId === null) {
460
+ return position === 'start' ? 0 : reader.getBlocksCount();
461
+ }
462
+ const parentIndex = reader.getBlockIndex(parentId);
463
+ if (parentIndex === undefined) {
464
+ return reader.getBlocksCount();
465
+ }
466
+ if (position === 'start') {
467
+ const childIndices = childFlatIndices(reader, parentId);
468
+ return childIndices.length === 0 ? parentIndex + 1 : childIndices[0];
469
+ }
470
+ // 'end': insert after the parent's last descendant.
471
+ return subtreeEndIndex(reader, parentIndex) + 1;
472
+ };
473
+ /** The flat toIndex for editor.blocks.move. */
474
+ const resolveMoveIndex = (reader, target) => {
475
+ if ('toIndex' in target) {
476
+ // Blok's Blocks.move() silently no-ops on an out-of-range index, so clamp
477
+ // an explicit toIndex into [0, count-1] rather than dropping the move.
478
+ const lastIndex = Math.max(0, reader.getBlocksCount() - 1);
479
+ return Math.min(Math.max(target.toIndex, 0), lastIndex);
480
+ }
481
+ const ref = 'before' in target ? target.before : target.after;
482
+ const refIndex = reader.getBlockIndex(ref);
483
+ if (refIndex === undefined) {
484
+ return reader.getBlocksCount();
485
+ }
486
+ // 'after' must clear the ref's entire subtree (see resolveInsertIndex), or the
487
+ // moved block lands among the ref's descendants instead of past them.
488
+ return 'before' in target ? refIndex : subtreeEndIndex(reader, refIndex) + 1;
489
+ };
490
+
491
+ // src/components/utils/blocks-api.ts
492
+ /** Adapt the live editor to the IndexReader the snapshot helpers expect. */
493
+ const readerFor = (editor) => {
494
+ const blocks = editor.blocks;
495
+ return {
496
+ getBlocksCount: () => blocks.getBlocksCount(),
497
+ getBlockByIndex: (i) => {
498
+ const b = blocks.getBlockByIndex(i);
499
+ return b === undefined ? undefined : { id: b.id, name: b.name, parentId: b.parentId };
500
+ },
501
+ getBlockIndex: (id) => blocks.getBlockIndex(id),
502
+ };
503
+ };
504
+ /**
505
+ * Stable API returned while the editor is null (before the adapter resolves).
506
+ * Every read returns empty/null and every MUTATOR is a no-op — EXCEPT
507
+ * `transact`/`transactWithoutCapture`, which still invoke their callback so a
508
+ * consumer wrapping conditional work in `transact` still runs that work even
509
+ * pre-ready.
510
+ */
511
+ const EMPTY_API = {
512
+ getById: () => null,
513
+ getChildren: () => [],
514
+ insert: () => null,
515
+ insertMany: () => [],
516
+ insertTree: () => null,
517
+ insertMarkdown: async () => [],
518
+ exportMarkdown: async () => '',
519
+ move: () => undefined,
520
+ nest: () => undefined,
521
+ unnest: () => undefined,
522
+ remove: () => undefined,
523
+ update: () => undefined,
524
+ convert: () => undefined,
525
+ // Not a no-op: still runs the callback (see EMPTY_API doc above).
526
+ transact: (fn) => fn(),
527
+ // Like transact, still runs the callback even pre-ready.
528
+ transactWithoutCapture: (fn) => fn(),
529
+ getBlocksCount: () => 0,
530
+ getCurrentBlockIndex: () => -1,
531
+ getBlockByIndex: () => null,
532
+ getBlockByElement: () => null,
533
+ getBlockData: () => null,
534
+ getBlockIndex: () => null,
535
+ composeBlockData: async () => ({}),
536
+ renderFromHTML: async () => undefined,
537
+ insertOutputData: () => [],
538
+ splitBlock: () => null,
539
+ insertInsideParent: () => null,
540
+ render: async () => undefined,
541
+ clear: async () => undefined,
542
+ isSyncingFromYjs: () => false,
543
+ };
544
+ /**
545
+ * Build the framework-agnostic, id/parentId-relative block-tree API over a LIVE
546
+ * editor. Mutators route through the editor-level `blocks` API (core's
547
+ * chokepoints), so undo/redo and Yjs sync are inherited rather than
548
+ * re-implemented; readers enumerate the live tree on every call.
549
+ *
550
+ * This is the shared engine behind both the React (`useSyncExternalStore`) and
551
+ * Vue (`shallowRef` version) `useBlocks` wrappers — one implementation so the
552
+ * two adapters cannot drift. Each adapter supplies the `onRead` seam:
553
+ *
554
+ * - React leaves it the default no-op: `useSyncExternalStore` re-renders the
555
+ * whole component on `block changed`, and the reads run live during that
556
+ * render, so no per-call dependency tracking is needed.
557
+ * - Vue passes `() => { void version.value }` so a read inside a `computed` /
558
+ * template touches the reactive version ref and re-runs on every structural
559
+ * mutation.
560
+ *
561
+ * `onRead` is invoked at the top of every read method; mutators never call it.
562
+ * The editor MUST be the raw (non-proxied) instance — adapters `toRaw`-unwrap
563
+ * before calling, so a Vue reactive proxy never reaches core (Risk R0).
564
+ *
565
+ * @param editor - the live Blok instance (never null; callers gate on EMPTY_API)
566
+ * @param onRead - reactivity seam called at the start of each read (default no-op)
567
+ */
568
+ const createBlocksApiForEditor = (editor, onRead = () => undefined) => {
569
+ const reader = readerFor(editor);
570
+ const getById = (id) => {
571
+ onRead();
572
+ const nodes = snapshotNodes(reader);
573
+ return nodes.find((n) => n.id === id) ?? null;
574
+ };
575
+ const getChildren = (parentId) => {
576
+ onRead();
577
+ return snapshotNodes(reader).filter((n) => n.parentId === parentId);
578
+ };
579
+ const transact = (fn) => {
580
+ if (editor.blocks.transact !== undefined) {
581
+ editor.blocks.transact(fn);
582
+ }
583
+ else {
584
+ fn();
585
+ }
586
+ };
587
+ /** Flat list of id + all transitive descendants, via the parentId graph. */
588
+ const collectSubtreeIds = (rootId) => {
589
+ const nodes = snapshotNodes(reader);
590
+ const childrenOf = new Map();
591
+ for (const n of nodes) {
592
+ if (n.parentId === null) {
593
+ continue;
594
+ }
595
+ const bucket = childrenOf.get(n.parentId) ?? [];
596
+ bucket.push(n.id);
597
+ childrenOf.set(n.parentId, bucket);
598
+ }
599
+ const out = [];
600
+ const visited = new Set();
601
+ const stack = [rootId];
602
+ // A parentId cycle (possible from a concurrent remote Yjs reparent) would
603
+ // make this DFS spin forever — track visited ids and skip re-entry so each
604
+ // block is emitted at most once and the traversal always terminates.
605
+ while (stack.length > 0) {
606
+ const id = stack.pop();
607
+ if (visited.has(id)) {
608
+ continue;
609
+ }
610
+ visited.add(id);
611
+ out.push(id);
612
+ const kids = childrenOf.get(id);
613
+ if (kids !== undefined) {
614
+ stack.push(...kids);
615
+ }
616
+ }
617
+ return out;
618
+ };
619
+ /** Each subtree member's parentId, captured from the current snapshot. */
620
+ const captureSubtreeParents = (rootId) => {
621
+ const parentOf = new Map(snapshotNodes(reader).map((n) => [n.id, n.parentId]));
622
+ return collectSubtreeIds(rootId).map((id) => ({ id, parentId: parentOf.get(id) ?? null }));
623
+ };
624
+ /**
625
+ * Relocate id AND its whole subtree to sit contiguously starting at a
626
+ * PRE-removal flat slot. Blok's Blocks.move() is post-removal index space and
627
+ * only carries DOM-contained descendants (resortNestedBlocks) — indent /
628
+ * parentId-nested children are NOT, so move each subtree member that a single
629
+ * root move didn't already pull into place. Keeps the flat array DFS-contiguous,
630
+ * the invariant the flat-index insert API and getChildren ordering depend on.
631
+ */
632
+ const relocateSubtree = (rootId, preRemovalSlot) => {
633
+ // Subtree members in current flat (document) order — root first.
634
+ const members = collectSubtreeIds(rootId)
635
+ .map((id) => ({ id, idx: editor.blocks.getBlockIndex(id) }))
636
+ .filter((m) => m.idx !== undefined)
637
+ .sort((a, b) => a.idx - b.idx)
638
+ .map((m) => m.id);
639
+ const rootFrom = editor.blocks.getBlockIndex(rootId);
640
+ if (rootFrom === undefined) {
641
+ return 'blocked';
642
+ }
643
+ // Self-overlap guard. `preRemovalSlot` is the destination the caller
644
+ // computed as the END of the (new or former) parent's WHOLE subtree — and
645
+ // when that parent is an ANCESTOR of the relocating block (always so for
646
+ // unnest, and for a nest into a block this one already sits under), that
647
+ // span still CONTAINS this block's own subtree. Moving the root to a slot
648
+ // at the tail of (or inside) its own footprint splices it in right after
649
+ // one of its OWN descendants; core's post-move auto-heal then reparents the
650
+ // block under that descendant (setBlockParent(B, B) when the neighbour is
651
+ // the block's last child), which BlockHierarchy REFUSES with a thrown cycle
652
+ // error — crashing the caller. But the block is already DFS-contiguous at
653
+ // that destination (its subtree is the tail of the parent's subtree), so
654
+ // the relocation is a no-op: skip the move and report 'skipped', leaving the
655
+ // caller to assert the reparent. Window: `rootFrom < preRemovalSlot <=
656
+ // subtreeEnd + 1`. A backward move (`preRemovalSlot <= rootFrom`) or a
657
+ // genuine forward move past unrelated content (`preRemovalSlot > subtreeEnd
658
+ // + 1`) is untouched.
659
+ //
660
+ // 'skipped' (vs 'moved') is reported DISTINCTLY because no `editor.blocks.
661
+ // move()` ran — so core's post-move parent auto-heal never fired. nest/unnest
662
+ // re-assert the root's parent explicitly (reparentSubtree), so they don't
663
+ // care; but move() leans on that auto-heal for the root's parent-adoption, so
664
+ // it must apply the adopted parent itself on this path (see move()).
665
+ const subtreeEnd = collectSubtreeIds(rootId)
666
+ .map((memberId) => editor.blocks.getBlockIndex(memberId))
667
+ .filter((i) => i !== undefined)
668
+ .reduce((max, i) => Math.max(max, i), rootFrom);
669
+ if (preRemovalSlot > rootFrom && preRemovalSlot <= subtreeEnd + 1) {
670
+ return 'skipped';
671
+ }
672
+ // Defensive clamp into [0, count-1]: Blok's move() silently no-ops on an
673
+ // out-of-range index, so an over-large preRemovalSlot would strand the
674
+ // root instead of appending it. (In practice resolveInsertIndex stays in
675
+ // range; this guards a concurrently-shrunk tree.)
676
+ const lastIndex = Math.max(0, editor.blocks.getBlocksCount() - 1);
677
+ const rootTarget = Math.min(Math.max(rootFrom < preRemovalSlot ? preRemovalSlot - 1 : preRemovalSlot, 0), lastIndex);
678
+ editor.blocks.move(rootTarget, rootFrom);
679
+ // Detect a BLOCKED relocation: Blok's move() clamps a cross-`column`-
680
+ // boundary move to a no-op. If the root could not reach its target (it
681
+ // didn't move and wasn't already there), the relocation failed — report
682
+ // it so the caller skips the reparent that would otherwise corrupt DFS
683
+ // contiguity (a child wedged outside its parent's flat run). A legitimate
684
+ // no-op (already at target, rootFrom === rootTarget) still counts as
685
+ // relocated so an in-place nest/unnest reparents as intended.
686
+ if (rootFrom !== rootTarget && editor.blocks.getBlockIndex(rootId) === rootFrom) {
687
+ return 'blocked';
688
+ }
689
+ // Place each descendant immediately after the previously-positioned member,
690
+ // re-reading the anchor's LIVE index every iteration. A cached root index
691
+ // (rootNew + k + 1) only holds for a BACKWARD relocation, where descendants
692
+ // already sit after the root. For a FORWARD relocation each descendant
693
+ // move() splices an element out from BEFORE the root, sliding the root (and
694
+ // a cached index) down one slot per step — so the target overshoots, trips
695
+ // Blocks.move()'s out-of-range no-op guard, and strands the descendant.
696
+ // Re-anchoring to the predecessor's current slot absorbs the drift in both
697
+ // directions and keeps the subtree DFS-contiguous. A descendant already in
698
+ // place (carried by resortNestedBlocks for a DOM-nested child) is skipped.
699
+ // The anchor for the k-th descendant is the (k-1)-th — read live each step.
700
+ const descendants = members.filter((memberId) => memberId !== rootId);
701
+ descendants.forEach((memberId, k) => {
702
+ const anchorId = k === 0 ? rootId : descendants[k - 1];
703
+ const from = editor.blocks.getBlockIndex(memberId);
704
+ const anchor = editor.blocks.getBlockIndex(anchorId);
705
+ if (from === undefined || anchor === undefined) {
706
+ return;
707
+ }
708
+ const target = anchor + 1;
709
+ if (from !== target) {
710
+ editor.blocks.move(from < target ? target - 1 : target, from);
711
+ }
712
+ });
713
+ return 'moved';
714
+ };
715
+ // Reparent `id` (root → newParentId, descendants → their original parent).
716
+ // Re-asserting after relocateSubtree heals move()'s auto-reparent (which sets
717
+ // a moved block's parentId to its new neighbour) and never relocates the flat
718
+ // array, so contiguity established by relocateSubtree is preserved.
719
+ const reparentSubtree = (members, rootId, newParentId) => {
720
+ for (const m of members) {
721
+ editor.blocks.setBlockParent(m.id, m.id === rootId ? newParentId : m.parentId);
722
+ }
723
+ };
724
+ // Re-assert ONLY the descendants' captured parents after a subtree relocation,
725
+ // leaving the ROOT's parent as core's post-move auto-heal set it — used by
726
+ // move(), whose documented side effect is that the root ADOPTS the parent of
727
+ // the slot it lands in while its subtree travels with it.
728
+ const reassertDescendantParents = (members, rootId) => {
729
+ members
730
+ .filter((m) => m.id !== rootId)
731
+ .forEach((m) => editor.blocks.setBlockParent(m.id, m.parentId));
732
+ };
733
+ /**
734
+ * Whether `id` is a DIRECT child of a `column` block. Column membership is
735
+ * owned by the drag UI, so a programmatic nest/unnest that would change it is
736
+ * a graceful no-op (see the nest/unnest docs). Checking this EXPLICITLY makes
737
+ * that contract reliable: relying on core's move-clamp alone leaks, because
738
+ * when the relocation needs no actual move (a tail column child already sits
739
+ * at its relocation target) no boundary-crossing move fires, so nothing
740
+ * clamps and the reparent would slip through.
741
+ */
742
+ const isColumnChild = (id) => {
743
+ const parent = getById(id)?.parentId ?? null;
744
+ return parent !== null && getById(parent)?.type === 'column';
745
+ };
746
+ /**
747
+ * Nest `id` (and its whole subtree) under `parentId`, as one undo step.
748
+ * No-op when either id is unknown (probed via the silent snapshot, NOT
749
+ * getBlockIndex, which warns on unknown ids), or when `parentId` is `id`
750
+ * itself or one of `id`'s own descendants — that would form a cycle, which
751
+ * core's setBlockParent THROWS on, so the hook guards it up front.
752
+ *
753
+ * Column boundary caveat: nesting a block that lives inside a `column`, or
754
+ * nesting directly INTO one, is a GRACEFUL no-op — column membership is owned
755
+ * by the drag UI. This is detected explicitly (via the block's and target's
756
+ * parent type), so the no-op holds even when no boundary-crossing move fires.
757
+ * Returns void.
758
+ */
759
+ const nest = (id, parentId) => {
760
+ if (getById(parentId) === null || getById(id) === null) {
761
+ return;
762
+ }
763
+ // Cycle guard: a block can't become a child of itself or of one of its
764
+ // own descendants. Without this, the relocate/reparent reaches core's
765
+ // setBlockParent, which throws on a cycle and crashes the caller.
766
+ if (parentId === id || isDescendantOf(parentMap(reader), parentId, id)) {
767
+ return;
768
+ }
769
+ // Column-boundary no-op: pulling a block out of a `column`, or pushing one
770
+ // directly into a column, changes column membership — owned by the drag UI.
771
+ if (isColumnChild(id) || getById(parentId)?.type === 'column') {
772
+ return;
773
+ }
774
+ // Relocate id's whole subtree to after the new parent's existing subtree,
775
+ // THEN assert the parents. Blok keeps the flat array as the canonical
776
+ // document order, and the flat-index insert API only resolves a parent's
777
+ // "end" slot when that parent's descendants are contiguous — a bare reparent
778
+ // (no relocation) would wedge unrelated blocks between the parent and its new
779
+ // child, corrupting later inserts/reads. Mirrors the drag move-then-reparent.
780
+ //
781
+ // Only reparent when the relocation actually placed the block: a clamped
782
+ // cross-column move leaves it where it was, and reparenting in place would
783
+ // break DFS contiguity.
784
+ const members = captureSubtreeParents(id);
785
+ transact(() => {
786
+ // reparentSubtree sets the root's parent EXPLICITLY, so nest is correct on
787
+ // both the 'moved' and 'skipped' paths — it only bails on a 'blocked'
788
+ // (clamped cross-column) relocation.
789
+ if (relocateSubtree(id, resolveInsertIndex(reader, parentId, 'end')) !== 'blocked') {
790
+ reparentSubtree(members, id, parentId);
791
+ }
792
+ });
793
+ };
794
+ /**
795
+ * Promote `id` (and its subtree) to root, as one undo step. No-op when the
796
+ * id is unknown or already at root. Same column-boundary caveat as
797
+ * {@link nest}: unnesting a `column` member out to root is a graceful no-op
798
+ * (column membership changes go through the drag UI). Returns void.
799
+ */
800
+ const unnest = (id) => {
801
+ const node = getById(id);
802
+ if (node === null) {
803
+ return;
804
+ }
805
+ const parentId = node.parentId;
806
+ if (parentId === null) {
807
+ return;
808
+ }
809
+ // Column-boundary no-op: a column member is detached only via the drag UI.
810
+ if (isColumnChild(id)) {
811
+ return;
812
+ }
813
+ // Move id's subtree out past its former parent's whole subtree before
814
+ // clearing the parent, so the promoted block doesn't strand itself between
815
+ // the parent and its remaining children (which would break contiguity).
816
+ // As with nest, only clear the parent when the relocation succeeded — a
817
+ // clamped cross-column move must stay a graceful no-op.
818
+ const members = captureSubtreeParents(id);
819
+ transact(() => {
820
+ // As with nest, reparentSubtree asserts the root's (null) parent on both
821
+ // 'moved' and 'skipped'; only a 'blocked' clamp keeps it a no-op.
822
+ if (relocateSubtree(id, resolveInsertIndex(reader, parentId, 'end')) !== 'blocked') {
823
+ reparentSubtree(members, id, null);
824
+ }
825
+ });
826
+ };
827
+ const remove = (id) => {
828
+ if (editor.blocks.getBlockIndex(id) === undefined) {
829
+ return;
830
+ }
831
+ // Remove the block AND its descendants. Blok's single-block delete promotes
832
+ // a (non-columns) container's children to root, which would orphan the
833
+ // nested structure the caller meant to discard. Delete deepest-first (by
834
+ // descending flat index) so a parent is childless by the time it's deleted
835
+ // (no promotion). One undo step.
836
+ //
837
+ // Re-resolve each member's index by id AT DELETE TIME rather than reusing a
838
+ // pre-captured snapshot: a core delete can cascade (deleting a column's last
839
+ // child auto-removes the empty column) or shift indices, so a stale index
840
+ // could target the wrong — or an already-gone — block.
841
+ const orderedIds = collectSubtreeIds(id)
842
+ .map((subId) => ({ subId, index: editor.blocks.getBlockIndex(subId) }))
843
+ .filter((m) => m.index !== undefined)
844
+ .sort((a, b) => b.index - a.index)
845
+ .map((m) => m.subId);
846
+ transact(() => {
847
+ for (const subId of orderedIds) {
848
+ const index = editor.blocks.getBlockIndex(subId);
849
+ // Already removed by a cascading delete of one of its descendants.
850
+ if (index === undefined) {
851
+ continue;
852
+ }
853
+ void editor.blocks.delete(index, false);
854
+ }
855
+ });
856
+ };
857
+ /**
858
+ * Move `id` to a flat slot, as a single operation. No-op when `id` is
859
+ * unknown (probed via the silent snapshot, NOT getBlockIndex, which warns on
860
+ * unknown ids); when a relative `{ before|after }` target references `id`
861
+ * itself, one of its descendants (a block can't be a sibling of its own
862
+ * child), or a ref that does not exist (an unresolved relative target must
863
+ * not silently dump the block at the document end); or for ANY absolute
864
+ * `{ toIndex }` of a block that HAS descendants (a multi-block subtree can't
865
+ * land on one index — see the subtree note below). Guards run BEFORE the
866
+ * index is resolved into a final move.
867
+ *
868
+ * Parent-adoption side effect: `before`/`after` make the moved block a
869
+ * SIBLING of the ref — it adopts the REF's parent (moving before/after a
870
+ * child of a container nests it into that container; moving before/after a
871
+ * root block unnests it to root). `after` clears the ref's WHOLE subtree, so
872
+ * the block lands past the ref's descendants, not among them. The adopted
873
+ * parent is the ref's — NOT the parent of whatever block happens to sit at the
874
+ * landing slot, which would nest under the ref for `after` a ref-with-children.
875
+ * Use {@link nest}/{@link unnest} to change the parent without choosing a
876
+ * sibling slot. Returns void.
877
+ *
878
+ * Subtree-aware: a block WITH descendants relocates its WHOLE subtree as one
879
+ * undo step (core's single move() carries only DOM-contained descendants, so
880
+ * a naive move would strand indent/parentId-nested children before their
881
+ * parent and corrupt DFS contiguity). Only relative `{ before|after }`
882
+ * targets name an unambiguous slot for a multi-block subtree; an absolute
883
+ * `{ toIndex }` of a block that HAS descendants is ambiguous (k blocks can't
884
+ * all land on one index) and is a graceful no-op — use `{ before|after }` to
885
+ * relocate a subtree.
886
+ */
887
+ const move = (id, target) => {
888
+ // Silent existence probe (NOT getBlockIndex, which warns on unknown ids).
889
+ if (getById(id) === null) {
890
+ return;
891
+ }
892
+ // id is known, so getBlockIndex won't warn.
893
+ const fromIndex = editor.blocks.getBlockIndex(id);
894
+ if (fromIndex === undefined) {
895
+ return;
896
+ }
897
+ if (!('toIndex' in target)) {
898
+ const ref = 'before' in target ? target.before : target.after;
899
+ // A relative target can't reference the block itself or any of its own
900
+ // descendants — a block can't become a sibling of its child.
901
+ if (ref === id || isDescendantOf(parentMap(reader), ref, id)) {
902
+ return;
903
+ }
904
+ // An unresolved relative ref must NOT fall through to a relocate-to-end:
905
+ // a missing target is a no-op, not a surprise jump to the document end.
906
+ if (getById(ref) === null) {
907
+ return;
908
+ }
909
+ }
910
+ // NB: an absolute { toIndex } needs no early guard here. A block WITH
911
+ // descendants no-ops EVERY toIndex move in the subtree branch below
912
+ // (ambiguous for a multi-block subtree); a leaf has no own-subtree range to
913
+ // land inside. So a dedicated "toIndex inside own subtree" guard would be
914
+ // dead code — the subtree-branch no-op already covers the cycle it guarded.
915
+ // Subtree-aware relocation. A block WITH descendants can't ride a single
916
+ // core move(): core carries only DOM-contained descendants (resortNested
917
+ // Blocks), so indent/parentId-nested children are left at their old slot —
918
+ // stranded BEFORE their now-moved parent in flat order, breaking the DFS
919
+ // contiguity getChildren ordering and flat-index inserts depend on, and
920
+ // corrupting saved document order. Relocate the whole subtree (as
921
+ // nest/unnest do) and re-assert each descendant's own parent; the ROOT
922
+ // keeps the parent core's post-move auto-heal gives it — move()'s
923
+ // documented parent-adoption side effect — so only descendants are
924
+ // re-parented. A blocked relocation (a clamped cross-`column` move) leaves
925
+ // everything in place, a graceful no-op like nest/unnest. An absolute
926
+ // { toIndex } is ambiguous for a multi-block subtree (see the move() doc),
927
+ // so it is a graceful no-op here — relative targets relocate the subtree.
928
+ // A leaf block (no descendants) falls through to the single-move fast path.
929
+ const subtreeMembers = captureSubtreeParents(id);
930
+ if (subtreeMembers.length > 1) {
931
+ if ('toIndex' in target) {
932
+ return;
933
+ }
934
+ const preRemovalSlot = resolveMoveIndex(reader, target);
935
+ // The relative ref whose parent the moved block adopts (toIndex returned
936
+ // above, so target is { before | after }).
937
+ const ref = 'before' in target ? target.before : target.after;
938
+ transact(() => {
939
+ const outcome = relocateSubtree(id, preRemovalSlot);
940
+ if (outcome === 'blocked') {
941
+ return;
942
+ }
943
+ reassertDescendantParents(subtreeMembers, id);
944
+ // Adopt the REF's parent (sibling-of-ref) on BOTH the 'moved' and
945
+ // 'skipped' paths. The position is already sibling-of-ref: resolveMoveIndex
946
+ // clears the ref's WHOLE subtree for `after` and uses the ref's own slot
947
+ // for `before`, so id lands as the ref's sibling. The parent must match.
948
+ // Core's post-move auto-heal does NOT give that — it adopts the parent of
949
+ // the block at the landing SLOT (pre-removal), which for `after` a
950
+ // ref-with-descendants is the ref's last descendant (parent = the ref →
951
+ // nests id UNDER the ref) and for `before` a ref whose flat predecessor
952
+ // sits in another container is that predecessor's parent. On the 'skipped'
953
+ // path no move() ran, so no auto-heal fired at all. Assert ref.parentId so
954
+ // position and parent agree regardless of path. Guarded (mirrors
955
+ // insertWithinTransaction) so a same-container reorder — the common case,
956
+ // where the landed parent already matches — fires no redundant reparent.
957
+ // Can't cycle: ref is neither id nor a descendant (guarded above), so
958
+ // ref's parent is outside id's subtree.
959
+ const intendedParent = getById(ref)?.parentId ?? null;
960
+ if ((getById(id)?.parentId ?? null) !== intendedParent) {
961
+ editor.blocks.setBlockParent(id, intendedParent);
962
+ }
963
+ });
964
+ return;
965
+ }
966
+ const resolved = resolveMoveIndex(reader, target);
967
+ // Blok's Blocks.move() removes the block (splice fromIndex) BEFORE
968
+ // re-inserting at toIndex, so toIndex lives in the POST-removal index
969
+ // space. For a relative (before/after) forward move the reference index
970
+ // shifts down by one once the block is removed, so compensate — this also
971
+ // keeps a move-to-end within Blok's `toIndex < length` bound. A literal
972
+ // { toIndex } is the caller's explicit final resting index: pass through.
973
+ const isRelative = !('toIndex' in target);
974
+ const toIndex = isRelative && fromIndex < resolved ? resolved - 1 : resolved;
975
+ if (!isRelative) {
976
+ // Absolute { toIndex }: the caller chose the slot, so let core's auto-heal
977
+ // adopt that slot's container (the documented absolute-move semantics).
978
+ editor.blocks.move(toIndex, fromIndex);
979
+ return;
980
+ }
981
+ // Relative leaf move: same sibling-of-ref rule as the subtree branch — the
982
+ // moved block adopts the REF's parent, not the landing-slot neighbour's
983
+ // (which auto-heal would give: the ref's descendant for `after`, or a
984
+ // cross-container predecessor for `before`). Assert it in the same undo step
985
+ // as the move, guarded so a same-container reorder fires no redundant
986
+ // reparent. Can't cycle: ref is guarded to be neither id nor a descendant,
987
+ // and a leaf has no descendants for ref's parent to fall inside.
988
+ const ref = 'before' in target ? target.before : target.after;
989
+ transact(() => {
990
+ editor.blocks.move(toIndex, fromIndex);
991
+ const intendedParent = getById(ref)?.parentId ?? null;
992
+ if ((getById(id)?.parentId ?? null) !== intendedParent) {
993
+ editor.blocks.setBlockParent(id, intendedParent);
994
+ }
995
+ });
996
+ };
997
+ /**
998
+ * Perform one insert (create + intended-parent assertion) WITHOUT opening a
999
+ * transaction — the caller owns the undo grouping. `insert` wraps a single
1000
+ * call in its own transact; `insertMany` shares ONE transact across the whole
1001
+ * batch so the bulk insert is a single undo step. Returns the created node,
1002
+ * or null when nothing was inserted (idempotent hit returns the existing
1003
+ * node; a guard failure returns null).
1004
+ */
1005
+ const insertWithinTransaction = (spec) => {
1006
+ const parentId = spec.parentId ?? null;
1007
+ const position = spec.position ?? 'end';
1008
+ const data = spec.data ?? {};
1009
+ // Programmatic insert must not steal the caret unless explicitly asked.
1010
+ const needToFocus = spec.focus ?? false;
1011
+ const replace = spec.replace ?? false;
1012
+ // Every pre-insert guard (id-exists, dangling-parent, missing-ref) and the
1013
+ // replace-parent lookup used to call `getById`, which re-enumerates the
1014
+ // WHOLE tree each time — O(probes·n) per spec, O(k·probes·n) per batch.
1015
+ // The tree can't change until `editor.blocks.insert` runs below, so take
1016
+ // ONE snapshot here and resolve every pre-insert probe against it.
1017
+ const preById = new Map(snapshotNodes(reader).map((n) => [n.id, n]));
1018
+ const probe = (id) => preById.get(id) ?? null;
1019
+ // Idempotent insert-if-absent: a stable explicit id that already exists
1020
+ // returns the existing node without inserting a duplicate, so a re-running
1021
+ // effect is safe. Probe via the silent snapshot getById, NOT
1022
+ // editor.blocks.getBlockIndex — the latter logs a `warn` for any unknown
1023
+ // id, which would spam the console on this (expected-absent) happy path.
1024
+ //
1025
+ // Skipped under `replace`: a replace is an explicit overwrite, not an
1026
+ // insert, so an existing id must not short-circuit it (the two would
1027
+ // otherwise silently conflict and replace nothing).
1028
+ if (spec.id !== undefined && !replace) {
1029
+ const existing = probe(spec.id);
1030
+ if (existing !== null) {
1031
+ // Insert-if-absent hit: the block already existed, nothing was created.
1032
+ // insert() still returns the existing node (documented), but insertMany
1033
+ // must EXCLUDE it from its created[] result — hence created: false.
1034
+ return { node: existing, created: false };
1035
+ }
1036
+ }
1037
+ // A dangling parentId would make core throw (dev) or silently misplace the
1038
+ // block at the document end (prod). Honor the null contract instead. Probe
1039
+ // via the silent snapshot getById, NOT editor.blocks.getBlockIndex, which
1040
+ // logs a `warn` for any unknown id and would spam the console on this
1041
+ // expected-absent no-op path. Skipped under replace: a replace ignores
1042
+ // parentId entirely (the overwritten block's own parent governs), so a
1043
+ // stale parentId must not abort the overwrite.
1044
+ if (!replace && parentId !== null && probe(parentId) === null) {
1045
+ return { node: null, created: false };
1046
+ }
1047
+ // A replace targets the before/after ref block ITSELF (the "turn into"
1048
+ // block being overwritten), not a sibling anchor. It therefore REQUIRES an
1049
+ // object position naming that ref:
1050
+ // - with position 'start'/'end' (or omitted) there is no target ref, so
1051
+ // a replace has nothing to overwrite — return null rather than silently
1052
+ // overwriting whatever block happens to sit at the resolved slot;
1053
+ // - with an object position whose ref doesn't exist there is likewise
1054
+ // nothing to overwrite — return null instead of falling through to
1055
+ // resolveInsertIndex's end-slot fallback (which would insert/replace at
1056
+ // the wrong place and reparent a dangling target).
1057
+ // Validation is skipped for a plain insert (above), so this is the only
1058
+ // guard that protects the replace path.
1059
+ if (replace) {
1060
+ if (typeof position !== 'object') {
1061
+ return { node: null, created: false };
1062
+ }
1063
+ const replaceTargetRef = 'before' in position ? position.before : position.after;
1064
+ if (probe(replaceTargetRef) === null) {
1065
+ return { node: null, created: false };
1066
+ }
1067
+ }
1068
+ // A plain insert with an object position naming a ref that does NOT exist
1069
+ // must be a no-op (return null), NOT a silent append at the document/parent
1070
+ // end. resolveInsertIndex falls back to the end slot for an unresolved ref,
1071
+ // which would dump the block somewhere surprising — a DX footgun. Mirror
1072
+ // move(), which already bails on a missing relative ref. (Skipped under
1073
+ // replace: the block above already validated the replace ref.)
1074
+ if (!replace && typeof position === 'object') {
1075
+ const positionTargetRef = 'before' in position ? position.before : position.after;
1076
+ if (probe(positionTargetRef) === null) {
1077
+ return { node: null, created: false };
1078
+ }
1079
+ }
1080
+ const flatIndex = resolveInsertIndex(reader, parentId, position, replace);
1081
+ // A replace is a positional type-swap that PRESERVES the replaced block's
1082
+ // parent link, so the intended parent is the target's existing parent, not
1083
+ // the caller's (root-defaulting) parentId. Capture it from the pre-insert
1084
+ // snapshot so the post-insert assertion re-nests the replacement correctly
1085
+ // instead of un-nesting it to root. A plain insert uses parentId as-is.
1086
+ const positionRef = (() => {
1087
+ if (typeof position !== 'object') {
1088
+ return null;
1089
+ }
1090
+ return 'before' in position ? position.before : position.after;
1091
+ })();
1092
+ const replaceRef = replace ? positionRef : null;
1093
+ const intendedParentId = replaceRef !== null ? probe(replaceRef)?.parentId ?? null : parentId;
1094
+ const created = (() => {
1095
+ try {
1096
+ return editor.blocks.insert(spec.type, data, {}, flatIndex, needToFocus, replace, spec.id, spec.tunes);
1097
+ }
1098
+ catch (error) {
1099
+ // The only EXPECTED throw here is an unknown/missing tool — core throws
1100
+ // a typed ToolNotFoundError. (A missing replace TARGET is a bare Error,
1101
+ // not this type, so it would re-throw — but the replace ref is already
1102
+ // pre-validated above, so it never reaches here.) Honor the null contract
1103
+ // for ToolNotFoundError; re-throw anything else so a genuine
1104
+ // bug surfaces instead of being masked as a null return. Keyed on the
1105
+ // error TYPE, not a 'not found' substring, so an unrelated error whose
1106
+ // message happens to contain "not found" is not wrongly swallowed.
1107
+ if (error instanceof ToolNotFoundError) {
1108
+ return null;
1109
+ }
1110
+ throw error;
1111
+ }
1112
+ })();
1113
+ if (created === undefined || created === null) {
1114
+ return { node: null, created: false };
1115
+ }
1116
+ // Assert the intended parent. Core derives a new block's parent from its
1117
+ // flat predecessor, so a block appended right after a `column` child is
1118
+ // auto-nested into that column. We know the caller's intent: for a
1119
+ // parented insert set the parent; for a root insert (parentId === null)
1120
+ // override back to root ONLY if core nested it, keeping the natural
1121
+ // `insert({ position: 'end' })` a root sibling instead of a stowaway. For
1122
+ // a replace, intendedParentId is the overwritten block's own parent, so
1123
+ // the replacement keeps its place in the tree.
1124
+ // ONE post-insert snapshot for both the landed-parent check and the return
1125
+ // node (was two separate getById enumerations). A freshly-created block has
1126
+ // no children, so reparenting it only flips its own parentId — its derived
1127
+ // contentIds stay `[]`. We therefore reconstruct the corrected node in place
1128
+ // rather than re-enumerating the tree a third time after setBlockParent.
1129
+ const createdNode = snapshotNodes(reader).find((n) => n.id === created.id) ?? null;
1130
+ const landedParentId = createdNode?.parentId ?? null;
1131
+ const node = landedParentId === intendedParentId
1132
+ ? createdNode
1133
+ : (() => {
1134
+ editor.blocks.setBlockParent(created.id, intendedParentId);
1135
+ return createdNode === null ? null : { ...createdNode, parentId: intendedParentId };
1136
+ })();
1137
+ // Position the caret inside the freshly-created block when the caller asked
1138
+ // for a specific spot (beyond the boolean `focus`). Applied only on a real
1139
+ // creation — an insert-if-absent hit returned earlier, so it never reaches
1140
+ // here. setToBlock takes the block id directly.
1141
+ if (spec.caret !== undefined) {
1142
+ editor.caret.setToBlock(created.id, spec.caret.position ?? 'default', spec.caret.offset ?? 0);
1143
+ }
1144
+ return { node, created: true };
1145
+ };
1146
+ /**
1147
+ * Insert one block. Returns the created {@link BlockNode}, or null when the
1148
+ * insert is rejected — an unknown tool type (core "…not found"), a dangling
1149
+ * `parentId`, or a `replace` whose target ref doesn't exist. An explicit
1150
+ * `id` that already exists is insert-if-absent: the existing node is returned
1151
+ * and nothing is created (skipped under `replace`, which is an explicit
1152
+ * overwrite). Validation runs BEFORE the slot is resolved; a `replace`
1153
+ * preserves the overwritten block's parent. Always one atomic undo step.
1154
+ *
1155
+ * The returned node is a fresh-snapshot view (its `contentIds` are derived
1156
+ * per call) — read it immediately; do NOT place it in a `useMemo`/`useEffect`
1157
+ * dependency array expecting per-mutation identity.
1158
+ */
1159
+ const insert = (spec = {}) => {
1160
+ // Mutable property on a const holder (no `let`): the node captured from
1161
+ // inside the transact closure for the post-transact return.
1162
+ const result = { node: null };
1163
+ // Always atomic: a single undo step removes the new block (and, for a
1164
+ // parented insert, its reparent) — and gives the insert its own boundary
1165
+ // instead of merging into adjacent typing history.
1166
+ transact(() => {
1167
+ // insert() returns the resolved node — including an insert-if-absent hit's
1168
+ // existing node (documented) — so it reads only `.node`, not `.created`.
1169
+ result.node = insertWithinTransaction(spec).node;
1170
+ });
1171
+ return result.node;
1172
+ };
1173
+ const insertMany = (specs) => {
1174
+ // An empty batch opens no transaction (no spurious undo boundary).
1175
+ if (specs.length === 0) {
1176
+ return [];
1177
+ }
1178
+ const created = [];
1179
+ // ONE transact for the whole batch → a single atomic undo step. Each spec
1180
+ // still runs the full single-insert path (parent assertion, positioning).
1181
+ // Per the documented contract, the result holds ONLY successfully-created
1182
+ // nodes: specs that fail to insert (null node) AND insert-if-absent hits
1183
+ // (existing block, created: false) are both dropped.
1184
+ transact(() => {
1185
+ for (const spec of specs) {
1186
+ const result = insertWithinTransaction(spec);
1187
+ if (result.created && result.node !== null) {
1188
+ created.push(result.node);
1189
+ }
1190
+ }
1191
+ });
1192
+ return created;
1193
+ };
1194
+ /**
1195
+ * Insert a pre-built nested subtree as ONE atomic operation. See the
1196
+ * {@link UseBlocksApi.insertTree} contract. Flattens the spec to a DFS
1197
+ * pre-order `OutputBlockData[]` — wiring every node's `parent`/`content`
1198
+ * links from ids generated up front — then delegates to core's tree-aware
1199
+ * `blocks.insertMany` inside a single transact.
1200
+ */
1201
+ const insertTree = (spec) => {
1202
+ const parentId = spec.parentId ?? null;
1203
+ const position = spec.position ?? 'end';
1204
+ // Dangling root parentId: mirror `insert`'s guard via the silent snapshot
1205
+ // getById (NOT getBlockIndex, which warns on unknown ids). Reject so the
1206
+ // subtree isn't silently dumped at the document end.
1207
+ if (parentId !== null && getById(parentId) === null) {
1208
+ return null;
1209
+ }
1210
+ // Dangling relative position ref: an object { before|after } naming a block
1211
+ // that does not exist must be a no-op, NOT a silent append at the document
1212
+ // end (resolveInsertIndex's unresolved-ref fallback). Mirror insert()'s
1213
+ // missing-ref guard and reject before flattening anything.
1214
+ if (typeof position === 'object') {
1215
+ const positionTargetRef = 'before' in position ? position.before : position.after;
1216
+ if (getById(positionTargetRef) === null) {
1217
+ return null;
1218
+ }
1219
+ }
1220
+ // Every node needs a stable id BEFORE flattening so parent/content links
1221
+ // can reference siblings/children. Respect an explicit node id; generate
1222
+ // one (core's nanoid scheme) otherwise.
1223
+ const flat = [];
1224
+ // Collision guard: an explicit id that already exists in the tree (or is
1225
+ // reused within this same spec) would create a duplicate-id block,
1226
+ // corrupting every id-keyed lookup (getById, parent/content links). A tree
1227
+ // insert always creates fresh blocks (it is NOT insert-if-absent), so a
1228
+ // collision is rejected up front — nothing is inserted and null is
1229
+ // returned, mirroring insert's null contract. Generated ids never collide.
1230
+ // Mutable property on a const holder (no `let`): flipped from inside the
1231
+ // recursive visit when a colliding explicit id is seen.
1232
+ const usedIds = new Set();
1233
+ const collision = { hit: false };
1234
+ // Pre-order DFS: push self FIRST, then recurse each child, so the flat
1235
+ // array is DFS-contiguous (the invariant core's insertMany + the hook's
1236
+ // getChildren depend on). `content` is the live child-id array, filled as
1237
+ // each child is visited and returns its id. The root's `parent` is the
1238
+ // resolved placement parent (undefined for root level); children's `parent`
1239
+ // is their enclosing node's id.
1240
+ const visit = (node, parent) => {
1241
+ if (node.id !== undefined && (usedIds.has(node.id) || getById(node.id) !== null)) {
1242
+ collision.hit = true;
1243
+ }
1244
+ const id = node.id ?? generateBlockId();
1245
+ usedIds.add(id);
1246
+ const content = [];
1247
+ const flatNode = {
1248
+ id,
1249
+ // `type` is OMITTED (not present-with-undefined) when absent so core's
1250
+ // `type || defaultBlock` fallback resolves the default block cleanly.
1251
+ ...(node.type !== undefined ? { type: node.type } : {}),
1252
+ data: node.data ?? {},
1253
+ content,
1254
+ ...(node.tunes !== undefined ? { tunes: node.tunes } : {}),
1255
+ ...(parent !== undefined ? { parent } : {}),
1256
+ };
1257
+ flat.push(flatNode);
1258
+ for (const child of node.children ?? []) {
1259
+ content.push(visit(child, id));
1260
+ }
1261
+ return id;
1262
+ };
1263
+ const rootId = visit(spec, parentId ?? undefined);
1264
+ // A colliding explicit id is rejected before any insert (see usedIds doc).
1265
+ if (collision.hit) {
1266
+ return null;
1267
+ }
1268
+ const flatIndex = resolveInsertIndex(reader, parentId, position);
1269
+ // ONE transact for the whole subtree → a single atomic undo step. Core's
1270
+ // insertMany composes EVERY node before inserting any, so an unknown tool
1271
+ // type throws a typed ToolNotFoundError with nothing inserted. Honor the
1272
+ // same null-on-unknown-tool contract as insert/insertMany rather than
1273
+ // surfacing the throw to the caller; re-throw any other (genuine-bug)
1274
+ // error. Keyed on the error TYPE, not a 'not found' substring.
1275
+ try {
1276
+ transact(() => {
1277
+ editor.blocks.insertMany(flat, flatIndex);
1278
+ });
1279
+ }
1280
+ catch (error) {
1281
+ if (error instanceof ToolNotFoundError) {
1282
+ return null;
1283
+ }
1284
+ throw error;
1285
+ }
1286
+ return getById(rootId);
1287
+ };
1288
+ /**
1289
+ * Convert markdown to blocks and insert them ADDITIVELY — see the
1290
+ * {@link UseBlocksApi.insertMarkdown} doc for the full contract. Async
1291
+ * because the converter is lazy-loaded; the insert itself is one atomic
1292
+ * undo step. parentId nesting is supported: top-level converted blocks are
1293
+ * reparented under parentId, internally-nested ones keep their parent.
1294
+ */
1295
+ const insertMarkdown = async (markdown, options) => {
1296
+ const parentId = options?.parentId ?? null;
1297
+ const position = options?.position ?? 'end';
1298
+ // A dangling parentId is a no-op (no insert), matching `insert`/`insertMany`.
1299
+ if (parentId !== null && getById(parentId) === null) {
1300
+ return [];
1301
+ }
1302
+ // A dangling relative position ref is likewise a no-op (no insert), NOT a
1303
+ // silent append at the document end — mirror insert()/insertTree's guard.
1304
+ if (typeof position === 'object') {
1305
+ const positionTargetRef = 'before' in position ? position.before : position.after;
1306
+ if (getById(positionTargetRef) === null) {
1307
+ return [];
1308
+ }
1309
+ }
1310
+ // Lazy-load the converter (dynamic import mirrors core's markdown lazy
1311
+ // loading and keeps the parser out of the main bundle) and run it,
1312
+ // forwarding the optional MarkdownImportConfig so custom-tool consumers can
1313
+ // map markdown nodes into their tools (gfm toggle, toolMap, extensions).
1314
+ // Both awaits can fail — a chunk-load error or a malformed-markdown throw;
1315
+ // swallow them and return [] so a converter failure is a graceful no-op
1316
+ // (matching update/convert) rather than an unhandled promise rejection in
1317
+ // the caller. This await resolves BEFORE the synchronous transact.
1318
+ // Mutable property on a const holder (no `let`): captured from the try.
1319
+ const conversion = { blocks: [] };
1320
+ try {
1321
+ const { markdownToBlocks } = await import('@bloklabs/core/markdown');
1322
+ conversion.blocks = await markdownToBlocks(markdown, options?.config);
1323
+ }
1324
+ catch (error) {
1325
+ // Graceful no-op: a converter failure (chunk-load or parse error) returns
1326
+ // [] rather than surfacing an unhandled rejection to the caller. But
1327
+ // surface it to the console so a genuine converter bug is DISTINGUISHABLE
1328
+ // from empty markdown (which returns [] via the blocks.length === 0 path
1329
+ // below, without ever reaching this catch) instead of being swallowed
1330
+ // silently and losing all diagnostics.
1331
+ console.warn('useBlocks.insertMarkdown: markdown conversion failed', error);
1332
+ return [];
1333
+ }
1334
+ const blocks = conversion.blocks;
1335
+ // Empty / whitespace-only markdown opens no transaction (no undo boundary).
1336
+ if (blocks.length === 0) {
1337
+ return [];
1338
+ }
1339
+ // Re-validate the parent AFTER the await: the pre-await existence check can
1340
+ // go stale if the parent was removed while the converter was in flight.
1341
+ // Stamping a now-dangling parent would orphan the blocks instead of the
1342
+ // promised [] no-op, so re-check and bail out here.
1343
+ if (parentId !== null && getById(parentId) === null) {
1344
+ return [];
1345
+ }
1346
+ // Re-validate the relative position ref after the await too: the target
1347
+ // could have been removed while the converter was in flight, in which case
1348
+ // inserting at the stale slot would surprise the caller. No-op instead.
1349
+ if (typeof position === 'object') {
1350
+ const positionTargetRef = 'before' in position ? position.before : position.after;
1351
+ if (getById(positionTargetRef) === null) {
1352
+ return [];
1353
+ }
1354
+ }
1355
+ // Nest under the parent by stamping `parent` on each TOP-LEVEL block (one
1356
+ // the converter left un-parented). Blocks the markdown nested internally
1357
+ // (their `parent` already points at a sibling in this batch) are untouched,
1358
+ // so the import's own structure is preserved.
1359
+ const seeded = parentId === null
1360
+ ? blocks
1361
+ : blocks.map((block) => block.parent === undefined || block.parent === null
1362
+ ? { ...block, parent: parentId }
1363
+ : block);
1364
+ const flatIndex = resolveInsertIndex(reader, parentId, position);
1365
+ // Mutable property on a const holder (no `let`): captured from inside the
1366
+ // transact closure. insertMany returns BlockAPI[] (each has `.id`) — the
1367
+ // reliable record of what was created, since the converter's ids may not
1368
+ // survive composition. ONE transact → a single atomic undo step.
1369
+ const result = { created: [] };
1370
+ // The insert lives OUTSIDE the conversion try/catch, so an unknown mapped
1371
+ // tool (core throws a typed ToolNotFoundError) would otherwise surface as an
1372
+ // unhandled promise rejection. Honor the same null-on-unknown-tool contract
1373
+ // as insertTree — return [] — and re-throw any other (genuine-bug) error.
1374
+ // Keyed on the error TYPE, not a 'not found' substring.
1375
+ try {
1376
+ transact(() => {
1377
+ result.created = editor.blocks.insertMany(seeded, flatIndex);
1378
+ });
1379
+ }
1380
+ catch (error) {
1381
+ if (error instanceof ToolNotFoundError) {
1382
+ return [];
1383
+ }
1384
+ throw error;
1385
+ }
1386
+ return result.created
1387
+ .map((block) => getById(block.id))
1388
+ .filter((node) => node !== null);
1389
+ };
1390
+ const update = (id, data, tunes) => {
1391
+ // Silent existence probe (NOT getBlockIndex, which warns on unknown ids).
1392
+ if (getById(id) === null) {
1393
+ return;
1394
+ }
1395
+ // Core update is async and forms its own undo/Yjs step, so it is NOT
1396
+ // wrapped in transact (that would close the group before the write lands).
1397
+ // Swallow any rejection so it can't surface as an unhandled rejection.
1398
+ void Promise.resolve(editor.blocks.update(id, data, tunes)).catch(() => undefined);
1399
+ };
1400
+ const convert = (id, newType, dataOverrides, options) => {
1401
+ if (getById(id) === null) {
1402
+ return;
1403
+ }
1404
+ // Core convert is async and rejects when a tool lacks a conversionConfig.
1405
+ // Like update, it owns its own history step (no transact). Position the
1406
+ // caret only AFTER a successful convert (matching the in-editor keyboard
1407
+ // turn-into, which preserves the caret) when the caller asked for it.
1408
+ // Swallow the rejection so a non-convertible block is a graceful no-op —
1409
+ // and on rejection the caret is left untouched.
1410
+ void Promise.resolve(editor.blocks.convert(id, newType, dataOverrides))
1411
+ .then((converted) => {
1412
+ if (options?.caret !== undefined) {
1413
+ // Core convert routes through replace(), which regenerates the block
1414
+ // id — the resolved BlockAPI carries the NEW id. Target it (falling
1415
+ // back to the original only if a faithless path resolves nothing) so
1416
+ // the caret lands in the converted block instead of a stale id.
1417
+ const targetId = converted?.id ?? id;
1418
+ editor.caret.setToBlock(targetId, options.caret.position ?? 'default', options.caret.offset ?? 0);
1419
+ }
1420
+ })
1421
+ .catch(() => undefined);
1422
+ };
1423
+ const transactWithoutCapture = (fn) => {
1424
+ if (editor.blocks.transactWithoutCapture !== undefined) {
1425
+ editor.blocks.transactWithoutCapture(fn);
1426
+ }
1427
+ else {
1428
+ fn();
1429
+ }
1430
+ };
1431
+ const getBlocksCount = () => {
1432
+ onRead();
1433
+ return editor.blocks.getBlocksCount();
1434
+ };
1435
+ const getCurrentBlockIndex = () => {
1436
+ onRead();
1437
+ return editor.blocks.getCurrentBlockIndex();
1438
+ };
1439
+ const getBlockByIndex = (index) => {
1440
+ onRead();
1441
+ const block = editor.blocks.getBlockByIndex(index);
1442
+ return block === undefined ? null : getById(block.id);
1443
+ };
1444
+ const getBlockByElement = (element) => {
1445
+ onRead();
1446
+ const block = editor.blocks.getBlockByElement(element);
1447
+ return block === undefined ? null : getById(block.id);
1448
+ };
1449
+ const composeBlockData = (toolName) => editor.blocks.composeBlockData(toolName);
1450
+ const getBlockData = (id) => {
1451
+ // Silent existence probe via the snapshot getById (NOT editor.blocks.getById,
1452
+ // which logs a `warn` for an unknown id) so a miss is a quiet null, matching
1453
+ // getBlockIndex and the other id-taking readers.
1454
+ if (getById(id) === null) {
1455
+ return null;
1456
+ }
1457
+ const block = editor.blocks.getById(id);
1458
+ if (block === null) {
1459
+ return null;
1460
+ }
1461
+ // preservedData/preservedTunes are core's SYNCHRONOUS last-extracted view —
1462
+ // the same snapshot clipboard ops read. Returning it (rather than the async
1463
+ // save()) keeps this reader synchronous so a block can be read and re-inserted
1464
+ // (duplicated) inside one render/handler without the ref escape hatch.
1465
+ return { data: block.preservedData, tunes: block.preservedTunes };
1466
+ };
1467
+ const getBlockIndex = (id) => {
1468
+ if (getById(id) === null) {
1469
+ // Silent existence probe (NOT editor.blocks.getBlockIndex, which warns on
1470
+ // unknown ids) so a miss is a quiet null, matching every other reader.
1471
+ return null;
1472
+ }
1473
+ return editor.blocks.getBlockIndex(id) ?? null;
1474
+ };
1475
+ const renderFromHTML = (html) => editor.blocks.renderFromHTML(html);
1476
+ const splitBlock = (currentBlockId, currentBlockData, newBlockType, newBlockData, insertIndex) => {
1477
+ // Silent no-op for an unknown current block, matching every other id-taking
1478
+ // mutator. Probe via the snapshot getById (NOT getBlockIndex, which warns).
1479
+ if (getById(currentBlockId) === null) {
1480
+ return null;
1481
+ }
1482
+ // A negative insertIndex is malformed — core would forward it to a splice
1483
+ // (which counts from the array end) and silently split at the wrong slot.
1484
+ // Honor the silent-no-op convention instead (consistent with
1485
+ // insertOutputData's negative-index guard): return null without touching core.
1486
+ if (insertIndex < 0) {
1487
+ return null;
1488
+ }
1489
+ // An unknown newBlockType makes core's compose path throw a typed
1490
+ // ToolNotFoundError — honor the null contract (mirroring insert/insertTree);
1491
+ // re-throw any other (genuine-bug) error. Keyed on the error TYPE.
1492
+ const created = (() => {
1493
+ try {
1494
+ return editor.blocks.splitBlock(currentBlockId, currentBlockData, newBlockType, newBlockData, insertIndex);
1495
+ }
1496
+ catch (error) {
1497
+ if (error instanceof ToolNotFoundError) {
1498
+ return null;
1499
+ }
1500
+ throw error;
1501
+ }
1502
+ })();
1503
+ return created === undefined || created === null ? null : getById(created.id);
1504
+ };
1505
+ const insertOutputData = (blocks, options) => {
1506
+ // An empty batch opens no transaction (no spurious undo boundary).
1507
+ if (blocks.length === 0) {
1508
+ return [];
1509
+ }
1510
+ // A negative index is malformed — core throws a bare validation Error. Honor
1511
+ // the silent-no-op convention instead: return [] without inserting (no
1512
+ // transaction, no surprise end-append), consistent with the rest of the API.
1513
+ if (options?.index !== undefined && options.index < 0) {
1514
+ return [];
1515
+ }
1516
+ // ONE transact for the whole batch → a single atomic undo step. Delegates
1517
+ // to core's raw insertMany, which honors each block's parent/content links.
1518
+ // Honor the same null/[]-on-unknown-tool contract as insertTree (core throws
1519
+ // a typed ToolNotFoundError); re-throw any other (genuine-bug) error.
1520
+ const result = { created: [] };
1521
+ try {
1522
+ transact(() => {
1523
+ result.created =
1524
+ options?.index !== undefined
1525
+ ? editor.blocks.insertMany(blocks, options.index)
1526
+ : editor.blocks.insertMany(blocks);
1527
+ });
1528
+ }
1529
+ catch (error) {
1530
+ if (error instanceof ToolNotFoundError) {
1531
+ return [];
1532
+ }
1533
+ throw error;
1534
+ }
1535
+ return result.created
1536
+ .map((block) => getById(block.id))
1537
+ .filter((node) => node !== null);
1538
+ };
1539
+ /**
1540
+ * Insert one child block under `parentId` at flat `insertIndex`, atomically.
1541
+ * Delegates to core's `insertInsideParent`, which groups the block creation
1542
+ * AND the parent assignment into a single undo entry itself — so this is NOT
1543
+ * wrapped in the hook's `transact` (that would be a redundant nested group).
1544
+ * A dangling parentId is a no-op (null), mirroring `insert`'s parent guard;
1545
+ * an unknown child tool throws a typed ToolNotFoundError from core's compose
1546
+ * path — honor the null contract and re-throw anything else (genuine bug).
1547
+ */
1548
+ const insertInsideParent = (parentId, insertIndex, childData) => {
1549
+ // Silent existence probe (NOT getBlockIndex, which warns on unknown ids).
1550
+ if (getById(parentId) === null) {
1551
+ return null;
1552
+ }
1553
+ const created = (() => {
1554
+ try {
1555
+ return editor.blocks.insertInsideParent(parentId, insertIndex, childData);
1556
+ }
1557
+ catch (error) {
1558
+ if (error instanceof ToolNotFoundError) {
1559
+ return null;
1560
+ }
1561
+ throw error;
1562
+ }
1563
+ })();
1564
+ return created === undefined || created === null ? null : getById(created.id);
1565
+ };
1566
+ /**
1567
+ * Replace the whole document with saved {@link OutputData} — a document-LOAD
1568
+ * primitive (clears existing content first), the counterpart of the additive
1569
+ * {@link insertOutputData}. Pure delegation to core's async `render`.
1570
+ */
1571
+ const render = (data) => editor.blocks.render(data);
1572
+ /** Remove every block — document reset. Pure delegation to core's async `clear`. */
1573
+ const clear = () => editor.blocks.clear();
1574
+ /**
1575
+ * Serialize the document to Markdown — the read-side twin of
1576
+ * {@link UseBlocksApi.insertMarkdown}. Pure delegation to core's async
1577
+ * `exportMarkdown` (which lazy-loads the serializer).
1578
+ */
1579
+ const exportMarkdown = () => editor.blocks.exportMarkdown();
1580
+ /**
1581
+ * The LIVE Yjs-sync flag, read at call time (the api handle is memoized, so a
1582
+ * cached property would go stale). Pure delegation to core's read-only flag.
1583
+ */
1584
+ const isSyncingFromYjs = () => {
1585
+ onRead();
1586
+ return editor.blocks.isSyncingFromYjs;
1587
+ };
1588
+ // Every key is listed EXPLICITLY — do NOT spread `...EMPTY_API` here. The
1589
+ // return is typed `UseBlocksApi`, so an explicit list makes a forgotten live
1590
+ // wiring a COMPILE error (missing property). Spreading EMPTY_API would instead
1591
+ // backfill the missing key with its pre-ready no-op stub, silently shipping a
1592
+ // method that does nothing when the editor IS ready — a hole no key-presence
1593
+ // test can catch (the key is present, just wrong). Keep it exhaustive.
1594
+ return {
1595
+ getById,
1596
+ getChildren,
1597
+ insert,
1598
+ insertMany,
1599
+ insertTree,
1600
+ insertMarkdown,
1601
+ exportMarkdown,
1602
+ move,
1603
+ nest,
1604
+ unnest,
1605
+ remove,
1606
+ update,
1607
+ convert,
1608
+ transact,
1609
+ transactWithoutCapture,
1610
+ getBlocksCount,
1611
+ getCurrentBlockIndex,
1612
+ getBlockByIndex,
1613
+ getBlockByElement,
1614
+ getBlockData,
1615
+ getBlockIndex,
1616
+ composeBlockData,
1617
+ renderFromHTML,
1618
+ insertOutputData,
1619
+ splitBlock,
1620
+ insertInsideParent,
1621
+ render,
1622
+ clear,
1623
+ isSyncingFromYjs,
1624
+ };
1625
+ };
1626
+
1627
+ /**
1628
+ * Single source of truth for interpreting the `readOnly` config option.
1629
+ * The object form always means read-only is enabled.
1630
+ * @param value - raw `readOnly` value from BlokConfig
1631
+ */
1632
+ function normalizeReadOnlyConfig(value) {
1633
+ if (typeof value === 'object' && value !== null) {
1634
+ return { enabled: true, hideControls: value.hideControls === true };
1635
+ }
1636
+ return { enabled: value === true, hideControls: false };
1637
+ }
1638
+
1639
+ /**
1640
+ * Structural deep equality for JSON-like values (Blok `OutputData`).
1641
+ *
1642
+ * Framework-agnostic: shared by the React and Angular adapters to dedupe
1643
+ * reactive `data` updates so identical content does not trigger a redundant
1644
+ * re-render that would clobber the caret/selection. Handles the value shapes
1645
+ * that appear in editor data: plain objects, arrays, and primitives (including
1646
+ * `null`/`undefined`).
1647
+ * @param a - first value to compare
1648
+ * @param b - second value to compare
1649
+ * @returns true when both values are structurally equal
1650
+ */
1651
+ function deepEqual(a, b) {
1652
+ if (a === b) {
1653
+ return true;
1654
+ }
1655
+ if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
1656
+ return false;
1657
+ }
1658
+ const aIsArray = Array.isArray(a);
1659
+ const bIsArray = Array.isArray(b);
1660
+ if (aIsArray !== bIsArray) {
1661
+ return false;
1662
+ }
1663
+ if (aIsArray && bIsArray) {
1664
+ if (a.length !== b.length) {
1665
+ return false;
1666
+ }
1667
+ return a.every((item, index) => deepEqual(item, b[index]));
1668
+ }
1669
+ const aObj = a;
1670
+ const bObj = b;
1671
+ const aKeys = Object.keys(aObj);
1672
+ const bKeys = Object.keys(bObj);
1673
+ if (aKeys.length !== bKeys.length) {
1674
+ return false;
1675
+ }
1676
+ return aKeys.every((key) => Object.prototype.hasOwnProperty.call(bObj, key) && deepEqual(aObj[key], bObj[key]));
1677
+ }
1678
+
1679
+ // src/shared/prop-schema.ts
1680
+ /**
1681
+ * Fill a data object against the schema: every schema key present, incoming
1682
+ * value when defined else the schema default, and ONLY schema keys (so `save()`
1683
+ * is never partial). Returns a frozen plain object — safe to hand straight to
1684
+ * core's per-key Yjs sync.
1685
+ */
1686
+ const fillDefaults = (schema, data) => {
1687
+ const result = {};
1688
+ for (const key of Object.keys(schema)) {
1689
+ result[key] = data[key] !== undefined ? data[key] : schema[key].default;
1690
+ }
1691
+ return Object.freeze(result);
1692
+ };
1693
+
1694
+ /**
1695
+ * Centralized data attributes used across the Blok editor.
1696
+ * This is the single source of truth for all data-blok-* attributes.
1697
+ *
1698
+ * Exported as Blok.DATA_ATTR for users.
1699
+ *
1700
+ * Naming convention:
1701
+ * - Remove 'data-blok-' prefix and convert to camelCase
1702
+ * - Global attributes: hidden, disabled, focused
1703
+ * - Component prefixed: popover, popoverItem, toolbar, etc.
1704
+ */
1705
+ const DATA_ATTR = {
1706
+ // ============================================
1707
+ // Core Element Identifiers
1708
+ // ============================================
1709
+ /** Interface type identifier (blok, inline-toolbar, tooltip) */
1710
+ interface: 'data-blok-interface',
1711
+ /** Block element wrapper */
1712
+ element: 'data-blok-element',
1713
+ /** Block element content wrapper */
1714
+ elementContent: 'data-blok-element-content',
1715
+ /** Editor wrapper container */
1716
+ editor: 'data-blok-editor',
1717
+ /** Redactor zone */
1718
+ redactor: 'data-blok-redactor',
1719
+ /** Present on the editor wrapper once a `blocks.render()` batch has finished
1720
+ * inserting blocks into the DOM; removed while a re-render is in flight.
1721
+ * Acts as a stable render-readiness gate for consumers (e.g. E2E waits). */
1722
+ rendered: 'data-blok-rendered',
1723
+ // ============================================
1724
+ // Block Identifiers
1725
+ // ============================================
1726
+ /** Block unique identifier */
1727
+ id: 'data-blok-id',
1728
+ /** Block component/tool type */
1729
+ component: 'data-blok-component',
1730
+ /** Tool type attribute */
1731
+ tool: 'data-blok-tool',
1732
+ /** Block nesting depth (derived from the parentId chain) */
1733
+ depth: 'data-blok-depth',
1734
+ /** Flat list-nesting indentation level (0 = root); tool-agnostic, mirrors list depth */
1735
+ indent: 'data-blok-indent',
1736
+ // ============================================
1737
+ // Global States
1738
+ // ============================================
1739
+ /** Element is hidden from view */
1740
+ hidden: 'data-blok-hidden',
1741
+ /** Element is disabled and non-interactive */
1742
+ disabled: 'data-blok-disabled',
1743
+ /** Element is focused via keyboard navigation */
1744
+ focused: 'data-blok-focused',
1745
+ /** Block is selected */
1746
+ selected: 'data-blok-selected',
1747
+ /** Block is stretched */
1748
+ stretched: 'data-blok-stretched',
1749
+ /** Editor or element is empty */
1750
+ empty: 'data-blok-empty',
1751
+ // ============================================
1752
+ // Editor Modes
1753
+ // ============================================
1754
+ /** Content alignment mode (left, center, right) */
1755
+ contentAlign: 'data-blok-content-align',
1756
+ /** Right-to-left mode */
1757
+ rtl: 'data-blok-rtl',
1758
+ /** Editor content width mode (present with value "full" for wide mode; absent = narrow) */
1759
+ width: 'data-blok-width',
1760
+ // ============================================
1761
+ // Drag and Drop
1762
+ // ============================================
1763
+ /** Block is being dragged */
1764
+ dragging: 'data-blok-dragging',
1765
+ /** Multiple blocks being dragged */
1766
+ draggingMulti: 'data-blok-dragging-multi',
1767
+ /** Block is being duplicated (Alt+drag) */
1768
+ duplicating: 'data-blok-duplicating',
1769
+ /** Drag handle element */
1770
+ dragHandle: 'data-blok-drag-handle',
1771
+ // ============================================
1772
+ // Toolbar
1773
+ // ============================================
1774
+ /** Toolbar element */
1775
+ toolbar: 'data-blok-toolbar',
1776
+ /** Settings toggler button */
1777
+ settingsToggler: 'data-blok-settings-toggler',
1778
+ /** Toolbox is open */
1779
+ toolboxOpened: 'data-blok-toolbox-opened',
1780
+ /** Block settings is open */
1781
+ blockSettingsOpened: 'data-blok-block-settings-opened',
1782
+ /** Element is opened (generic) */
1783
+ opened: 'data-blok-opened',
1784
+ // ============================================
1785
+ // Popover Container
1786
+ // ============================================
1787
+ /** Root popover element */
1788
+ popover: 'data-blok-popover',
1789
+ /** Popover container wrapper */
1790
+ popoverContainer: 'data-blok-popover-container',
1791
+ /** Popover items list */
1792
+ popoverItems: 'data-blok-popover-items',
1793
+ /** Custom, engine-independent scrollbar thumb overlaid on the popover items */
1794
+ popoverScrollbar: 'data-blok-popover-scrollbar',
1795
+ /** Stamped on the custom scrollbar thumb while it is being dragged (keeps it revealed) */
1796
+ popoverScrollbarDragging: 'data-blok-dragging',
1797
+ /** Stamped on a scroll container while it is actively scrolling (reveals the auto-hidden scrollbar thumb) */
1798
+ scrolling: 'data-blok-scrolling',
1799
+ /** Popover overlay element */
1800
+ popoverOverlay: 'data-blok-popover-overlay',
1801
+ /** Popover custom content area */
1802
+ popoverCustomContent: 'data-blok-popover-custom-content',
1803
+ /** Popover custom class */
1804
+ popoverCustomClass: 'data-blok-popover-custom-class',
1805
+ /** Inline popover variant */
1806
+ popoverInline: 'data-blok-popover-inline',
1807
+ /** Popover is open */
1808
+ popoverOpened: 'data-blok-popover-opened',
1809
+ /** Popover opens upward */
1810
+ popoverOpenTop: 'data-blok-popover-open-top',
1811
+ /** Popover opens leftward */
1812
+ popoverOpenLeft: 'data-blok-popover-open-left',
1813
+ // ============================================
1814
+ // Popover Nesting
1815
+ // ============================================
1816
+ /** Nested popover indicator */
1817
+ nested: 'data-blok-nested',
1818
+ /** Nesting level value */
1819
+ nestedLevel: 'data-blok-nested-level',
1820
+ /** Group label for promoted search results from nested children */
1821
+ promotedGroupLabel: 'data-blok-promoted-group-label',
1822
+ /** Group label for top-level matches in search results */
1823
+ topLevelGroupLabel: 'data-blok-top-level-group-label',
1824
+ // ============================================
1825
+ // Popover Header
1826
+ // ============================================
1827
+ /** Header container */
1828
+ popoverHeader: 'data-blok-popover-header',
1829
+ /** Header text element */
1830
+ popoverHeaderText: 'data-blok-popover-header-text',
1831
+ /** Back button in nested popover */
1832
+ popoverHeaderBackButton: 'data-blok-popover-header-back-button',
1833
+ // ============================================
1834
+ // Popover Items
1835
+ // ============================================
1836
+ /** Item container */
1837
+ popoverItem: 'data-blok-popover-item',
1838
+ /** Item icon wrapper */
1839
+ popoverItemIcon: 'data-blok-popover-item-icon',
1840
+ /** Chevron icon for nested items */
1841
+ popoverItemIconChevronRight: 'data-blok-popover-item-icon-chevron-right',
1842
+ /** Item title text */
1843
+ popoverItemTitle: 'data-blok-popover-item-title',
1844
+ /** Item secondary title */
1845
+ popoverItemSecondaryTitle: 'data-blok-popover-item-secondary-title',
1846
+ /** Item is active/selected */
1847
+ popoverItemActive: 'data-blok-popover-item-active',
1848
+ /** Confirmation state */
1849
+ popoverItemConfirmation: 'data-blok-popover-item-confirmation',
1850
+ /** Disable hover styling */
1851
+ popoverItemNoHover: 'data-blok-popover-item-no-hover',
1852
+ /** Disable focus handling */
1853
+ popoverItemNoFocus: 'data-blok-popover-item-no-focus',
1854
+ /** Destructive action item (e.g. delete) */
1855
+ popoverItemDestructive: 'data-blok-popover-item-destructive',
1856
+ /** Separator item */
1857
+ popoverItemSeparator: 'data-blok-popover-item-separator',
1858
+ /** Separator line element */
1859
+ popoverItemSeparatorLine: 'data-blok-popover-item-separator-line',
1860
+ /** HTML-based item */
1861
+ popoverItemHtml: 'data-blok-popover-item-html',
1862
+ /** Item has child menu */
1863
+ hasChildren: 'data-blok-has-children',
1864
+ /** Item name identifier */
1865
+ itemName: 'data-blok-item-name',
1866
+ /** No search results shown */
1867
+ nothingFoundDisplayed: 'data-blok-nothing-found-displayed',
1868
+ // ============================================
1869
+ // Overlay / Selection
1870
+ // ============================================
1871
+ /** Selection overlay */
1872
+ overlay: 'data-blok-overlay',
1873
+ /** Overlay container */
1874
+ overlayContainer: 'data-blok-overlay-container',
1875
+ /** Selection rectangle */
1876
+ overlayRectangle: 'data-blok-overlay-rectangle',
1877
+ /** Overlay is hidden */
1878
+ overlayHidden: 'data-blok-overlay-hidden',
1879
+ /** Fake cursor indicator */
1880
+ fakeCursor: 'data-blok-fake-cursor',
1881
+ /** Fake background for selection */
1882
+ fakeBackground: 'data-blok-fake-background',
1883
+ // ============================================
1884
+ // Scroll
1885
+ // ============================================
1886
+ /** Auto-scroll zone (top/bottom) */
1887
+ scrollZone: 'data-blok-scroll-zone',
1888
+ /** Scroll is locked */
1889
+ scrollLocked: 'data-blok-scroll-locked',
1890
+ /** Hard scroll lock */
1891
+ scrollLockedHard: 'data-blok-scroll-locked-hard',
1892
+ // ============================================
1893
+ // Caret
1894
+ // ============================================
1895
+ /** Shadow caret element */
1896
+ shadowCaret: 'data-blok-shadow-caret',
1897
+ // ============================================
1898
+ // Placeholders
1899
+ // ============================================
1900
+ /** Placeholder text */
1901
+ placeholder: 'data-blok-placeholder',
1902
+ /** Active placeholder text */
1903
+ placeholderActive: 'data-blok-placeholder-active',
1904
+ // ============================================
1905
+ // Nested Blocks
1906
+ // ============================================
1907
+ /** Container that hosts nested block holders (table cells, toggle/callout/header children).
1908
+ * Used as a universal guard: before moving a block holder via appendChild,
1909
+ * check `holder.closest([nestedBlocks])` — if truthy, the holder is already
1910
+ * claimed by another container and must not be stolen. */
1911
+ nestedBlocks: 'data-blok-nested-blocks',
1912
+ // ============================================
1913
+ // Mutation Tracking
1914
+ // ============================================
1915
+ /** Element excluded from mutation tracking */
1916
+ mutationFree: 'data-blok-mutation-free',
1917
+ // ============================================
1918
+ // Navigation
1919
+ // ============================================
1920
+ /** Block has navigation focus */
1921
+ navigationFocused: 'data-blok-navigation-focused',
1922
+ /** Flipper navigation target */
1923
+ flipperNavigationTarget: 'data-blok-flipper-navigation-target',
1924
+ // ============================================
1925
+ // Inline Toolbar
1926
+ // ============================================
1927
+ /** Inline toolbar enabled on external element */
1928
+ inlineToolbar: 'data-blok-inline-toolbar',
1929
+ // ============================================
1930
+ // Link Tool
1931
+ // ============================================
1932
+ /** Link tool is active */
1933
+ linkToolActive: 'data-blok-link-tool-active',
1934
+ /** Link tool unlink mode */
1935
+ linkToolUnlink: 'data-blok-link-tool-unlink',
1936
+ /** Link tool input is opened */
1937
+ linkToolInputOpened: 'data-blok-link-tool-input-opened',
1938
+ // ============================================
1939
+ // Bold Tool
1940
+ // ============================================
1941
+ /** Bold collapsed length tracking */
1942
+ boldCollapsedLength: 'data-blok-bold-collapsed-length',
1943
+ /** Bold collapsed active state */
1944
+ boldCollapsedActive: 'data-blok-bold-collapsed-active',
1945
+ /** Bold previous length tracking */
1946
+ boldPrevLength: 'data-blok-bold-prev-length',
1947
+ /** Bold leading whitespace */
1948
+ boldLeadingWs: 'data-blok-bold-leading-ws',
1949
+ /** Bold marker */
1950
+ boldMarker: 'data-blok-bold-marker',
1951
+ // ============================================
1952
+ // Tooltip
1953
+ // ============================================
1954
+ /** Tooltip is shown */
1955
+ shown: 'data-blok-shown',
1956
+ /** Tooltip placement */
1957
+ placement: 'data-blok-placement',
1958
+ // ============================================
1959
+ // Notifier
1960
+ // ============================================
1961
+ /** Bounce in animation */
1962
+ bounceIn: 'data-blok-bounce-in',
1963
+ // ============================================
1964
+ // Announcer (Accessibility)
1965
+ // ============================================
1966
+ /** Live region announcer */
1967
+ announcer: 'data-blok-announcer',
1968
+ // ============================================
1969
+ // Stub Block
1970
+ // ============================================
1971
+ /** Stub block element */
1972
+ stub: 'data-blok-stub',
1973
+ /** Stub info section */
1974
+ stubInfo: 'data-blok-stub-info',
1975
+ /** Stub title */
1976
+ stubTitle: 'data-blok-stub-title',
1977
+ /** Stub subtitle */
1978
+ stubSubtitle: 'data-blok-stub-subtitle',
1979
+ // ============================================
1980
+ // Slash Search
1981
+ // ============================================
1982
+ /** Slash search active on content editable */
1983
+ slashSearch: 'data-blok-slash-search',
1984
+ // ============================================
1985
+ // Testing
1986
+ // ============================================
1987
+ /** Test identifier (for E2E tests) */
1988
+ testid: 'data-blok-testid',
1989
+ /** Force hover state (for tests/storybook) */
1990
+ forceHover: 'data-blok-force-hover',
1991
+ };
1992
+ /**
1993
+ * Helper function to create a CSS selector from an attribute
1994
+ *
1995
+ * @param attr - The data attribute name from DATA_ATTR
1996
+ * @param value - Optional value for the attribute (defaults to presence selector)
1997
+ * @returns CSS selector string
1998
+ *
1999
+ * @example
2000
+ * createSelector(DATA_ATTR.element) // '[data-blok-element]'
2001
+ * createSelector(DATA_ATTR.selected, true) // '[data-blok-selected="true"]'
2002
+ * createSelector(DATA_ATTR.tool, 'paragraph') // '[data-blok-tool="paragraph"]'
2003
+ */
2004
+ const createSelector = (attr, value) => {
2005
+ if (value === undefined) {
2006
+ return `[${attr}]`;
2007
+ }
2008
+ return `[${attr}="${value}"]`;
2009
+ };
2010
+
2011
+ /**
2012
+ * Mount child block holders into a container, skipping children that are
2013
+ * already in place or claimed by another nested-blocks container.
2014
+ *
2015
+ * Used by toggle, header, column, and callout tools to reconcile child holders
2016
+ * during the `rendered()` lifecycle hook.
2017
+ */
2018
+ const mountChildBlocks = (container, children) => {
2019
+ for (const child of children) {
2020
+ if (child.holder.parentElement === container) {
2021
+ continue;
2022
+ }
2023
+ const nested = child.holder.closest(`[${DATA_ATTR.nestedBlocks}]`);
2024
+ // Claim a holder stranded in the column_list ROW. A drag reparent into a
2025
+ // column drops the moved holder directly into the [data-blok-columns] row
2026
+ // (itself a nested-blocks container that wraps each column's own container).
2027
+ // Left there it renders as a rogue new column, so pull it down into THIS
2028
+ // column container where it belongs. Scoped to the column_list row
2029
+ // specifically — claiming from arbitrary ancestor containers would let one
2030
+ // table cell steal a paragraph transiently parked in an outer container.
2031
+ if (nested !== null &&
2032
+ nested !== container &&
2033
+ nested.hasAttribute('data-blok-columns') &&
2034
+ nested.contains(container)) {
2035
+ container.appendChild(child.holder);
2036
+ continue;
2037
+ }
2038
+ // Otherwise leave holders that already live inside another nested container
2039
+ // (a sibling, or a deeper nesting within this one) where they are.
2040
+ if (nested !== null) {
2041
+ continue;
2042
+ }
2043
+ container.appendChild(child.holder);
2044
+ }
2045
+ };
2046
+
2047
+ /**
2048
+ * The blessed all-in-one Angular component for embedding Blok (mirrors React's
2049
+ * `BlokEditor`). Delegates instance lifecycle to an internal `BlokContentDirective`
2050
+ * and layers the typed reactive input/output API on top.
2051
+ *
2052
+ * Reactive inputs (`readOnly`, `theme`, `width`, `placeholder`, `autofocus`) are
2053
+ * synced in place after mount via effects; everything else seeds construction.
2054
+ *
2055
+ * Implementation note: classic `@Input()`/`@ViewChild()` decorators are used
2056
+ * (not signal `input()`/`viewChild()`) for JIT compatibility — see
2057
+ * `BlokContentDirective`. Each reactive input is backed by an internal `signal`
2058
+ * so effect-based syncing still works; the public template API is unchanged.
2059
+ */
2060
+ class BlokEditorComponent {
2061
+ ngZone = inject(NgZone);
2062
+ /** App-wide defaults from `provideBlok()`; merged UNDER per-instance inputs. */
2063
+ defaults = inject(BLOK_DEFAULT_CONFIG, { optional: true }) ?? {};
2064
+ contentQuery;
2065
+ /** Signal bridge for the @ViewChild directive so effects react to its arrival. */
2066
+ content = signal(null, ...(ngDevMode ? [{ debugName: "content" }] : []));
2067
+ /**
2068
+ * Output half of two-way `[(data)]`. Emits the editor's serialized content on
2069
+ * every change. Wiring the core `onSave` callback is gated on this being
2070
+ * observed, since its mere presence makes the core serialize on every batch.
2071
+ */
2072
+ /** Emits the live Blok instance once ready, after `instance()` is populated. */
2073
+ ready = new EventEmitter();
2074
+ dataChange = new EventEmitter();
2075
+ /** Fires with the full serialized content on every change (notification half). */
2076
+ save = new EventEmitter();
2077
+ /** Raw block mutation channel (core `onChange`). */
2078
+ change = new EventEmitter();
2079
+ /** Fires after the editor finishes (re-)rendering (core `onAfterRender`). */
2080
+ afterRender = new EventEmitter();
2081
+ /** Fires with the resolved theme whenever it changes (core `onThemeChange`). */
2082
+ themeChange = new EventEmitter();
2083
+ /** Fires after a batch render completes (core `blocks:rendered` event). */
2084
+ blocksRendered = new EventEmitter();
2085
+ /** Fires for each block rendered into the DOM (core `block:rendered` event). */
2086
+ blockRendered = new EventEmitter();
2087
+ /**
2088
+ * Transform hook applied to blocks before render (core `onBeforeRender`). Must
2089
+ * return the (possibly modified) block list. An input, not an output, because
2090
+ * it returns a value.
2091
+ */
2092
+ onBeforeRender;
2093
+ /**
2094
+ * Transform hook applied to pasted HTML (core `onBeforePaste`). Returns the
2095
+ * (possibly modified) html, or null to drop the paste.
2096
+ */
2097
+ onBeforePaste;
2098
+ // ---- Reactive inputs (signal-backed setters; synced in place, never recreate) ----
2099
+ // Default undefined (not false) so an unset input falls back to a provideBlok
2100
+ // default; the readOnly effect / buildConfig coerce undefined to false.
2101
+ readOnly$ = signal(undefined, ...(ngDevMode ? [{ debugName: "readOnly$" }] : []));
2102
+ set readOnly(value) {
2103
+ this.readOnly$.set(value);
2104
+ }
2105
+ theme$ = signal(undefined, ...(ngDevMode ? [{ debugName: "theme$" }] : []));
2106
+ set theme(value) {
2107
+ this.theme$.set(value);
2108
+ }
2109
+ width$ = signal(undefined, ...(ngDevMode ? [{ debugName: "width$" }] : []));
2110
+ set width(value) {
2111
+ this.width$.set(value);
2112
+ }
2113
+ placeholder$ = signal(undefined, ...(ngDevMode ? [{ debugName: "placeholder$" }] : []));
2114
+ set placeholder(value) {
2115
+ this.placeholder$.set(value);
2116
+ }
2117
+ autofocus$ = signal(false, ...(ngDevMode ? [{ debugName: "autofocus$" }] : []));
2118
+ set autofocus(value) {
2119
+ this.autofocus$.set(value);
2120
+ }
2121
+ // ---- Reactive content (seeds construction, then re-renders on change) ----
2122
+ data$ = signal(undefined, ...(ngDevMode ? [{ debugName: "data$" }] : []));
2123
+ set data(value) {
2124
+ this.data$.set(value);
2125
+ }
2126
+ // ---- Construction-only seed inputs (read once at construction) ----
2127
+ tools;
2128
+ /**
2129
+ * Escape hatch: a full config object for keys without a dedicated input
2130
+ * (i18n, sanitizer, minHeight, inlineToolbar, …). Layered between provideBlok
2131
+ * defaults and the discrete inputs.
2132
+ */
2133
+ config;
2134
+ /** Changing this input's identity destroys and recreates the editor (≙ React `deps`). */
2135
+ recreateKey;
2136
+ /**
2137
+ * Content the editor currently reflects. Set when seeded or rendered (the data
2138
+ * effect below). A controlled `data` echo that deep-equals this baseline is a
2139
+ * no-op, so it won't clobber the caret. Renders are serialized via `renderChain`.
2140
+ */
2141
+ lastRenderedData;
2142
+ seededEditor = null;
2143
+ renderChain = Promise.resolve();
2144
+ /** Registered by `ControlValueAccessor.registerOnChange` (Angular forms). */
2145
+ cvaOnChange;
2146
+ cvaOnTouched;
2147
+ /**
2148
+ * Core `onSave` wrapper. Records the editor's own serialized output as the
2149
+ * rendered baseline BEFORE notifying Angular, so a controlled consumer echoing
2150
+ * it straight back into `data` deep-equals the baseline and is deduped to a
2151
+ * no-op (no redundant render, no caret reset). Re-enters the Angular zone since
2152
+ * the editor runs outside it.
2153
+ */
2154
+ coreOnSave = (data) => {
2155
+ this.lastRenderedData = data;
2156
+ this.ngZone.run(() => {
2157
+ this.dataChange.emit(data);
2158
+ this.save.emit(data);
2159
+ this.cvaOnChange?.(data);
2160
+ this.cvaOnTouched?.();
2161
+ });
2162
+ };
2163
+ /** Live Blok instance, or null until `isReady` resolves / after destroy. */
2164
+ instance = computed(() => this.content()?.instance() ?? null, ...(ngDevMode ? [{ debugName: "instance" }] : []));
2165
+ /** Guards `ready` to one emission per editor instance. */
2166
+ lastReadyInstance = null;
2167
+ /**
2168
+ * Construction config handed to the directive. Seeds the editor's initial
2169
+ * values; the directive only reads it once (at construction), so post-mount
2170
+ * changes to construction-only inputs are no-ops by design.
2171
+ */
2172
+ buildConfig() {
2173
+ // Precedence: provideBlok defaults < [config] escape hatch < discrete inputs.
2174
+ const cfg = { ...this.defaults, ...this.config };
2175
+ cfg.readOnly = this.readOnly$() ?? this.config?.readOnly ?? this.defaults.readOnly ?? false;
2176
+ // tools registries are MERGED (not replaced) across all three layers so a
2177
+ // shared registry composes with per-instance additions.
2178
+ if (this.tools !== undefined ||
2179
+ this.config?.tools !== undefined ||
2180
+ this.defaults.tools !== undefined) {
2181
+ cfg.tools = { ...this.defaults.tools, ...this.config?.tools, ...this.tools };
2182
+ }
2183
+ const data = this.data$();
2184
+ if (data !== undefined) {
2185
+ cfg.data = data;
2186
+ }
2187
+ const theme = this.theme$();
2188
+ if (theme !== undefined) {
2189
+ cfg.theme = theme;
2190
+ }
2191
+ const width = this.width$();
2192
+ if (width !== undefined) {
2193
+ cfg.width = width;
2194
+ }
2195
+ const placeholder = this.placeholder$();
2196
+ if (placeholder !== undefined) {
2197
+ cfg.placeholder = placeholder;
2198
+ }
2199
+ // Opt-in: only attach each core callback when the consumer actually consumes
2200
+ // it (an observed output, a registered forms hook, or a provided transform).
2201
+ // Their mere presence makes the core serialize / run hooks on every change.
2202
+ if (this.dataChange.observed || this.save.observed || this.cvaOnChange !== undefined) {
2203
+ cfg.onSave = this.coreOnSave;
2204
+ }
2205
+ if (this.change.observed) {
2206
+ cfg.onChange = (api, event) => this.ngZone.run(() => this.change.emit({ api, event }));
2207
+ }
2208
+ if (this.afterRender.observed) {
2209
+ cfg.onAfterRender = (api) => this.ngZone.run(() => this.afterRender.emit(api));
2210
+ }
2211
+ if (this.themeChange.observed) {
2212
+ cfg.onThemeChange = (resolved) => this.ngZone.run(() => this.themeChange.emit(resolved));
2213
+ }
2214
+ // Transforms run synchronously inside core's render/paste pipeline and must
2215
+ // return a value — never wrap them in ngZone.run.
2216
+ const beforeRender = this.onBeforeRender;
2217
+ if (beforeRender !== undefined) {
2218
+ cfg.onBeforeRender = (blocks) => beforeRender(blocks);
2219
+ }
2220
+ const beforePaste = this.onBeforePaste;
2221
+ if (beforePaste !== undefined) {
2222
+ cfg.onBeforePaste = (html) => beforePaste(html);
2223
+ }
2224
+ return cfg;
2225
+ }
2226
+ ngAfterViewInit() {
2227
+ this.content.set(this.contentQuery ?? null);
2228
+ }
2229
+ // ---- Curated imperative facade (delegates to the live instance; no-ops until ready) ----
2230
+ /** Serialize the current content. Resolves undefined until the editor is ready. */
2231
+ save$() {
2232
+ return this.instance()?.save();
2233
+ }
2234
+ /** Move the caret into the editor. */
2235
+ focus(atEnd) {
2236
+ this.instance()?.focus(atEnd);
2237
+ }
2238
+ /** Replace the editor content. Resolves undefined until the editor is ready. */
2239
+ render(data) {
2240
+ return this.instance()?.render(data);
2241
+ }
2242
+ constructor() {
2243
+ // Each effect reads `instance()` so it re-applies once the editor appears
2244
+ // (the Angular analog of React's `editor` effect-dependency).
2245
+ effect(() => {
2246
+ const editor = this.instance();
2247
+ const readOnly = this.readOnly$();
2248
+ if (editor) {
2249
+ void editor.readOnly.set(readOnly ?? normalizeReadOnlyConfig(this.config?.readOnly).enabled);
2250
+ }
2251
+ });
2252
+ effect(() => {
2253
+ const editor = this.instance();
2254
+ const theme = this.theme$();
2255
+ if (editor && theme !== undefined) {
2256
+ editor.theme.set(theme);
2257
+ }
2258
+ });
2259
+ effect(() => {
2260
+ const editor = this.instance();
2261
+ const width = this.width$();
2262
+ if (editor && width !== undefined) {
2263
+ editor.width.set(width);
2264
+ }
2265
+ });
2266
+ effect(() => {
2267
+ const editor = this.instance();
2268
+ const placeholder = this.placeholder$();
2269
+ if (editor && placeholder !== undefined) {
2270
+ editor.placeholder.set(placeholder);
2271
+ }
2272
+ });
2273
+ effect(() => {
2274
+ const editor = this.instance();
2275
+ if (editor && this.autofocus$()) {
2276
+ editor.focus();
2277
+ }
2278
+ });
2279
+ // Reactive content. `data` seeds the editor at construction; afterwards a new
2280
+ // *content* value re-renders via the public render() API (deep-equal-deduped
2281
+ // and serialized, so an unchanged reference — including the editor's own
2282
+ // echoed output — is a no-op and never clobbers the caret).
2283
+ effect(() => {
2284
+ const editor = this.instance();
2285
+ const data = this.data$();
2286
+ if (!editor || data === undefined) {
2287
+ return;
2288
+ }
2289
+ // A freshly created editor was already seeded with `data` at construction;
2290
+ // record it without re-rendering.
2291
+ if (this.seededEditor !== editor) {
2292
+ this.seededEditor = editor;
2293
+ this.lastRenderedData = data;
2294
+ return;
2295
+ }
2296
+ if (deepEqual(data, this.lastRenderedData)) {
2297
+ return;
2298
+ }
2299
+ this.lastRenderedData = data;
2300
+ this.renderChain = this.renderChain.catch(() => undefined).then(() => editor.render(data));
2301
+ });
2302
+ // Emit `ready` once per editor, after `instance()` is populated so a consumer
2303
+ // reading the component/ref instance inside the handler sees it.
2304
+ effect(() => {
2305
+ const editor = this.instance();
2306
+ if (editor && editor !== this.lastReadyInstance) {
2307
+ this.lastReadyInstance = editor;
2308
+ this.ready.emit(editor);
2309
+ }
2310
+ });
2311
+ // Subscribe to the editor's rendered-lifecycle events once it exists (gated on
2312
+ // the outputs being observed). `onCleanup` unsubscribes when the instance is
2313
+ // replaced or the component is destroyed.
2314
+ effect((onCleanup) => {
2315
+ const editor = this.instance();
2316
+ if (!editor) {
2317
+ return;
2318
+ }
2319
+ const handlers = [];
2320
+ if (this.blocksRendered.observed) {
2321
+ const handler = (payload) => this.ngZone.run(() => this.blocksRendered.emit(payload));
2322
+ editor.on('blocks:rendered', handler);
2323
+ handlers.push(['blocks:rendered', handler]);
2324
+ }
2325
+ if (this.blockRendered.observed) {
2326
+ const handler = (payload) => this.ngZone.run(() => this.blockRendered.emit(payload));
2327
+ editor.on('block:rendered', handler);
2328
+ handlers.push(['block:rendered', handler]);
2329
+ }
2330
+ onCleanup(() => {
2331
+ for (const [name, handler] of handlers) {
2332
+ editor.off(name, handler);
2333
+ }
2334
+ });
2335
+ });
2336
+ }
2337
+ // ---- ControlValueAccessor (optional forms integration) ----
2338
+ /** Seeds/renders an externally-set form value through the same dedup machinery. */
2339
+ writeValue(value) {
2340
+ this.data$.set(value ?? undefined);
2341
+ }
2342
+ registerOnChange(fn) {
2343
+ this.cvaOnChange = fn;
2344
+ }
2345
+ registerOnTouched(fn) {
2346
+ this.cvaOnTouched = fn;
2347
+ }
2348
+ /** Form disabled state maps to the editor's read-only mode (via the readOnly effect). */
2349
+ setDisabledState(isDisabled) {
2350
+ this.readOnly$.set(isDisabled);
2351
+ }
2352
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: BlokEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2353
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.25", type: BlokEditorComponent, isStandalone: true, selector: "blok-editor", inputs: { onBeforeRender: "onBeforeRender", onBeforePaste: "onBeforePaste", readOnly: "readOnly", theme: "theme", width: "width", placeholder: "placeholder", autofocus: "autofocus", data: "data", tools: "tools", config: "config", recreateKey: "recreateKey" }, outputs: { ready: "ready", dataChange: "dataChange", save: "save", change: "change", afterRender: "afterRender", themeChange: "themeChange", blocksRendered: "blocksRendered", blockRendered: "blockRendered" }, providers: [
2354
+ {
2355
+ provide: NG_VALUE_ACCESSOR,
2356
+ useExisting: forwardRef(() => BlokEditorComponent),
2357
+ multi: true,
2358
+ },
2359
+ ], viewQueries: [{ propertyName: "contentQuery", first: true, predicate: BlokContentDirective, descendants: true }], exportAs: ["blok"], ngImport: i0, template: `<div blokContent [config]="buildConfig()" [recreateKey]="recreateKey"></div>`, isInline: true, dependencies: [{ kind: "directive", type: BlokContentDirective, selector: "[blokContent]", inputs: ["config", "recreateKey"], outputs: ["ready"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2360
+ }
2361
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: BlokEditorComponent, decorators: [{
2362
+ type: Component,
2363
+ args: [{
2364
+ selector: 'blok-editor',
2365
+ standalone: true,
2366
+ changeDetection: ChangeDetectionStrategy.OnPush,
2367
+ exportAs: 'blok',
2368
+ imports: [BlokContentDirective],
2369
+ template: `<div blokContent [config]="buildConfig()" [recreateKey]="recreateKey"></div>`,
2370
+ providers: [
2371
+ {
2372
+ provide: NG_VALUE_ACCESSOR,
2373
+ useExisting: forwardRef(() => BlokEditorComponent),
2374
+ multi: true,
2375
+ },
2376
+ ],
2377
+ }]
2378
+ }], ctorParameters: () => [], propDecorators: { contentQuery: [{
2379
+ type: ViewChild,
2380
+ args: [BlokContentDirective]
2381
+ }], ready: [{
2382
+ type: Output
2383
+ }], dataChange: [{
2384
+ type: Output
2385
+ }], save: [{
2386
+ type: Output
2387
+ }], change: [{
2388
+ type: Output
2389
+ }], afterRender: [{
2390
+ type: Output
2391
+ }], themeChange: [{
2392
+ type: Output
2393
+ }], blocksRendered: [{
2394
+ type: Output
2395
+ }], blockRendered: [{
2396
+ type: Output
2397
+ }], onBeforeRender: [{
2398
+ type: Input
2399
+ }], onBeforePaste: [{
2400
+ type: Input
2401
+ }], readOnly: [{
2402
+ type: Input
2403
+ }], theme: [{
2404
+ type: Input
2405
+ }], width: [{
2406
+ type: Input
2407
+ }], placeholder: [{
2408
+ type: Input
2409
+ }], autofocus: [{
2410
+ type: Input
2411
+ }], data: [{
2412
+ type: Input
2413
+ }], tools: [{
2414
+ type: Input
2415
+ }], config: [{
2416
+ type: Input
2417
+ }], recreateKey: [{
2418
+ type: Input
2419
+ }] } });
2420
+
2421
+ // packages/angular/src/createAngularBlock.ts
2422
+ /**
2423
+ * Author a first-party Angular block. Returns a `BlockToolConstructable`
2424
+ * registered exactly like a vanilla tool (`tools: { type: { class:
2425
+ * createAngularBlock(...) } }`).
2426
+ *
2427
+ * The factory owns the host element (`data-blok-mutation-free`), a frozen
2428
+ * defaults-filled data mirror, and signals the component reads. It mounts the
2429
+ * component into the host via the editor's shared portal registry (the analog of
2430
+ * Vue's Teleport registry), bridging Blok's block lifecycle to Angular:
2431
+ * - `render()` creates the host and registers the portal entry (mounted sync).
2432
+ * - `setData()` dedups, swaps the reactive snapshot, flushes CD, resolves true.
2433
+ * - `save()` returns the complete frozen mirror (never the DOM, never partial).
2434
+ * - `commit()` merges a patch and fires `dispatchChange` exactly once.
2435
+ * - `setReadOnly()` flips a reactive flag and flushes CD (in-place, no remount).
2436
+ * - `removed()`/`destroy()` unregister the portal (deterministic unmount).
2437
+ */
2438
+ function createAngularBlock(spec) {
2439
+ return class AngularBlockTool {
2440
+ /** Marker so the directive can detect Angular-block tools and inject the registry. */
2441
+ static __isBlokAngularBlock = true;
2442
+ static get toolbox() {
2443
+ return spec.toolbox;
2444
+ }
2445
+ /**
2446
+ * Angular blocks support read-only mode: `setReadOnly` flips a reactive flag
2447
+ * the component reads, so the block re-renders read-only IN PLACE. Without
2448
+ * this static, core's ReadOnly module throws when read-only is enabled and an
2449
+ * Angular block is present.
2450
+ */
2451
+ static get isReadOnlySupported() {
2452
+ return true;
2453
+ }
2454
+ blockApi;
2455
+ registry;
2456
+ pointerDrag;
2457
+ dataSig;
2458
+ readOnlySig;
2459
+ ctx;
2460
+ mirror;
2461
+ /** Dedup baseline: skip a redundant flush of identical data. */
2462
+ lastRendered;
2463
+ hostEl = null;
2464
+ /** Last host passed to ctx.mountChildren, re-mounted on each data change. */
2465
+ childHost = null;
2466
+ /** True while a pointer drag suppresses dispatchChange. */
2467
+ pendingDispatch = false;
2468
+ constructor(options) {
2469
+ this.blockApi = options.block;
2470
+ const config = (options.config ?? {});
2471
+ this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY];
2472
+ // Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
2473
+ // (core silently drops a dispatchChange while a drag is active).
2474
+ const api = options.api;
2475
+ this.pointerDrag = () => api?.blocks?.isPointerDragActive === true;
2476
+ this.mirror = fillDefaults(spec.propSchema, (options.data ?? {}));
2477
+ this.lastRendered = this.mirror;
2478
+ this.dataSig = signal(this.mirror, ...(ngDevMode ? [{ debugName: "dataSig" }] : []));
2479
+ this.readOnlySig = signal(options.readOnly, ...(ngDevMode ? [{ debugName: "readOnlySig" }] : []));
2480
+ this.ctx = {
2481
+ data: this.dataSig.asReadonly(),
2482
+ commit: this.commit,
2483
+ block: this.blockApi,
2484
+ readOnly: this.readOnlySig.asReadonly(),
2485
+ mountChildren: this.mountChildren,
2486
+ };
2487
+ }
2488
+ render() {
2489
+ const host = document.createElement('div');
2490
+ // Core's MutationObserver ignores this subtree, so Angular's DOM writes
2491
+ // never register as a user edit.
2492
+ host.setAttribute('data-blok-mutation-free', 'true');
2493
+ this.hostEl = host;
2494
+ this.registry?.register(this.blockApi.id, {
2495
+ hostEl: host,
2496
+ component: spec.component,
2497
+ context: this.ctx,
2498
+ });
2499
+ return host;
2500
+ }
2501
+ rendered() {
2502
+ spec.onRendered?.(this.blockApi);
2503
+ }
2504
+ save() {
2505
+ return this.mirror;
2506
+ }
2507
+ async setData(newData) {
2508
+ const next = fillDefaults(spec.propSchema, (newData ?? {}));
2509
+ // Dedup: identical data → skip the flush, but still return true so core
2510
+ // keeps the block in place (no remount).
2511
+ if (deepEqual(next, this.lastRendered)) {
2512
+ return true;
2513
+ }
2514
+ this.mirror = next;
2515
+ this.lastRendered = next;
2516
+ this.dataSig.set(next);
2517
+ // Synchronous CD: core drives setData outside NgZone, so nothing else
2518
+ // schedules a render. Never throw (a throw would make core remount).
2519
+ this.registry?.flush(this.blockApi.id);
2520
+ this.remountChildren();
2521
+ // Resolve after a microtask for interface symmetry with the async core
2522
+ // setData contract; CD has already flushed synchronously above.
2523
+ await Promise.resolve();
2524
+ return true;
2525
+ }
2526
+ /**
2527
+ * In-place read-only toggle. Flips the reactive flag the component reads via
2528
+ * `ctx.readOnly`, then flushes CD so the block re-renders read-only WITHOUT a
2529
+ * remount (ephemeral state survives). A prototype method (not an arrow field)
2530
+ * so core's `supportsInPlaceReadOnly` — which probes the PROTOTYPE — selects
2531
+ * the in-place path.
2532
+ */
2533
+ setReadOnly(state) {
2534
+ this.readOnlySig.set(state);
2535
+ this.registry?.flush(this.blockApi.id);
2536
+ }
2537
+ moved() {
2538
+ // No remount: core relocates the host element; the mounted view rides along
2539
+ // as its DOM children.
2540
+ spec.onMoved?.(this.blockApi);
2541
+ }
2542
+ removed() {
2543
+ this.registry?.unregister(this.blockApi.id);
2544
+ spec.onRemoved?.(this.blockApi);
2545
+ }
2546
+ destroy() {
2547
+ // Idempotent with removed(); unregister is safe when already absent.
2548
+ this.registry?.unregister(this.blockApi.id);
2549
+ }
2550
+ /** Container blocks: remember the host and (re)mount the real child holders. */
2551
+ mountChildren = (host) => {
2552
+ this.childHost = host;
2553
+ host.setAttribute(DATA_ATTR.nestedBlocks, '');
2554
+ this.remountChildren();
2555
+ };
2556
+ remountChildren() {
2557
+ if (this.childHost !== null) {
2558
+ mountChildBlocks(this.childHost, this.blockApi.getChildren());
2559
+ }
2560
+ }
2561
+ /**
2562
+ * The only data write path. Merges the patch into the frozen mirror, swaps
2563
+ * the reactive snapshot, flushes CD, and fires dispatchChange EXACTLY once —
2564
+ * deferring it while a pointer drag is active (core would otherwise silently
2565
+ * drop it).
2566
+ */
2567
+ commit = (patch) => {
2568
+ const next = fillDefaults(spec.propSchema, {
2569
+ ...this.mirror,
2570
+ ...patch,
2571
+ });
2572
+ this.mirror = next;
2573
+ this.lastRendered = next;
2574
+ this.dataSig.set(next);
2575
+ this.registry?.flush(this.blockApi.id);
2576
+ this.remountChildren();
2577
+ this.flushDispatch();
2578
+ };
2579
+ /** Dispatch the change, or retry on the next frame if a drag is in progress. */
2580
+ flushDispatch() {
2581
+ if (!this.pointerDrag()) {
2582
+ this.pendingDispatch = false;
2583
+ this.blockApi.dispatchChange();
2584
+ return;
2585
+ }
2586
+ if (this.pendingDispatch) {
2587
+ return;
2588
+ }
2589
+ this.pendingDispatch = true;
2590
+ const retry = () => {
2591
+ if (this.pointerDrag()) {
2592
+ requestAnimationFrame(retry);
2593
+ return;
2594
+ }
2595
+ this.pendingDispatch = false;
2596
+ this.blockApi.dispatchChange();
2597
+ };
2598
+ requestAnimationFrame(retry);
2599
+ }
2600
+ };
2601
+ }
2602
+
2603
+ // packages/angular/src/useBlocks.ts
2604
+ const BLOCK_CHANGED_EVENT = 'block changed';
2605
+ /**
2606
+ * Angular factory exposing an id/parentId-relative, reactive view of the block
2607
+ * tree. Reads refresh whenever the editor emits `block changed`; mutators route
2608
+ * through the editor-level `blocks` API (core's chokepoints), so undo/redo and
2609
+ * Yjs sync are inherited rather than re-implemented.
2610
+ *
2611
+ * The block-tree logic is framework-agnostic and lives in the shared
2612
+ * {@link createBlocksApiForEditor} core — the SAME implementation behind React's
2613
+ * and Vue's `useBlocks`, so all three adapters expose the identical 28-method
2614
+ * surface and cannot drift. This wrapper supplies only Angular's reactivity:
2615
+ *
2616
+ * - A private `version` signal is bumped on every `block changed`. The shared API
2617
+ * is built with an `onRead` seam (`() => { version() }`) that every read method
2618
+ * calls, so reading inside a `computed`/template tracks `version` and re-runs on
2619
+ * each structural mutation.
2620
+ * - The bound API is rebuilt (via `bindToEditor`, called eagerly on first call and
2621
+ * then tracked by an `effect` for subsequent identity changes) when the editor
2622
+ * IDENTITY changes; the returned facade is stable across that swap.
2623
+ *
2624
+ * Call it in an injection context (component constructor / field initializer),
2625
+ * passing the editor signal (e.g. `BlokEditorComponent.instance` /
2626
+ * `BlokContentDirective.instance`). Pre-ready (editor null) the bound API is the
2627
+ * shared {@link EMPTY_API}: every mutator a no-op, reads empty/null — except
2628
+ * `transact`/`transactWithoutCapture`, which still run their callback.
2629
+ *
2630
+ * Note: Angular 20's `effect()` is scheduled (not eager) — it does not fire
2631
+ * synchronously on first call. The initial binding therefore runs synchronously
2632
+ * in the `injectBlocks` body itself (mirroring Vue's `{ immediate: true }` watch),
2633
+ * and the effect re-binds only on subsequent editor identity changes. When the
2634
+ * effect fires for the first time with the same editor already bound, the guard
2635
+ * `ed === sub.editor` makes it a no-op, so no double-subscription occurs.
2636
+ *
2637
+ * @param editor - a signal of the Blok instance, or null pre-ready
2638
+ */
2639
+ function injectBlocks(editor) {
2640
+ // Bumped on every `block changed`; the shared API's read methods touch it (via
2641
+ // the onRead seam) so template/computed reads stay reactive.
2642
+ const version = signal(0, ...(ngDevMode ? [{ debugName: "version" }] : []));
2643
+ const touch = () => {
2644
+ version();
2645
+ };
2646
+ // The API bound to the CURRENT editor; EMPTY_API while null. A signal so the
2647
+ // facade's reads track the editor-identity swap.
2648
+ const bound = signal(EMPTY_API, ...(ngDevMode ? [{ debugName: "bound" }] : []));
2649
+ // One subscription record (no `let` reassignment), re-bound on editor change.
2650
+ const sub = { editor: null, handler: null };
2651
+ const unsubscribe = () => {
2652
+ if (sub.editor !== null && sub.handler !== null) {
2653
+ sub.editor.off(BLOCK_CHANGED_EVENT, sub.handler);
2654
+ }
2655
+ sub.editor = null;
2656
+ sub.handler = null;
2657
+ };
2658
+ /**
2659
+ * Bind to `ed` if the identity has changed. Guards `ed === sub.editor` so a
2660
+ * deferred first effect-run with the same editor already bound is a no-op
2661
+ * (no double-subscription). Called synchronously on first call (immediate
2662
+ * binding) and then re-tracked by the effect below (future identity changes).
2663
+ */
2664
+ const bindToEditor = (ed) => {
2665
+ if (ed === sub.editor) {
2666
+ return; // Same editor already bound; nothing to do.
2667
+ }
2668
+ unsubscribe();
2669
+ // A changed editor identity is itself a reason to re-read.
2670
+ version.update((v) => v + 1);
2671
+ if (ed === null) {
2672
+ bound.set(EMPTY_API);
2673
+ return;
2674
+ }
2675
+ const handler = () => {
2676
+ version.update((v) => v + 1);
2677
+ };
2678
+ ed.on(BLOCK_CHANGED_EVENT, handler);
2679
+ sub.editor = ed;
2680
+ sub.handler = handler;
2681
+ bound.set(createBlocksApiForEditor(ed, touch));
2682
+ };
2683
+ // Run the initial binding SYNCHRONOUSLY so the returned facade is immediately
2684
+ // usable (Angular's effect() is deferred — it does not fire on the same tick).
2685
+ // This mirrors Vue's `watch(..., { immediate: true })`.
2686
+ bindToEditor(editor());
2687
+ // Re-bind when the editor identity changes. Angular 20 permits signal writes
2688
+ // inside effect() by default (allowSignalWrites option was removed in v20).
2689
+ effect(() => {
2690
+ bindToEditor(editor());
2691
+ });
2692
+ inject(DestroyRef).onDestroy(unsubscribe);
2693
+ // A stable facade whose methods delegate to the currently-bound API. Reading
2694
+ // `bound()` tracks the editor swap; shared reads track `version` via onRead.
2695
+ // Every key listed EXPLICITLY (no spread) so a forgotten delegation is a
2696
+ // COMPILE error against UseBlocksApi rather than a silently-missing method.
2697
+ return {
2698
+ getById: (id) => bound().getById(id),
2699
+ getChildren: (parentId) => bound().getChildren(parentId),
2700
+ insert: (spec) => bound().insert(spec),
2701
+ insertMany: (specs) => bound().insertMany(specs),
2702
+ insertTree: (spec) => bound().insertTree(spec),
2703
+ insertMarkdown: (markdown, options) => bound().insertMarkdown(markdown, options),
2704
+ exportMarkdown: () => bound().exportMarkdown(),
2705
+ move: (id, target) => bound().move(id, target),
2706
+ nest: (id, parentId) => bound().nest(id, parentId),
2707
+ unnest: (id) => bound().unnest(id),
2708
+ remove: (id) => bound().remove(id),
2709
+ update: (id, data, tunes) => bound().update(id, data, tunes),
2710
+ convert: (id, newType, dataOverrides, options) => bound().convert(id, newType, dataOverrides, options),
2711
+ transact: (fn) => bound().transact(fn),
2712
+ transactWithoutCapture: (fn) => bound().transactWithoutCapture(fn),
2713
+ getBlocksCount: () => bound().getBlocksCount(),
2714
+ getCurrentBlockIndex: () => bound().getCurrentBlockIndex(),
2715
+ getBlockByIndex: (index) => bound().getBlockByIndex(index),
2716
+ getBlockByElement: (element) => bound().getBlockByElement(element),
2717
+ getBlockData: (id) => bound().getBlockData(id),
2718
+ getBlockIndex: (id) => bound().getBlockIndex(id),
2719
+ composeBlockData: (toolName) => bound().composeBlockData(toolName),
2720
+ renderFromHTML: (html) => bound().renderFromHTML(html),
2721
+ insertOutputData: (blocks, options) => bound().insertOutputData(blocks, options),
2722
+ splitBlock: (currentBlockId, currentBlockData, newBlockType, newBlockData, insertIndex) => bound().splitBlock(currentBlockId, currentBlockData, newBlockType, newBlockData, insertIndex),
2723
+ insertInsideParent: (parentId, insertIndex, childData) => bound().insertInsideParent(parentId, insertIndex, childData),
2724
+ render: (data) => bound().render(data),
2725
+ clear: () => bound().clear(),
2726
+ isSyncingFromYjs: () => bound().isSyncingFromYjs(),
2727
+ };
2728
+ }
2729
+
2730
+ /**
2731
+ * Generated bundle index. Do not edit.
2732
+ */
2733
+
2734
+ export { BLOK_BLOCK_CONTEXT, BLOK_DEFAULT_CONFIG, BlokContentDirective, BlokEditorComponent, createAngularBlock, injectBlocks, provideBlok };
2735
+ //# sourceMappingURL=bloklabs-angular.mjs.map