@bloklabs/vue 1.16.1 → 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/createVueBlock.ts
DELETED
|
@@ -1,740 +0,0 @@
|
|
|
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 ComponentPublicInstance,
|
|
13
|
-
type PropType,
|
|
14
|
-
type ShallowRef,
|
|
15
|
-
type VNodeChild,
|
|
16
|
-
} from 'vue';
|
|
17
|
-
|
|
18
|
-
import type { API } from '@/types';
|
|
19
|
-
import type { BlockAPI } from '@/types/api';
|
|
20
|
-
import type {
|
|
21
|
-
BlockOrigin,
|
|
22
|
-
BlockToolConstructable,
|
|
23
|
-
BlockToolConstructorOptions,
|
|
24
|
-
BlockToolData,
|
|
25
|
-
ToolboxConfig,
|
|
26
|
-
} from '@/types/tools';
|
|
27
|
-
import {
|
|
28
|
-
applyChildDecoration,
|
|
29
|
-
BlockChildrenMounted,
|
|
30
|
-
createChildDecorationLedger,
|
|
31
|
-
DATA_ATTR,
|
|
32
|
-
deepEqual,
|
|
33
|
-
mountChildBlocks,
|
|
34
|
-
withSlotlessDescendants,
|
|
35
|
-
} from '@bloklabs/core/adapters';
|
|
36
|
-
import { fillDefaults, type PropSchema } from '@bloklabs/core/adapters';
|
|
37
|
-
|
|
38
|
-
import {
|
|
39
|
-
BLOK_PORTAL_REGISTRY_CONFIG_KEY,
|
|
40
|
-
type BlockPortalRegistry,
|
|
41
|
-
} from './block-portal-registry';
|
|
42
|
-
|
|
43
|
-
export type { PropSchema, PropSchemaEntry } from '@bloklabs/core/adapters';
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Every STATIC member of core's block-tool contract a Vue block may declare for
|
|
47
|
-
* itself — `ownsChildren`, `keepsChildrenOnEnter`, `conversionConfig`,
|
|
48
|
-
* `pasteConfig`, `sanitize`, `shortcut`, `upgradeData`, and whatever core adds
|
|
49
|
-
* next. Derived from
|
|
50
|
-
* `BlockToolConstructable` rather than enumerated, so a new core static needs no
|
|
51
|
-
* adapter change to become reachable.
|
|
52
|
-
*
|
|
53
|
-
* `toolbox` and `isReadOnlySupported` are excluded because the factory owns
|
|
54
|
-
* them: `toolbox` is authored as {@link CreateVueBlockSpec.toolbox}, and
|
|
55
|
-
* in-place read-only support is unconditional.
|
|
56
|
-
*/
|
|
57
|
-
export type BlockToolStatics = Omit<BlockToolConstructable, 'toolbox' | 'isReadOnlySupported'>;
|
|
58
|
-
|
|
59
|
-
/** Statics the generated class owns; an authored `statics` bag can never take them over. */
|
|
60
|
-
const RESERVED_STATICS: readonly string[] = ['toolbox', 'isReadOnlySupported', '__isBlokVueBlock'];
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Origins that mean "the author just made this block" — the only ones that fire
|
|
64
|
-
* {@link CreateVueBlockSpec.onCreated}. Written as an allow-list so a future
|
|
65
|
-
* core origin fails CLOSED (no creation signal) instead of silently opting in.
|
|
66
|
-
*
|
|
67
|
-
* `undefined` is included because core always supplies an origin: an absent one
|
|
68
|
-
* means a host hand-built the constructor options, which is an explicit
|
|
69
|
-
* creation — the same reading core's own container tools apply.
|
|
70
|
-
*
|
|
71
|
-
* Note what is NOT the axis here: `origin === 'user'`. A container gated on that
|
|
72
|
-
* alone would refuse to seed for `api.blocks.insert('steps')` and for a
|
|
73
|
-
* turn-into, leaving an empty, unusable container.
|
|
74
|
-
*/
|
|
75
|
-
const CREATION_ORIGINS: ReadonlySet<BlockOrigin | undefined> = new Set<BlockOrigin | undefined>([
|
|
76
|
-
undefined,
|
|
77
|
-
'user',
|
|
78
|
-
'api',
|
|
79
|
-
'convert',
|
|
80
|
-
]);
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* One child's decoration: attribute name → value. `null`/`undefined` removes the
|
|
84
|
-
* attribute; a boolean or number is stringified (so `false` writes
|
|
85
|
-
* `data-active="false"`, which CSS can select, rather than dropping the hook).
|
|
86
|
-
*/
|
|
87
|
-
export type ChildAttributes = Record<string, string | number | boolean | null | undefined>;
|
|
88
|
-
|
|
89
|
-
/** The `BlockChildren` per-child decorator (see {@link VueBlockRenderProps.BlockChildren}). */
|
|
90
|
-
export type ChildAttributesFn = (child: BlockAPI, index: number) => ChildAttributes;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Announce that a container's child holders have settled in its slot — the
|
|
94
|
-
* signal a host waits on before putting the caret into a freshly inserted
|
|
95
|
-
* child, since the teleport commits a tick after core's `rendered()`.
|
|
96
|
-
*
|
|
97
|
-
* Optional-chained: the editor always supplies `events`, but a host unit test
|
|
98
|
-
* may hand a block a partial `api`, and an observability signal must never be
|
|
99
|
-
* able to break a render.
|
|
100
|
-
* @param api - the editor-level API
|
|
101
|
-
* @param blockId - the container block's id
|
|
102
|
-
* @param children - the container's model children, in model order
|
|
103
|
-
*/
|
|
104
|
-
const emitChildrenMounted = (api: API, blockId: string, children: BlockAPI[]): void => {
|
|
105
|
-
api?.events?.emit(BlockChildrenMounted, {
|
|
106
|
-
blockId,
|
|
107
|
-
childIds: children.map(child => child.id),
|
|
108
|
-
});
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/** Context handed to a Vue block's `setup` (the only data write path is `commit`). */
|
|
112
|
-
export interface VueBlockRenderProps<Data> {
|
|
113
|
-
/** Reactive, FROZEN snapshot of the block data. Read `data.value`; never mutate. */
|
|
114
|
-
data: ShallowRef<Readonly<Data>>;
|
|
115
|
-
/** The ONLY data write path: merge a partial patch and sync once. */
|
|
116
|
-
commit: (patch: Partial<Data>) => void;
|
|
117
|
-
/** This block's per-block API (id, connection methods, dispatchChange…). */
|
|
118
|
-
block: BlockAPI;
|
|
119
|
-
/**
|
|
120
|
-
* The EDITOR-level API this block belongs to (`api.blocks`, `api.caret`,
|
|
121
|
-
* `api.toolbar`…) — the same object a vanilla tool receives in its
|
|
122
|
-
* constructor. Reach for it when a block has to drive the document around it
|
|
123
|
-
* instead of routing everything through `block.call()` string dispatch.
|
|
124
|
-
*
|
|
125
|
-
* For the reactive, id/parentId-relative view of the tree (and to re-render
|
|
126
|
-
* when your own children change), pair `useBlocks` with `useBlokInstance()`
|
|
127
|
-
* instead — the api handle itself is not reactive.
|
|
128
|
-
*/
|
|
129
|
-
api: API;
|
|
130
|
-
/**
|
|
131
|
-
* Reactive read-only flag. Read `readOnly.value` in render to disable editing
|
|
132
|
-
* (e.g. drop `contenteditable`, hide controls). Toggled IN PLACE by core's
|
|
133
|
-
* read-only switch — the component reacts without a remount, so ephemeral state
|
|
134
|
-
* survives. Honor it: a block that ignores `readOnly` stays interactive when
|
|
135
|
-
* the editor is read-only (same contract as a vanilla tool's `setReadOnly`).
|
|
136
|
-
*/
|
|
137
|
-
readOnly: Readonly<ShallowRef<boolean>>;
|
|
138
|
-
/**
|
|
139
|
-
* Engine-owned child slot — render `h(BlockChildren)` for a container block.
|
|
140
|
-
*
|
|
141
|
-
* Accepts one optional prop, `childAttributes: (child, index) => ChildAttributes`,
|
|
142
|
-
* applied to each child's HOLDER after the holders are mounted. Named hooks
|
|
143
|
-
* (`data-step-index`, `data-active`…) replace positional `:nth-child()` CSS
|
|
144
|
-
* over Blok's holders, which breaks the moment a child is inserted, removed or
|
|
145
|
-
* reordered. The holders stay DIRECT children of the slot — core requires that
|
|
146
|
-
* (hierarchy reparenting and caret navigation compare `holder.parentElement`
|
|
147
|
-
* by identity), so decoration is attributes, never wrapper elements. Attributes
|
|
148
|
-
* the callback stops producing are removed on the next pass.
|
|
149
|
-
*
|
|
150
|
-
* A second prop, `childContentAttributes`, is the same decoration applied one
|
|
151
|
-
* level IN — on each child's `[data-blok-element-content]` wrapper instead of
|
|
152
|
-
* its holder. Core's decoration law blesses both, and a container needs both:
|
|
153
|
-
* the holder is the child's outer box (rails, indices, hover states), the
|
|
154
|
-
* content wrapper is where the child's own text box begins, which is what a
|
|
155
|
-
* numbered rail or a connector line has to align to. Reach for it instead of
|
|
156
|
-
* walking core's wrapper chain from a holder hook (`[data-step] >
|
|
157
|
-
* [data-blok-element-content] > …`) — those selectors encode engine DOM in host
|
|
158
|
-
* CSS and break when that structure changes. A child whose DOM has not
|
|
159
|
-
* committed yet has no wrapper to write to and is stamped on the next pass.
|
|
160
|
-
*
|
|
161
|
-
* Writing on a child's holder is inert by design: core's mutation filter drops
|
|
162
|
-
* a holder-targeted attribute record for the child block and suppresses it for
|
|
163
|
-
* the container. The guarantee stops at the holder and its
|
|
164
|
-
* `[data-blok-element-content]` wrapper — writing AT or BELOW a child's tool
|
|
165
|
-
* root DOES score as that child's edit.
|
|
166
|
-
*/
|
|
167
|
-
BlockChildren: Component;
|
|
168
|
-
/**
|
|
169
|
-
* Attach this to the element the +/drag toolbar should vertically center on:
|
|
170
|
-
* `h('div', { ref: ctx.toolbarAnchorRef })`, or `:ref="ctx.toolbarAnchorRef"`
|
|
171
|
-
* in a template. The Vue-native way to answer core's
|
|
172
|
-
* `getToolbarAnchorElement`, which is otherwise a `(host, block) => Element`
|
|
173
|
-
* hook resolved OUTSIDE the render tree — so pointing at an element you render
|
|
174
|
-
* meant inventing a data attribute and `querySelector`-ing for it from
|
|
175
|
-
* {@link CreateVueBlockSpec.getToolbarAnchorElement}.
|
|
176
|
-
*
|
|
177
|
-
* A container block whose own chrome is not editable needs an anchor: with
|
|
178
|
-
* none, core centers the toolbar on the first `[contenteditable]` under the
|
|
179
|
-
* host, which for a container is its FIRST CHILD BLOCK — parking the +/drag
|
|
180
|
-
* handles halfway down, beside content that has a toolbar of its own.
|
|
181
|
-
*
|
|
182
|
-
* The ref is stable across renders and outranks
|
|
183
|
-
* {@link CreateVueBlockSpec.getToolbarAnchorElement} while it holds a MOUNTED
|
|
184
|
-
* element; when the element unmounts (Vue calls the ref with null, or it is
|
|
185
|
-
* detached) the declared hook takes over again, so the toolbar is never
|
|
186
|
-
* positioned against a stale node. Leave it unattached to keep core's default.
|
|
187
|
-
*/
|
|
188
|
-
toolbarAnchorRef: (element: Element | ComponentPublicInstance | null) => void;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Second argument of {@link CreateVueBlockSpec.onMounted} and
|
|
193
|
-
* {@link CreateVueBlockSpec.onCreated} — everything the block cannot read off
|
|
194
|
-
* its own `BlockAPI`.
|
|
195
|
-
*/
|
|
196
|
-
export interface VueBlockMountedContext {
|
|
197
|
-
/**
|
|
198
|
-
* Why this block instance was constructed: a CREATION origin (`user`, `api`,
|
|
199
|
-
* `convert`) means the author just made it, so seeding default children is
|
|
200
|
-
* correct; a RESTORE origin (`load`, `replay`, `paste`) means the document
|
|
201
|
-
* already says what the children are. `probe` is an off-tree instance built
|
|
202
|
-
* only to read a tool's default data — it must not touch the block tree at
|
|
203
|
-
* all. Defaults to `'api'` when the constructor was handed no origin.
|
|
204
|
-
*/
|
|
205
|
-
origin: BlockOrigin;
|
|
206
|
-
/** The editor-level API (`api.blocks`, `api.caret`, `api.events`…). */
|
|
207
|
-
api: API;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Spec for {@link createVueBlock}. Authored as a `.ts` render function (no SFC). */
|
|
211
|
-
export interface CreateVueBlockSpec<Data = BlockToolData> {
|
|
212
|
-
/** Tool type name (registered key). */
|
|
213
|
-
type: string;
|
|
214
|
-
/** Optional toolbox entry. */
|
|
215
|
-
toolbox?: ToolboxConfig;
|
|
216
|
-
/** Declarative defaults that also define the exact `save()` key set. */
|
|
217
|
-
propSchema: PropSchema;
|
|
218
|
-
/** Returns a render function (the `defineComponent` + `setup`-returns-`h` shape). */
|
|
219
|
-
setup: (props: VueBlockRenderProps<Data>) => () => VNodeChild;
|
|
220
|
-
/**
|
|
221
|
-
* Static members of core's tool contract, forwarded verbatim onto the
|
|
222
|
-
* generated tool class — the single channel for everything core reads off the
|
|
223
|
-
* CLASS rather than the instance (`ownsChildren`, `keepsChildrenOnEnter`,
|
|
224
|
-
* `conversionConfig`, `pasteConfig`, `sanitize`, `shortcut`, `upgradeData`…).
|
|
225
|
-
* Without it the only way to declare one was to subclass the generated class.
|
|
226
|
-
*
|
|
227
|
-
* `keepsChildrenOnEnter` is the per-tool Enter POLICY: declare it and Enter on
|
|
228
|
-
* this container's empty LAST child creates the new line INSIDE the container
|
|
229
|
-
* instead of escaping to the container's parent (Blok's default, which is
|
|
230
|
-
* Notion's callout behaviour). Core cannot read that off the DOM — a callout
|
|
231
|
-
* renders the same `data-blok-nested-blocks` slot as a column yet wants the
|
|
232
|
-
* escape — so before it existed a layout container (a card, a `steps` block)
|
|
233
|
-
* had to hijack the editor-global `config.onEnter` and re-derive containment.
|
|
234
|
-
*
|
|
235
|
-
* `toolbox` and `isReadOnlySupported` are owned by the factory and cannot be
|
|
236
|
-
* overridden here (see {@link BlockToolStatics}).
|
|
237
|
-
*/
|
|
238
|
-
statics?: BlockToolStatics;
|
|
239
|
-
/**
|
|
240
|
-
* The element the +/drag toolbar should vertically center on — core's
|
|
241
|
-
* `getToolbarAnchorElement` hook, resolved against this block's host element
|
|
242
|
-
* on every call (never cached, so it tracks re-renders).
|
|
243
|
-
*
|
|
244
|
-
* A container block whose own chrome is not editable needs it: with no anchor,
|
|
245
|
-
* core centers the toolbar on the first `[contenteditable]` under the host,
|
|
246
|
-
* which for a container is its FIRST CHILD BLOCK. Return `null`/`undefined`
|
|
247
|
-
* (or omit the field) to keep core's default.
|
|
248
|
-
* @param host - this block's mutation-free host element
|
|
249
|
-
* @param block - this block's per-block API
|
|
250
|
-
*/
|
|
251
|
-
getToolbarAnchorElement?: (host: HTMLElement, block: BlockAPI) => HTMLElement | null | undefined;
|
|
252
|
-
/**
|
|
253
|
-
* Enter (or Cmd/Ctrl+Enter) on this block while it is the keyboard
|
|
254
|
-
* navigation target — core's `onNavigationEnter` hook. Return true when the
|
|
255
|
-
* block handled it (e.g. opened a link); omit it, or return false, to let
|
|
256
|
-
* core put the caret in the block.
|
|
257
|
-
* @param event - the Enter keydown
|
|
258
|
-
* @param block - this block's per-block API
|
|
259
|
-
*/
|
|
260
|
-
onNavigationEnter?: (event: KeyboardEvent, block: BlockAPI) => boolean;
|
|
261
|
-
/** Optional lifecycle callbacks mapped from Blok's block hooks. */
|
|
262
|
-
onRendered?: (block: BlockAPI) => void;
|
|
263
|
-
/**
|
|
264
|
-
* Fired ONCE per block instance, after the teleport's FIRST commit — the
|
|
265
|
-
* first moment this block's rendered DOM exists (and, for a container, the
|
|
266
|
-
* moment `BlockChildren` has adopted the child holders). `onRendered` cannot
|
|
267
|
-
* mean that: core calls `rendered()` in the same tick as `render()`, before
|
|
268
|
-
* Vue has drawn anything, which is why hosts ended up setting the caret twice
|
|
269
|
-
* around a `requestAnimationFrame`.
|
|
270
|
-
*
|
|
271
|
-
* It is also the create-vs-restore signal: `context.origin` says whether the
|
|
272
|
-
* author just made this block (`user`/`api`/`convert`) or the document is
|
|
273
|
-
* being re-materialised (`load`/`replay`/`paste`) — so a container can seed
|
|
274
|
-
* its default children here exactly once, without the "children are
|
|
275
|
-
* transiently empty during a replay" trap.
|
|
276
|
-
* @example
|
|
277
|
-
* ```ts
|
|
278
|
-
* onMounted: (block, { origin, api }) => {
|
|
279
|
-
* if (origin === 'user' && block.getChildren().length === 0) {
|
|
280
|
-
* api.blocks.insertInsideParent(block.id);
|
|
281
|
-
* }
|
|
282
|
-
* }
|
|
283
|
-
* ```
|
|
284
|
-
*/
|
|
285
|
-
onMounted?: (block: BlockAPI, context: VueBlockMountedContext) => void;
|
|
286
|
-
/**
|
|
287
|
-
* The SEEDING hook: `onMounted`, narrowed to a genuine creation. Fired ONCE
|
|
288
|
-
* per block instance, after the teleport's first commit, and only when this
|
|
289
|
-
* instance is the author making a new block (`origin` of `user`, `api` or
|
|
290
|
-
* `convert`) — never for a `load`/`replay`/`paste` restore, and never for the
|
|
291
|
-
* off-tree `probe` instance core builds to read a tool's default data.
|
|
292
|
-
*
|
|
293
|
-
* That predicate is why the hook exists rather than leaving every block to
|
|
294
|
-
* read `context.origin` in `onMounted`: the intuitive `origin === 'user'` test
|
|
295
|
-
* is wrong. It drops `api.blocks.insert('steps')` and turn-into, so a
|
|
296
|
-
* container seeded that way comes up empty for every path except a keystroke.
|
|
297
|
-
* Core refused to ship that axis into its own `column`/`column_list`; this
|
|
298
|
-
* encodes the correct one once, here.
|
|
299
|
-
*
|
|
300
|
-
* The `context` is the same object {@link CreateVueBlockSpec.onMounted}
|
|
301
|
-
* receives, so a block that only seeds can read `origin` for finer decisions.
|
|
302
|
-
* @example
|
|
303
|
-
* ```ts
|
|
304
|
-
* onCreated: (block, { api }) => {
|
|
305
|
-
* if (block.getChildren().length === 0) {
|
|
306
|
-
* api.blocks.insertInsideParent(block.id);
|
|
307
|
-
* }
|
|
308
|
-
* }
|
|
309
|
-
* ```
|
|
310
|
-
*/
|
|
311
|
-
onCreated?: (block: BlockAPI, context: VueBlockMountedContext) => void;
|
|
312
|
-
onMoved?: (block: BlockAPI) => void;
|
|
313
|
-
onRemoved?: (block: BlockAPI) => void;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Author a first-party Vue block. Returns a `BlockToolConstructable` registered
|
|
318
|
-
* exactly like a vanilla tool (`tools: { type: { class: createVueBlock(...) } }`).
|
|
319
|
-
*
|
|
320
|
-
* The factory owns the host element (`data-blok-mutation-free`), a frozen
|
|
321
|
-
* defaults-filled data mirror, and a reactive `shallowRef` the component reads.
|
|
322
|
-
* It teleports the component into the host via the editor's shared portal
|
|
323
|
-
* registry (the TipTap `VueNodeViewRenderer` pattern), and bridges Blok's block
|
|
324
|
-
* lifecycle to Vue:
|
|
325
|
-
* - `render()` creates the host and registers the portal entry (returned sync).
|
|
326
|
-
* - `setData()` is async: it dedups, swaps the reactive snapshot, and `await`s
|
|
327
|
-
* Vue's commit before resolving `true` — defeating core's default remount and
|
|
328
|
-
* landing the update inside core's RAF-extended Yjs suppression window.
|
|
329
|
-
* - `save()` returns the complete frozen mirror (never the DOM, never partial).
|
|
330
|
-
* - `commit()` merges a patch and fires `dispatchChange` exactly once.
|
|
331
|
-
* - `removed()`/`destroy()` unregister the portal (deterministic unmount).
|
|
332
|
-
*/
|
|
333
|
-
export function createVueBlock<Data = BlockToolData>(
|
|
334
|
-
spec: CreateVueBlockSpec<Data>
|
|
335
|
-
): (new (options: BlockToolConstructorOptions) => {
|
|
336
|
-
render(): HTMLElement;
|
|
337
|
-
save(): BlockToolData;
|
|
338
|
-
setData(newData: BlockToolData): Promise<boolean>;
|
|
339
|
-
setReadOnly(state: boolean): void;
|
|
340
|
-
getToolbarAnchorElement(): HTMLElement | undefined;
|
|
341
|
-
onNavigationEnter(event: KeyboardEvent): boolean;
|
|
342
|
-
rendered(): void;
|
|
343
|
-
moved(): void;
|
|
344
|
-
removed(): void;
|
|
345
|
-
destroy(): void;
|
|
346
|
-
}) & BlockToolStatics & {
|
|
347
|
-
readonly __isBlokVueBlock: true;
|
|
348
|
-
readonly toolbox: ToolboxConfig | undefined;
|
|
349
|
-
readonly isReadOnlySupported: boolean;
|
|
350
|
-
} {
|
|
351
|
-
/**
|
|
352
|
-
* One wrapped component definition per BLOCK INSTANCE.
|
|
353
|
-
*
|
|
354
|
-
* Per-TYPE looks equivalent — each instance gets its own context through the
|
|
355
|
-
* `ctx` prop — but the portal host keys its `<Teleport>` by BLOCK ID, and core
|
|
356
|
-
* composes a replacement block (registering under that same id) BEFORE it
|
|
357
|
-
* destroys the block it replaces. With a shared type, Vue sees same key + same
|
|
358
|
-
* type and PATCHES the mounted instance instead of replacing it: `setup` has
|
|
359
|
-
* already run and its render closure captured the SUPERSEDED instance's `ctx`,
|
|
360
|
-
* so the live host renders the old block's data forever. A distinct type per
|
|
361
|
-
* instance makes Vue replace the instance, so the replacement's ctx renders.
|
|
362
|
-
* Within one instance the type is stable, so in-place prop updates still never
|
|
363
|
-
* remount.
|
|
364
|
-
*/
|
|
365
|
-
const defineWrappedComponent = (): Component =>
|
|
366
|
-
defineComponent({
|
|
367
|
-
name: `VueBlock(${spec.type})`,
|
|
368
|
-
props: {
|
|
369
|
-
ctx: { type: Object as PropType<VueBlockRenderProps<Data>>, required: true },
|
|
370
|
-
/**
|
|
371
|
-
* Adapter-internal: the tool instance's post-commit notifier. Declared as
|
|
372
|
-
* a prop (not part of `ctx`) so it never reaches the authored `setup`.
|
|
373
|
-
*/
|
|
374
|
-
notifyMounted: { type: Function as PropType<() => void>, default: undefined },
|
|
375
|
-
},
|
|
376
|
-
setup(props) {
|
|
377
|
-
// The block's DOM exists from here on — this is the first commit core's
|
|
378
|
-
// `rendered()` hook could not wait for.
|
|
379
|
-
onMounted(() => props.notifyMounted?.());
|
|
380
|
-
|
|
381
|
-
// props is shallow-reactive: `props.ctx` is the raw context object, so the
|
|
382
|
-
// `data` ref inside it is NOT unwrapped (no reactive ref-unwrap gotcha).
|
|
383
|
-
return spec.setup(props.ctx);
|
|
384
|
-
},
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
const VueBlockTool = class VueBlockTool {
|
|
388
|
-
/** Marker so `useBlok` can detect vue-block tools and inject the registry. */
|
|
389
|
-
public static readonly __isBlokVueBlock = true as const;
|
|
390
|
-
|
|
391
|
-
public static get toolbox(): ToolboxConfig | undefined {
|
|
392
|
-
return spec.toolbox;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Vue blocks support read-only mode: `setReadOnly` flips a reactive flag the
|
|
397
|
-
* component reads, so the block re-renders read-only IN PLACE. Without this
|
|
398
|
-
* flag core's ReadOnly module throws a critical error when read-only is
|
|
399
|
-
* enabled and a Vue block is present.
|
|
400
|
-
*/
|
|
401
|
-
public static get isReadOnlySupported(): boolean {
|
|
402
|
-
return true;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
private readonly blockApi: BlockAPI;
|
|
406
|
-
/** The editor-level API, handed to the block setup as `ctx.api`. */
|
|
407
|
-
private readonly api: API;
|
|
408
|
-
private readonly registry: BlockPortalRegistry | undefined;
|
|
409
|
-
private readonly pointerDrag: () => boolean;
|
|
410
|
-
private readonly dataRef: ShallowRef<Readonly<Data>>;
|
|
411
|
-
/** Reactive read-only flag handed to setup; flipped in place by setReadOnly. */
|
|
412
|
-
private readonly readOnlyRef: ShallowRef<boolean>;
|
|
413
|
-
private readonly childrenComponent: Component;
|
|
414
|
-
/** This instance's own wrapper type — see {@link defineWrappedComponent}. */
|
|
415
|
-
private readonly wrappedComponent: Component = defineWrappedComponent();
|
|
416
|
-
/** Why core built this instance — gates `onCreated`, handed to `onMounted`. */
|
|
417
|
-
private readonly origin: BlockOrigin;
|
|
418
|
-
/** True once the post-mount hooks fired; a re-mounted teleport must not re-fire them. */
|
|
419
|
-
private mountSignalled = false;
|
|
420
|
-
private mirror: Readonly<Data>;
|
|
421
|
-
/** Dedup baseline (Risk R3/R6): skip a redundant render of identical data. */
|
|
422
|
-
private lastRendered: Readonly<Data>;
|
|
423
|
-
private hostEl: HTMLElement | null = null;
|
|
424
|
-
/** Latest patch queued while a pointer drag suppresses dispatchChange. */
|
|
425
|
-
private pendingDispatch = false;
|
|
426
|
-
/** Element the block pointed `toolbarAnchorRef` at, if any. */
|
|
427
|
-
private anchorEl: HTMLElement | null = null;
|
|
428
|
-
/**
|
|
429
|
-
* The `toolbarAnchorRef` context member. A bound field so its identity is
|
|
430
|
-
* STABLE across renders — a fresh function ref each render would make Vue
|
|
431
|
-
* detach (null) and re-attach it on every patch.
|
|
432
|
-
* @param element - what Vue attached: an element, a component instance, or
|
|
433
|
-
* null on detach
|
|
434
|
-
*/
|
|
435
|
-
private readonly toolbarAnchorRef = (
|
|
436
|
-
element: Element | ComponentPublicInstance | null
|
|
437
|
-
): void => {
|
|
438
|
-
// A ref placed on a child COMPONENT hands over its instance, not its DOM.
|
|
439
|
-
const resolved = element === null || element instanceof Element
|
|
440
|
-
? element
|
|
441
|
-
: (element.$el as unknown);
|
|
442
|
-
|
|
443
|
-
this.anchorEl = resolved instanceof HTMLElement ? resolved : null;
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
public constructor(options: BlockToolConstructorOptions) {
|
|
447
|
-
this.blockApi = options.block;
|
|
448
|
-
|
|
449
|
-
const config = (options.config ?? {}) as Record<string, unknown>;
|
|
450
|
-
|
|
451
|
-
this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
|
|
452
|
-
|
|
453
|
-
this.api = options.api;
|
|
454
|
-
|
|
455
|
-
// Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
|
|
456
|
-
// (core silently drops a dispatchChange while a drag is active).
|
|
457
|
-
const drag = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
|
|
458
|
-
|
|
459
|
-
this.pointerDrag = (): boolean => drag?.blocks?.isPointerDragActive === true;
|
|
460
|
-
|
|
461
|
-
this.mirror = fillDefaults<Data>(spec.propSchema, toRaw(options.data ?? {}) as Record<string, unknown>);
|
|
462
|
-
this.lastRendered = this.mirror;
|
|
463
|
-
this.dataRef = shallowRef(this.mirror);
|
|
464
|
-
this.readOnlyRef = shallowRef(options.readOnly);
|
|
465
|
-
// Absent origin means a caller that predates the signal; 'api' is core's
|
|
466
|
-
// own default, so it is never mistaken for a user gesture.
|
|
467
|
-
this.origin = options.origin ?? 'api';
|
|
468
|
-
|
|
469
|
-
const blockApi = this.blockApi;
|
|
470
|
-
const api = this.api;
|
|
471
|
-
|
|
472
|
-
// Per-instance child slot: a childless ref'd div Blok owns; mountChildBlocks
|
|
473
|
-
// appends the real child holders imperatively (Vue never reconciles them).
|
|
474
|
-
this.childrenComponent = defineComponent({
|
|
475
|
-
name: `VueBlockChildren(${spec.type})`,
|
|
476
|
-
props: {
|
|
477
|
-
childAttributes: {
|
|
478
|
-
type: Function as PropType<ChildAttributesFn>,
|
|
479
|
-
default: undefined,
|
|
480
|
-
},
|
|
481
|
-
childContentAttributes: {
|
|
482
|
-
type: Function as PropType<ChildAttributesFn>,
|
|
483
|
-
default: undefined,
|
|
484
|
-
},
|
|
485
|
-
},
|
|
486
|
-
setup(props) {
|
|
487
|
-
const slot = ref<HTMLElement | null>(null);
|
|
488
|
-
// What the last decoration pass wrote, so a dropped key is cleaned up.
|
|
489
|
-
const ledger = createChildDecorationLedger();
|
|
490
|
-
const mountKids = (): void => {
|
|
491
|
-
if (slot.value === null) {
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
const children = blockApi.getChildren();
|
|
496
|
-
|
|
497
|
-
// A slotless child's own children live in this slot too. Decoration
|
|
498
|
-
// and the mounted event stay per DIRECT child.
|
|
499
|
-
mountChildBlocks(slot.value, withSlotlessDescendants(children, id => api.blocks.getChildren(id)));
|
|
500
|
-
applyChildDecoration(ledger, children, {
|
|
501
|
-
childAttributes: props.childAttributes,
|
|
502
|
-
childContentAttributes: props.childContentAttributes,
|
|
503
|
-
});
|
|
504
|
-
emitChildrenMounted(api, blockApi.id, children);
|
|
505
|
-
};
|
|
506
|
-
|
|
507
|
-
onMounted(mountKids);
|
|
508
|
-
onUpdated(mountKids);
|
|
509
|
-
|
|
510
|
-
return () => h('div', { ref: slot, [DATA_ATTR.nestedBlocks]: '' });
|
|
511
|
-
},
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
public render(): HTMLElement {
|
|
516
|
-
const host = document.createElement('div');
|
|
517
|
-
|
|
518
|
-
// The Blok-owned host: Vue reconciles the chrome teleported INTO it, but
|
|
519
|
-
// this attribute makes core's MutationObserver ignore those mutations, so
|
|
520
|
-
// Vue's reconciliation never registers as a user edit.
|
|
521
|
-
host.setAttribute('data-blok-mutation-free', 'true');
|
|
522
|
-
this.hostEl = host;
|
|
523
|
-
|
|
524
|
-
const ctx: VueBlockRenderProps<Data> = {
|
|
525
|
-
data: this.dataRef,
|
|
526
|
-
commit: this.commit,
|
|
527
|
-
block: this.blockApi,
|
|
528
|
-
api: this.api,
|
|
529
|
-
readOnly: this.readOnlyRef,
|
|
530
|
-
BlockChildren: this.childrenComponent,
|
|
531
|
-
toolbarAnchorRef: this.toolbarAnchorRef,
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
this.registry?.register(this.blockApi.id, {
|
|
535
|
-
hostEl: host,
|
|
536
|
-
component: this.wrappedComponent,
|
|
537
|
-
props: { ctx, notifyMounted: this.notifyMounted },
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
// Returned synchronously; Vue flushes the teleport on the next tick.
|
|
541
|
-
return host;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
public rendered(): void {
|
|
545
|
-
spec.onRendered?.(this.blockApi);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* The wrapped component's post-commit callback: the first moment this
|
|
550
|
-
* block's DOM (and, for a container, its mounted child holders) exists.
|
|
551
|
-
* Fires the post-mount hooks once per block instance — a teleport that
|
|
552
|
-
* re-mounts must not repeat a creation signal.
|
|
553
|
-
*/
|
|
554
|
-
private readonly notifyMounted = (): void => {
|
|
555
|
-
if (this.mountSignalled) {
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
this.mountSignalled = true;
|
|
560
|
-
|
|
561
|
-
const context = { origin: this.origin, api: this.api };
|
|
562
|
-
|
|
563
|
-
spec.onMounted?.(this.blockApi, context);
|
|
564
|
-
|
|
565
|
-
// Creation-only, and after onMounted: a seeding hook must see whatever
|
|
566
|
-
// the mount hook already put in place.
|
|
567
|
-
if (CREATION_ORIGINS.has(this.origin)) {
|
|
568
|
-
spec.onCreated?.(this.blockApi, context);
|
|
569
|
-
}
|
|
570
|
-
};
|
|
571
|
-
|
|
572
|
-
public save(): BlockToolData {
|
|
573
|
-
// The complete, frozen, toRaw-clean mirror — never the DOM, never partial.
|
|
574
|
-
return this.mirror;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
public async setData(newData: BlockToolData): Promise<boolean> {
|
|
578
|
-
const next = fillDefaults<Data>(spec.propSchema, toRaw(newData ?? {}));
|
|
579
|
-
|
|
580
|
-
// Dedup (Risk R3): identical data → skip the render entirely, but still
|
|
581
|
-
// return true so core keeps the block in place (no remount).
|
|
582
|
-
if (deepEqual(next, this.lastRendered)) {
|
|
583
|
-
return true;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
this.mirror = next;
|
|
587
|
-
this.lastRendered = next;
|
|
588
|
-
this.dataRef.value = next;
|
|
589
|
-
|
|
590
|
-
// Await Vue's commit. Core drives setData asynchronously inside its
|
|
591
|
-
// RAF-extended Yjs-suppression window, so awaiting nextTick lands the
|
|
592
|
-
// re-render before suppression lifts — without ever remounting.
|
|
593
|
-
await nextTick();
|
|
594
|
-
|
|
595
|
-
return true;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
/**
|
|
599
|
-
* In-place read-only toggle. Flips the reactive flag the component reads via
|
|
600
|
-
* `ctx.readOnly`, so the block re-renders read-only WITHOUT a remount
|
|
601
|
-
* (ephemeral component state survives). A regular prototype method (not an
|
|
602
|
-
* arrow field) so core's `supportsInPlaceReadOnly` — which probes the
|
|
603
|
-
* constructable's PROTOTYPE for `setReadOnly` — selects the in-place path.
|
|
604
|
-
*/
|
|
605
|
-
public setReadOnly(state: boolean): void {
|
|
606
|
-
this.readOnlyRef.value = state;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Core's toolbar-anchor hook. Always defined so the delegation is one
|
|
611
|
-
* `typeof … === 'function'` probe away, and always resolved fresh against
|
|
612
|
-
* the LIVE host — the anchor element is Vue-rendered, so it does not exist
|
|
613
|
-
* yet when the tool is constructed and may be replaced on any re-render. A
|
|
614
|
-
* spec without a resolver returns undefined, which is exactly what core's
|
|
615
|
-
* default positioning already assumes.
|
|
616
|
-
*/
|
|
617
|
-
public getToolbarAnchorElement(): HTMLElement | undefined {
|
|
618
|
-
/**
|
|
619
|
-
* A ref the block attached wins — it names the exact element, chosen from
|
|
620
|
-
* inside the render tree. `isConnected` is the guard that matters: Vue
|
|
621
|
-
* nulls the ref on unmount, but a block that re-parents its anchor can
|
|
622
|
-
* leave a detached node here, and positioning the toolbar against one
|
|
623
|
-
* silently parks it at 0,0.
|
|
624
|
-
*/
|
|
625
|
-
const fromRef = this.anchorEl;
|
|
626
|
-
|
|
627
|
-
if (fromRef !== null && fromRef.isConnected) {
|
|
628
|
-
return fromRef;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
const host = this.hostEl;
|
|
632
|
-
|
|
633
|
-
if (host === null || spec.getToolbarAnchorElement === undefined) {
|
|
634
|
-
return undefined;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
return spec.getToolbarAnchorElement(host, this.blockApi) ?? undefined;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* Core's navigation-mode Enter hook. Only a real `true` from the spec
|
|
642
|
-
* counts as handled.
|
|
643
|
-
*/
|
|
644
|
-
public onNavigationEnter(event: KeyboardEvent): boolean {
|
|
645
|
-
return spec.onNavigationEnter?.(event, this.blockApi) === true;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
public moved(): void {
|
|
649
|
-
// No remount: Blok relocates the host element; the teleport follows it.
|
|
650
|
-
spec.onMoved?.(this.blockApi);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
public removed(): void {
|
|
654
|
-
// Ownership-scoped: core composes a REPLACEMENT block (which registers
|
|
655
|
-
// under the SAME id) before it tears this one down, so an unqualified
|
|
656
|
-
// unregister here would delete the live entry and blank the block.
|
|
657
|
-
this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
|
|
658
|
-
spec.onRemoved?.(this.blockApi);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
public destroy(): void {
|
|
662
|
-
// Idempotent with removed(); unregister is safe when already absent.
|
|
663
|
-
this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* The only data write path. Merges the patch into the frozen mirror, swaps
|
|
668
|
-
* the reactive snapshot, records the dedup baseline, and fires
|
|
669
|
-
* dispatchChange EXACTLY once — deferring it while a pointer drag is active
|
|
670
|
-
* (core would otherwise silently drop it).
|
|
671
|
-
*/
|
|
672
|
-
private readonly commit = (patch: Partial<Data>): void => {
|
|
673
|
-
const next = fillDefaults<Data>(spec.propSchema, {
|
|
674
|
-
...(this.mirror as Record<string, unknown>),
|
|
675
|
-
...(patch as Record<string, unknown>),
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
// Idempotent: a patch that changes nothing is a full no-op — no reactive
|
|
679
|
-
// swap, no dispatchChange — so a watcher echoing the current value back
|
|
680
|
-
// through commit can never loop.
|
|
681
|
-
if (deepEqual(next, this.mirror)) {
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
this.mirror = next;
|
|
686
|
-
this.lastRendered = next;
|
|
687
|
-
this.dataRef.value = next;
|
|
688
|
-
|
|
689
|
-
this.flushDispatch();
|
|
690
|
-
};
|
|
691
|
-
|
|
692
|
-
/** Dispatch the change, or retry on the next frame if a drag is in progress. */
|
|
693
|
-
private flushDispatch(): void {
|
|
694
|
-
if (!this.pointerDrag()) {
|
|
695
|
-
this.pendingDispatch = false;
|
|
696
|
-
this.blockApi.dispatchChange();
|
|
697
|
-
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
if (this.pendingDispatch) {
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
this.pendingDispatch = true;
|
|
706
|
-
|
|
707
|
-
const retry = (): void => {
|
|
708
|
-
if (this.pointerDrag()) {
|
|
709
|
-
requestAnimationFrame(retry);
|
|
710
|
-
|
|
711
|
-
return;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
this.pendingDispatch = false;
|
|
715
|
-
this.blockApi.dispatchChange();
|
|
716
|
-
};
|
|
717
|
-
|
|
718
|
-
requestAnimationFrame(retry);
|
|
719
|
-
}
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
// Forward the authored statics onto the generated class. `defineProperty`
|
|
723
|
-
// (not assignment) because `toolbox`/`isReadOnlySupported` are accessors and a
|
|
724
|
-
// plain write would throw in strict mode; the reserved list keeps a stray bag
|
|
725
|
-
// from taking those — or the adapter's own marker — over.
|
|
726
|
-
for (const [key, value] of Object.entries(spec.statics ?? {})) {
|
|
727
|
-
if (RESERVED_STATICS.includes(key)) {
|
|
728
|
-
continue;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
Object.defineProperty(VueBlockTool, key, {
|
|
732
|
-
value,
|
|
733
|
-
writable: true,
|
|
734
|
-
enumerable: true,
|
|
735
|
-
configurable: true,
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
return VueBlockTool;
|
|
740
|
-
}
|