@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.
- package/fesm2022/bloklabs-angular.mjs +2735 -0
- package/fesm2022/bloklabs-angular.mjs.map +1 -0
- package/index.d.ts +675 -0
- package/package.json +36 -0
|
@@ -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
|