@bloklabs/vue 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/src/useBlok.ts ADDED
@@ -0,0 +1,299 @@
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 { setHolder, removeHolder } from './holder-map';
14
+ import { deepEqual , normalizeReadOnlyConfig } from '@bloklabs/core/adapters';
15
+ import { BLOK_DEFAULT_CONFIG, mergeBlokDefaults } from './provide-blok';
16
+ import {
17
+ createBlockPortalRegistry,
18
+ BLOK_PORTAL_REGISTRY_CONFIG_KEY,
19
+ type BlockPortalRegistry,
20
+ } from './block-portal-registry';
21
+ import { setRegistry, removeRegistry } from './registry-map';
22
+ import type { Blok, OutputData } from '@/types';
23
+ import type { UseBlokConfig } from './types';
24
+
25
+ /**
26
+ * Inject the editor's portal registry into every `createVueBlock` tool's config
27
+ * (vanilla tools are left untouched), returning a NEW tools object so the
28
+ * consumer's config is never mutated. A vue-block tool is constructed by CORE,
29
+ * outside any Vue `setup`, so it cannot `inject()` — this config bridge is how
30
+ * the tool reaches its editor-scoped registry.
31
+ */
32
+ const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): unknown => {
33
+ if (tools === null || typeof tools !== 'object') {
34
+ return tools;
35
+ }
36
+
37
+ const result: Record<string, unknown> = {};
38
+
39
+ for (const [name, entry] of Object.entries(tools as Record<string, unknown>)) {
40
+ const toolClass = typeof entry === 'function' ? entry : (entry as { class?: unknown })?.class;
41
+ const isVueBlock =
42
+ typeof toolClass === 'function' && (toolClass as { __isBlokVueBlock?: boolean }).__isBlokVueBlock === true;
43
+
44
+ if (!isVueBlock) {
45
+ result[name] = entry;
46
+
47
+ continue;
48
+ }
49
+
50
+ const base: Record<string, unknown> =
51
+ typeof entry === 'function' ? { class: entry } : { ...(entry as Record<string, unknown>) };
52
+
53
+ base.config = {
54
+ ...((base.config as Record<string, unknown> | undefined) ?? {}),
55
+ [BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry,
56
+ };
57
+ result[name] = base;
58
+ }
59
+
60
+ return result;
61
+ };
62
+
63
+ /**
64
+ * Composable that manages a Blok editor instance lifecycle.
65
+ *
66
+ * Creates a detached holder div, instantiates Blok with a plain (de-proxied)
67
+ * config, and exposes the instance through the returned ref once `isReady`
68
+ * resolves. Destroys the editor on unmount. The instance is published only when
69
+ * the resolving editor is still the current one (identity guard), so a late
70
+ * `isReady` from a superseded editor never leaks.
71
+ *
72
+ * @param config - reactive config source (ref/getter) without `holder`
73
+ * @returns a ref to the live Blok instance, or null before ready / after destroy
74
+ */
75
+ export function useBlok(
76
+ config: MaybeRefOrGetter<UseBlokConfig>,
77
+ recreateKey?: MaybeRefOrGetter<unknown>
78
+ ): Ref<Blok | null> {
79
+ // shallowRef (not ref): the live Blok instance must be stored as-is. A deep
80
+ // ref would wrap core in a reactive proxy, breaking its identity checks and
81
+ // handing consumers a proxy instead of the real editor.
82
+ const editor = shallowRef<Blok | null>(null);
83
+
84
+ // App-wide defaults from the nearest `provideBlok()`, injected once (the Vue
85
+ // analog of React's `useContext(BlokDefaultsContext)`). Merged UNDER the
86
+ // per-instance config so the escape-hatch path (`useBlok` + `BlokContent`)
87
+ // honors them exactly like `<BlokEditor>`. `inject` must run synchronously in
88
+ // `setup`, hence at the top of the composable.
89
+ const defaults = inject(BLOK_DEFAULT_CONFIG, {});
90
+
91
+ /** Per-instance config with `provideBlok` defaults merged under it (instance wins). */
92
+ const mergedConfig = (): UseBlokConfig => mergeBlokDefaults(defaults, toValue(config));
93
+
94
+ // Mutable adapter state (held in one object to avoid `let` reassignment):
95
+ // - `current` is the editor that should own the holder/ref; it guards async
96
+ // isReady resolution against a stale editor (e.g. after a recreate).
97
+ // - `lastRenderedData` is the content the editor currently reflects, updated
98
+ // when `data` is seeded/rendered AND when the editor emits its own serialized
99
+ // output via the `onSave` wrapper — so a controlled `update:data -> data`
100
+ // echo deep-equals this baseline and is deduped to a no-op (no caret reset).
101
+ const state: {
102
+ current: Blok | null;
103
+ holder: HTMLDivElement | null;
104
+ lastRenderedData: OutputData | undefined;
105
+ seededEditor: Blok | null;
106
+ renderChain: Promise<void>;
107
+ } = {
108
+ current: null,
109
+ holder: null,
110
+ lastRenderedData: mergedConfig().data,
111
+ seededEditor: null,
112
+ renderChain: Promise.resolve(),
113
+ };
114
+
115
+ /**
116
+ * Snapshot the reactive config into a plain object for core. `toRaw` unwraps
117
+ * the config and its `data` so no Vue reactive proxy reaches core (Risk R0):
118
+ * proxies would break core's identity checks and the holder WeakMap key.
119
+ */
120
+ const buildConfig = (): Record<string, unknown> => {
121
+ // Merge `provideBlok` defaults under the raw per-instance config, then
122
+ // de-proxy. `toRaw` unwraps both the config and its `data` so no Vue reactive
123
+ // proxy reaches core (Risk R0): proxies would break core's identity checks
124
+ // and the holder WeakMap key.
125
+ const snapshot = { ...mergeBlokDefaults(toRaw(defaults), toRaw(toValue(config))) } as Record<string, unknown>;
126
+
127
+ delete snapshot.holder;
128
+
129
+ if (snapshot.data !== undefined) {
130
+ snapshot.data = toRaw(snapshot.data);
131
+ }
132
+
133
+ return snapshot;
134
+ };
135
+
136
+ const teardown = (): void => {
137
+ if (state.current !== null) {
138
+ removeHolder(state.current);
139
+ removeRegistry(state.current);
140
+ try {
141
+ state.current.destroy();
142
+ } catch {
143
+ // destroy may throw — still clean up adapter state
144
+ }
145
+ }
146
+
147
+ state.current = null;
148
+ state.holder = null;
149
+ editor.value = null;
150
+ };
151
+
152
+ const create = (): void => {
153
+ const holder = document.createElement('div');
154
+
155
+ state.holder = holder;
156
+
157
+ const snapshot = buildConfig();
158
+
159
+ // Per-editor portal registry for `createVueBlock` tools: inject it into each
160
+ // vue-block tool's config (so the core-constructed tool can reach it) and
161
+ // associate it with the editor below (so BlokContent can mount the host).
162
+ const registry = createBlockPortalRegistry();
163
+
164
+ if (snapshot.tools !== undefined) {
165
+ snapshot.tools = injectPortalRegistry(snapshot.tools, registry);
166
+ }
167
+
168
+ // Wrap onSave (when the consumer opted in): record the editor's own
169
+ // serialized output as the dedup baseline BEFORE notifying the consumer, so a
170
+ // controlled `update:data -> data` echo deep-equals it and never re-renders.
171
+ // Reads the latest callback through the config getter so it never goes stale.
172
+ if (typeof snapshot.onSave === 'function') {
173
+ snapshot.onSave = (...args: Parameters<NonNullable<UseBlokConfig['onSave']>>): void => {
174
+ state.lastRenderedData = args[0];
175
+ mergedConfig().onSave?.(...args);
176
+ };
177
+ }
178
+
179
+ const blok = new BlokRuntime({ ...snapshot, holder }) as unknown as Blok;
180
+
181
+ state.current = blok;
182
+ setHolder(blok, holder);
183
+ setRegistry(blok, registry);
184
+
185
+ void blok.isReady
186
+ .then(() => {
187
+ if (state.current === blok) {
188
+ editor.value = blok;
189
+ }
190
+ })
191
+ .catch(() => {
192
+ if (state.current === blok) {
193
+ teardown();
194
+ }
195
+ });
196
+ };
197
+
198
+ // Reactive prop sync. Each watcher also depends on `editor`, so it re-applies
199
+ // once the instance appears (the Vue analog of React's `editor` effect-dep).
200
+ // `theme`/`width`/`placeholder` guard on `=== undefined` (NOT falsiness) so a
201
+ // real `placeholder: false` (clear) still propagates.
202
+ watch(
203
+ [editor, () => normalizeReadOnlyConfig(mergedConfig().readOnly).enabled],
204
+ ([ed, readOnlyEnabled]) => {
205
+ if (ed) {
206
+ void ed.readOnly.set(readOnlyEnabled);
207
+ }
208
+ },
209
+ { immediate: true }
210
+ );
211
+
212
+ watch(
213
+ [editor, () => mergedConfig().theme],
214
+ ([ed, theme]) => {
215
+ if (ed && theme !== undefined) {
216
+ ed.theme.set(theme);
217
+ }
218
+ },
219
+ { immediate: true }
220
+ );
221
+
222
+ watch(
223
+ [editor, () => mergedConfig().width],
224
+ ([ed, width]) => {
225
+ if (ed && width !== undefined) {
226
+ ed.width.set(width);
227
+ }
228
+ },
229
+ { immediate: true }
230
+ );
231
+
232
+ watch(
233
+ [editor, () => mergedConfig().placeholder],
234
+ ([ed, placeholder]) => {
235
+ if (ed && placeholder !== undefined) {
236
+ ed.placeholder.set(placeholder);
237
+ }
238
+ },
239
+ { immediate: true }
240
+ );
241
+
242
+ watch(
243
+ [editor, () => mergedConfig().autofocus],
244
+ ([ed, autofocus]) => {
245
+ if (ed && autofocus) {
246
+ ed.focus();
247
+ }
248
+ },
249
+ { immediate: true }
250
+ );
251
+
252
+ // Reactive content. `data` seeds the editor at construction; afterwards a new
253
+ // *content* value re-renders via the public render() API — deep-equal-deduped
254
+ // against the baseline (so an unchanged reference, including the editor's own
255
+ // echoed output, is a no-op) and serialized via `renderChain`.
256
+ watch(
257
+ [editor, () => mergedConfig().data],
258
+ ([ed, data]) => {
259
+ if (!ed || data === undefined) {
260
+ return;
261
+ }
262
+
263
+ // A freshly created editor was already seeded with `data` at construction;
264
+ // record it as the baseline without re-rendering.
265
+ if (state.seededEditor !== ed) {
266
+ state.seededEditor = ed;
267
+ state.lastRenderedData = data;
268
+
269
+ return;
270
+ }
271
+
272
+ if (deepEqual(data, state.lastRenderedData)) {
273
+ return;
274
+ }
275
+
276
+ state.lastRenderedData = data;
277
+ state.renderChain = state.renderChain.catch(() => undefined).then(() => ed.render(toRaw(data)));
278
+ },
279
+ { immediate: true }
280
+ );
281
+
282
+ // Recreate on recreateKey identity change (the Vue analog of React `deps` /
283
+ // Angular `recreateKey`). Tears the current editor down and rebuilds; the old
284
+ // editor's late isReady is dropped by the identity guard in `create`.
285
+ if (recreateKey !== undefined) {
286
+ watch(
287
+ () => toValue(recreateKey),
288
+ () => {
289
+ teardown();
290
+ create();
291
+ }
292
+ );
293
+ }
294
+
295
+ onMounted(create);
296
+ onBeforeUnmount(teardown);
297
+
298
+ return editor;
299
+ }