add vue-2 and vue-3
This commit is contained in:
14
packages/vue-3/README.md
Normal file
14
packages/vue-3/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# @tiptap/vue-3
|
||||
[](https://www.npmjs.com/package/@tiptap/vue-3)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/vue-3)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
|
||||
|
||||
## Offical Documentation
|
||||
Documentation can be found on the [tiptap website](https://tiptap.dev).
|
||||
|
||||
## License
|
||||
tiptap is open-sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap-next/blob/main/LICENSE.md).
|
||||
31
packages/vue-3/package.json
Normal file
31
packages/vue-3/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@tiptap/vue-3",
|
||||
"description": "Vue components for tiptap",
|
||||
"version": "2.0.0-alpha.0",
|
||||
"homepage": "https://tiptap.dev",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"tiptap vue components"
|
||||
],
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"main": "dist/tiptap-vue-3.cjs.js",
|
||||
"umd": "dist/tiptap-vue-3.umd.js",
|
||||
"module": "dist/tiptap-vue-3.esm.js",
|
||||
"unpkg": "dist/tiptap-vue-3.bundle.umd.min.js",
|
||||
"types": "dist/packages/vue-3/src/index.d.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.0.0-alpha.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"prosemirror-view": "^1.17.8",
|
||||
"vue": "^3.0.6"
|
||||
}
|
||||
}
|
||||
70
packages/vue-3/src/Editor.ts
Normal file
70
packages/vue-3/src/Editor.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Editor as CoreEditor, EditorOptions } from '@tiptap/core'
|
||||
import {
|
||||
markRaw,
|
||||
Ref,
|
||||
customRef,
|
||||
ComponentInternalInstance,
|
||||
ComponentPublicInstance,
|
||||
reactive,
|
||||
} from 'vue'
|
||||
import { EditorState } from 'prosemirror-state'
|
||||
import { VueRenderer } from './VueRenderer'
|
||||
|
||||
function useDebouncedRef<T>(value: T) {
|
||||
return customRef<T>((track, trigger) => {
|
||||
return {
|
||||
get() {
|
||||
track()
|
||||
return value
|
||||
},
|
||||
set(newValue) {
|
||||
// update state
|
||||
value = newValue
|
||||
|
||||
// update view as soon as possible
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
trigger()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type ContentComponent = ComponentInternalInstance & {
|
||||
ctx: ComponentPublicInstance,
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Editor {
|
||||
contentComponent: ContentComponent | null,
|
||||
vueRenderers: Map<string, VueRenderer>,
|
||||
}
|
||||
}
|
||||
|
||||
export class Editor extends CoreEditor {
|
||||
private reactiveState: Ref<EditorState>
|
||||
|
||||
public vueRenderers = reactive<Map<string, VueRenderer>>(new Map())
|
||||
|
||||
public contentComponent: ContentComponent | null = null
|
||||
|
||||
constructor(options: Partial<EditorOptions> = {}) {
|
||||
super(options)
|
||||
|
||||
this.reactiveState = useDebouncedRef(this.view.state)
|
||||
|
||||
this.on('transaction', () => {
|
||||
this.reactiveState.value = this.view.state
|
||||
})
|
||||
|
||||
return markRaw(this)
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this.reactiveState
|
||||
? this.reactiveState.value
|
||||
: this.view.state
|
||||
}
|
||||
}
|
||||
117
packages/vue-3/src/EditorContent.ts
Normal file
117
packages/vue-3/src/EditorContent.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
h,
|
||||
ref,
|
||||
Ref,
|
||||
unref,
|
||||
Teleport,
|
||||
PropType,
|
||||
defineComponent,
|
||||
DefineComponent,
|
||||
watchEffect,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
getCurrentInstance,
|
||||
} from 'vue'
|
||||
import { Editor } from './Editor'
|
||||
|
||||
export const EditorContent = defineComponent({
|
||||
name: 'EditorContent',
|
||||
|
||||
props: {
|
||||
editor: {
|
||||
default: null,
|
||||
type: Object as PropType<Editor>,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const rootEl: Ref<Element | undefined> = ref()
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
watchEffect(() => {
|
||||
const editor = props.editor
|
||||
|
||||
if (editor && editor.options.element && rootEl.value) {
|
||||
nextTick(() => {
|
||||
if (!rootEl.value || !editor.options.element.firstChild) {
|
||||
return
|
||||
}
|
||||
|
||||
const el = unref(rootEl.value)
|
||||
|
||||
rootEl.value.appendChild(editor.options.element.firstChild)
|
||||
|
||||
// @ts-ignore
|
||||
editor.contentComponent = instance.ctx._
|
||||
|
||||
editor.setOptions({
|
||||
element: el,
|
||||
})
|
||||
|
||||
editor.createNodeViews()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = props.editor
|
||||
|
||||
// destroy nodeviews before vue removes dom element
|
||||
// @ts-ignore
|
||||
if (editor.view?.docView) {
|
||||
editor.view.setProps({
|
||||
nodeViews: {},
|
||||
})
|
||||
}
|
||||
|
||||
editor.contentComponent = null
|
||||
|
||||
if (!editor.options.element.firstChild) {
|
||||
return
|
||||
}
|
||||
|
||||
const newEl = document.createElement('div')
|
||||
|
||||
newEl.appendChild(editor.options.element.firstChild)
|
||||
|
||||
editor.setOptions({
|
||||
element: newEl,
|
||||
})
|
||||
})
|
||||
|
||||
return { rootEl }
|
||||
},
|
||||
|
||||
render() {
|
||||
const vueRenderers: any[] = []
|
||||
|
||||
if (this.editor) {
|
||||
this.editor.vueRenderers.forEach(vueRenderer => {
|
||||
const node = h(
|
||||
Teleport,
|
||||
{
|
||||
to: vueRenderer.teleportElement,
|
||||
key: vueRenderer.id,
|
||||
},
|
||||
h(
|
||||
vueRenderer.component as DefineComponent,
|
||||
{
|
||||
ref: vueRenderer.id,
|
||||
...vueRenderer.props,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
vueRenderers.push(node)
|
||||
})
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
ref: (el: any) => { this.rootEl = el },
|
||||
},
|
||||
...vueRenderers,
|
||||
)
|
||||
},
|
||||
})
|
||||
331
packages/vue-3/src/VueNodeViewRenderer.ts
Normal file
331
packages/vue-3/src/VueNodeViewRenderer.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
Node,
|
||||
NodeViewRenderer,
|
||||
NodeViewRendererProps,
|
||||
} from '@tiptap/core'
|
||||
import {
|
||||
h,
|
||||
markRaw,
|
||||
Component,
|
||||
defineComponent,
|
||||
} from 'vue'
|
||||
import { Decoration, NodeView } from 'prosemirror-view'
|
||||
import { NodeSelection } from 'prosemirror-state'
|
||||
import { Node as ProseMirrorNode } from 'prosemirror-model'
|
||||
import { Editor } from './Editor'
|
||||
import { VueRenderer } from './VueRenderer'
|
||||
|
||||
// function getComponentFromElement(element: HTMLElement): Component {
|
||||
// // @ts-ignore
|
||||
// // eslint-disable-next-line
|
||||
// return element.__vueParentComponent
|
||||
// }
|
||||
|
||||
interface VueNodeViewRendererOptions {
|
||||
stopEvent: ((event: Event) => boolean) | null,
|
||||
update: ((node: ProseMirrorNode, decorations: Decoration[]) => boolean) | null,
|
||||
}
|
||||
|
||||
class VueNodeView implements NodeView {
|
||||
|
||||
renderer!: VueRenderer
|
||||
|
||||
editor: Editor
|
||||
|
||||
extension!: Node
|
||||
|
||||
node!: ProseMirrorNode
|
||||
|
||||
decorations!: Decoration[]
|
||||
|
||||
id!: string
|
||||
|
||||
getPos!: any
|
||||
|
||||
isDragging = false
|
||||
|
||||
options: VueNodeViewRendererOptions = {
|
||||
stopEvent: null,
|
||||
update: null,
|
||||
}
|
||||
|
||||
constructor(component: Component, props: NodeViewRendererProps, options?: Partial<VueNodeViewRendererOptions>) {
|
||||
this.options = { ...this.options, ...options }
|
||||
this.editor = props.editor as Editor
|
||||
this.extension = props.extension
|
||||
this.node = props.node
|
||||
this.getPos = props.getPos
|
||||
this.createUniqueId()
|
||||
this.mount(component)
|
||||
}
|
||||
|
||||
createUniqueId() {
|
||||
this.id = `id_${Math.floor(Math.random() * 0xFFFFFFFF)}`
|
||||
}
|
||||
|
||||
createNodeViewWrapper() {
|
||||
const { handleDragStart } = this
|
||||
const dragstart = handleDragStart.bind(this)
|
||||
|
||||
return markRaw(defineComponent({
|
||||
props: {
|
||||
as: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return h(
|
||||
this.as, {
|
||||
style: {
|
||||
whiteSpace: 'normal',
|
||||
},
|
||||
onDragStart: dragstart,
|
||||
},
|
||||
this.$slots.default?.(),
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
handleDragStart(event: DragEvent) {
|
||||
const { view } = this.editor
|
||||
const target = (event.target as HTMLElement)
|
||||
|
||||
if (this.contentDOM?.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
// sometimes `event.target` is not the `dom` element
|
||||
event.dataTransfer?.setDragImage(this.dom, 0, 0)
|
||||
|
||||
const selection = NodeSelection.create(view.state.doc, this.getPos())
|
||||
const transaction = view.state.tr.setSelection(selection)
|
||||
|
||||
view.dispatch(transaction)
|
||||
}
|
||||
|
||||
createNodeViewContent() {
|
||||
const { id } = this
|
||||
const { isEditable } = this.editor
|
||||
|
||||
return markRaw(defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
as: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return h(
|
||||
this.as, {
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
id,
|
||||
contenteditable: isEditable,
|
||||
},
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
mount(component: Component) {
|
||||
const NodeViewWrapper = this.createNodeViewWrapper()
|
||||
const NodeViewContent = this.createNodeViewContent()
|
||||
|
||||
const props = {
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
editor: this.editor,
|
||||
node: this.node,
|
||||
decorations: this.decorations,
|
||||
selected: false,
|
||||
extension: this.extension,
|
||||
getPos: () => this.getPos(),
|
||||
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
|
||||
}
|
||||
|
||||
const extendedComponent = defineComponent({
|
||||
extends: { ...component },
|
||||
props: Object.keys(props),
|
||||
components: {
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
},
|
||||
})
|
||||
|
||||
this.renderer = new VueRenderer(extendedComponent, {
|
||||
editor: this.editor,
|
||||
props,
|
||||
})
|
||||
}
|
||||
|
||||
get dom() {
|
||||
return this.renderer.element
|
||||
}
|
||||
|
||||
get contentDOM() {
|
||||
if (this.dom.id === this.id) {
|
||||
return this.dom
|
||||
}
|
||||
|
||||
return this.dom.querySelector(`#${this.id}`)
|
||||
}
|
||||
|
||||
stopEvent(event: Event) {
|
||||
if (typeof this.options.stopEvent === 'function') {
|
||||
return this.options.stopEvent(event)
|
||||
}
|
||||
|
||||
const target = (event.target as HTMLElement)
|
||||
const isInElement = this.dom.contains(target) && !this.contentDOM?.contains(target)
|
||||
|
||||
// ignore all events from child nodes
|
||||
if (!isInElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { isEditable } = this.editor
|
||||
const { isDragging } = this
|
||||
const isDraggable = !!this.node.type.spec.draggable
|
||||
const isSelectable = NodeSelection.isSelectable(this.node)
|
||||
const isCopyEvent = event.type === 'copy'
|
||||
const isPasteEvent = event.type === 'paste'
|
||||
const isCutEvent = event.type === 'cut'
|
||||
const isClickEvent = event.type === 'mousedown'
|
||||
const isDragEvent = event.type.startsWith('drag') || event.type === 'drop'
|
||||
|
||||
// ProseMirror tries to drag selectable nodes
|
||||
// even if `draggable` is set to `false`
|
||||
// this fix prevents that
|
||||
if (!isDraggable && isSelectable && isDragEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
if (isDraggable && isDragEvent && !isDragging) {
|
||||
event.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
// we have to store that dragging started
|
||||
if (isDraggable && isEditable && !isDragging && isClickEvent) {
|
||||
const dragHandle = target.closest('[data-drag-handle]')
|
||||
const isValidDragHandle = dragHandle
|
||||
&& (this.dom === dragHandle || (this.dom.contains(dragHandle)))
|
||||
|
||||
if (isValidDragHandle) {
|
||||
this.isDragging = true
|
||||
document.addEventListener('dragend', () => {
|
||||
this.isDragging = false
|
||||
}, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
// these events are handled by prosemirror
|
||||
if (
|
||||
isDragging
|
||||
|| isCopyEvent
|
||||
|| isPasteEvent
|
||||
|| isCutEvent
|
||||
|| (isClickEvent && isSelectable)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreMutation(mutation: MutationRecord | { type: 'selection'; target: Element }) {
|
||||
if (mutation.type === 'selection') {
|
||||
if (this.node.isLeaf) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.contentDOM) {
|
||||
return true
|
||||
}
|
||||
|
||||
const contentDOMHasChanged = !this.contentDOM.contains(mutation.target)
|
||||
|| this.contentDOM === mutation.target
|
||||
|
||||
return contentDOMHasChanged
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.renderer.destroy()
|
||||
}
|
||||
|
||||
update(node: ProseMirrorNode, decorations: Decoration[]) {
|
||||
if (typeof this.options.update === 'function') {
|
||||
return this.options.update(node, decorations)
|
||||
}
|
||||
|
||||
if (node.type !== this.node.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (node === this.node && this.decorations === decorations) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.renderer.updateProps({ node, decorations })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
updateAttributes(attributes: {}) {
|
||||
if (!this.editor.view.editable) {
|
||||
return
|
||||
}
|
||||
|
||||
const { state } = this.editor.view
|
||||
const pos = this.getPos()
|
||||
const transaction = state.tr.setNodeMarkup(pos, undefined, {
|
||||
...this.node.attrs,
|
||||
...attributes,
|
||||
})
|
||||
|
||||
this.editor.view.dispatch(transaction)
|
||||
}
|
||||
|
||||
selectNode() {
|
||||
this.renderer.updateProps({
|
||||
selected: true,
|
||||
})
|
||||
}
|
||||
|
||||
deselectNode() {
|
||||
this.renderer.updateProps({
|
||||
selected: false,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function VueNodeViewRenderer(component: Component, options?: Partial<VueNodeViewRendererOptions>): NodeViewRenderer {
|
||||
return (props: NodeViewRendererProps) => {
|
||||
// try to get the parent component
|
||||
// this is important for vue devtools to show the component hierarchy correctly
|
||||
// maybe it’s `undefined` because <editor-content> isn’t rendered yet
|
||||
// const parent = props.editor.view.dom.parentElement
|
||||
// ? getComponentFromElement(props.editor.view.dom.parentElement)
|
||||
// : undefined
|
||||
|
||||
// if (!parent) {
|
||||
// return {}
|
||||
// }
|
||||
|
||||
if (!props.editor.contentComponent) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return new VueNodeView(component, props, options) as NodeView
|
||||
}
|
||||
}
|
||||
49
packages/vue-3/src/VueRenderer.ts
Normal file
49
packages/vue-3/src/VueRenderer.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ref, markRaw, Component } from 'vue'
|
||||
import { Editor } from './Editor'
|
||||
|
||||
export interface VueRendererOptions {
|
||||
as?: string;
|
||||
editor: Editor;
|
||||
props?: { [key: string]: any };
|
||||
}
|
||||
|
||||
export class VueRenderer {
|
||||
id: string
|
||||
|
||||
editor: Editor
|
||||
|
||||
component: Component
|
||||
|
||||
teleportElement: Element
|
||||
|
||||
element: Element
|
||||
|
||||
props: { [key: string]: any }
|
||||
|
||||
constructor(component: Component, { props = {}, editor }: VueRendererOptions) {
|
||||
this.id = Math.floor(Math.random() * 0xFFFFFFFF).toString()
|
||||
this.editor = editor
|
||||
this.component = markRaw(component)
|
||||
this.teleportElement = document.createElement('div')
|
||||
this.element = this.teleportElement
|
||||
this.props = ref(props)
|
||||
this.editor.vueRenderers.set(this.id, this)
|
||||
|
||||
if (this.editor.contentComponent) {
|
||||
this.editor.contentComponent.update()
|
||||
this.element = this.teleportElement.firstElementChild as Element
|
||||
}
|
||||
}
|
||||
|
||||
get ref() {
|
||||
return this.editor.contentComponent?.ctx.$refs[this.id]
|
||||
}
|
||||
|
||||
updateProps(props: { [key: string]: any } = {}) {
|
||||
this.props.value = props
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.editor.vueRenderers.delete(this.id)
|
||||
}
|
||||
}
|
||||
5
packages/vue-3/src/index.ts
Normal file
5
packages/vue-3/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from '@tiptap/core'
|
||||
export { VueRenderer } from './VueRenderer'
|
||||
export { VueNodeViewRenderer } from './VueNodeViewRenderer'
|
||||
export { Editor } from './Editor'
|
||||
export { EditorContent } from './EditorContent'
|
||||
Reference in New Issue
Block a user