@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.
- package/LICENSE.md +36 -0
- package/dist/Editor.d.ts +24 -0
- package/dist/EditorContent.d.ts +18 -0
- package/dist/FloatingMenu-BKkixozS.js +226 -0
- package/dist/FloatingMenu-By8Qi7tW.cjs +1 -0
- package/dist/NodeViewContent.d.ts +13 -0
- package/dist/NodeViewWrapper.d.ts +13 -0
- package/dist/VueMarkViewRenderer.d.ts +63 -0
- package/dist/VueNodeViewRenderer.d.ts +63 -0
- package/dist/VueRenderer.d.ts +35 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +718 -0
- package/dist/menus/BubbleMenu.d.ts +6 -0
- package/dist/menus/FloatingMenu.d.ts +6 -0
- package/dist/menus/index.d.ts +3 -0
- package/dist/menus.cjs +1 -0
- package/dist/menus.d.ts +1 -0
- package/dist/menus.js +6 -0
- package/dist/useEditor.d.ts +4 -0
- package/dist/useSlideEditor.d.ts +36 -0
- package/package.json +69 -0
- package/src/Editor.ts +93 -0
- package/src/EditorContent.ts +77 -0
- package/src/NodeViewContent.ts +21 -0
- package/src/NodeViewWrapper.ts +31 -0
- package/src/SlideEditor.vue +54 -0
- package/src/VueMarkViewRenderer.ts +130 -0
- package/src/VueNodeViewRenderer.ts +317 -0
- package/src/VueRenderer.ts +104 -0
- package/src/index.ts +12 -0
- package/src/menus/BubbleMenu.ts +111 -0
- package/src/menus/BubbleMenuPreset.vue +137 -0
- package/src/menus/FloatingMenu.ts +84 -0
- package/src/menus/index.ts +3 -0
- package/src/useEditor.ts +24 -0
- package/src/useSlideEditor.ts +255 -0
- package/src/vue-shims.d.ts +5 -0
|
@@ -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>
|