@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.
@@ -0,0 +1,46 @@
1
+ // src/vue/BlockPortalHost.ts
2
+ import { defineComponent, h, Teleport, Fragment, onErrorCaptured, type PropType } from 'vue';
3
+
4
+ import type { BlockPortalRegistry } from './block-portal-registry';
5
+
6
+ /**
7
+ * Wraps a single block's render so a throw inside it can't tear down the whole
8
+ * editor's render tree. `onErrorCaptured` returning false stops propagation.
9
+ * Authored as a render function (no `.vue` SFC) per the adapter invariant.
10
+ */
11
+ const BlockErrorBoundary = defineComponent({
12
+ name: 'BlockErrorBoundary',
13
+ setup(_props, { slots }) {
14
+ onErrorCaptured(() => false);
15
+
16
+ return () => slots.default?.();
17
+ },
18
+ });
19
+
20
+ /**
21
+ * The single shared render tree that teleports each registered Vue block into
22
+ * its Blok-owned host element. Mounted once per editor (by `BlokContent`), it
23
+ * iterates the reactive registry and emits one `<Teleport>` per entry — so all
24
+ * blocks share one reconciler and the surrounding app's provide/inject context
25
+ * (Teleport preserves the component render context, not the DOM target's).
26
+ */
27
+ export const BlockPortalHost = defineComponent({
28
+ name: 'BlockPortalHost',
29
+ props: {
30
+ registry: { type: Object as PropType<BlockPortalRegistry>, required: true },
31
+ },
32
+ setup(props) {
33
+ return () =>
34
+ h(
35
+ Fragment,
36
+ // Iterating the reactive Map tracks it, so register/unregister re-renders.
37
+ Array.from(props.registry.entries.entries()).map(([id, entry]) =>
38
+ h(Teleport, { key: id, to: entry.hostEl }, [
39
+ h(BlockErrorBoundary, null, {
40
+ default: () => h(entry.component, entry.props),
41
+ }),
42
+ ])
43
+ )
44
+ );
45
+ },
46
+ });
@@ -0,0 +1,80 @@
1
+ import { defineComponent, h, onMounted, onBeforeUnmount, ref, toRaw, watch, type PropType } from 'vue';
2
+ import { getHolder } from './holder-map';
3
+ import { getRegistry } from './registry-map';
4
+ import { BlockPortalHost } from './BlockPortalHost';
5
+ import type { Blok } from '@/types';
6
+
7
+ /**
8
+ * Provides the DOM mount point for a Blok editor. Renders a `<div>` and adopts
9
+ * the editor's detached holder element into it (the Vue analog of React's
10
+ * `BlokContent`). When `editor` is null it renders an empty div for layout
11
+ * stability. The holder subtree is owned entirely by core — Vue's renderer never
12
+ * manages its children, so the container is always rendered with no vnode
13
+ * children and the holder is appended/removed imperatively.
14
+ *
15
+ * `toRaw` unwraps the editor before the holder-map lookup: Vue stores the object
16
+ * prop behind a reactive proxy, and the holder WeakMap is keyed by the raw Blok
17
+ * instance (Risk R0 — Vue proxies must never be used as identity keys).
18
+ */
19
+ export const BlokContent = defineComponent({
20
+ name: 'BlokContent',
21
+ props: {
22
+ editor: {
23
+ type: Object as PropType<Blok | null>,
24
+ default: null,
25
+ },
26
+ },
27
+ setup(props) {
28
+ const container = ref<HTMLDivElement | null>(null);
29
+
30
+ const adopt = (editor: Blok | null): void => {
31
+ if (container.value === null || editor === null) {
32
+ return;
33
+ }
34
+
35
+ const holder = getHolder(toRaw(editor));
36
+
37
+ if (holder === undefined) {
38
+ return;
39
+ }
40
+
41
+ container.value.appendChild(holder);
42
+ };
43
+
44
+ const detach = (editor: Blok | null): void => {
45
+ if (editor === null) {
46
+ return;
47
+ }
48
+
49
+ getHolder(toRaw(editor))?.remove();
50
+ };
51
+
52
+ onMounted(() => {
53
+ adopt(props.editor);
54
+ });
55
+
56
+ watch(
57
+ () => props.editor,
58
+ (next, previous) => {
59
+ detach(previous ?? null);
60
+ adopt(next);
61
+ }
62
+ );
63
+
64
+ onBeforeUnmount(() => {
65
+ detach(props.editor);
66
+ });
67
+
68
+ return () => {
69
+ const editorVal = props.editor;
70
+ // The shared portal host for `createVueBlock` tools (when this editor has
71
+ // one). It emits ONLY <Teleport> anchors — no real DOM in the container —
72
+ // so the imperatively-appended holder is undisturbed and the container
73
+ // stays a single root for attribute fallthrough. Registry-less editors
74
+ // (vanilla-only) render the bare div exactly as before.
75
+ const registry = editorVal === null ? undefined : getRegistry(toRaw(editorVal));
76
+
77
+ return h('div', { ref: container }, registry === undefined ? [] : [h(BlockPortalHost, { registry })]);
78
+ };
79
+ },
80
+ });
@@ -0,0 +1,197 @@
1
+ import { computed, defineComponent, getCurrentInstance, h, watch, type PropType } from 'vue';
2
+ import { useBlok } from './useBlok';
3
+ import { BlokContent } from './BlokContent';
4
+ import { BLOK_EDITOR_CONFIG_KEYS } from './config-keys';
5
+ import type {
6
+ API,
7
+ BlockMutationEvent,
8
+ BlokConfig,
9
+ EditorWidth,
10
+ OutputData,
11
+ ResolvedTheme,
12
+ ThemeMode,
13
+ } from '@/types';
14
+ import type { UseBlokConfig } from './types';
15
+
16
+ /**
17
+ * The blessed all-in-one component for embedding Blok in Vue. Wires `useBlok`
18
+ * and `BlokContent`, maps Vue emits onto the core config callbacks (gated on
19
+ * listener presence, since their mere presence makes core do extra work), and
20
+ * threads the return-valued transform hooks through.
21
+ *
22
+ * Every declared prop is removed from `$attrs`, so any extra attribute
23
+ * (`id`/`class`/`aria-*`/`data-*`) falls through to the editor container.
24
+ */
25
+ export const BlokEditor = defineComponent({
26
+ name: 'BlokEditor',
27
+ // Bind fallthrough attrs explicitly onto the container (below) instead of
28
+ // relying on implicit double-fallthrough through BlokContent.
29
+ inheritAttrs: false,
30
+ // Reactive config + seed props. `default: undefined` on the boolean props keeps
31
+ // an unset prop `undefined` (Vue otherwise coerces absent Boolean props to
32
+ // false), so `provideBlok` defaults can apply and core can coerce.
33
+ props: {
34
+ autofocus: { type: Boolean as PropType<boolean | undefined>, default: undefined },
35
+ defaultBlock: { type: String, default: undefined },
36
+ dataModel: { type: String as PropType<BlokConfig['dataModel']>, default: undefined },
37
+ placeholder: { type: [String, Boolean] as PropType<string | false>, default: undefined },
38
+ sanitizer: { type: Object as PropType<BlokConfig['sanitizer']>, default: undefined },
39
+ hideToolbar: { type: Boolean as PropType<boolean | undefined>, default: undefined },
40
+ maxHistoryLength: { type: Number, default: undefined },
41
+ historyDebounceTime: { type: Number, default: undefined },
42
+ newGroupDelay: { type: Number, default: undefined },
43
+ globalUndoRedo: { type: Boolean as PropType<boolean | undefined>, default: undefined },
44
+ tools: { type: Object as PropType<BlokConfig['tools']>, default: undefined },
45
+ data: { type: Object as PropType<OutputData>, default: undefined },
46
+ minHeight: { type: Number, default: undefined },
47
+ logLevel: { type: String as PropType<BlokConfig['logLevel']>, default: undefined },
48
+ readOnly: { type: Boolean as PropType<boolean | undefined>, default: undefined },
49
+ i18n: { type: Object as PropType<BlokConfig['i18n']>, default: undefined },
50
+ link: { type: Object as PropType<BlokConfig['link']>, default: undefined },
51
+ linkPaste: { type: Object as PropType<BlokConfig['linkPaste']>, default: undefined },
52
+ inlineToolbar: { type: [Array, Boolean] as PropType<string[] | boolean>, default: undefined },
53
+ tunes: { type: Array as PropType<string[]>, default: undefined },
54
+ style: { type: Object as PropType<BlokConfig['style']>, default: undefined },
55
+ theme: { type: String as PropType<ThemeMode>, default: undefined },
56
+ scrollToBlock: { type: Object as PropType<BlokConfig['scrollToBlock']>, default: undefined },
57
+ user: { type: Object as PropType<BlokConfig['user']>, default: undefined },
58
+ resolveUser: { type: Function as PropType<BlokConfig['resolveUser']>, default: undefined },
59
+ notifierPosition: { type: String as PropType<BlokConfig['notifierPosition']>, default: undefined },
60
+ notifier: { type: Function as PropType<BlokConfig['notifier']>, default: undefined },
61
+ width: { type: String as PropType<EditorWidth>, default: undefined },
62
+ onBeforeRender: { type: Function as PropType<BlokConfig['onBeforeRender']>, default: undefined },
63
+ onBeforePaste: { type: Function as PropType<BlokConfig['onBeforePaste']>, default: undefined },
64
+ /** Changing this prop's identity destroys and recreates the editor. */
65
+ recreateKey: { type: null as unknown as PropType<unknown> },
66
+ },
67
+ emits: {
68
+ ready: (_instance: unknown): boolean => true,
69
+ change: (_payload: { api: API; event: BlockMutationEvent | BlockMutationEvent[] }): boolean => true,
70
+ save: (_data: OutputData): boolean => true,
71
+ 'update:data': (_data: OutputData): boolean => true,
72
+ 'after-render': (_api: API): boolean => true,
73
+ 'theme-change': (_theme: ResolvedTheme): boolean => true,
74
+ 'blocks-rendered': (_payload: unknown): boolean => true,
75
+ 'block-rendered': (_payload: unknown): boolean => true,
76
+ },
77
+ setup(props, { emit, expose, attrs }) {
78
+ const instance = getCurrentInstance();
79
+
80
+ /** Listener presence (the Vue analog of Angular's `output.observed`). */
81
+ const hasListener = (handlerKey: string): boolean => {
82
+ const vnodeProps = instance?.vnode.props;
83
+
84
+ return vnodeProps != null && handlerKey in vnodeProps;
85
+ };
86
+
87
+ /**
88
+ * Snapshot the props + gated emit-callbacks into a `UseBlokConfig`. App-wide
89
+ * `provideBlok` defaults are merged in by `useBlok` (the lifecycle engine),
90
+ * so this component stays thin and the escape-hatch path honors defaults too
91
+ * — mirroring React, where only `useBlok` merges.
92
+ */
93
+ const buildConfig = (): UseBlokConfig => {
94
+ const config: Record<string, unknown> = {};
95
+
96
+ for (const key of BLOK_EDITOR_CONFIG_KEYS) {
97
+ const value = props[key];
98
+
99
+ if (value !== undefined) {
100
+ config[key] = value;
101
+ }
102
+ }
103
+
104
+ if (hasListener('onChange')) {
105
+ config.onChange = (api: API, event: BlockMutationEvent | BlockMutationEvent[]): void => {
106
+ emit('change', { api, event });
107
+ };
108
+ }
109
+
110
+ // onSave makes core serialize on every change batch — wire it only when a
111
+ // v-model:data binding or an explicit @save listener consumes it.
112
+ if (hasListener('onSave') || hasListener('onUpdate:data')) {
113
+ config.onSave = (data: OutputData): void => {
114
+ emit('update:data', data);
115
+ emit('save', data);
116
+ };
117
+ }
118
+
119
+ if (hasListener('onAfterRender')) {
120
+ config.onAfterRender = (api: API): void => {
121
+ emit('after-render', api);
122
+ };
123
+ }
124
+
125
+ if (hasListener('onThemeChange')) {
126
+ config.onThemeChange = (resolved: ResolvedTheme): void => {
127
+ emit('theme-change', resolved);
128
+ };
129
+ }
130
+
131
+ return config as UseBlokConfig;
132
+ };
133
+
134
+ const editor = useBlok(
135
+ () => buildConfig(),
136
+ () => props.recreateKey
137
+ );
138
+
139
+ // Subscribe to the editor's rendered-lifecycle events once it exists (gated
140
+ // on the emits being listened). `onCleanup` unsubscribes when the instance is
141
+ // replaced or the component unmounts.
142
+ watch(editor, (ed, _previous, onCleanup) => {
143
+ if (ed === null) {
144
+ return;
145
+ }
146
+
147
+ const subscriptions: Array<[string, (payload?: unknown) => void]> = [];
148
+
149
+ if (hasListener('onBlocksRendered')) {
150
+ const handler = (payload?: unknown): void => emit('blocks-rendered', payload);
151
+
152
+ ed.on('blocks:rendered', handler);
153
+ subscriptions.push(['blocks:rendered', handler]);
154
+ }
155
+
156
+ if (hasListener('onBlockRendered')) {
157
+ const handler = (payload?: unknown): void => emit('block-rendered', payload);
158
+
159
+ ed.on('block:rendered', handler);
160
+ subscriptions.push(['block:rendered', handler]);
161
+ }
162
+
163
+ onCleanup(() => {
164
+ for (const [name, handler] of subscriptions) {
165
+ ed.off(name, handler);
166
+ }
167
+ });
168
+ });
169
+
170
+ // Emit `ready` once per editor, after the instance is published to the ref
171
+ // (and to `expose` below), so a consumer reading the instance inside the
172
+ // handler — or via a template ref — sees the live editor. The last-emitted
173
+ // instance is held in an object to avoid `let` reassignment.
174
+ const readyState: { last: typeof editor.value } = { last: null };
175
+
176
+ watch(editor, (ed) => {
177
+ if (ed !== null && ed !== readyState.last) {
178
+ readyState.last = ed;
179
+ emit('ready', ed);
180
+ }
181
+ });
182
+
183
+ // Curated imperative facade (delegates to the live instance; no-ops until
184
+ // ready). `instance` is live (null before ready, the Blok after).
185
+ expose({
186
+ instance: computed(() => editor.value),
187
+ save: (): ReturnType<NonNullable<typeof editor.value>['save']> | undefined => editor.value?.save(),
188
+ focus: (atEnd?: boolean): void => {
189
+ editor.value?.focus(atEnd);
190
+ },
191
+ render: (data: OutputData): ReturnType<NonNullable<typeof editor.value>['render']> | undefined =>
192
+ editor.value?.render(data),
193
+ });
194
+
195
+ return () => h(BlokContent, { editor: editor.value, ...attrs });
196
+ },
197
+ });
@@ -0,0 +1,79 @@
1
+ // src/vue/block-portal-registry.ts
2
+ import { markRaw, reactive, type Component } from 'vue';
3
+
4
+ /**
5
+ * Tool-config key carrying the editor's portal registry into a `createVueBlock`
6
+ * tool. The tool is constructed by CORE (outside any Vue `setup`), so it cannot
7
+ * `inject()` — `useBlok` injects the editor-scoped registry through each
8
+ * vue-block tool's `config`, and the tool reads it here.
9
+ */
10
+ export const BLOK_PORTAL_REGISTRY_CONFIG_KEY = '__blokPortalRegistry';
11
+
12
+ /**
13
+ * One mounted Vue block in the portal registry. `hostEl` is the Blok-owned
14
+ * `toolRenderedElement` (tagged `data-blok-mutation-free`) the component is
15
+ * teleported into; `props` is a REACTIVE object so `setProps` updates the
16
+ * rendered block in place (no remount).
17
+ */
18
+ export interface BlockPortalEntry {
19
+ hostEl: HTMLElement;
20
+ component: Component;
21
+ props: Record<string, unknown>;
22
+ }
23
+
24
+ /**
25
+ * Per-editor registry of Vue blocks. A single shared {@link BlockPortalHost}
26
+ * renders one `<Teleport>` per entry, so every block lives in ONE render tree
27
+ * (shared provide/inject + app plugins, one effect scope) — the TipTap
28
+ * `VueNodeViewRenderer` pattern, not a Vue app per block.
29
+ */
30
+ export interface BlockPortalRegistry {
31
+ /** Reactive set of entries the host iterates (keyed by block id). */
32
+ readonly entries: ReadonlyMap<string, BlockPortalEntry>;
33
+ /**
34
+ * Register (or replace) the entry for `id`. Idempotent: a second register
35
+ * for the same id replaces the entry rather than mounting a duplicate.
36
+ */
37
+ register(id: string, entry: BlockPortalEntry): void;
38
+ /** Remove the entry for `id`. Safe (no-op) when absent. */
39
+ unregister(id: string): void;
40
+ /** Merge `props` into the live entry's reactive props (in-place update). */
41
+ setProps(id: string, props: Record<string, unknown>): void;
42
+ }
43
+
44
+ /**
45
+ * Create a fresh portal registry. One per editor instance (associated via the
46
+ * registry map, mirroring the holder WeakMap), so multiple editors on a page
47
+ * never cross-render each other's blocks.
48
+ */
49
+ export const createBlockPortalRegistry = (): BlockPortalRegistry => {
50
+ // Vue's reactive Map tracks iteration (entries/size), so the host re-renders
51
+ // on register/unregister.
52
+ const entries = reactive(new Map<string, BlockPortalEntry>());
53
+
54
+ return {
55
+ entries: entries as ReadonlyMap<string, BlockPortalEntry>,
56
+ register(id: string, entry: BlockPortalEntry): void {
57
+ // markRaw so the reactive Map (which must still track add/delete) does NOT
58
+ // deep-proxy the entry — a proxied component DEFINITION or DOM host would
59
+ // break rendering ("received a Component made reactive"). Per-entry prop
60
+ // reactivity, when needed, lives on the `props` object itself.
61
+ // set() replaces an existing entry — idempotent, never duplicates.
62
+ entries.set(id, markRaw(entry));
63
+ },
64
+ unregister(id: string): void {
65
+ entries.delete(id);
66
+ },
67
+ setProps(id: string, props: Record<string, unknown>): void {
68
+ const entry = entries.get(id);
69
+
70
+ if (entry === undefined) {
71
+ return;
72
+ }
73
+
74
+ // Mutate the reactive props object in place so the mounted component
75
+ // re-renders without being remounted (preserving its internal state).
76
+ Object.assign(entry.props, props);
77
+ },
78
+ };
79
+ };
@@ -0,0 +1,17 @@
1
+ // src/vue/blocks-snapshot.ts
2
+ //
3
+ // The block-tree data types, pure flat-index resolvers, AND the `UseBlocksApi`
4
+ // connection contract are framework-agnostic and live in the shared core module
5
+ // (`components/utils/blocks-tree`). The Vue `useBlocks` is a thin reactivity
6
+ // wrapper over the shared `createBlocksApiForEditor`, so it exposes the IDENTICAL
7
+ // surface as the React adapter — this file re-exports those shapes (no Vue-local
8
+ // redefinition, so the two adapters cannot drift).
9
+ export type {
10
+ BlockNode,
11
+ CaretTarget,
12
+ InsertPosition,
13
+ InsertSpec,
14
+ TreeInsertSpec,
15
+ MoveTarget,
16
+ UseBlocksApi,
17
+ } from '@bloklabs/core/adapters';
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Prop keys that `<BlokEditor>` copies straight into the Blok config (the plain
3
+ * seed/reactive options plus the two return-valued transform hooks). The
4
+ * emit-mapped callbacks — `onReady`/`onChange`/`onSave`/`onAfterRender` — are NOT
5
+ * here: they are derived from Vue emits, gated on listener presence.
6
+ *
7
+ * Any prop on `<BlokEditor>` whose key is NOT a declared prop falls through to
8
+ * the container div via `$attrs` (Vue removes declared props from `$attrs`
9
+ * automatically — the analog of React's manual config/div partition).
10
+ *
11
+ * The compile-time guard in this file (step 14) keeps this list in sync with the
12
+ * adapter's public config surface.
13
+ */
14
+ import type { UseBlokConfig } from './types';
15
+
16
+ export const BLOK_EDITOR_CONFIG_KEYS = [
17
+ 'autofocus',
18
+ 'defaultBlock',
19
+ 'dataModel',
20
+ 'placeholder',
21
+ 'sanitizer',
22
+ 'hideToolbar',
23
+ 'maxHistoryLength',
24
+ 'historyDebounceTime',
25
+ 'newGroupDelay',
26
+ 'globalUndoRedo',
27
+ 'tools',
28
+ 'data',
29
+ 'minHeight',
30
+ 'logLevel',
31
+ 'readOnly',
32
+ 'i18n',
33
+ 'link',
34
+ 'linkPaste',
35
+ 'inlineToolbar',
36
+ 'tunes',
37
+ 'style',
38
+ 'theme',
39
+ 'scrollToBlock',
40
+ 'user',
41
+ 'resolveUser',
42
+ 'notifierPosition',
43
+ 'notifier',
44
+ 'width',
45
+ 'onBeforeRender',
46
+ 'onBeforePaste',
47
+ ] as const satisfies readonly (keyof UseBlokConfig)[];
48
+
49
+ export type BlokEditorConfigKey = (typeof BLOK_EDITOR_CONFIG_KEYS)[number];
50
+
51
+ /**
52
+ * Config callbacks that `<BlokEditor>` surfaces as Vue emits (gated on listener
53
+ * presence) instead of declared props.
54
+ */
55
+ export type EmitMappedConfigKey = 'onReady' | 'onChange' | 'onSave' | 'onAfterRender' | 'onThemeChange';
56
+
57
+ /**
58
+ * Compile-time exhaustiveness guard: every `UseBlokConfig` key must be either a
59
+ * declared `<BlokEditor>` prop (`BLOK_EDITOR_CONFIG_KEYS`) or emit-mapped. If a
60
+ * new `BlokConfig` key is added and not handled here, `_uncovered` reds under
61
+ * `tsc`, forcing the adapter (and `types/vue.d.ts`) to keep pace.
62
+ */
63
+ type _UncoveredConfigKey = Exclude<keyof UseBlokConfig, BlokEditorConfigKey | EmitMappedConfigKey>;
64
+ const _uncovered: _UncoveredConfigKey extends never ? true : never = true;
65
+
66
+ void _uncovered;