@blockslides/vue-3 0.2.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.
@@ -0,0 +1,317 @@
1
+ /* eslint-disable no-underscore-dangle */
2
+ import type { DecorationWithType, NodeViewProps, NodeViewRenderer, NodeViewRendererOptions } from '@blockslides/core'
3
+ import { NodeView } from '@blockslides/core'
4
+ import type { Node as ProseMirrorNode } from '@blockslides/pm/model'
5
+ import type { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@blockslides/pm/view'
6
+ import type { Component, PropType, Ref } from 'vue'
7
+ import { defineComponent, provide, ref } from 'vue'
8
+
9
+ import type { Editor } from './Editor.js'
10
+ import { VueRenderer } from './VueRenderer.js'
11
+
12
+ export const nodeViewProps = {
13
+ editor: {
14
+ type: Object as PropType<NodeViewProps['editor']>,
15
+ required: true as const,
16
+ },
17
+ node: {
18
+ type: Object as PropType<NodeViewProps['node']>,
19
+ required: true as const,
20
+ },
21
+ decorations: {
22
+ type: Object as PropType<NodeViewProps['decorations']>,
23
+ required: true as const,
24
+ },
25
+ selected: {
26
+ type: Boolean as PropType<NodeViewProps['selected']>,
27
+ required: true as const,
28
+ },
29
+ extension: {
30
+ type: Object as PropType<NodeViewProps['extension']>,
31
+ required: true as const,
32
+ },
33
+ getPos: {
34
+ type: Function as PropType<NodeViewProps['getPos']>,
35
+ required: true as const,
36
+ },
37
+ updateAttributes: {
38
+ type: Function as PropType<NodeViewProps['updateAttributes']>,
39
+ required: true as const,
40
+ },
41
+ deleteNode: {
42
+ type: Function as PropType<NodeViewProps['deleteNode']>,
43
+ required: true as const,
44
+ },
45
+ view: {
46
+ type: Object as PropType<NodeViewProps['view']>,
47
+ required: true as const,
48
+ },
49
+ innerDecorations: {
50
+ type: Object as PropType<NodeViewProps['innerDecorations']>,
51
+ required: true as const,
52
+ },
53
+ HTMLAttributes: {
54
+ type: Object as PropType<NodeViewProps['HTMLAttributes']>,
55
+ required: true as const,
56
+ },
57
+ }
58
+
59
+ export interface VueNodeViewRendererOptions extends NodeViewRendererOptions {
60
+ update:
61
+ | ((props: {
62
+ oldNode: ProseMirrorNode
63
+ oldDecorations: readonly Decoration[]
64
+ oldInnerDecorations: DecorationSource
65
+ newNode: ProseMirrorNode
66
+ newDecorations: readonly Decoration[]
67
+ innerDecorations: DecorationSource
68
+ updateProps: () => void
69
+ }) => boolean)
70
+ | null
71
+ }
72
+
73
+ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions> {
74
+ renderer!: VueRenderer
75
+
76
+ decorationClasses!: Ref<string>
77
+
78
+ private cachedExtensionWithSyncedStorage: NodeViewProps['extension'] | null = null
79
+
80
+ /**
81
+ * Returns a proxy of the extension that redirects storage access to the editor's mutable storage.
82
+ * This preserves the original prototype chain (instanceof checks, methods like configure/extend work).
83
+ * Cached to avoid proxy creation on every update.
84
+ */
85
+ get extensionWithSyncedStorage(): NodeViewProps['extension'] {
86
+ if (!this.cachedExtensionWithSyncedStorage) {
87
+ const editor = this.editor
88
+ const extension = this.extension
89
+
90
+ this.cachedExtensionWithSyncedStorage = new Proxy(extension, {
91
+ get(target, prop, receiver) {
92
+ if (prop === 'storage') {
93
+ return editor.storage[extension.name as keyof typeof editor.storage] ?? {}
94
+ }
95
+ return Reflect.get(target, prop, receiver)
96
+ },
97
+ })
98
+ }
99
+
100
+ return this.cachedExtensionWithSyncedStorage
101
+ }
102
+
103
+ mount() {
104
+ const props = {
105
+ editor: this.editor,
106
+ node: this.node,
107
+ decorations: this.decorations as DecorationWithType[],
108
+ innerDecorations: this.innerDecorations,
109
+ view: this.view,
110
+ selected: false,
111
+ extension: this.extensionWithSyncedStorage,
112
+ HTMLAttributes: this.HTMLAttributes,
113
+ getPos: () => this.getPos(),
114
+ updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
115
+ deleteNode: () => this.deleteNode(),
116
+ } satisfies NodeViewProps
117
+
118
+ const onDragStart = this.onDragStart.bind(this)
119
+
120
+ this.decorationClasses = ref(this.getDecorationClasses())
121
+
122
+ const extendedComponent = defineComponent({
123
+ extends: { ...this.component },
124
+ props: Object.keys(props),
125
+ template: (this.component as any).template,
126
+ setup: reactiveProps => {
127
+ provide('onDragStart', onDragStart)
128
+ provide('decorationClasses', this.decorationClasses)
129
+
130
+ return (this.component as any).setup?.(reactiveProps, {
131
+ expose: () => undefined,
132
+ })
133
+ },
134
+ // add support for scoped styles
135
+ // @ts-ignore
136
+ // eslint-disable-next-line
137
+ __scopeId: this.component.__scopeId,
138
+ // add support for CSS Modules
139
+ // @ts-ignore
140
+ // eslint-disable-next-line
141
+ __cssModules: this.component.__cssModules,
142
+ // add support for vue devtools
143
+ // @ts-ignore
144
+ // eslint-disable-next-line
145
+ __name: this.component.__name,
146
+ // @ts-ignore
147
+ // eslint-disable-next-line
148
+ __file: this.component.__file,
149
+ })
150
+
151
+ this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this)
152
+ this.editor.on('selectionUpdate', this.handleSelectionUpdate)
153
+
154
+ this.renderer = new VueRenderer(extendedComponent, {
155
+ editor: this.editor,
156
+ props,
157
+ })
158
+ }
159
+
160
+ /**
161
+ * Return the DOM element.
162
+ * This is the element that will be used to display the node view.
163
+ */
164
+ get dom() {
165
+ if (!this.renderer.element || !this.renderer.element.hasAttribute('data-node-view-wrapper')) {
166
+ throw Error('Please use the NodeViewWrapper component for your node view.')
167
+ }
168
+
169
+ return this.renderer.element as HTMLElement
170
+ }
171
+
172
+ /**
173
+ * Return the content DOM element.
174
+ * This is the element that will be used to display the rich-text content of the node.
175
+ */
176
+ get contentDOM() {
177
+ if (this.node.isLeaf) {
178
+ return null
179
+ }
180
+
181
+ return this.dom.querySelector('[data-node-view-content]') as HTMLElement | null
182
+ }
183
+
184
+ /**
185
+ * On editor selection update, check if the node is selected.
186
+ * If it is, call `selectNode`, otherwise call `deselectNode`.
187
+ */
188
+ handleSelectionUpdate() {
189
+ const { from, to } = this.editor.state.selection
190
+ const pos = this.getPos()
191
+
192
+ if (typeof pos !== 'number') {
193
+ return
194
+ }
195
+
196
+ if (from <= pos && to >= pos + this.node.nodeSize) {
197
+ if (this.renderer.props.selected) {
198
+ return
199
+ }
200
+
201
+ this.selectNode()
202
+ } else {
203
+ if (!this.renderer.props.selected) {
204
+ return
205
+ }
206
+
207
+ this.deselectNode()
208
+ }
209
+ }
210
+
211
+ /**
212
+ * On update, update the Vue component.
213
+ * To prevent unnecessary updates, the `update` option can be used.
214
+ */
215
+ update(node: ProseMirrorNode, decorations: readonly Decoration[], innerDecorations: DecorationSource): boolean {
216
+ const rerenderComponent = (props?: Record<string, any>) => {
217
+ this.decorationClasses.value = this.getDecorationClasses()
218
+ this.renderer.updateProps(props)
219
+ }
220
+
221
+ if (typeof this.options.update === 'function') {
222
+ const oldNode = this.node
223
+ const oldDecorations = this.decorations
224
+ const oldInnerDecorations = this.innerDecorations
225
+
226
+ this.node = node
227
+ this.decorations = decorations
228
+ this.innerDecorations = innerDecorations
229
+
230
+ return this.options.update({
231
+ oldNode,
232
+ oldDecorations,
233
+ newNode: node,
234
+ newDecorations: decorations,
235
+ oldInnerDecorations,
236
+ innerDecorations,
237
+ updateProps: () =>
238
+ rerenderComponent({ node, decorations, innerDecorations, extension: this.extensionWithSyncedStorage }),
239
+ })
240
+ }
241
+
242
+ if (node.type !== this.node.type) {
243
+ return false
244
+ }
245
+
246
+ if (node === this.node && this.decorations === decorations && this.innerDecorations === innerDecorations) {
247
+ return true
248
+ }
249
+
250
+ this.node = node
251
+ this.decorations = decorations
252
+ this.innerDecorations = innerDecorations
253
+
254
+ rerenderComponent({ node, decorations, innerDecorations, extension: this.extensionWithSyncedStorage })
255
+
256
+ return true
257
+ }
258
+
259
+ /**
260
+ * Select the node.
261
+ * Add the `selected` prop and the `ProseMirror-selectednode` class.
262
+ */
263
+ selectNode() {
264
+ this.renderer.updateProps({
265
+ selected: true,
266
+ })
267
+ if (this.renderer.element) {
268
+ this.renderer.element.classList.add('ProseMirror-selectednode')
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Deselect the node.
274
+ * Remove the `selected` prop and the `ProseMirror-selectednode` class.
275
+ */
276
+ deselectNode() {
277
+ this.renderer.updateProps({
278
+ selected: false,
279
+ })
280
+ if (this.renderer.element) {
281
+ this.renderer.element.classList.remove('ProseMirror-selectednode')
282
+ }
283
+ }
284
+
285
+ getDecorationClasses() {
286
+ return (
287
+ this.decorations
288
+ // @ts-ignore
289
+ .flatMap(item => item.type.attrs.class)
290
+ .join(' ')
291
+ )
292
+ }
293
+
294
+ destroy() {
295
+ this.renderer.destroy()
296
+ this.editor.off('selectionUpdate', this.handleSelectionUpdate)
297
+ }
298
+ }
299
+
300
+ export function VueNodeViewRenderer(
301
+ component: Component<NodeViewProps>,
302
+ options?: Partial<VueNodeViewRendererOptions>,
303
+ ): NodeViewRenderer {
304
+ return props => {
305
+ // try to get the parent component
306
+ // this is important for vue devtools to show the component hierarchy correctly
307
+ // maybe it's `undefined` because <editor-content> isn't rendered yet
308
+ if (!(props.editor as Editor).contentComponent) {
309
+ return {} as unknown as ProseMirrorNodeView
310
+ }
311
+ // check for class-component and normalize if neccessary
312
+ const normalizedComponent =
313
+ typeof component === 'function' && '__vccOpts' in component ? (component.__vccOpts as Component) : component
314
+
315
+ return new VueNodeView(normalizedComponent, props, options)
316
+ }
317
+ }
@@ -0,0 +1,104 @@
1
+ import type { Editor } from '@blockslides/core'
2
+ import type { Component, DefineComponent } from 'vue'
3
+ import { h, markRaw, reactive, render } from 'vue'
4
+
5
+ import type { Editor as ExtendedEditor } from './Editor.js'
6
+
7
+ export interface VueRendererOptions {
8
+ editor: Editor
9
+ props?: Record<string, any>
10
+ }
11
+
12
+ type ExtendedVNode = ReturnType<typeof h> | null
13
+
14
+ interface RenderedComponent {
15
+ vNode: ExtendedVNode
16
+ destroy: () => void
17
+ el: Element | null
18
+ }
19
+
20
+ /**
21
+ * This class is used to render Vue components inside the editor.
22
+ */
23
+ export class VueRenderer {
24
+ renderedComponent!: RenderedComponent
25
+
26
+ editor: ExtendedEditor
27
+
28
+ component: Component
29
+
30
+ el: Element | null
31
+
32
+ props: Record<string, any>
33
+
34
+ /**
35
+ * Flag to track if the renderer has been destroyed, preventing queued or asynchronous renders from executing after teardown.
36
+ */
37
+ destroyed = false
38
+
39
+ constructor(component: Component, { props = {}, editor }: VueRendererOptions) {
40
+ this.editor = editor as ExtendedEditor
41
+ this.component = markRaw(component)
42
+ this.el = document.createElement('div')
43
+ this.props = reactive(props)
44
+ this.renderedComponent = this.renderComponent()
45
+ }
46
+
47
+ get element(): Element | null {
48
+ return this.renderedComponent.el
49
+ }
50
+
51
+ get ref(): any {
52
+ // Composition API
53
+ if (this.renderedComponent.vNode?.component?.exposed) {
54
+ return this.renderedComponent.vNode.component.exposed
55
+ }
56
+ // Option API
57
+ return this.renderedComponent.vNode?.component?.proxy
58
+ }
59
+
60
+ renderComponent() {
61
+ if (this.destroyed) {
62
+ return this.renderedComponent
63
+ }
64
+
65
+ let vNode: ExtendedVNode = h(this.component as DefineComponent, this.props)
66
+
67
+ if (this.editor.appContext) {
68
+ vNode.appContext = this.editor.appContext
69
+ }
70
+ if (typeof document !== 'undefined' && this.el) {
71
+ render(vNode, this.el)
72
+ }
73
+
74
+ const destroy = () => {
75
+ if (this.el) {
76
+ render(null, this.el)
77
+ }
78
+ this.el = null
79
+ vNode = null
80
+ }
81
+
82
+ return { vNode, destroy, el: this.el ? this.el.firstElementChild : null }
83
+ }
84
+
85
+ updateProps(props: Record<string, any> = {}): void {
86
+ if (this.destroyed) {
87
+ return
88
+ }
89
+
90
+ Object.entries(props).forEach(([key, value]) => {
91
+ this.props[key] = value
92
+ })
93
+ this.renderComponent()
94
+ }
95
+
96
+ destroy(): void {
97
+ if (this.destroyed) {
98
+ return
99
+ }
100
+
101
+ this.destroyed = true
102
+ this.renderedComponent.destroy()
103
+ }
104
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { Editor } from './Editor.js'
2
+ export * from './EditorContent.js'
3
+ export * from './NodeViewContent.js'
4
+ export * from './NodeViewWrapper.js'
5
+ export * from './useEditor.js'
6
+ export * from './useSlideEditor.js'
7
+ export { default as SlideEditor } from './SlideEditor.vue'
8
+ export * from './VueMarkViewRenderer.js'
9
+ export * from './VueNodeViewRenderer.js'
10
+ export * from './VueRenderer.js'
11
+ export * from '@blockslides/core'
12
+ export * from './menus/index.js'
@@ -0,0 +1,111 @@
1
+ import type { BubbleMenuPluginProps } from '@blockslides/extension-bubble-menu'
2
+ import { BubbleMenuPlugin } from '@blockslides/extension-bubble-menu'
3
+ import type { PropType } from 'vue'
4
+ import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
5
+
6
+ type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
7
+
8
+ export type BubbleMenuProps = Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>
9
+
10
+ export const BubbleMenu = defineComponent({
11
+ name: 'BubbleMenu',
12
+
13
+ inheritAttrs: false,
14
+
15
+ props: {
16
+ pluginKey: {
17
+ type: [String, Object] as PropType<BubbleMenuPluginProps['pluginKey']>,
18
+ default: 'bubbleMenu',
19
+ },
20
+
21
+ editor: {
22
+ type: Object as PropType<BubbleMenuPluginProps['editor']>,
23
+ required: true,
24
+ },
25
+
26
+ updateDelay: {
27
+ type: Number as PropType<BubbleMenuPluginProps['updateDelay']>,
28
+ default: undefined,
29
+ },
30
+
31
+ resizeDelay: {
32
+ type: Number as PropType<BubbleMenuPluginProps['resizeDelay']>,
33
+ default: undefined,
34
+ },
35
+
36
+ options: {
37
+ type: Object as PropType<BubbleMenuPluginProps['options']>,
38
+ default: () => ({}),
39
+ },
40
+
41
+ appendTo: {
42
+ type: [Object, Function] as PropType<BubbleMenuPluginProps['appendTo']>,
43
+ default: undefined,
44
+ },
45
+
46
+ shouldShow: {
47
+ type: Function as PropType<Exclude<Required<BubbleMenuPluginProps>['shouldShow'], null>>,
48
+ default: null,
49
+ },
50
+
51
+ getReferencedVirtualElement: {
52
+ type: Function as PropType<Exclude<Required<BubbleMenuPluginProps>['getReferencedVirtualElement'], null>>,
53
+ default: undefined,
54
+ },
55
+ },
56
+
57
+ setup(props, { slots, attrs }) {
58
+ const root = ref<HTMLElement | null>(null)
59
+
60
+ onMounted(() => {
61
+ const {
62
+ editor,
63
+ options,
64
+ pluginKey,
65
+ resizeDelay,
66
+ appendTo,
67
+ shouldShow,
68
+ getReferencedVirtualElement,
69
+ updateDelay,
70
+ } = props
71
+
72
+ const el = root.value
73
+
74
+ if (!el) {
75
+ return
76
+ }
77
+
78
+ el.style.visibility = 'hidden'
79
+ el.style.position = 'absolute'
80
+
81
+ // Remove element from DOM; plugin will re-parent it when shown
82
+ el.remove()
83
+
84
+ nextTick(() => {
85
+ editor.registerPlugin(
86
+ BubbleMenuPlugin({
87
+ editor,
88
+ element: el,
89
+ options,
90
+ pluginKey,
91
+ resizeDelay,
92
+ appendTo,
93
+ shouldShow,
94
+ getReferencedVirtualElement,
95
+ updateDelay,
96
+ }),
97
+ )
98
+ })
99
+ })
100
+
101
+ onBeforeUnmount(() => {
102
+ const { pluginKey, editor } = props
103
+
104
+ editor.unregisterPlugin(pluginKey)
105
+ })
106
+
107
+ // Vue owns this element; attrs are applied reactively by Vue
108
+ // Plugin re-parents it when showing the menu
109
+ return () => h('div', { ref: root, ...attrs }, slots.default?.())
110
+ },
111
+ }) as any
@@ -0,0 +1,137 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import { BubbleMenuPlugin, type BubbleMenuPluginProps } from '@blockslides/extension-bubble-menu'
4
+ import { NodeSelection } from '@blockslides/pm/state'
5
+ import {
6
+ type BubbleMenuPresetOptions,
7
+ buildMenuElement,
8
+ DEFAULT_ITEMS,
9
+ DEFAULT_COLOR_PALETTE,
10
+ DEFAULT_HIGHLIGHT_PALETTE,
11
+ DEFAULT_FONTS,
12
+ DEFAULT_FONT_SIZES,
13
+ DEFAULT_ALIGNMENTS,
14
+ } from '@blockslides/extension-bubble-menu-preset'
15
+ import { isTextSelection, type Editor } from '@blockslides/core'
16
+
17
+ export interface BubbleMenuPresetProps extends Omit<BubbleMenuPresetOptions, 'element' | 'pluginKey'> {
18
+ editor: Editor
19
+ updateDelay?: number
20
+ resizeDelay?: number
21
+ appendTo?: HTMLElement | (() => HTMLElement)
22
+ shouldShow?: BubbleMenuPluginProps['shouldShow']
23
+ getReferencedVirtualElement?: () => any
24
+ options?: any
25
+ }
26
+
27
+ const props = withDefaults(defineProps<BubbleMenuPresetProps>(), {
28
+ injectStyles: true,
29
+ })
30
+
31
+ const pluginKey = 'bubbleMenuPreset'
32
+ const menuEl = ref<HTMLElement | null>(null)
33
+ let cleanup: (() => void) | undefined
34
+
35
+ const setupBubbleMenu = () => {
36
+ const attachToEditor = props.editor
37
+
38
+ if (!attachToEditor || (attachToEditor as any).isDestroyed) {
39
+ return
40
+ }
41
+
42
+ // Cleanup previous instance if any
43
+ if (cleanup) {
44
+ cleanup()
45
+ cleanup = undefined
46
+ }
47
+
48
+ const { element, cleanup: elementCleanup } = buildMenuElement(attachToEditor, {
49
+ items: props.items ?? DEFAULT_ITEMS,
50
+ className: props.className ?? '',
51
+ injectStyles: props.injectStyles !== false,
52
+ textColors: props.textColors ?? DEFAULT_COLOR_PALETTE,
53
+ highlightColors: props.highlightColors ?? DEFAULT_HIGHLIGHT_PALETTE,
54
+ fonts: props.fonts ?? DEFAULT_FONTS,
55
+ fontSizes: props.fontSizes ?? DEFAULT_FONT_SIZES,
56
+ alignments: props.alignments ?? DEFAULT_ALIGNMENTS,
57
+ onTextAction: props.onTextAction,
58
+ onImageReplace: props.onImageReplace,
59
+ })
60
+
61
+ menuEl.value = element
62
+ cleanup = elementCleanup
63
+
64
+ const defaultShouldShow: Exclude<BubbleMenuPluginProps['shouldShow'], null> = ({ state, editor }) => {
65
+ const sel = state.selection
66
+ const imageSelection =
67
+ (sel instanceof NodeSelection && ['image', 'imageBlock'].includes((sel as any).node?.type?.name)) ||
68
+ editor.isActive('image') ||
69
+ editor.isActive('imageBlock')
70
+
71
+ if (imageSelection) return true
72
+ if (isTextSelection(sel) && !sel.empty && !imageSelection) return true
73
+ return false
74
+ }
75
+
76
+ const plugin = BubbleMenuPlugin({
77
+ editor: attachToEditor,
78
+ element,
79
+ updateDelay: props.updateDelay,
80
+ resizeDelay: props.resizeDelay,
81
+ appendTo: props.appendTo,
82
+ pluginKey,
83
+ shouldShow: props.shouldShow ?? defaultShouldShow,
84
+ getReferencedVirtualElement: props.getReferencedVirtualElement,
85
+ options: props.options,
86
+ })
87
+
88
+ attachToEditor.registerPlugin(plugin)
89
+ }
90
+
91
+ onMounted(() => {
92
+ setupBubbleMenu()
93
+ })
94
+
95
+ // Watch for prop changes and rebuild
96
+ watch(
97
+ () => [
98
+ props.editor,
99
+ props.updateDelay,
100
+ props.resizeDelay,
101
+ props.appendTo,
102
+ props.shouldShow,
103
+ props.getReferencedVirtualElement,
104
+ props.options,
105
+ props.items,
106
+ props.className,
107
+ props.injectStyles,
108
+ props.textColors,
109
+ props.highlightColors,
110
+ props.fonts,
111
+ props.fontSizes,
112
+ props.alignments,
113
+ props.onTextAction,
114
+ props.onImageReplace,
115
+ ],
116
+ () => {
117
+ setupBubbleMenu()
118
+ }
119
+ )
120
+
121
+ onBeforeUnmount(() => {
122
+ if (props.editor) {
123
+ props.editor.unregisterPlugin(pluginKey)
124
+ }
125
+ if (cleanup) {
126
+ cleanup()
127
+ }
128
+ if (menuEl.value?.parentNode) {
129
+ menuEl.value.parentNode.removeChild(menuEl.value)
130
+ }
131
+ })
132
+ </script>
133
+
134
+ <template>
135
+ <!-- Vue doesn't need portal here as buildMenuElement handles DOM placement -->
136
+ <div v-if="menuEl" style="display: none"></div>
137
+ </template>