@bloklabs/vue 1.16.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/useBlok.ts DELETED
@@ -1,686 +0,0 @@
1
- import {
2
- inject,
3
- onBeforeUnmount,
4
- onMounted,
5
- shallowRef,
6
- toRaw,
7
- toValue,
8
- watch,
9
- type MaybeRefOrGetter,
10
- type Ref,
11
- } from 'vue';
12
- import { Blok as BlokRuntime } from '@bloklabs/core';
13
- import { setContentBaseline, removeContentBaseline } from './content-baseline-map';
14
- import { setHolder, removeHolder } from './holder-map';
15
- import {
16
- createEmittedEchoWindow,
17
- deepEqual,
18
- equalsOutputData,
19
- normalizeReadOnlyConfig,
20
- toRenderableData,
21
- } from '@bloklabs/core/adapters';
22
- import { BLOK_DEFAULT_CONFIG, mergeBlokDefaults } from './provide-blok';
23
- import {
24
- createBlockPortalRegistry,
25
- BLOK_PORTAL_REGISTRY_CONFIG_KEY,
26
- type BlockPortalRegistry,
27
- } from './block-portal-registry';
28
- import { setRegistry, removeRegistry } from './registry-map';
29
- import type { Blok, BlokConfig, LiveHandlers, LooseOutputData, OutputData } from '@/types';
30
- import type { UseBlokConfig } from './types';
31
-
32
- /** Presence map for the live callback config (see the handler watcher below). */
33
- type HandlerPresence = Record<keyof LiveHandlers, boolean>;
34
-
35
- /**
36
- * Which live callbacks the config currently carries. PRESENCE is load-bearing
37
- * in core — an `onSubmit` turns Enter into serialize-and-submit, an `onSave`
38
- * arms the change-observation pipeline — so it is tracked separately from the
39
- * callbacks themselves (whose identity the adapter keeps stable).
40
- * @param config - the merged per-render config
41
- * @returns one boolean per live handler key
42
- */
43
- const readHandlerPresence = (config: UseBlokConfig): HandlerPresence => ({
44
- onChange: config.onChange !== undefined,
45
- onSave: config.onSave !== undefined,
46
- onEnter: config.onEnter !== undefined,
47
- onSubmit: config.onSubmit !== undefined,
48
- onBeforeRender: config.onBeforeRender !== undefined,
49
- onAfterRender: config.onAfterRender !== undefined,
50
- });
51
-
52
- /**
53
- * Warning text for a controlled `data` change the adapter refuses to render
54
- * under collaboration. Kept verbatim in all three adapters (they share no code).
55
- * @param doc - the collaboration document id, for the reset endpoint in the text
56
- * @returns the message to warn with, once per adapter instance
57
- */
58
- const collaborationDataIgnoredMessage = (doc?: string): string =>
59
- '[Blok] `data` is ignored while collaboration is on: the document lives on the sync service ' +
60
- 'and is shared with everyone editing it, so replacing it from this editor would overwrite their work. ' +
61
- `To replace the whole document, call POST /sync/${doc ?? '{doc}'}/reset on your server — ` +
62
- 'it reloads the document from your own document endpoint and every open editor picks it up. ' +
63
- 'To change part of it, use the blocks API (insert/update/delete).';
64
-
65
- /**
66
- * Inject the editor's portal registry into every `createVueBlock` tool's config
67
- * (vanilla tools are left untouched), returning a NEW tools object so the
68
- * consumer's config is never mutated. A vue-block tool is constructed by CORE,
69
- * outside any Vue `setup`, so it cannot `inject()` — this config bridge is how
70
- * the tool reaches its editor-scoped registry.
71
- */
72
- const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): unknown => {
73
- if (tools === null || typeof tools !== 'object') {
74
- return tools;
75
- }
76
-
77
- const result: Record<string, unknown> = {};
78
-
79
- for (const [name, entry] of Object.entries(tools as Record<string, unknown>)) {
80
- const toolClass = typeof entry === 'function' ? entry : (entry as { class?: unknown })?.class;
81
- const isVueBlock =
82
- typeof toolClass === 'function' && (toolClass as { __isBlokVueBlock?: boolean }).__isBlokVueBlock === true;
83
-
84
- if (!isVueBlock) {
85
- result[name] = entry;
86
-
87
- continue;
88
- }
89
-
90
- const base: Record<string, unknown> =
91
- typeof entry === 'function' ? { class: entry } : { ...(entry as Record<string, unknown>) };
92
-
93
- base.config = {
94
- ...((base.config) ?? {}),
95
- [BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry,
96
- };
97
- result[name] = base;
98
- }
99
-
100
- return result;
101
- };
102
-
103
- /**
104
- * Composable that manages a Blok editor instance lifecycle.
105
- *
106
- * Creates a detached holder div, instantiates Blok with a plain (de-proxied)
107
- * config, and exposes the instance through the returned ref once `isReady`
108
- * resolves. Destroys the editor on unmount. The instance is published only when
109
- * the resolving editor is still the current one (identity guard), so a late
110
- * `isReady` from a superseded editor never leaks.
111
- *
112
- * @param config - reactive config source (ref/getter) without `holder`
113
- * @returns a ref to the live Blok instance, or null before ready / after destroy
114
- */
115
- export function useBlok(
116
- config: MaybeRefOrGetter<UseBlokConfig>,
117
- recreateKey?: MaybeRefOrGetter<unknown>
118
- ): Ref<Blok | null> {
119
- // shallowRef (not ref): the live Blok instance must be stored as-is. A deep
120
- // ref would wrap core in a reactive proxy, breaking its identity checks and
121
- // handing consumers a proxy instead of the real editor.
122
- const editor = shallowRef<Blok | null>(null);
123
-
124
- // App-wide defaults from the nearest `provideBlok()`, injected once (the Vue
125
- // analog of React's `useContext(BlokDefaultsContext)`). Merged UNDER the
126
- // per-instance config so the escape-hatch path (`useBlok` + `BlokContent`)
127
- // honors them exactly like `<BlokEditor>`. `inject` must run synchronously in
128
- // `setup`, hence at the top of the composable.
129
- const defaults = inject(BLOK_DEFAULT_CONFIG, {});
130
-
131
- /** Per-instance config with `provideBlok` defaults merged under it (instance wins). */
132
- const mergedConfig = (): UseBlokConfig => mergeBlokDefaults(defaults, toValue(config));
133
-
134
- // Mutable adapter state (held in one object to avoid `let` reassignment):
135
- // - `current` is the editor that should own the holder/ref; it guards async
136
- // isReady resolution against a stale editor (e.g. after a recreate).
137
- // - `lastRenderedData` is the content the editor currently reflects, updated
138
- // when `data` is seeded/rendered AND when the editor emits its own serialized
139
- // output via the `onSave` wrapper — so a controlled `update:data -> data`
140
- // echo content-equals this baseline and is deduped to a no-op (no caret
141
- // reset). `undefined` means "nothing recorded yet" — never an empty document.
142
- const state: {
143
- current: Blok | null;
144
- holder: HTMLDivElement | null;
145
- lastRenderedData: OutputData | LooseOutputData | null | undefined;
146
- // Content of the render currently queued/in-flight — distinct from
147
- // `lastRenderedData`, which is the last SUCCESSFULLY rendered content.
148
- // Dedupes a re-queue of the same in-flight content without advancing the
149
- // success baseline, so a failed render cannot strand the baseline on
150
- // content the editor never showed.
151
- pendingData: OutputData | LooseOutputData | null | undefined;
152
- seededEditor: Blok | null;
153
- renderChain: Promise<void>;
154
- // Editor the single collaboration warning has already been emitted for.
155
- // Keyed by the editor, not a boolean, so the editor a `recreateKey` bump
156
- // creates warns on its own first ignored `data` change instead of
157
- // inheriting the previous editor's "already warned" flag and going silent.
158
- collaborationWarnedFor: Blok | null;
159
- // The `collaboration` the live editor was CONSTRUCTED with. The key is
160
- // mount-fixed, so dropping it from the config (`collaboration: on ? {...} :
161
- // undefined`) cannot turn a live session into a single-player editor —
162
- // reading the current config instead would let that change fall through
163
- // into render(), which refuses under collaboration and rejects with
164
- // nothing surfaced anywhere.
165
- constructedCollaboration: UseBlokConfig['collaboration'];
166
- appliedHandlerPresence: HandlerPresence;
167
- } = {
168
- current: null,
169
- holder: null,
170
- lastRenderedData: mergedConfig().data,
171
- pendingData: undefined,
172
- seededEditor: null,
173
- renderChain: Promise.resolve(),
174
- collaborationWarnedFor: null,
175
- constructedCollaboration: undefined,
176
- appliedHandlerPresence: {
177
- onChange: false,
178
- onSave: false,
179
- onEnter: false,
180
- onSubmit: false,
181
- onBeforeRender: false,
182
- onAfterRender: false,
183
- },
184
- };
185
-
186
- // Window of recently emitted `onSave` payloads. `state.lastRenderedData`
187
- // remembers only the LAST one, but a host that persists on save and refetches
188
- // can echo an EARLIER save back after a newer one already replaced the
189
- // baseline (the user kept typing). That stale echo is still the editor's own
190
- // output, and rendering it is a whole-document replace — it would drop every
191
- // character typed between the two saves and hand the rewound document to the
192
- // next save, making the loss permanent in the host's store.
193
- const emittedEcho = createEmittedEchoWindow();
194
-
195
- /**
196
- * Stable wrappers for the live callback config. Each forwards to the LATEST
197
- * config through `mergedConfig()`, so callback identity never goes stale and
198
- * only PRESENCE has to be synced after mount (the watcher below).
199
- */
200
- const handlerWrappers: Required<LiveHandlers> = {
201
- onChange: (...args: Parameters<NonNullable<UseBlokConfig['onChange']>>): void => {
202
- mergedConfig().onChange?.(...args);
203
- },
204
- onSave: (...args: Parameters<NonNullable<UseBlokConfig['onSave']>>): void => {
205
- // Record the editor's own serialized output as the dedup baseline BEFORE
206
- // notifying the consumer, so a controlled `update:data -> data` echo
207
- // content-equals it and never re-renders.
208
- state.lastRenderedData = args[0];
209
- emittedEcho.record(args[0]);
210
- mergedConfig().onSave?.(...args);
211
- },
212
- // Forward the return value: it is the "handled" signal the core acts on.
213
- onEnter: (...args: Parameters<NonNullable<UseBlokConfig['onEnter']>>): boolean | void =>
214
- mergedConfig().onEnter?.(...args),
215
- onSubmit: (...args: Parameters<NonNullable<UseBlokConfig['onSubmit']>>): void => {
216
- mergedConfig().onSubmit?.(...args);
217
- },
218
- onBeforeRender: (
219
- ...args: Parameters<NonNullable<UseBlokConfig['onBeforeRender']>>
220
- ): ReturnType<NonNullable<UseBlokConfig['onBeforeRender']>> =>
221
- mergedConfig().onBeforeRender?.(...args) ?? args[0],
222
- onAfterRender: (...args: Parameters<NonNullable<UseBlokConfig['onAfterRender']>>): void => {
223
- mergedConfig().onAfterRender?.(...args);
224
- },
225
- };
226
-
227
- /**
228
- * Snapshot the reactive config into a plain object for core. `toRaw` unwraps
229
- * the config and its `data` so no Vue reactive proxy reaches core (Risk R0):
230
- * proxies would break core's identity checks and the holder WeakMap key.
231
- */
232
- const buildConfig = (): Record<string, unknown> => {
233
- // Merge `provideBlok` defaults under the raw per-instance config, then
234
- // de-proxy. `toRaw` unwraps both the config and its `data` so no Vue reactive
235
- // proxy reaches core (Risk R0): proxies would break core's identity checks
236
- // and the holder WeakMap key.
237
- const snapshot = { ...mergeBlokDefaults(toRaw(defaults), toRaw(toValue(config))) } as Record<string, unknown>;
238
-
239
- delete snapshot.holder;
240
-
241
- if (snapshot.data !== undefined) {
242
- snapshot.data = toRaw(snapshot.data);
243
- }
244
-
245
- return snapshot;
246
- };
247
-
248
- const teardown = (): void => {
249
- if (state.current !== null) {
250
- removeHolder(state.current);
251
- removeRegistry(state.current);
252
- removeContentBaseline(state.current);
253
- try {
254
- state.current.destroy();
255
- } catch {
256
- // destroy may throw — still clean up adapter state
257
- }
258
- }
259
-
260
- state.current = null;
261
- state.holder = null;
262
- editor.value = null;
263
- };
264
-
265
- const create = (): void => {
266
- const holder = document.createElement('div');
267
-
268
- state.holder = holder;
269
-
270
- const snapshot = buildConfig();
271
-
272
- // Per-editor portal registry for `createVueBlock` tools: inject it into each
273
- // vue-block tool's config (so the core-constructed tool can reach it) and
274
- // associate it with the editor below (so BlokContent can mount the host).
275
- const registry = createBlockPortalRegistry();
276
-
277
- if (snapshot.tools !== undefined) {
278
- snapshot.tools = injectPortalRegistry(snapshot.tools, registry);
279
- }
280
-
281
- // Install the stable wrappers for the callbacks the consumer actually
282
- // passed, and clear the rest: an absent handler must stay absent, because
283
- // core reads PRESENCE as intent. What was installed is recorded so the
284
- // watcher below can push later flips instead of recreating the editor.
285
- const presence = readHandlerPresence(mergedConfig());
286
-
287
- snapshot.onChange = presence.onChange ? handlerWrappers.onChange : undefined;
288
- snapshot.onSave = presence.onSave ? handlerWrappers.onSave : undefined;
289
- snapshot.onEnter = presence.onEnter ? handlerWrappers.onEnter : undefined;
290
- snapshot.onSubmit = presence.onSubmit ? handlerWrappers.onSubmit : undefined;
291
- snapshot.onBeforeRender = presence.onBeforeRender ? handlerWrappers.onBeforeRender : undefined;
292
- snapshot.onAfterRender = presence.onAfterRender ? handlerWrappers.onAfterRender : undefined;
293
-
294
- state.appliedHandlerPresence = presence;
295
-
296
- const blok = new BlokRuntime({ ...snapshot, holder });
297
-
298
- state.current = blok;
299
- state.constructedCollaboration = snapshot.collaboration as UseBlokConfig['collaboration'];
300
- setHolder(blok, holder);
301
- setRegistry(blok, registry);
302
- // The out-of-band content channel: `<BlokEditor>`'s exposed `render()`
303
- // changes the document without going through the reactive `data` watcher,
304
- // so it reports the result here. Without it the watcher's baseline names a
305
- // document the editor no longer shows, and a controlled `data` set back to
306
- // that document is deduped away — leaving the editor on the imperative
307
- // content while the host's state says otherwise.
308
- setContentBaseline(blok, {
309
- markRendered: (content): void => {
310
- // Out-of-band content: what the editor emitted before describes a
311
- // document it no longer shows, so a later `data` set back to one of
312
- // those payloads must render instead of being dismissed as an echo.
313
- emittedEcho.clear();
314
- state.lastRenderedData = content;
315
- },
316
- });
317
-
318
- void blok.isReady
319
- .then(() => {
320
- if (state.current === blok) {
321
- editor.value = blok;
322
- }
323
- })
324
- .catch(() => {
325
- if (state.current === blok) {
326
- teardown();
327
- }
328
- });
329
- };
330
-
331
- // Reactive prop sync. Each watcher also depends on `editor`, so it re-applies
332
- // once the instance appears (the Vue analog of React's `editor` effect-dep).
333
- // `theme`/`width`/`placeholder` guard on `=== undefined` (NOT falsiness) so a
334
- // real `placeholder: false` (clear) still propagates.
335
- // readOnly syncs the full normalized pair: `enabled` always, `hideControls`
336
- // only for the object form (`readOnly.set` toggles IN PLACE — see
337
- // `readOnly.togglesInPlace` — so no recreation is ever needed).
338
- watch(
339
- [
340
- editor,
341
- () => normalizeReadOnlyConfig(mergedConfig().readOnly).enabled,
342
- () => normalizeReadOnlyConfig(mergedConfig().readOnly).hideControls,
343
- () => typeof mergedConfig().readOnly === 'object',
344
- ],
345
- ([ed, readOnlyEnabled, hideControls, isObjectForm]) => {
346
- if (ed) {
347
- void (isObjectForm ? ed.readOnly.set(readOnlyEnabled, { hideControls }) : ed.readOnly.set(readOnlyEnabled));
348
- }
349
- },
350
- { immediate: true }
351
- );
352
-
353
- // hideToolbar toggles the hover toolbar and its gutter in place. Coerces
354
- // undefined to false (like readOnly) so clearing the option propagates.
355
- watch(
356
- [editor, () => mergedConfig().hideToolbar],
357
- ([ed, hideToolbar]) => {
358
- if (ed) {
359
- ed.toolbar.setHidden(Boolean(hideToolbar));
360
- }
361
- },
362
- { immediate: true }
363
- );
364
-
365
- // toolbarPosition moves the controls between the start/end gutters in place.
366
- // Unset stays unset (no call), so an app that never passes the prop keeps
367
- // core's own default rather than having it re-asserted on every boot.
368
- watch(
369
- [editor, () => mergedConfig().toolbarPosition],
370
- ([ed, toolbarPosition]) => {
371
- if (ed && toolbarPosition !== undefined) {
372
- ed.toolbar.setPosition(toolbarPosition);
373
- }
374
- },
375
- { immediate: true }
376
- );
377
-
378
- watch(
379
- [editor, () => mergedConfig().theme],
380
- ([ed, theme]) => {
381
- if (ed && theme !== undefined) {
382
- ed.theme.set(theme);
383
- }
384
- },
385
- { immediate: true }
386
- );
387
-
388
- watch(
389
- [editor, () => mergedConfig().width],
390
- ([ed, width]) => {
391
- if (ed && width !== undefined) {
392
- ed.width.set(width);
393
- }
394
- },
395
- { immediate: true }
396
- );
397
-
398
- watch(
399
- [editor, () => mergedConfig().placeholder],
400
- ([ed, placeholder]) => {
401
- if (ed && placeholder !== undefined) {
402
- ed.placeholder.set(placeholder);
403
- }
404
- },
405
- { immediate: true }
406
- );
407
-
408
- // Theme tokens were construction-only, so a host with a live light/dark
409
- // toggle had to recreate the editor or hand-write the global stylesheet Blok
410
- // already injects. Deep-equal-deduped because `tokens` is typically a fresh
411
- // object literal; `deep: true` so mutating the same object still propagates.
412
- const appliedTokens: { value: Record<string, string> | undefined } = { value: undefined };
413
-
414
- watch(
415
- [editor, () => mergedConfig().style?.tokens],
416
- ([ed, tokens]) => {
417
- if (!ed || tokens === undefined || deepEqual(tokens, appliedTokens.value)) {
418
- return;
419
- }
420
-
421
- appliedTokens.value = { ...tokens };
422
- ed.tokens.set(tokens);
423
- },
424
- { immediate: true, deep: true }
425
- );
426
-
427
- // `config.i18n` was consumed once during boot, so a host driving a language
428
- // switcher had to recreate the editor (losing caret, focus and undo stack)
429
- // to relabel the UI. This drives the runtime `i18n.update` API instead.
430
- // Deep-equal-deduped and `deep: true` for the same reasons as tokens.
431
- // `defaultLocale` is not forwarded: it only affects the INITIAL locale
432
- // resolution, so it is inert after mount.
433
- /*
434
- * Seeded with the mount-time value: construction already applied it, so the
435
- * immediate run is a no-op and only genuine changes push.
436
- */
437
- const appliedI18n: { value: BlokConfig['i18n'] | undefined } = { value: mergedConfig().i18n };
438
-
439
- watch(
440
- [editor, () => mergedConfig().i18n],
441
- ([ed, i18n]) => {
442
- const previous = appliedI18n.value;
443
-
444
- if (!ed || (i18n === undefined && previous?.direction === undefined) || deepEqual(i18n, previous)) {
445
- return;
446
- }
447
-
448
- appliedI18n.value = i18n === undefined ? undefined : { ...i18n };
449
-
450
- const { locale, messages, direction } = i18n ?? {};
451
- // A dropped direction must be cleared, or the editor keeps the old one.
452
- const nextDirection = direction ?? (previous?.direction === undefined ? undefined : null);
453
-
454
- void ed.i18n.update({
455
- ...(locale === undefined ? {} : { locale }),
456
- ...(messages === undefined ? {} : { messages }),
457
- ...(nextDirection === undefined ? {} : { direction: nextDirection }),
458
- });
459
- },
460
- { immediate: true, deep: true }
461
- );
462
-
463
- // inlineToolbar re-assigns inline tools in place. Content-compared (not
464
- // identity-compared) because `boolean | string[]` configs are typically fresh
465
- // array literals; the baseline is tracked per editor instance so a recreated
466
- // editor gets the current value applied again. `deep: true` so mutating the
467
- // same array still propagates (mirrors the tokens watch).
468
- const appliedInlineToolbar: { editor: Blok | null; value: boolean | string[] | undefined } = {
469
- editor: null,
470
- value: undefined,
471
- };
472
-
473
- watch(
474
- [editor, () => mergedConfig().inlineToolbar],
475
- ([ed, inlineToolbar]) => {
476
- if (!ed || inlineToolbar === undefined) {
477
- return;
478
- }
479
-
480
- if (appliedInlineToolbar.editor === ed && deepEqual(inlineToolbar, appliedInlineToolbar.value)) {
481
- return;
482
- }
483
-
484
- // De-proxy: copy an array so no Vue reactive proxy reaches core.
485
- const plain = Array.isArray(inlineToolbar) ? [...inlineToolbar] : inlineToolbar;
486
-
487
- appliedInlineToolbar.editor = ed;
488
- appliedInlineToolbar.value = plain;
489
- ed.tools.setInlineToolbar(plain);
490
- },
491
- { immediate: true, deep: true }
492
- );
493
-
494
- watch(
495
- [editor, () => mergedConfig().autofocus],
496
- ([ed, autofocus]) => {
497
- if (ed && autofocus) {
498
- ed.focus();
499
- }
500
- },
501
- { immediate: true }
502
- );
503
-
504
- // Live callback config. Callback presence was frozen at construction and IS
505
- // the semantics in core (`onSubmit` makes Enter serialize-and-submit instead
506
- // of splitting the block; `onSave` arms the change pipeline), so a `@save`
507
- // listener or an "Enter sends" toggle added after mount used to need an
508
- // editor recreate — losing caret and undo history. Diff presence against what
509
- // is installed and push genuine flips through the runtime `handlers.set` API,
510
- // writing `undefined` for a callback that disappeared so the change is
511
- // reversible. Identity is never diffed: the wrappers forward to the latest
512
- // config already.
513
- watch(
514
- [editor, () => JSON.stringify(readHandlerPresence(mergedConfig()))],
515
- ([ed]) => {
516
- if (!ed) {
517
- return;
518
- }
519
-
520
- const latest = readHandlerPresence(mergedConfig());
521
- const applied = state.appliedHandlerPresence;
522
- const diff: LiveHandlers = {};
523
- const changed: { value: boolean } = { value: false };
524
-
525
- const sync = <K extends keyof LiveHandlers>(key: K, write: (value: LiveHandlers[K]) => void): void => {
526
- if (latest[key] === applied[key]) {
527
- return;
528
- }
529
-
530
- applied[key] = latest[key];
531
- changed.value = true;
532
- write(latest[key] ? handlerWrappers[key] : undefined);
533
- };
534
-
535
- sync('onChange', (value) => {
536
- diff.onChange = value;
537
- });
538
- sync('onSave', (value) => {
539
- diff.onSave = value;
540
- });
541
- sync('onEnter', (value) => {
542
- diff.onEnter = value;
543
- });
544
- sync('onSubmit', (value) => {
545
- diff.onSubmit = value;
546
- });
547
- sync('onBeforeRender', (value) => {
548
- diff.onBeforeRender = value;
549
- });
550
- sync('onAfterRender', (value) => {
551
- diff.onAfterRender = value;
552
- });
553
-
554
- if (changed.value) {
555
- ed.handlers.set(diff);
556
- }
557
- },
558
- { immediate: true }
559
- );
560
-
561
- // Reactive content. `data` seeds the editor at construction; afterwards a new
562
- // *content* value re-renders via the public render() API — deep-equal-deduped
563
- // against the baseline (so an unchanged reference, including the editor's own
564
- // echoed output, is a no-op) and serialized via `renderChain`.
565
- watch(
566
- [editor, () => mergedConfig().data],
567
- ([ed, data]) => {
568
- if (!ed || data === undefined) {
569
- return;
570
- }
571
-
572
- // A freshly created editor was already seeded with `data` at construction;
573
- // record it as the baseline without re-rendering.
574
- if (state.seededEditor !== ed) {
575
- state.seededEditor = ed;
576
- state.lastRenderedData = data;
577
- // A render still pending on a PRIOR editor is moot for this fresh
578
- // instance; clear it so its content can't dedupe a needed render here.
579
- state.pendingData = undefined;
580
- // Payloads emitted by a PRIOR editor are likewise moot for this one.
581
- emittedEcho.clear();
582
-
583
- return;
584
- }
585
-
586
- // Skipped when the editor already reflects this content (the last
587
- // SUCCESSFUL render) OR a render of the same content is already
588
- // queued/in-flight — re-rendering identical content would needlessly
589
- // reset the caret.
590
- //
591
- // Structural comparison (`equalsOutputData`, not a raw deep-equal): a host
592
- // that persists the editor's own document and hands back a stripped copy —
593
- // fresh `time`, dropped ids, no `lastEditedAt` stamp — is still echoing
594
- // content the editor already shows. `undefined` means "nothing recorded
595
- // yet", which is NOT an empty document, so it must never match.
596
- const baseline = state.lastRenderedData;
597
- const inFlight = state.pendingData;
598
-
599
- if (
600
- (baseline !== undefined && equalsOutputData(data, baseline)) ||
601
- (inFlight !== undefined && equalsOutputData(data, inFlight))
602
- ) {
603
- return;
604
- }
605
-
606
- // Skip stale echoes: content the editor itself emitted via `onSave`
607
- // recently, arriving back reshaped (fresh envelope, stripped ids) and/or
608
- // late — after a newer save already replaced the baseline above.
609
- if (emittedEcho.matches(data)) {
610
- return;
611
- }
612
-
613
- // Under collaboration the document belongs to the sync service, and
614
- // render() refuses a wholesale replace — so don't attempt one. The prop's
615
- // premise ("this value IS the document") does not hold here. Decided
616
- // against the MOUNTED config, never the current one: `collaboration` is
617
- // mount-fixed, so a host that drops the key is still driving a
618
- // collaboration session and deserves the warning rather than a silently
619
- // rejected render.
620
- const collaboration = state.constructedCollaboration;
621
-
622
- if (collaboration !== undefined) {
623
- if (state.collaborationWarnedFor !== ed) {
624
- state.collaborationWarnedFor = ed;
625
- console.warn(collaborationDataIgnoredMessage(collaboration.doc));
626
- }
627
-
628
- return;
629
- }
630
-
631
- // Genuinely external content takes over: earlier emitted payloads are
632
- // moot, and keeping them could wrongly no-op a later deliberate revert to
633
- // one of them.
634
- emittedEcho.clear();
635
-
636
- // Mark the content in-flight, but DON'T advance the success baseline until
637
- // the render resolves. A failed render leaves the editor on its last
638
- // successful content, so the baseline must keep pointing there — otherwise
639
- // a run of failures would strand it on a never-rendered payload and
640
- // wrongly dedupe a later retry.
641
- state.pendingData = data;
642
- // `data` may be null (a controlled "clear to empty"); render() throws on
643
- // null, so normalize null → { blocks: [] } at the boundary. toRaw(null)
644
- // stays null, so the helper still sees it.
645
- // The rejection handler OWNS a failed render: without it the rejection
646
- // sits in `renderChain` unhandled until the next `data` change attaches a
647
- // handler, which the runtime reports as an unhandled rejection. Because
648
- // the chain can no longer reject, no leading catch is needed to keep the
649
- // next queued render running.
650
- state.renderChain = state.renderChain
651
- .then(() => ed.render(toRenderableData(toRaw(data))))
652
- .then(
653
- () => {
654
- state.lastRenderedData = data;
655
- },
656
- () => undefined
657
- )
658
- .then(() => {
659
- // Clear the in-flight marker once settled, unless a newer render has
660
- // since claimed it (then that newer render owns the clear).
661
- if (state.pendingData === data) {
662
- state.pendingData = undefined;
663
- }
664
- });
665
- },
666
- { immediate: true }
667
- );
668
-
669
- // Recreate on recreateKey identity change (the Vue analog of React `deps` /
670
- // Angular `recreateKey`). Tears the current editor down and rebuilds; the old
671
- // editor's late isReady is dropped by the identity guard in `create`.
672
- if (recreateKey !== undefined) {
673
- watch(
674
- () => toValue(recreateKey),
675
- () => {
676
- teardown();
677
- create();
678
- }
679
- );
680
- }
681
-
682
- onMounted(create);
683
- onBeforeUnmount(teardown);
684
-
685
- return editor;
686
- }