@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/dist/index.cjs +1 -0
- package/dist/index.mjs +602 -0
- package/package.json +39 -0
- package/src/BlockPortalHost.ts +46 -0
- package/src/BlokContent.ts +80 -0
- package/src/BlokEditor.ts +197 -0
- package/src/block-portal-registry.ts +79 -0
- package/src/blocks-snapshot.ts +17 -0
- package/src/config-keys.ts +66 -0
- package/src/createVueBlock.ts +319 -0
- package/src/holder-map.ts +19 -0
- package/src/index.ts +22 -0
- package/src/provide-blok.ts +65 -0
- package/src/registry-map.ts +21 -0
- package/src/types.ts +30 -0
- package/src/useBlocks.ts +147 -0
- package/src/useBlok.ts +299 -0
- package/types/index.d.ts +502 -0
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
|
+
}
|