@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
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// src/vue/createVueBlock.ts
|
|
2
|
+
import {
|
|
3
|
+
defineComponent,
|
|
4
|
+
h,
|
|
5
|
+
nextTick,
|
|
6
|
+
onMounted,
|
|
7
|
+
onUpdated,
|
|
8
|
+
ref,
|
|
9
|
+
shallowRef,
|
|
10
|
+
toRaw,
|
|
11
|
+
type Component,
|
|
12
|
+
type PropType,
|
|
13
|
+
type ShallowRef,
|
|
14
|
+
type VNodeChild,
|
|
15
|
+
} from 'vue';
|
|
16
|
+
|
|
17
|
+
import type { BlockAPI } from '@/types/api';
|
|
18
|
+
import type {
|
|
19
|
+
BlockToolConstructorOptions,
|
|
20
|
+
BlockToolData,
|
|
21
|
+
ToolboxConfig,
|
|
22
|
+
} from '@/types/tools';
|
|
23
|
+
import { DATA_ATTR , deepEqual , mountChildBlocks } from '@bloklabs/core/adapters';
|
|
24
|
+
import { fillDefaults, type PropSchema } from '@bloklabs/core/adapters';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
BLOK_PORTAL_REGISTRY_CONFIG_KEY,
|
|
28
|
+
type BlockPortalRegistry,
|
|
29
|
+
} from './block-portal-registry';
|
|
30
|
+
|
|
31
|
+
export type { PropSchema, PropSchemaEntry } from '@bloklabs/core/adapters';
|
|
32
|
+
|
|
33
|
+
/** Context handed to a Vue block's `setup` (the only data write path is `commit`). */
|
|
34
|
+
export interface VueBlockRenderProps<Data> {
|
|
35
|
+
/** Reactive, FROZEN snapshot of the block data. Read `data.value`; never mutate. */
|
|
36
|
+
data: ShallowRef<Readonly<Data>>;
|
|
37
|
+
/** The ONLY data write path: merge a partial patch and sync once. */
|
|
38
|
+
commit: (patch: Partial<Data>) => void;
|
|
39
|
+
/** This block's per-block API (id, connection methods, dispatchChange…). */
|
|
40
|
+
block: BlockAPI;
|
|
41
|
+
/**
|
|
42
|
+
* Reactive read-only flag. Read `readOnly.value` in render to disable editing
|
|
43
|
+
* (e.g. drop `contenteditable`, hide controls). Toggled IN PLACE by core's
|
|
44
|
+
* read-only switch — the component reacts without a remount, so ephemeral state
|
|
45
|
+
* survives. Honor it: a block that ignores `readOnly` stays interactive when
|
|
46
|
+
* the editor is read-only (same contract as a vanilla tool's `setReadOnly`).
|
|
47
|
+
*/
|
|
48
|
+
readOnly: Readonly<ShallowRef<boolean>>;
|
|
49
|
+
/** Engine-owned child slot — render `h(BlockChildren)` for a container block. */
|
|
50
|
+
BlockChildren: Component;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Spec for {@link createVueBlock}. Authored as a `.ts` render function (no SFC). */
|
|
54
|
+
export interface CreateVueBlockSpec<Data = BlockToolData> {
|
|
55
|
+
/** Tool type name (registered key). */
|
|
56
|
+
type: string;
|
|
57
|
+
/** Optional toolbox entry. */
|
|
58
|
+
toolbox?: ToolboxConfig;
|
|
59
|
+
/** Declarative defaults that also define the exact `save()` key set. */
|
|
60
|
+
propSchema: PropSchema;
|
|
61
|
+
/** Returns a render function (the `defineComponent` + `setup`-returns-`h` shape). */
|
|
62
|
+
setup: (props: VueBlockRenderProps<Data>) => () => VNodeChild;
|
|
63
|
+
/** Optional lifecycle callbacks mapped from Blok's block hooks. */
|
|
64
|
+
onRendered?: (block: BlockAPI) => void;
|
|
65
|
+
onMoved?: (block: BlockAPI) => void;
|
|
66
|
+
onRemoved?: (block: BlockAPI) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Author a first-party Vue block. Returns a `BlockToolConstructable` registered
|
|
71
|
+
* exactly like a vanilla tool (`tools: { type: { class: createVueBlock(...) } }`).
|
|
72
|
+
*
|
|
73
|
+
* The factory owns the host element (`data-blok-mutation-free`), a frozen
|
|
74
|
+
* defaults-filled data mirror, and a reactive `shallowRef` the component reads.
|
|
75
|
+
* It teleports the component into the host via the editor's shared portal
|
|
76
|
+
* registry (the TipTap `VueNodeViewRenderer` pattern), and bridges Blok's block
|
|
77
|
+
* lifecycle to Vue:
|
|
78
|
+
* - `render()` creates the host and registers the portal entry (returned sync).
|
|
79
|
+
* - `setData()` is async: it dedups, swaps the reactive snapshot, and `await`s
|
|
80
|
+
* Vue's commit before resolving `true` — defeating core's default remount and
|
|
81
|
+
* landing the update inside core's RAF-extended Yjs suppression window.
|
|
82
|
+
* - `save()` returns the complete frozen mirror (never the DOM, never partial).
|
|
83
|
+
* - `commit()` merges a patch and fires `dispatchChange` exactly once.
|
|
84
|
+
* - `removed()`/`destroy()` unregister the portal (deterministic unmount).
|
|
85
|
+
*/
|
|
86
|
+
export function createVueBlock<Data = BlockToolData>(
|
|
87
|
+
spec: CreateVueBlockSpec<Data>
|
|
88
|
+
): (new (options: BlockToolConstructorOptions) => {
|
|
89
|
+
render(): HTMLElement;
|
|
90
|
+
save(): BlockToolData;
|
|
91
|
+
setData(newData: BlockToolData): Promise<boolean>;
|
|
92
|
+
setReadOnly(state: boolean): void;
|
|
93
|
+
rendered(): void;
|
|
94
|
+
moved(): void;
|
|
95
|
+
removed(): void;
|
|
96
|
+
destroy(): void;
|
|
97
|
+
}) & {
|
|
98
|
+
readonly __isBlokVueBlock: true;
|
|
99
|
+
readonly toolbox: ToolboxConfig | undefined;
|
|
100
|
+
readonly isReadOnlySupported: boolean;
|
|
101
|
+
} {
|
|
102
|
+
// One wrapped component definition per TYPE; each block instance gets its own
|
|
103
|
+
// context via the `ctx` prop, so Vue mounts a distinct instance per entry.
|
|
104
|
+
const WrappedComponent = defineComponent({
|
|
105
|
+
name: `VueBlock(${spec.type})`,
|
|
106
|
+
props: {
|
|
107
|
+
ctx: { type: Object as PropType<VueBlockRenderProps<Data>>, required: true },
|
|
108
|
+
},
|
|
109
|
+
setup(props) {
|
|
110
|
+
// props is shallow-reactive: `props.ctx` is the raw context object, so the
|
|
111
|
+
// `data` ref inside it is NOT unwrapped (no reactive ref-unwrap gotcha).
|
|
112
|
+
return spec.setup(props.ctx);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return class VueBlockTool {
|
|
117
|
+
/** Marker so `useBlok` can detect vue-block tools and inject the registry. */
|
|
118
|
+
public static readonly __isBlokVueBlock = true as const;
|
|
119
|
+
|
|
120
|
+
public static get toolbox(): ToolboxConfig | undefined {
|
|
121
|
+
return spec.toolbox;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Vue blocks support read-only mode: `setReadOnly` flips a reactive flag the
|
|
126
|
+
* component reads, so the block re-renders read-only IN PLACE. Without this
|
|
127
|
+
* flag core's ReadOnly module throws a critical error when read-only is
|
|
128
|
+
* enabled and a Vue block is present.
|
|
129
|
+
*/
|
|
130
|
+
public static get isReadOnlySupported(): boolean {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private readonly blockApi: BlockAPI;
|
|
135
|
+
private readonly registry: BlockPortalRegistry | undefined;
|
|
136
|
+
private readonly pointerDrag: () => boolean;
|
|
137
|
+
private readonly dataRef: ShallowRef<Readonly<Data>>;
|
|
138
|
+
/** Reactive read-only flag handed to setup; flipped in place by setReadOnly. */
|
|
139
|
+
private readonly readOnlyRef: ShallowRef<boolean>;
|
|
140
|
+
private readonly childrenComponent: Component;
|
|
141
|
+
private mirror: Readonly<Data>;
|
|
142
|
+
/** Dedup baseline (Risk R3/R6): skip a redundant render of identical data. */
|
|
143
|
+
private lastRendered: Readonly<Data>;
|
|
144
|
+
private hostEl: HTMLElement | null = null;
|
|
145
|
+
/** Latest patch queued while a pointer drag suppresses dispatchChange. */
|
|
146
|
+
private pendingDispatch = false;
|
|
147
|
+
|
|
148
|
+
public constructor(options: BlockToolConstructorOptions) {
|
|
149
|
+
this.blockApi = options.block;
|
|
150
|
+
|
|
151
|
+
const config = (options.config ?? {}) as Record<string, unknown>;
|
|
152
|
+
|
|
153
|
+
this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
|
|
154
|
+
|
|
155
|
+
// Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
|
|
156
|
+
// (core silently drops a dispatchChange while a drag is active).
|
|
157
|
+
const api = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
|
|
158
|
+
|
|
159
|
+
this.pointerDrag = (): boolean => api?.blocks?.isPointerDragActive === true;
|
|
160
|
+
|
|
161
|
+
this.mirror = fillDefaults<Data>(spec.propSchema, toRaw(options.data ?? {}) as Record<string, unknown>);
|
|
162
|
+
this.lastRendered = this.mirror;
|
|
163
|
+
this.dataRef = shallowRef(this.mirror);
|
|
164
|
+
this.readOnlyRef = shallowRef(options.readOnly);
|
|
165
|
+
|
|
166
|
+
const blockApi = this.blockApi;
|
|
167
|
+
|
|
168
|
+
// Per-instance child slot: a childless ref'd div Blok owns; mountChildBlocks
|
|
169
|
+
// appends the real child holders imperatively (Vue never reconciles them).
|
|
170
|
+
this.childrenComponent = defineComponent({
|
|
171
|
+
name: `VueBlockChildren(${spec.type})`,
|
|
172
|
+
setup() {
|
|
173
|
+
const slot = ref<HTMLElement | null>(null);
|
|
174
|
+
const mountKids = (): void => {
|
|
175
|
+
if (slot.value !== null) {
|
|
176
|
+
mountChildBlocks(slot.value, blockApi.getChildren());
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
onMounted(mountKids);
|
|
181
|
+
onUpdated(mountKids);
|
|
182
|
+
|
|
183
|
+
return () => h('div', { ref: slot, [DATA_ATTR.nestedBlocks]: '' });
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
public render(): HTMLElement {
|
|
189
|
+
const host = document.createElement('div');
|
|
190
|
+
|
|
191
|
+
// The Blok-owned host: Vue reconciles the chrome teleported INTO it, but
|
|
192
|
+
// this attribute makes core's MutationObserver ignore those mutations, so
|
|
193
|
+
// Vue's reconciliation never registers as a user edit.
|
|
194
|
+
host.setAttribute('data-blok-mutation-free', 'true');
|
|
195
|
+
this.hostEl = host;
|
|
196
|
+
|
|
197
|
+
const ctx: VueBlockRenderProps<Data> = {
|
|
198
|
+
data: this.dataRef,
|
|
199
|
+
commit: this.commit,
|
|
200
|
+
block: this.blockApi,
|
|
201
|
+
readOnly: this.readOnlyRef,
|
|
202
|
+
BlockChildren: this.childrenComponent,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
this.registry?.register(this.blockApi.id, {
|
|
206
|
+
hostEl: host,
|
|
207
|
+
component: WrappedComponent,
|
|
208
|
+
props: { ctx },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Returned synchronously; Vue flushes the teleport on the next tick.
|
|
212
|
+
return host;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
public rendered(): void {
|
|
216
|
+
spec.onRendered?.(this.blockApi);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
public save(): BlockToolData {
|
|
220
|
+
// The complete, frozen, toRaw-clean mirror — never the DOM, never partial.
|
|
221
|
+
return this.mirror as BlockToolData;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
public async setData(newData: BlockToolData): Promise<boolean> {
|
|
225
|
+
const next = fillDefaults<Data>(spec.propSchema, toRaw(newData ?? {}));
|
|
226
|
+
|
|
227
|
+
// Dedup (Risk R3): identical data → skip the render entirely, but still
|
|
228
|
+
// return true so core keeps the block in place (no remount).
|
|
229
|
+
if (deepEqual(next, this.lastRendered)) {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.mirror = next;
|
|
234
|
+
this.lastRendered = next;
|
|
235
|
+
this.dataRef.value = next;
|
|
236
|
+
|
|
237
|
+
// Await Vue's commit. Core drives setData asynchronously inside its
|
|
238
|
+
// RAF-extended Yjs-suppression window, so awaiting nextTick lands the
|
|
239
|
+
// re-render before suppression lifts — without ever remounting.
|
|
240
|
+
await nextTick();
|
|
241
|
+
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* In-place read-only toggle. Flips the reactive flag the component reads via
|
|
247
|
+
* `ctx.readOnly`, so the block re-renders read-only WITHOUT a remount
|
|
248
|
+
* (ephemeral component state survives). A regular prototype method (not an
|
|
249
|
+
* arrow field) so core's `supportsInPlaceReadOnly` — which probes the
|
|
250
|
+
* constructable's PROTOTYPE for `setReadOnly` — selects the in-place path.
|
|
251
|
+
*/
|
|
252
|
+
public setReadOnly(state: boolean): void {
|
|
253
|
+
this.readOnlyRef.value = state;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
public moved(): void {
|
|
257
|
+
// No remount: Blok relocates the host element; the teleport follows it.
|
|
258
|
+
spec.onMoved?.(this.blockApi);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
public removed(): void {
|
|
262
|
+
this.registry?.unregister(this.blockApi.id);
|
|
263
|
+
spec.onRemoved?.(this.blockApi);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
public destroy(): void {
|
|
267
|
+
// Idempotent with removed(); unregister is safe when already absent.
|
|
268
|
+
this.registry?.unregister(this.blockApi.id);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The only data write path. Merges the patch into the frozen mirror, swaps
|
|
273
|
+
* the reactive snapshot, records the dedup baseline, and fires
|
|
274
|
+
* dispatchChange EXACTLY once — deferring it while a pointer drag is active
|
|
275
|
+
* (core would otherwise silently drop it).
|
|
276
|
+
*/
|
|
277
|
+
private readonly commit = (patch: Partial<Data>): void => {
|
|
278
|
+
const next = fillDefaults<Data>(spec.propSchema, {
|
|
279
|
+
...(this.mirror as Record<string, unknown>),
|
|
280
|
+
...(patch as Record<string, unknown>),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
this.mirror = next;
|
|
284
|
+
this.lastRendered = next;
|
|
285
|
+
this.dataRef.value = next;
|
|
286
|
+
|
|
287
|
+
this.flushDispatch();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/** Dispatch the change, or retry on the next frame if a drag is in progress. */
|
|
291
|
+
private flushDispatch(): void {
|
|
292
|
+
if (!this.pointerDrag()) {
|
|
293
|
+
this.pendingDispatch = false;
|
|
294
|
+
this.blockApi.dispatchChange();
|
|
295
|
+
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (this.pendingDispatch) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.pendingDispatch = true;
|
|
304
|
+
|
|
305
|
+
const retry = (): void => {
|
|
306
|
+
if (this.pointerDrag()) {
|
|
307
|
+
requestAnimationFrame(retry);
|
|
308
|
+
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.pendingDispatch = false;
|
|
313
|
+
this.blockApi.dispatchChange();
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
requestAnimationFrame(retry);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level WeakMap associating editor instances with their detached holder
|
|
3
|
+
* divs. WeakMap ensures no memory leaks — when the editor is garbage collected,
|
|
4
|
+
* the holder reference is released. `useBlok` registers the holder at creation;
|
|
5
|
+
* `BlokContent` reads it to adopt the editor's DOM into its container.
|
|
6
|
+
*/
|
|
7
|
+
const holders = new WeakMap<WeakKey, HTMLDivElement>();
|
|
8
|
+
|
|
9
|
+
export function setHolder(editor: WeakKey, holder: HTMLDivElement): void {
|
|
10
|
+
holders.set(editor, holder);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getHolder(editor: WeakKey): HTMLDivElement | undefined {
|
|
14
|
+
return holders.get(editor);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function removeHolder(editor: WeakKey): void {
|
|
18
|
+
holders.delete(editor);
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { useBlok } from './useBlok';
|
|
2
|
+
export { useBlocks } from './useBlocks';
|
|
3
|
+
export { BlokContent } from './BlokContent';
|
|
4
|
+
export { BlokEditor } from './BlokEditor';
|
|
5
|
+
export { provideBlok, useBlokDefaults, BLOK_DEFAULT_CONFIG } from './provide-blok';
|
|
6
|
+
export { createVueBlock } from './createVueBlock';
|
|
7
|
+
export type { UseBlokConfig, BlokContentProps } from './types';
|
|
8
|
+
export type {
|
|
9
|
+
UseBlocksApi,
|
|
10
|
+
BlockNode,
|
|
11
|
+
CaretTarget,
|
|
12
|
+
InsertPosition,
|
|
13
|
+
InsertSpec,
|
|
14
|
+
TreeInsertSpec,
|
|
15
|
+
MoveTarget,
|
|
16
|
+
} from './blocks-snapshot';
|
|
17
|
+
export type {
|
|
18
|
+
CreateVueBlockSpec,
|
|
19
|
+
PropSchema,
|
|
20
|
+
PropSchemaEntry,
|
|
21
|
+
VueBlockRenderProps,
|
|
22
|
+
} from './createVueBlock';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { inject, provide, type InjectionKey } from 'vue';
|
|
2
|
+
import type { UseBlokConfig } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Injection key holding app-wide Blok defaults (shared tools registry, default
|
|
6
|
+
* theme, i18n). Merged UNDER per-instance config by `useBlok` (and therefore by
|
|
7
|
+
* `<BlokEditor>`, which wires `useBlok`).
|
|
8
|
+
*/
|
|
9
|
+
export const BLOK_DEFAULT_CONFIG: InjectionKey<Partial<UseBlokConfig>> = Symbol('BLOK_DEFAULT_CONFIG');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Registers app-wide Blok defaults in the current component's provide scope
|
|
13
|
+
* (call inside a parent component's `setup`). Mirrors React's `BlokProvider` and
|
|
14
|
+
* Angular's `provideBlok`.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* setup() {
|
|
19
|
+
* provideBlok({ theme: 'dark', tools: sharedTools });
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function provideBlok(defaults: Partial<UseBlokConfig>): void {
|
|
24
|
+
provide(BLOK_DEFAULT_CONFIG, defaults);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reads the app-wide Blok defaults from the nearest `provideBlok` (or `{}`).
|
|
29
|
+
* Mirrors React's `useBlokDefaults`. Call inside `setup`.
|
|
30
|
+
*/
|
|
31
|
+
export function useBlokDefaults(): Partial<UseBlokConfig> {
|
|
32
|
+
return inject(BLOK_DEFAULT_CONFIG, {});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Merge app-wide `defaults` UNDER a per-instance `config`: a defined config value
|
|
37
|
+
* overrides the default, and the `tools` registry is merged across both layers
|
|
38
|
+
* (a shared registry composes with per-instance additions) rather than replaced.
|
|
39
|
+
* Returns the original `config` reference when there are no defaults, so the
|
|
40
|
+
* no-provider path stays identity-stable. Mirrors React's `mergeBlokDefaults`.
|
|
41
|
+
*/
|
|
42
|
+
export function mergeBlokDefaults(
|
|
43
|
+
defaults: Partial<UseBlokConfig>,
|
|
44
|
+
config: UseBlokConfig
|
|
45
|
+
): UseBlokConfig {
|
|
46
|
+
if (Object.keys(defaults).length === 0) {
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const merged: Record<string, unknown> = { ...defaults };
|
|
51
|
+
|
|
52
|
+
for (const key of Object.keys(config)) {
|
|
53
|
+
const value = (config as Record<string, unknown>)[key];
|
|
54
|
+
|
|
55
|
+
if (value !== undefined) {
|
|
56
|
+
merged[key] = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (defaults.tools !== undefined || config.tools !== undefined) {
|
|
61
|
+
merged.tools = { ...defaults.tools, ...config.tools };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return merged as UseBlokConfig;
|
|
65
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/vue/registry-map.ts
|
|
2
|
+
import type { BlockPortalRegistry } from './block-portal-registry';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Associates each live Blok instance with its portal registry, keyed by the RAW
|
|
6
|
+
* editor (mirrors the holder WeakMap). `useBlok` sets it at creation; `BlokContent`
|
|
7
|
+
* reads it to mount the shared `BlockPortalHost`. WeakMap → no leak on destroy.
|
|
8
|
+
*/
|
|
9
|
+
const registries = new WeakMap<WeakKey, BlockPortalRegistry>();
|
|
10
|
+
|
|
11
|
+
export function setRegistry(editor: WeakKey, registry: BlockPortalRegistry): void {
|
|
12
|
+
registries.set(editor, registry);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getRegistry(editor: WeakKey): BlockPortalRegistry | undefined {
|
|
16
|
+
return registries.get(editor);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function removeRegistry(editor: WeakKey): void {
|
|
20
|
+
registries.delete(editor);
|
|
21
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { BlokConfig, Blok, EditorWidth } from '@/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for the `useBlok` composable and the `<BlokEditor>` component.
|
|
5
|
+
* Same as `BlokConfig` but without `holder` (managed by the adapter), plus a
|
|
6
|
+
* reactive `width` prop.
|
|
7
|
+
*
|
|
8
|
+
* Reactive props (sync after mount without recreation):
|
|
9
|
+
* - `readOnly` — calls `editor.readOnly.set(value)`
|
|
10
|
+
* - `autofocus` — calls `editor.focus()` when changed to true
|
|
11
|
+
* - `theme` — calls `editor.theme.set(value)`
|
|
12
|
+
* - `width` — calls `editor.width.set(value)`
|
|
13
|
+
* - `placeholder` — calls `editor.placeholder.set(value)`
|
|
14
|
+
* - `data` — re-renders via `editor.render(value)` when content changes
|
|
15
|
+
* (deep-equal–deduped and serialized; seeds the initial content at creation)
|
|
16
|
+
*
|
|
17
|
+
* The `onSave` config (inherited from `BlokConfig`) is the controlled output
|
|
18
|
+
* half: it fires with the full serialized `OutputData` on every content change,
|
|
19
|
+
* so `data` + `onSave` (or `v-model:data`) form a controlled component.
|
|
20
|
+
*/
|
|
21
|
+
export interface UseBlokConfig extends Omit<BlokConfig, 'holder'> {
|
|
22
|
+
/** Editor content width mode. Synced reactively after mount via `editor.width.set()`. */
|
|
23
|
+
width?: EditorWidth;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Props for the `BlokContent` component. */
|
|
27
|
+
export interface BlokContentProps {
|
|
28
|
+
/** The Blok editor instance returned by `useBlok`. Pass null before it is ready. */
|
|
29
|
+
editor: Blok | null;
|
|
30
|
+
}
|
package/src/useBlocks.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// src/vue/useBlocks.ts
|
|
2
|
+
import {
|
|
3
|
+
shallowRef,
|
|
4
|
+
watch,
|
|
5
|
+
toValue,
|
|
6
|
+
toRaw,
|
|
7
|
+
onScopeDispose,
|
|
8
|
+
type MaybeRefOrGetter,
|
|
9
|
+
} from 'vue';
|
|
10
|
+
|
|
11
|
+
import type { Blok } from '@/types';
|
|
12
|
+
import { createBlocksApiForEditor, EMPTY_API } from '@bloklabs/core/adapters';
|
|
13
|
+
|
|
14
|
+
import type { UseBlocksApi } from './blocks-snapshot';
|
|
15
|
+
|
|
16
|
+
const BLOCK_CHANGED_EVENT = 'block changed';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Vue composable exposing an id/parentId-relative, reactive view of the block
|
|
20
|
+
* tree. Reads refresh whenever the editor emits `block changed`; mutators route
|
|
21
|
+
* through the editor-level `blocks` API (core's chokepoints), so undo/redo and
|
|
22
|
+
* Yjs sync are inherited rather than re-implemented.
|
|
23
|
+
*
|
|
24
|
+
* The block-tree logic itself is framework-agnostic and lives in the shared
|
|
25
|
+
* {@link createBlocksApiForEditor} core — the SAME implementation behind React's
|
|
26
|
+
* `useBlocks`, so the two adapters expose the identical 28-method surface and
|
|
27
|
+
* cannot drift. This composable supplies only Vue's reactivity wrapper:
|
|
28
|
+
*
|
|
29
|
+
* - A private `version` ref is bumped on every `block changed`. The shared API
|
|
30
|
+
* is built with an `onRead` seam (`() => { void version.value }`) that every
|
|
31
|
+
* read method calls, so reading `getChildren`/`getById`/… inside a `computed`
|
|
32
|
+
* or template tracks `version` and re-runs on each structural mutation
|
|
33
|
+
* (including programmatic nest/unnest, which core surfaces as a change).
|
|
34
|
+
* - The bound API is rebuilt when the editor IDENTITY changes (held in a
|
|
35
|
+
* `shallowRef`), and the returned facade is stable across that swap.
|
|
36
|
+
*
|
|
37
|
+
* The editor is accepted as a value, ref, or getter (so the `shallowRef` that
|
|
38
|
+
* `useBlok` returns can be passed directly) and is `toRaw`-unwrapped before any
|
|
39
|
+
* read or core handoff, never letting a Vue reactive proxy reach core (Risk R0).
|
|
40
|
+
*
|
|
41
|
+
* Pre-ready: while the editor is null the bound API is the shared
|
|
42
|
+
* {@link EMPTY_API} — every mutator a no-op, reads empty/null — except
|
|
43
|
+
* `transact`/`transactWithoutCapture`, which still run their callback.
|
|
44
|
+
*
|
|
45
|
+
* @param editor - the Blok instance (or ref/getter of it), or null pre-ready
|
|
46
|
+
*/
|
|
47
|
+
export function useBlocks(editor: MaybeRefOrGetter<Blok | null>): UseBlocksApi {
|
|
48
|
+
// Bumped on every `block changed`; the shared API's read methods touch it
|
|
49
|
+
// (via the onRead seam below) so template/computed reads stay reactive.
|
|
50
|
+
const version = shallowRef(0);
|
|
51
|
+
|
|
52
|
+
const resolve = (): Blok | null => {
|
|
53
|
+
const ed = toValue(editor);
|
|
54
|
+
|
|
55
|
+
return ed === null ? null : toRaw(ed);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// The reactivity seam handed to the shared core: every read method calls it,
|
|
59
|
+
// so a read inside a Vue effect tracks `version` and re-runs on mutation.
|
|
60
|
+
const touch = (): void => {
|
|
61
|
+
void version.value;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// The API bound to the CURRENT editor. Rebuilt by the watch below when the
|
|
65
|
+
// editor identity changes; EMPTY_API while null. A shallowRef so swapping the
|
|
66
|
+
// bound API (on editor change) is itself a tracked reactive read.
|
|
67
|
+
const bound = shallowRef<UseBlocksApi>(EMPTY_API);
|
|
68
|
+
|
|
69
|
+
// Manage the `block changed` subscription, re-binding when the editor changes.
|
|
70
|
+
// Held in one object (no `let` reassignment) — the same pattern useBlok uses.
|
|
71
|
+
const sub: { editor: Blok | null; handler: (() => void) | null } = { editor: null, handler: null };
|
|
72
|
+
|
|
73
|
+
const unsubscribe = (): void => {
|
|
74
|
+
if (sub.editor !== null && sub.handler !== null) {
|
|
75
|
+
sub.editor.off(BLOCK_CHANGED_EVENT, sub.handler);
|
|
76
|
+
}
|
|
77
|
+
sub.editor = null;
|
|
78
|
+
sub.handler = null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
watch(
|
|
82
|
+
() => resolve(),
|
|
83
|
+
(ed) => {
|
|
84
|
+
unsubscribe();
|
|
85
|
+
// A changed editor identity is itself a reason to re-read.
|
|
86
|
+
version.value += 1;
|
|
87
|
+
|
|
88
|
+
if (ed === null) {
|
|
89
|
+
bound.value = EMPTY_API;
|
|
90
|
+
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handler = (): void => {
|
|
95
|
+
version.value += 1;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
ed.on(BLOCK_CHANGED_EVENT, handler);
|
|
99
|
+
sub.editor = ed;
|
|
100
|
+
sub.handler = handler;
|
|
101
|
+
bound.value = createBlocksApiForEditor(ed, touch);
|
|
102
|
+
},
|
|
103
|
+
{ immediate: true }
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
onScopeDispose(unsubscribe);
|
|
107
|
+
|
|
108
|
+
// A stable facade whose methods delegate to the currently-bound API. Reading
|
|
109
|
+
// `bound.value` tracks the editor-identity swap; the shared reads track
|
|
110
|
+
// `version` via the onRead seam — so both kinds of change re-run a consumer's
|
|
111
|
+
// computed/template. Every key is listed EXPLICITLY (no spread) so a forgotten
|
|
112
|
+
// delegation is a COMPILE error against the UseBlocksApi return type rather
|
|
113
|
+
// than a silently-missing method.
|
|
114
|
+
return {
|
|
115
|
+
getById: (id) => bound.value.getById(id),
|
|
116
|
+
getChildren: (parentId) => bound.value.getChildren(parentId),
|
|
117
|
+
insert: (spec) => bound.value.insert(spec),
|
|
118
|
+
insertMany: (specs) => bound.value.insertMany(specs),
|
|
119
|
+
insertTree: (spec) => bound.value.insertTree(spec),
|
|
120
|
+
insertMarkdown: (markdown, options) => bound.value.insertMarkdown(markdown, options),
|
|
121
|
+
exportMarkdown: () => bound.value.exportMarkdown(),
|
|
122
|
+
move: (id, target) => bound.value.move(id, target),
|
|
123
|
+
nest: (id, parentId) => bound.value.nest(id, parentId),
|
|
124
|
+
unnest: (id) => bound.value.unnest(id),
|
|
125
|
+
remove: (id) => bound.value.remove(id),
|
|
126
|
+
update: (id, data, tunes) => bound.value.update(id, data, tunes),
|
|
127
|
+
convert: (id, newType, dataOverrides, options) => bound.value.convert(id, newType, dataOverrides, options),
|
|
128
|
+
transact: (fn) => bound.value.transact(fn),
|
|
129
|
+
transactWithoutCapture: (fn) => bound.value.transactWithoutCapture(fn),
|
|
130
|
+
getBlocksCount: () => bound.value.getBlocksCount(),
|
|
131
|
+
getCurrentBlockIndex: () => bound.value.getCurrentBlockIndex(),
|
|
132
|
+
getBlockByIndex: (index) => bound.value.getBlockByIndex(index),
|
|
133
|
+
getBlockByElement: (element) => bound.value.getBlockByElement(element),
|
|
134
|
+
getBlockData: (id) => bound.value.getBlockData(id),
|
|
135
|
+
getBlockIndex: (id) => bound.value.getBlockIndex(id),
|
|
136
|
+
composeBlockData: (toolName) => bound.value.composeBlockData(toolName),
|
|
137
|
+
renderFromHTML: (html) => bound.value.renderFromHTML(html),
|
|
138
|
+
insertOutputData: (blocks, options) => bound.value.insertOutputData(blocks, options),
|
|
139
|
+
splitBlock: (currentBlockId, currentBlockData, newBlockType, newBlockData, insertIndex) =>
|
|
140
|
+
bound.value.splitBlock(currentBlockId, currentBlockData, newBlockType, newBlockData, insertIndex),
|
|
141
|
+
insertInsideParent: (parentId, insertIndex, childData) =>
|
|
142
|
+
bound.value.insertInsideParent(parentId, insertIndex, childData),
|
|
143
|
+
render: (data) => bound.value.render(data),
|
|
144
|
+
clear: () => bound.value.clear(),
|
|
145
|
+
isSyncingFromYjs: () => bound.value.isSyncingFromYjs(),
|
|
146
|
+
};
|
|
147
|
+
}
|