@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/README.md +20 -0
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +146 -104
- package/package.json +3 -4
- package/types/index.d.ts +57 -3
- package/src/BlockPortalHost.ts +0 -46
- package/src/BlokContent.ts +0 -103
- package/src/BlokEditor.ts +0 -326
- package/src/block-portal-registry.ts +0 -93
- package/src/blocks-snapshot.ts +0 -17
- package/src/blok-instance.ts +0 -40
- package/src/config-keys.ts +0 -82
- package/src/content-baseline-map.ts +0 -38
- package/src/createVueBlock.ts +0 -740
- package/src/holder-map.ts +0 -19
- package/src/index.ts +0 -30
- package/src/provide-blok.ts +0 -65
- package/src/registry-map.ts +0 -21
- package/src/types.ts +0 -36
- package/src/useBlocks.ts +0 -182
- package/src/useBlok.ts +0 -686
- package/src/useBlokReady.ts +0 -70
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
|
-
}
|