@bloklabs/vue 1.7.0 → 1.8.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/dist/index.cjs +1 -1
- package/dist/index.mjs +328 -160
- package/package.json +2 -2
- package/src/BlokContent.ts +24 -1
- package/src/BlokEditor.ts +78 -8
- package/src/block-portal-registry.ts +17 -3
- package/src/blok-instance.ts +40 -0
- package/src/config-keys.ts +1 -0
- package/src/content-baseline-map.ts +38 -0
- package/src/createVueBlock.ts +389 -27
- package/src/index.ts +5 -0
- package/src/useBlok.ts +157 -14
- package/types/index.d.ts +139 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bloklabs/vue",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Vue block editor — official Vue 3 bindings for Blok, the headless Notion-like rich text editor. Composables and components that render blocks and save to JSON.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "JackUait",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"build": "yarn run -T vite build --mode production"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@bloklabs/core": "^1.
|
|
57
|
+
"@bloklabs/core": "^1.8.0",
|
|
58
58
|
"vue": "^3.5.0"
|
|
59
59
|
}
|
|
60
60
|
}
|
package/src/BlokContent.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
defineComponent,
|
|
4
|
+
h,
|
|
5
|
+
onMounted,
|
|
6
|
+
onBeforeUnmount,
|
|
7
|
+
provide,
|
|
8
|
+
ref,
|
|
9
|
+
toRaw,
|
|
10
|
+
watch,
|
|
11
|
+
type PropType,
|
|
12
|
+
} from 'vue';
|
|
2
13
|
import { getHolder } from './holder-map';
|
|
3
14
|
import { getRegistry } from './registry-map';
|
|
4
15
|
import { BlockPortalHost } from './BlockPortalHost';
|
|
16
|
+
import { BLOK_EDITOR_INSTANCE } from './blok-instance';
|
|
5
17
|
import type { Blok } from '@/types';
|
|
6
18
|
|
|
7
19
|
/**
|
|
@@ -27,6 +39,17 @@ export const BlokContent = defineComponent({
|
|
|
27
39
|
setup(props) {
|
|
28
40
|
const container = ref<HTMLDivElement | null>(null);
|
|
29
41
|
|
|
42
|
+
// Publish the live instance to everything rendered beneath — including every
|
|
43
|
+
// `createVueBlock` block, because <Teleport> preserves the COMPONENT render
|
|
44
|
+
// context. That is what lets a block call `useBlocks(useBlokInstance())`
|
|
45
|
+
// without the host prop-drilling the instance into it. `toRaw` for the same
|
|
46
|
+
// reason the holder lookup uses it (Risk R0): the prop may arrive behind a
|
|
47
|
+
// reactive proxy, and consumers compare the instance BY IDENTITY.
|
|
48
|
+
provide(
|
|
49
|
+
BLOK_EDITOR_INSTANCE,
|
|
50
|
+
computed(() => (props.editor === null ? null : toRaw(props.editor)))
|
|
51
|
+
);
|
|
52
|
+
|
|
30
53
|
const adopt = (editor: Blok | null): void => {
|
|
31
54
|
if (container.value === null || editor === null) {
|
|
32
55
|
return;
|
package/src/BlokEditor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { computed, defineComponent, getCurrentInstance, h, watch, type PropType } from 'vue';
|
|
1
|
+
import { computed, defineComponent, getCurrentInstance, h, onUpdated, ref, watch, type PropType } from 'vue';
|
|
2
2
|
import { useBlok } from './useBlok';
|
|
3
3
|
import { BlokContent } from './BlokContent';
|
|
4
|
+
import { getContentBaseline } from './content-baseline-map';
|
|
4
5
|
import { BLOK_EDITOR_CONFIG_KEYS } from './config-keys';
|
|
5
6
|
import type {
|
|
6
7
|
API,
|
|
@@ -75,6 +76,8 @@ export const BlokEditor = defineComponent({
|
|
|
75
76
|
migrations: { type: Object as PropType<BlokConfig['migrations']>, default: undefined },
|
|
76
77
|
/** Editor-level uploader, routed by asset kind (images, video, audio, files). */
|
|
77
78
|
uploader: { type: Object as PropType<BlokConfig['uploader']>, default: undefined },
|
|
79
|
+
/** Opt-in: clicks on the host page below the editor append a block. */
|
|
80
|
+
captureClicksBelowEditor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
|
|
78
81
|
/** Changing this prop's identity destroys and recreates the editor. */
|
|
79
82
|
recreateKey: { type: null as unknown as PropType<unknown> },
|
|
80
83
|
},
|
|
@@ -91,13 +94,56 @@ export const BlokEditor = defineComponent({
|
|
|
91
94
|
setup(props, { emit, expose, attrs }) {
|
|
92
95
|
const instance = getCurrentInstance();
|
|
93
96
|
|
|
94
|
-
/**
|
|
95
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The `onXxx` keys currently on the component's vnode, as a sorted, stable
|
|
99
|
+
* string. Emit-mapped listeners (`@change`, `@save`, `v-model:data`,
|
|
100
|
+
* `@after-render`, …) are not declared props, so `vnode.props` is the only
|
|
101
|
+
* place they exist.
|
|
102
|
+
*/
|
|
103
|
+
const readListenerKeys = (): string => {
|
|
96
104
|
const vnodeProps = instance?.vnode.props;
|
|
97
105
|
|
|
98
|
-
|
|
106
|
+
if (vnodeProps == null) {
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Object.keys(vnodeProps)
|
|
111
|
+
.filter((key) => key.startsWith('on'))
|
|
112
|
+
.sort()
|
|
113
|
+
.join('|');
|
|
99
114
|
};
|
|
100
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Reactive mirror of `readListenerKeys()`. `vnode.props` is a plain object
|
|
118
|
+
* Vue swaps out on each patch — reading it tracks nothing, so a host that
|
|
119
|
+
* added or removed a listener WITHOUT also changing a reactive prop never
|
|
120
|
+
* re-ran the config snapshot and the flip was silently lost (core reads
|
|
121
|
+
* callback PRESENCE as intent: `onSave` arms the change pipeline).
|
|
122
|
+
*
|
|
123
|
+
* This ref is the missing reactive source. It is READ by `hasListener` (so
|
|
124
|
+
* every watcher that snapshots the config depends on it) and WRITTEN only
|
|
125
|
+
* from `onUpdated` — never from the snapshot itself, which would make the
|
|
126
|
+
* read its own write and self-trigger an endless update loop. Storing the
|
|
127
|
+
* key set rather than a counter makes "bump only on a genuine presence
|
|
128
|
+
* change" structural: re-assigning an equal string is inert.
|
|
129
|
+
*
|
|
130
|
+
* One case stays out of reach, and it is Vue's, not the ref's:
|
|
131
|
+
* `hasPropsChanged` skips keys that name a declared emit, so REPLACING one
|
|
132
|
+
* emit listener with a different one while the vnode's prop count and every
|
|
133
|
+
* other prop stay identical (`@change` out, `@save` in, nothing else moving)
|
|
134
|
+
* skips the child update entirely — no hook fires anywhere. Anything that
|
|
135
|
+
* also changes the prop count, including plain add/remove, updates.
|
|
136
|
+
*/
|
|
137
|
+
const listenerKeys = ref(readListenerKeys());
|
|
138
|
+
|
|
139
|
+
onUpdated(() => {
|
|
140
|
+
listenerKeys.value = readListenerKeys();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** Listener presence (the Vue analog of Angular's `output.observed`). */
|
|
144
|
+
const hasListener = (handlerKey: string): boolean =>
|
|
145
|
+
listenerKeys.value.split('|').includes(handlerKey);
|
|
146
|
+
|
|
101
147
|
/**
|
|
102
148
|
* Snapshot the props + gated emit-callbacks into a `UseBlokConfig`. App-wide
|
|
103
149
|
* `provideBlok` defaults are merged in by `useBlok` (the lifecycle engine),
|
|
@@ -152,8 +198,11 @@ export const BlokEditor = defineComponent({
|
|
|
152
198
|
|
|
153
199
|
// Subscribe to the editor's rendered-lifecycle events once it exists (gated
|
|
154
200
|
// on the emits being listened). `onCleanup` unsubscribes when the instance is
|
|
155
|
-
// replaced or the component unmounts.
|
|
156
|
-
|
|
201
|
+
// replaced or the component unmounts. `listenerKeys` is a source too, so a
|
|
202
|
+
// listener added after mount subscribes instead of waiting for a recreate —
|
|
203
|
+
// presence is re-read from scratch each run, and the cleanup keeps the
|
|
204
|
+
// subscriptions balanced.
|
|
205
|
+
watch([editor, listenerKeys], ([ed], _previous, onCleanup) => {
|
|
157
206
|
if (ed === null) {
|
|
158
207
|
return;
|
|
159
208
|
}
|
|
@@ -202,8 +251,29 @@ export const BlokEditor = defineComponent({
|
|
|
202
251
|
focus: (atEnd?: boolean): void => {
|
|
203
252
|
editor.value?.focus(atEnd);
|
|
204
253
|
},
|
|
205
|
-
|
|
206
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Replace the editor content.
|
|
256
|
+
*
|
|
257
|
+
* Safe to mix with a controlled `data` prop: the rendered document
|
|
258
|
+
* becomes the adapter's content baseline once the call lands, so a later
|
|
259
|
+
* `data` change back to the previous document still re-renders instead of
|
|
260
|
+
* being deduped against a baseline the editor no longer reflects.
|
|
261
|
+
* Recorded only on the RESOLVED path — a failed render leaves the editor
|
|
262
|
+
* on its previous content, and a baseline naming content it never showed
|
|
263
|
+
* would dedupe away the correcting update.
|
|
264
|
+
* @param data - the document to render
|
|
265
|
+
*/
|
|
266
|
+
render: (data: OutputData): Promise<void> | undefined => {
|
|
267
|
+
const ed = editor.value;
|
|
268
|
+
|
|
269
|
+
if (ed === null) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return ed.render(data).then(() => {
|
|
274
|
+
getContentBaseline(ed)?.markRendered(data);
|
|
275
|
+
});
|
|
276
|
+
},
|
|
207
277
|
});
|
|
208
278
|
|
|
209
279
|
return () => h(BlokContent, { editor: editor.value, ...attrs });
|
|
@@ -35,8 +35,17 @@ export interface BlockPortalRegistry {
|
|
|
35
35
|
* for the same id replaces the entry rather than mounting a duplicate.
|
|
36
36
|
*/
|
|
37
37
|
register(id: string, entry: BlockPortalEntry): void;
|
|
38
|
-
/**
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Remove the entry for `id`. Safe (no-op) when absent.
|
|
40
|
+
*
|
|
41
|
+
* Pass the caller's own `hostEl` to make the removal OWNERSHIP-CHECKED: the
|
|
42
|
+
* entry is deleted only when it is still the one that host registered. Core
|
|
43
|
+
* composes a replacement block (which registers under the SAME id) BEFORE it
|
|
44
|
+
* destroys the block it replaces, so a superseded tool's `removed()`/
|
|
45
|
+
* `destroy()` teardown arrives after the live entry is already in place —
|
|
46
|
+
* without the check it deletes that live entry and the holder stays empty.
|
|
47
|
+
*/
|
|
48
|
+
unregister(id: string, hostEl?: HTMLElement): void;
|
|
40
49
|
/** Merge `props` into the live entry's reactive props (in-place update). */
|
|
41
50
|
setProps(id: string, props: Record<string, unknown>): void;
|
|
42
51
|
}
|
|
@@ -61,7 +70,12 @@ export const createBlockPortalRegistry = (): BlockPortalRegistry => {
|
|
|
61
70
|
// set() replaces an existing entry — idempotent, never duplicates.
|
|
62
71
|
entries.set(id, markRaw(entry));
|
|
63
72
|
},
|
|
64
|
-
unregister(id: string): void {
|
|
73
|
+
unregister(id: string, hostEl?: HTMLElement): void {
|
|
74
|
+
// A superseded owner may not evict the entry that replaced it.
|
|
75
|
+
if (hostEl !== undefined && entries.get(id)?.hostEl !== hostEl) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
entries.delete(id);
|
|
66
80
|
},
|
|
67
81
|
setProps(id: string, props: Record<string, unknown>): void {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { inject, shallowRef, type InjectionKey, type ShallowRef } from 'vue';
|
|
2
|
+
import type { Blok } from '@/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Injection key carrying the LIVE editor instance down to everything
|
|
6
|
+
* `BlokContent` renders — which, because `<Teleport>` preserves the COMPONENT
|
|
7
|
+
* render context (not the DOM target's), includes every `createVueBlock` block
|
|
8
|
+
* mounted through the editor's shared `BlockPortalHost`.
|
|
9
|
+
*
|
|
10
|
+
* The provided ref is null until the instance exists, matching `useBlok`'s own
|
|
11
|
+
* pre-ready contract.
|
|
12
|
+
*/
|
|
13
|
+
export const BLOK_EDITOR_INSTANCE: InjectionKey<Readonly<ShallowRef<Blok | null>>> =
|
|
14
|
+
Symbol('BLOK_EDITOR_INSTANCE');
|
|
15
|
+
|
|
16
|
+
/** Stable "no editor here" ref for components rendered outside a BlokContent. */
|
|
17
|
+
const NO_EDITOR: Readonly<ShallowRef<Blok | null>> = shallowRef(null);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The live Blok instance a component is rendered inside, as a ref, or a ref of
|
|
21
|
+
* null before it exists. Inside a `createVueBlock` block this is the block's OWN
|
|
22
|
+
* editor — so a block can drive the tree it lives in without the host
|
|
23
|
+
* prop-drilling the instance into it:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* const blocks = useBlocks(useBlokInstance());
|
|
27
|
+
* const children = computed(() => blocks.getChildren(ctx.block.id));
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* That pairing is also what makes a container block REACTIVE to its own child
|
|
31
|
+
* tree: `useBlocks` refreshes on the editor's `block changed` event, which core
|
|
32
|
+
* emits for every structural mutation — including children the adapter itself
|
|
33
|
+
* never sees (a pasted paragraph, a Tab-indent from the keyboard).
|
|
34
|
+
*
|
|
35
|
+
* Call it inside `setup`. Outside a `BlokContent`/`BlokEditor` subtree it
|
|
36
|
+
* returns a ref of null.
|
|
37
|
+
*/
|
|
38
|
+
export function useBlokInstance(): Readonly<ShallowRef<Blok | null>> {
|
|
39
|
+
return inject(BLOK_EDITOR_INSTANCE, NO_EDITOR);
|
|
40
|
+
}
|
package/src/config-keys.ts
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { LooseOutputData, OutputData } from '@/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The controlled-content bookkeeping `useBlok` keeps for one editor: the
|
|
5
|
+
* baseline it believes the editor currently reflects.
|
|
6
|
+
*
|
|
7
|
+
* The reactive `data` watcher dedupes against that baseline, which is only sound
|
|
8
|
+
* while the watcher is the ONLY thing changing the content. `<BlokEditor>`'s
|
|
9
|
+
* exposed `render()` moves the editor out from under it: without this channel
|
|
10
|
+
* the baseline still names the document shown BEFORE the imperative call, so
|
|
11
|
+
* setting `data` back to that document is dismissed as an unchanged value and
|
|
12
|
+
* the editor keeps showing whatever `render()` installed.
|
|
13
|
+
*
|
|
14
|
+
* Registered per instance (mirrors the holder / portal-registry WeakMaps) so the
|
|
15
|
+
* exposed facade can reach it from the editor alone — `useBlok` returns only the
|
|
16
|
+
* instance ref, never its internal state.
|
|
17
|
+
*/
|
|
18
|
+
export interface ContentBaseline {
|
|
19
|
+
/**
|
|
20
|
+
* Record `content` as the document the editor now reflects.
|
|
21
|
+
* @param content - the document the editor now reflects
|
|
22
|
+
*/
|
|
23
|
+
markRendered(content: OutputData | LooseOutputData | null): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const baselines = new WeakMap<WeakKey, ContentBaseline>();
|
|
27
|
+
|
|
28
|
+
export function setContentBaseline(editor: WeakKey, baseline: ContentBaseline): void {
|
|
29
|
+
baselines.set(editor, baseline);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getContentBaseline(editor: WeakKey): ContentBaseline | undefined {
|
|
33
|
+
return baselines.get(editor);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function removeContentBaseline(editor: WeakKey): void {
|
|
37
|
+
baselines.delete(editor);
|
|
38
|
+
}
|