-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Vue 3 Support #1166
Comments
We’ve done literally nothing regarding Vue 3 currently. We hope the Gridsome update is available soon and we can kick off the work and don't need to find workarounds to start with it. 😬 |
Thanks Hans, I understand completely. If it wasn't for the fact the Ionic requires Vue 3 I would not be moving to it yet either as the rest of the ecosystem isn't quite there. I have started trying to port Vue 2: Vue 3: In both the editor is working (including keyboard shortcuts) but the "B" bold button only works in Vue 2. With my Vue 3 port it is throwing a "RangeError - Applying a mismatched transaction" Prosemirror error. I know you are not ready to work on the port yourselves but any pointers as to what could be causing this error would be helpful. I don't think it's any mistake in my port of One thing to note, if you select a word and click the "B" button, dismiss the error, and you will see the section has jumped back one place (See attached video). I'm suspicious its something to do with TipTap's new use of Proxy objects and Vue 3's heavy use of Proxy objects in its new reactive system, so the TipTap.Vue3.Range.Error.mov |
Just to add, in this version I attach the editor instance to the global window object and call the bold command via that from the button rather than via the Vue components data reference. This works (but is obviously not a solution), so I think that indicates there is something odd with tiptap and the Vue 3 reactive system... |
@holtwick Did you port the |
A little more experimentation, using One think to note, when you call Vue2: Vue3: |
Sure, here is my code: This is the way you use in TypeScript: export default defineComponent({
components: {
EditorContent,
},
setup() {
let content = "<p>Hello World</p>"
let editor = new Editor({
content,
extensions: [
...defaultExtensions()
],
})
onBeforeUnmount(() => {
editor.destroy()
})
return {
editor,
}
},
}) |
@samwillis your solution looks slick. Nice! I had a similar problem with selections, and it turned out I had |
Thats brilliant! Thanks @holtwick, that's saved me so much time. Nice to see your version of Of course, adding editor though setup() means its not wrapped in a Vue ref(), I should have thought of that, much neater. Have you found a way to make ether of these reactive? It pretty essential to take advantage of Vue when building toolbars and controls. |
Right, I have a slightly crude implementation that enables you to do this within a Vue 3 template to have reactive
Using this vue enabled editor subclass:
|
You're welcome, I also spend a lot of time on it ;) I can confirm that Your workaround looks very smart and valid. @hanspagel what do you think? I tested it and it works very very well. Thanks @samwillis for all the work. That help's me getting further with my project as well. |
Wow @samwillis you made it all work. Forget about all my code, it was not needed ;) I wrapped up your code in a singe file, maybe this can find its way into TipTap2? |
Thanks, it may seem a bit hackish but I think it may actually be the best way to adding some reactive properties/methods to the Editor. I don't know if we should debounce the on('transaction') handler? It looks like you can get multiple transactions at once but it can't be on just 'update' as we need 'selection', 'focus' and 'blur' as well. It still needs types for the reactive methods rather than the complete hack of (... as any) I have put everywhere, I should also add isEditable, isEmpty and isFocused (any others?). Edit: |
Ok, this should do the job (I'm getting the correct auto complete and hints in VS Code): import { Editor as CoreEditor, EditorOptions } from '@tiptap/core';
import { shallowReactive } from 'vue';
export { EditorOptions };
export interface ReactiveProps {
isActive: CoreEditor['isActive'];
can: CoreEditor['can'];
isEditable: CoreEditor['isEditable'];
isFocused: CoreEditor['isFocused'];
}
export class Editor extends CoreEditor {
public reactive: ReactiveProps;
constructor(options: Partial<EditorOptions> = {}) {
super(options);
this.reactive = shallowReactive({
isActive: (...args: any) => (this.isActive as any)(...args),
can: () => this.can(),
isEditable: this.isEditable,
isFocused: this.isFocused,
});
this.on('transaction', () => {
this.reactive.isActive = (...args: any) => (this.isActive as any)(...args);
this.reactive.can = () => this.can(),
this.reactive.isEditable = this.isEditable;
this.reactive.isFocused = this.isFocused;
});
}
} |
Great that you found a way with Vue 3! Are you sure the problem is because of the proxy that tiptap is using? Fun fact: currently the proxy is no longer needed, so I could probably remove it. The code for it is still from an earlier version of tipap 2. |
To be honest I'm not completely sure, however as @holtwick pointed out with the new Vue 3 api you should return the editor instance from the view setup() function rather than as a data attribute. Its therefor not wrapped in a ref() and isn't automatically reactive. We can then offer better fine grade control on what we want to be reactive and trigger it at the right point. If you haven't worked much with Vue 3 yet, the reactive system wrappes everything in a Proxy and builds a dependancy graph from every method call/property access. So my assumption was if you wrap an editor in a ref it's trying to keep track of everything that is happening within the editor instance... and not quite working. Yesterday I had a good look around to see is there was a way to indicate to Vue that a Ref or Reactive property was stale (after a transaction), rather than having to replace them, but couldn't find anything. So we end up with the slightly odd looking code replacing of the reactive methods and properties. |
So, I have made good progress on a VueRenderer implementation for Vue3. It uses the new Anyway the code: EditorContent.ts import { defineComponent, h, ref, Teleport, onBeforeUpdate } from "vue"
import VueRenderer from '../VueRenderer'
function setupVueRenderers(){
const vueRenderers = ref(([] as VueRenderer[]));
const vueRendererEls = ref(new Map());
const addVueRenderer = (vueRenderer: VueRenderer) => {
vueRenderers.value.push(vueRenderer)
}
const deleteVueRenderer = (vueRenderer: VueRenderer) => {
const index = vueRenderers.value.indexOf(vueRenderer);
if (index > -1) {
vueRenderers.value.splice(index, 1);
}
}
onBeforeUpdate(() => {
vueRendererEls.value = new Map();
});
return {
vueRenderers,
vueRendererEls,
addVueRenderer,
deleteVueRenderer,
}
}
export default defineComponent({
name: "EditorContent",
props: {
editor: {
default: null,
type: Object,
},
},
setup() {
return {
...setupVueRenderers(),
}
},
watch: {
editor: {
immediate: true,
handler(editor) {
if (editor && editor.options.element) {
this.$nextTick(() => {
this.$el.appendChild(editor.options.element)
editor.createNodeViews()
})
}
},
},
},
render() {
const components = [];
for (const vueRenderer of this.vueRenderers) {
components.push(h(
Teleport,
{ to: vueRenderer.element },
h(
vueRenderer.component,
{
ref: (el: any) => { this.vueRendererEls.set(vueRenderer, el) },
...vueRenderer.props
},
)
))
}
return h("div", {class:"TipTapEditorContent"}, components)
},
beforeUnmount() {
this.editor.setOptions({
element: this.$el,
})
},
}) VueRenderer.ts import { ref, markRaw } from "vue"
import { Editor } from '@tiptap/core';
import EditorContent from './components/EditorContent'
export interface VueRendererProps {
props: any;
editor?: Editor;
element?: Element;
}
export default class VueRenderer {
private _element: Element;
private _component: any;
public props: any;
public editor: Editor;
constructor(component: any, options: VueRendererProps) {
this._component = markRaw(component);
this.props = ref(options.props || {});
if (options.editor) {
this.editor = options.editor;
} else {
this.editor = options.props.editor;
}
if (options.element) {
this._element = options.element;
} else {
this._element = document.createElement('div');
}
this.editorContent.ctx.addVueRenderer(this)
}
get editorContent(): typeof EditorContent {
return (this.editor.options.element.parentElement as any).__vueParentComponent
}
get element() {
return this._element;
}
get component() {
return this._component;
}
get ref() {
// This is the instance of the component,
// you can call methods on the component from this
return this.editorContent.ctx.vueRendererEls.get(this);
}
updateProps(props: { [key: string]: any } = {}) {
Object
.entries(props)
.forEach(([key, value]) => {
this.props.value[key] = value
})
}
destroy() {
this.editorContent.ctx.deleteVueRenderer(this)
}
} |
@samwillis wow this is really helpful! thank you! 🙌 |
This will help a lot of people! I added it to the documentation for now: https://next.tiptap.dev/guide/getting-started/vue#introduction 👏 |
Thanks, happy to help out! @philippkuehn I have a couple of questions about the original Vue2 EditorContent. Why are you detaching the first child (the ProseMirror Element) from your TipTap editor element and mounting that in the EditorContent, and not just the I had that to change it to to just this.editor.options.element.parentElement.__vueParentComponent
// element.parentElement is the EditorContent's <div> in the dom
// __vueParentComponent is the vue component instance Also what is the perpose of this, I want to make sure my changes haven't impacted your intention here: (I'm going to refactor the One final thing, I considered an alternative method for getting the |
The reason is because <div>
<div class="ProseMirror"></div>
</div> With just using <div>
<div>
<div class="ProseMirror"></div>
</div>
</div>
The current <editor-content v-if="someCondition" :editor="editor" />
Yes, the proxy is only left because some time ago we allowed to register commands inside an |
Ah, got it, so the this.$el.appendChild(editor.options.element.firstChild) so that when vue destroys the However is that not slightly unexpected behaviour for the user, they would expect to be able to get the prosemirror element from Also is there possibly a memory leak in the current version, you are not exactly revising the original operation in the beforeDestroy() {
this.editor.element.appendChild(this.$el.firstChild)
}, |
Just a quick update on my work on a I have a few ideas out how to fix this, first is that you would would need to mount a `tiptap-renderer' element somewhere high up in your apps component tree (ideally at eh very top as it need to be permanently mounted, it would be invisible and have no content), this would then manage the rendering of teleported components even for un-mounted editors. The other is for each editor to have its own "Vue App" with The more I lean about the differences between Vue2 and Vue3 the more its seems like the transition from Python 2 to 3... it may look the same but some big fundamental changes. |
I had a similar issue with svelte where (I thought) you need to mount it before dependent components can access it. If it's the same issue, this fixed it for me: https://github.com/ueberdosis/tiptap-next/issues/103#issuecomment-768252760 (The rest of the thread useful for context) |
It's a similar issue but more on the Vue side, Vue 3 makes it quite a lot harder to render "offscreen/unmounted" elements than it was in Vue 2. |
I have a working VueNodeViewRenderer! Its still very much a work in progress but importantly it works (including dragging). Have posted it as a Gist here: https://gist.github.com/samwillis/c8fa8327055d63f04f26b22006989acc It turns out the way rendering works with Vue3 is very different than Vue2:
These two issues together mean we have to create dom elements outside of a Vue component for the root of the node view and the content element. Prosemirror does not like changing (swapping out for others) the two elements and requires them immediately. So currently we end up with extra nodes in the DOM compared with the Vue2 implementation, one wrapping the node view and the other around the content element. I have a few ideas about how to get closer to the vue 2 implementation allowing at least modifying the attributes on the two elements reactively from within the vue component. So thats next. The gist of using it: Include the <template>
<div>
<your-app-components></your-app-components>
<tip-tap-node-renderer></tip-tap-node-renderer>
</div>
</template>
<script lang="ts">
import TipTapNodeRenderer from '@/tiptap/vue3';
import { defineComponent } from 'vue';
export default defineComponent({
name: 'App',
components: {
TipTapNodeRenderer,
}
});
</script> You can then use the render(){
let component: any;
let popup: ReturnType<typeof tippy>;
return {
onStart: props => {
component = new VueRenderer(SuggestList, {
props: props,
})
popup = tippy('body', {
getReferenceClientRect: () => (props.clientRect() as DOMRect),
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
arrow: false,
trigger: 'manual',
placement: 'bottom-start',
})
},
... And finally the // In a Node.create
addNodeView() {
return VueNodeViewRenderer(CalcLineComponent, { hasContent: true })
}, // the corresponding Vue component
<template>
<div data-type="myNodeType">
<node-view-content class="some-class"></node-view-content>
<div contenteditable="false">
Some "un-editable" content
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue';
import { NodeViewContent, defaultComponentProps } from '@/tiptap/vue3';
export default defineComponent({
name: 'MyComponent',
components: {
NodeViewContent,
},
props: {
...defaultComponentProps
},
})
</script> As I said more work to do but thought I would post it incase anyone else was trying to get something working. Maybe this could eventually become the basis of official Vue 3 support. |
I started to play around with Vue 3 today. Here is my implementation for a wrapper around import { Editor as CoreEditor, EditorOptions } from '@tiptap/core'
import { markRaw, shallowRef, Ref } from 'vue'
import { EditorState } from 'prosemirror-state'
export class Editor extends CoreEditor {
private reactiveState: Ref<EditorState>
constructor(options: Partial<EditorOptions> = {}) {
super(options)
this.reactiveState = shallowRef(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
}
} In my first tests this will work for the old API: import { defineComponent } from 'vue'
import { Editor } from './Editor'
import { EditorContent } from './EditorContent'
export default defineComponent({
components: {
EditorContent,
},
data() {
return {
editor: null as (Editor | null),
}
},
mounted() {
this.editor = new Editor({
content: '<p>test</p>',
extensions: defaultExtensions(),
})
},
beforeUnmount() {
this.editor?.destroy()
},
}) ... and for the new API: import { defineComponent, onMounted, onBeforeUnmount, ref } from 'vue'
import { defaultExtensions } from '@tiptap/starter-kit'
import { Editor } from './Editor'
import { EditorContent } from './EditorContent'
export default defineComponent({
components: {
EditorContent,
},
setup() {
const editor = ref()
onMounted(() => {
editor.value = new Editor({
content: '<p>test</p>',
extensions: defaultExtensions(),
})
})
onBeforeUnmount(() => {
editor.value.destroy()
})
return {
editor,
}
},
}) @samwillis what do you think about that? |
Ooo, thats neat! I will drop it into my app tomorrow and see if it all holds up.
I had wanted to try and find a way to match the api (skipping my reactive props), from first look that should work perfectly!
|
Hmm… with longer texts it is definitely slower than with Vue 2. But your version is also slow :( |
I tested a debounded ref, so the internal state is updated immediately and the view update is updated debounced but it doesn't help either. Bildschirmaufnahme.2021-02-23.um.09.12.45.mp4import { Editor as CoreEditor, EditorOptions } from '@tiptap/core'
import { markRaw, Ref, customRef } from 'vue'
import { EditorState } from 'prosemirror-state'
function useDebouncedRef(value, delay = 1000) {
let timeout
return customRef((track, trigger) => {
return {
get() {
track()
return value
},
set(newValue) {
// update state
value = newValue
// update view later
clearTimeout(timeout)
timeout = setTimeout(() => {
trigger()
}, delay)
}
}
})
}
export class Editor extends CoreEditor {
private reactiveState: Ref<EditorState>
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
}
} |
How long is the text when you see the slow down? I haven’t seen that but then haven’t had massively long text. |
Haha I tried exactly that yesterday. It works but the problem for me with that is styling. Image markup like that: <div style="display: contents">
<p>
paragraph node view
</p>
</div>
<div style="display: contents">
<p>
paragraph node view
</p>
</div> You still can't style your paragraphs like that: p + p {
margin-top: 1rem;
} So probably this would be a bit weird? |
Yep, not ideal. I think attribute mapping and accepting that the type of the container node needs to be defined outside the component is the way to go. So we could create a The slightly more complicated one is for the contentDOM in a |
Hmm, I'll have to think about it. Currently, it all sounds very error-prone. Maybe we should accept that there is a wrapping element. But of course, when you look at nested node views, it all gets much worse. So imagine two nested node views for <blockquote class="blockquote-node-view">
<p class="paragraph-node-view">
text
</p>
</blockquote> With Vue 3 and a wrapping element for node views and its content it looks something like that, right? <blockquote class="blockquote-node-view-wrapper">
<div class="blockquote-node-view">
<div class="blockquote-node-view-content-wrapper">
<p class="paragraph-node-view-wrapper">
<div class="paragraph-node-view">
<div class="paragraph-node-view-content-wrapper">
text
</div>
</div>
</p>
</div>
</blockquote> Uff 😬 It gets even worse when you want to do the same for Some background information about why I'm using In tiptap 1 there was only the top element inside a node view ( <div>
<custom-component /> // contentDOM should be anywhere inside. not possible in tiptap 1
</div> In tiptap 2 <div>
<custom-component :node-view-content="NodeViewContent" /> // contentDOM can be placed anywhere inside of other components
</div> But maybe there is a better solution for this too. |
Hmm, it's all not very ideal. Although I have managed to trigger a synchronous dom update! I did some poking around and changed my VueRender to the code below. By calling I'm note sure exactly what is happening here, or how we then want to use it. But I think we can use it to retrieve an element for the contentDOM if not unwrap the component completely for the nodeview. import { ref, markRaw, Component, nextTick } from "vue"
import { Editor } from '@tiptap/core';
export const mountedNodeRenderers: Map<string, any> = new Map()
export interface VueRendererOptions {
as?: string;
editor?: Editor;
nodeRendererName?: string;
props: { [key: string]: any };
}
export default class VueRenderer {
props: { [key: string]: any };
editor: Editor;
readonly component: Component;
readonly nodeRenderer: any;
element: Element;
constructor(component: Component, {
as = 'div',
props,
editor = undefined,
nodeRendererName = 'default',
}: VueRendererOptions) {
this.props = ref(props || {});
this.component = markRaw(component);
this.element = document.createElement(as);
this.editor = editor ? editor : props.editor;
if (!mountedNodeRenderers.has(nodeRendererName)) {
throw Error('No TipTapNodeRenderer found!') // TODO: Error in dev should link to docs
}
this.nodeRenderer = mountedNodeRenderers.get(nodeRendererName);
this.nodeRenderer.ctx.addVueRenderer(this)
// MY CHANGE HERE:
console.log('Before update', this.ref?.$el?.outerHTML) // DOM not available
// This seems to forces a render straight away:!
this.nodeRenderer.update()
console.log('After update', this.ref?.$el?.outerHTML) // DOM now available!
nextTick(() => {
console.log('On nextTick', this.ref?.$el?.outerHTML)
})
}
get ref() {
// This is the instance of the component,
// you can call methods on the component from this
return this.nodeRenderer.ctx.vueRendererRefs.get(this);
}
updateProps(props: { [key: string]: any } = {}) {
Object
.entries(props)
.forEach(([key, value]) => {
this.props.value[key] = value
})
}
destroy() {
this.nodeRenderer.ctx.deleteVueRenderer(this)
}
} |
Oh that sounds great! I'm going to try this later today. |
Unwrapping the component seems to work! It, at least in my testing, continues to update the component dom even if you move it out of its original target el. Updated VueRenderer and TipTapNodeRenderer: // VueRenderer.ts
import { ref, markRaw, Component, nextTick } from "vue"
import { Editor } from '@tiptap/core';
export const mountedNodeRenderers: Map<string, any> = new Map()
export interface VueRendererOptions {
editor?: Editor;
nodeRendererName?: string;
props: { [key: string]: any };
}
export default class VueRenderer {
props: { [key: string]: any };
editor: Editor;
readonly component: Component;
readonly nodeRenderer: any;
element: Element;
teleportElement: Element;
constructor(component: Component, {
props,
editor = undefined,
nodeRendererName = 'default',
}: VueRendererOptions) {
this.props = ref(props || {});
this.component = markRaw(component);
this.teleportElement = document.createElement('div');
this.editor = editor ? editor : props.editor;
if (!mountedNodeRenderers.has(nodeRendererName)) {
throw Error('No TipTapNodeRenderer found!') // TODO: Error in dev should link to docs
}
this.nodeRenderer = mountedNodeRenderers.get(nodeRendererName);
this.nodeRenderer.ctx.addVueRenderer(this)
this.nodeRenderer.update()
// Unwrap component:
this.element = this.ref.$el
}
get ref() {
// This is the instance of the component,
// you can call methods on the component from this
return this.nodeRenderer.ctx.vueRendererRefs.get(this);
}
updateProps(props: { [key: string]: any } = {}) {
Object
.entries(props)
.forEach(([key, value]) => {
this.props.value[key] = value
})
}
destroy() {
this.nodeRenderer.ctx.deleteVueRenderer(this)
}
} // TipTapNodeRenderer.ts
import {
defineComponent,
DefineComponent,
getCurrentInstance,
h,
onBeforeUpdate,
onMounted,
onUnmounted,
ref,
Teleport,
} from "vue"
import VueRenderer, { mountedNodeRenderers } from '../VueRenderer'
export default defineComponent({
name: "TipTapNodeRenderer",
props: {
componentProps: {
default: () => ({}),
type: Object,
},
name: {
default: 'default',
type: String,
},
},
setup(props, context) {
const tiptapNodeRenderer = getCurrentInstance();
const vueRenderers = ref(([] as VueRenderer[]));
const vueRendererRefs = ref(new Map());
onMounted(() => {
if (!mountedNodeRenderers.has(props.name)) {
mountedNodeRenderers.set(props.name, tiptapNodeRenderer);
}
})
onUnmounted(() => {
if (mountedNodeRenderers.has(props.name)) {
mountedNodeRenderers.delete(props.name)
}
})
onBeforeUpdate(() => {
vueRendererRefs.value = new Map();
});
const addVueRenderer = (vueRenderer: VueRenderer) => {
vueRenderers.value.push(vueRenderer);
}
const deleteVueRenderer = (vueRenderer: VueRenderer) => {
const index = vueRenderers.value.indexOf(vueRenderer);
if (index > -1) {
vueRenderers.value.splice(index, 1);
}
}
return {
vueRenderers,
vueRendererRefs,
addVueRenderer,
deleteVueRenderer,
}
},
render() {
const components = [];
for (const vueRenderer of this.vueRenderers) {
components.push(h(
Teleport,
{ to: vueRenderer.teleportElement },
h(
vueRenderer.component as DefineComponent,
{
ref: (el: any) => { this.vueRendererRefs.set(vueRenderer, el) },
...vueRenderer.props,
...this.componentProps,
},
)
))
}
return components
},
}) |
And updated VueNodeViewRenderer with unwrapped component and no wrapper on the contentDOM. So completely clean dom tree! You mark which element is the contentDOM with a Obviously needs lots of testing but at least so far this works. import {
Editor,
Node,
NodeViewRenderer,
NodeViewRendererProps,
} from '@tiptap/core'
import { Decoration, NodeView } from "prosemirror-view"
import { NodeSelection } from "prosemirror-state"
import { Node as ProseMirrorNode } from "prosemirror-model"
import { Component } from "vue"
import VueRenderer, { VueRendererOptions } from './VueRenderer'
export const defaultComponentProps = {
decorations: Array,
editor: Editor,
extension: Object,
getPos: Function,
node: Object,
selected: Boolean,
updateAttributes: Function,
contentAs: String,
}
export interface VueNodeViewRendererOptions {
hasContent: boolean | null;
nodeRendererName?: string;
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[]
getPos!: any
isDragging = false
#contentDOM!: Element
options: VueNodeViewRendererOptions = {
hasContent: false,
stopEvent: null,
update: null,
}
constructor(component: Component, props: NodeViewRendererProps, options?: Partial<VueNodeViewRendererOptions>) {
this.options = { ...this.options, ...options }
this.editor = props.editor
this.extension = props.extension
this.node = props.node
this.getPos = props.getPos
this.mount(component)
}
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)
}
mount(component: Component) {
const { handleDragStart } = this
const dragstart = handleDragStart.bind(this)
const props = {
editor: this.editor,
node: this.node,
decorations: this.decorations,
selected: false,
extension: this.extension,
getPos: () => this.getPos(),
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
}
const rendererOptions: VueRendererOptions = {
props: {
...props,
}
}
if (this.options.nodeRendererName) {
rendererOptions.nodeRendererName = this.options.nodeRendererName
}
this.renderer = new VueRenderer(component, rendererOptions)
this.renderer.element.addEventListener('dragstart', (ev) => dragstart(ev as DragEvent));
(this.renderer.element as HTMLElement).style.whiteSpace = 'normal';
if (this.options.hasContent) {
const el = this.dom.querySelector('[data-NodeViewContent]');
if (!el) {
throw Error('No slot for node new content found.')
}
this.#contentDOM = el;
}
}
get dom() {
return this.renderer.element
}
get contentDOM() {
return this.#contentDOM
}
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 isDraggable = this.node.type.spec.draggable
const isCopyEvent = event.type === "copy"
const isPasteEvent = event.type === "paste"
const isCutEvent = event.type === "cut"
const isDragEvent = event.type.startsWith("drag") || event.type === "drop"
if (isDraggable && isDragEvent && !this.isDragging) {
event.preventDefault()
return false
}
// we have to store that dragging started
if (isDraggable && isEditable && !this.isDragging && event.type === "mousedown") {
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 (this.isDragging || isCopyEvent || isPasteEvent || isCutEvent) {
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 default function VueNodeViewRenderer(component: any, options?: Partial<VueNodeViewRendererOptions>): NodeViewRenderer {
return (props: NodeViewRendererProps) => {
return new VueNodeView(component, props, options) as NodeView
}
} |
Great news! I think another reason for a <div data-NodeViewContent :class="someClassThatWillChangeLater" /> I need to test that again. And something like that definitely doesn't work (already had this as a bug report for tiptap 1) <component :is="dynamicComponent" data-NodeViewContent /> |
Ah, I see. The main reason I avoided adding NodeViewWrapper and (tried to avoid) NodeViewContent was that I couldn't find a way to inject them in the same way you did with the Vue2 VueNodeView. I think they will have to be imported in the component file you are using. EDIT: |
For reference this is the |
It works! 🎉 I have a working draft with the exact same API (still needs some refactoring).
No need for a // Editor.ts
import { Editor as CoreEditor, EditorOptions } from '@tiptap/core'
import { markRaw, Ref, customRef, 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()
})
})
}
}
})
}
declare module '@tiptap/core' {
interface Editor {
vueRenderers: Map<string, VueRenderer>
addVueRenderer(vueRenderer: VueRenderer): void,
removeVueRenderer(id: string): void,
}
}
export class Editor extends CoreEditor {
private reactiveState: Ref<EditorState>
public vueRenderers = reactive<Map<string, VueRenderer>>(new Map())
constructor(options: Partial<EditorOptions> = {}) {
super(options)
this.reactiveState = useDebouncedRef(this.view.state)
this.on('transaction', () => {
this.reactiveState.value = this.view.state
})
return markRaw(this)
}
addVueRenderer(vueRenderer: VueRenderer) {
this.vueRenderers.set(vueRenderer.id, vueRenderer)
}
removeVueRenderer(id: string) {
this.vueRenderers.delete(id)
}
get state() {
return this.reactiveState
? this.reactiveState.value
: this.view.state
}
} // EditorContent.ts
import {
h,
ref,
Ref,
unref,
Teleport,
defineComponent,
DefineComponent,
watchEffect,
nextTick,
onBeforeUnmount,
} from 'vue'
import { Editor } from './Editor'
export const EditorContent = defineComponent({
name: 'EditorContent',
props: {
editor: {
default: null,
type: Editor,
}
},
setup(props) {
const rootEl: Ref<Element | undefined> = ref()
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)
editor.setOptions({
element: el,
})
editor.createNodeViews();
})
}
})
onBeforeUnmount(() => {
const editor = props.editor
// destroy nodeviews before vue removes dom element
if (editor.view?.docView) {
editor.view.setProps({
nodeViews: {},
})
}
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 => {
vueRenderers.push(h(
Teleport,
{ to: vueRenderer.teleportElement },
h(vueRenderer.component as DefineComponent, {
ref: vueRenderer.id,
...vueRenderer.props,
})
))
})
}
return h(
'div',
{
ref: (el: any) => { this.rootEl = el },
},
...vueRenderers,
)
},
}) // VueRenderer.ts
import { ref, markRaw, Component } from 'vue'
import { Editor } from '@tiptap/core'
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.addVueRenderer(this)
if (this.editorContent){
this.editorContent.update()
this.element = this.teleportElement.firstElementChild
}
}
get editorContent() {
// @ts-ignore
// eslint-disable-next-line
return this.editor.view.dom?.parentElement?.__vueParentComponent
}
get ref() {
return this.editorContent.ctx.$refs[this.id]
}
updateProps(props: { [key: string]: any } = {}) {
this.props.value = props
}
destroy() {
this.editor.removeVueRenderer(this.id)
}
} // VueNodeViewRenderer
import {
Editor,
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 { 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
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 Component = defineComponent({
extends: { ...component },
props: Object.keys(props),
components: {
NodeViewWrapper,
NodeViewContent,
}
})
this.renderer = new VueRenderer(Component, {
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 {}
}
return new VueNodeView(component, props, options) as NodeView
}
} |
Brilliant! I will test it out tomorrow. We are nearly there! Mostly as a note to self, I think And I originally had the |
Ah good catch! (https://github.com/vuejs/vue-next/blob/d0ea74556f74d8c503ffb7b70f41cbe2ce14db98/packages/runtime-core/src/renderer.ts#L774-L783)
Hmm, that makes sense. So maybe I'll leave it in the |
You both are amazing! 🙌 It’s such a joy to follow this conversation. |
Thanks Hans, as a solo developer in a small (family) business it's been an absolute joy to work collaboratively on something for a change, especially this year after not getting out much... |
Although I did at one point implement an alternative to __vueParentComponent I never posted it, I ended up doing this with a global map instead when I made the tiptapVueRenderer component: https://gist.github.com/samwillis/c8fa8327055d63f04f26b22006989acc#file-vuerenderer-ts-L5
I the trouble may be that if you load saved content into an editor that has custom vue node views before you mount it into a EditorContent it may throw an error (that's what I remember happening). And so the idea of the Also I think if you do this It will destroy the node views when you hide the EditorContent:
Will have a play with it later today. |
With that check no node views will be created until 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 {}
}
return new VueNodeView(component, props, options) as NodeView
}
}
jep you are right but that's why I added this to onBeforeUnmount(() => {
const editor = props.editor
// destroy nodeviews before vue removes dom element
if (editor.view?.docView) {
editor.view.setProps({
nodeViews: {},
})
}
// ...
}) Toggling But you are right: I have to find an alternative solution for |
Thinking about how to release this code. There are several options:
|
Agree on Vue-Demi, it will just create more work. I think the second option is probably the best. I’m guessing the plan is once gridsome has final vue3 support you will switch to it and maybe even create some vue3 based components? So vue2 is long term only going to have basic support? Maybe even explicit |
Okay are are not done 😬 I've added a simple nodeview for paragraphs:
An get this weird behavior: Bildschirmaufnahme.2021-02-26.um.13.49.13.mp4@samwillis I have no idea what is going on here. Do you see something like that in your implementation too? |
Ah, sorry! Got it! const node = h(
Teleport,
{
to: vueRenderer.teleportElement,
+ key: vueRenderer.id,
},
h(
vueRenderer.component as DefineComponent,
{
ref: vueRenderer.id,
...vueRenderer.props,
},
),
) |
Yes unfortunately Pretty sure its because of the unwrapping of the vue component, it looks like its the nodes that are created after the one you delete get re-rendered by vue and so destroyed. Maybe we need to try and separate the VueRenderer components in the rendered list of teleports so it doesn't try to re-renader the whole list? Maybe wrap each in a dom node (a div with display:none? they will have to appear in the dom) and hopefully that will stop vue rerendering the whole list of teleports? Screen.Recording.2021-02-26.at.13.06.47.mov |
Ah! perfect |
I also solved replacing So I think I can try to publish the first vue 3 version this evening 🎉 |
I have just published I will deprecate the packages For all further problems with the Vue 2 or 3 implementation, you can open new issues. Especially with the implementation of |
This is such an awesome thread! Thank you for working so hard on this implementation! |
I understand Vue 3 support is on the roadmap and you are waiting for Gridsome to support it in order to move the documentation to Vue 3: #735 (comment)
I'm using TipTap in a Vue 3 (Ionic) app and so intend to at least port EditorContent but probably also VueRenderer to Vue 3. Has any work been done on this? I seems wasteful to replicate something that may have already been done. Happy to contribute my changes if not.
One thing that needs to be considered is if TipTap 2 will support both Vue 2 and 3. It seems that most libraries are adding Vue 3 support by having a 'next' branch (this could be tiptap/packages/vue3 rather than a branch) although there is at least one attempt at creating a tool to enable you to have a single codebase that supports both 2 and 3 (https://github.com/antfu/vue-demi).
The text was updated successfully, but these errors were encountered: