Skip to content
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

Closed
samwillis opened this issue Jan 11, 2021 · 76 comments
Closed

Vue 3 Support #1166

samwillis opened this issue Jan 11, 2021 · 76 comments

Comments

@samwillis
Copy link

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).

@hanspagel
Copy link
Contributor

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. 😬

@samwillis
Copy link
Author

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 EditorContent (which is such a simple component) and have immediately run into a problem I'm struggling to understand. I have mocked up two functionally identical Code Sandboxes, one with Vue 2 + the official tiptap EditorContent and the other with Vue 3 and my quick port of it:

Vue 2:
https://codesandbox.io/s/tiptap-issue-template-forked-mv2uu

Vue 3:
https://codesandbox.io/s/tiptap-issue-template-forked-n4m5q

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 EditorContent, I think there may be a conflict between the core TipTap Editor and Vue 3.

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 editor.chain().toggleBold().focus().run() command is being proxied twice I think.

TipTap.Vue3.Range.Error.mov

@samwillis
Copy link
Author

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...

https://codesandbox.io/s/tiptap-issue-template-forked-lgczd

@hanspagel
Copy link
Contributor

@holtwick Did you port the <editor-content /> to Vue 3 already? Would you mind to chime in here? Would be highly appreciated.

@samwillis
Copy link
Author

A little more experimentation, using editor.commands.toggleBold() rather than editor.chain().toggleBold().focus().run() works the same in both vue2 and 3! So there is a problem with command chains when used with vue 3.

One think to note, when you call editor.commands.toggleBold() the selection changes and jumps in both Vue2 and 3 the same. Which I'm not sure if it's supposed to do.

Vue2:
https://codesandbox.io/s/tiptap-issue-template-forked-p5u7p

Vue3:
https://codesandbox.io/s/tiptap-issue-template-forked-ksj45

@holtwick
Copy link

Sure, here is my code:
tiptap.zip

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,
    }
  },
})

@holtwick
Copy link

holtwick commented Jan 12, 2021

@samwillis your solution looks slick. Nice!

I had a similar problem with selections, and it turned out I had editor being reactive. If you just use it plain as in my example, the problems should be gone.

@samwillis
Copy link
Author

samwillis commented Jan 12, 2021

Thats brilliant! Thanks @holtwick, that's saved me so much time.

Nice to see your version of EditorContent is identical to mine, I was on the right track.

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?
editor.isActive('bold')
editor.can().toggleBold()

It pretty essential to take advantage of Vue when building toolbars and controls.

@samwillis
Copy link
Author

samwillis commented Jan 12, 2021

Right, I have a slightly crude implementation that enables you to do this within a Vue 3 template to have reactive isActive() and can() calls:

  <button
    @click="editor.chain().toggleBold().focus().run()"
    :class="{
      'active': editor.reactive.isActive('bold')
    }"
    :disabled="!editor.reactive.can().toggleBold()"
  >B</button>

Using this vue enabled editor subclass:

import { Editor as CoreEditor, EditorOptions } from '@tiptap/core';
import { shallowReactive } from 'vue';

export class Editor extends CoreEditor {
  public reactive: any;

  constructor(options: Partial<EditorOptions> = {}) {
    super(options);
    
    this.reactive = shallowReactive({
      isActive: (...args: any) => ( (this.isActive as any)(...args) ),
      can: (...args: any) => ( (this.can as any)(...args) ),
    });
    this.on('transaction', () => {
      this.reactive.isActive = (...args: any) => ( (this.isActive as any)(...args) );
      this.reactive.can = (...args: any) => ( (this.can as any)(...args) );
    });  
  }

}

@holtwick
Copy link

holtwick commented Jan 12, 2021

Thats brilliant! Thanks @holtwick, that's saved me so much time.

You're welcome, I also spend a lot of time on it ;)

I can confirm that editor.isActive('bold') isn't working in my code as well.

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.

@holtwick
Copy link

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?

tiptap.zip

@samwillis
Copy link
Author

samwillis commented Jan 12, 2021

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:
Actually, not sure isEmpty should be reactive as its quite a heavy function:
https://github.com/ueberdosis/tiptap-next/blob/8ee0a03666cff5805be492095948b23f8bf6e6a1/packages/core/src/Editor.ts#L391-L396

@samwillis
Copy link
Author

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;
    });  

  }

}

@philippkuehn
Copy link
Contributor

philippkuehn commented Jan 13, 2021

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.

@samwillis
Copy link
Author

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.

@samwillis
Copy link
Author

samwillis commented Feb 6, 2021

So, I have made good progress on a VueRenderer implementation for Vue3. It uses the new Teleport component in Vue3, so the components rendered by VueRenderer are direct children of the EditorContent component and teleported to the location you want them. I have it working for a custom Hashtag/Suggest extension (happy to contribute that to the project too). I haven't taken a look at making VueNodeViewRenderer compatible yet, that's next...

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)
  }
}

@philippkuehn
Copy link
Contributor

@samwillis wow this is really helpful! thank you! 🙌

@hanspagel
Copy link
Contributor

This will help a lot of people! I added it to the documentation for now: https://next.tiptap.dev/guide/getting-started/vue#introduction

👏

@samwillis
Copy link
Author

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 editor.options.element?

https://github.com/ueberdosis/tiptap-next/blob/13ad3acf632cc25a4f0c801804188b4c20db1f9f/packages/vue/src/components/EditorContent.ts#L19

I had that to change it to to just editor.options.element to ensure that I can traverse the DOM to get the EditorContent from an editor instance (using firstChild breaks that link). In my code it let me do this:

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:

https://github.com/ueberdosis/tiptap-next/blob/13ad3acf632cc25a4f0c801804188b4c20db1f9f/packages/vue/src/components/EditorContent.ts#L31-L35

(I'm going to refactor the watch and beforeUnmount sections into the Vue3 composition api setup() function.)

One final thing, I considered an alternative method for getting the EditorContent; subclassing Editor (which I have already done for Vue3 reactivity) so that it natively keeps track of if it's mounted in an EditorContent (rather than traversing the dom). However I found it doesn't seem possible to add methods to an Editor subclass, I think this is because the constructor is returning a Proxy. Not a problem, as we have a working solution but worth noting that it doesn't work (and I think you said before that the Proxy wasn't necessarily needed anymore).

@philippkuehn
Copy link
Contributor

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 editor.options.element?

The reason is because EditorContent currently produces markup like this:

<div>
  <div class="ProseMirror"></div>
</div>

With just using editor.options.element it will add an additional element :(

<div>
  <div>
    <div class="ProseMirror"></div>
  </div>
</div>

Also what is the perpose of this, I want to make sure my changes haven't impacted your intention here:

The current EditorContent supports conditional rendering without re-creating the whole editor:

<editor-content v-if="someCondition" :editor="editor" />

One final thing, I considered an alternative method for getting the EditorContent; subclassing Editor (which I have already done for Vue3 reactivity) so that it natively keeps track of if it's mounted in an EditorContent (rather than traversing the dom). However I found it doesn't seem possible to add methods to an Editor subclass, I think this is because the constructor is returning a Proxy. Not a problem, as we have a working solution but worth noting that it doesn't work (and I think you said before that the Proxy wasn't necessarily needed anymore).

Yes, the proxy is only left because some time ago we allowed to register commands inside an extension and use them directly from the editor: So editor.setBold() instead of editor.commands.setBold(). This was only possible via a proxy (I think). We also planned to register helper functions. But probably we should then use editor.helpers.isWhatever() instead of editor.isWhatever() to avoid the proxy.

@samwillis
Copy link
Author

Ah, got it, so the beforeDestroy() is effectively undoing this line:

this.$el.appendChild(editor.options.element.firstChild) 

so that when vue destroys the editor-content the editor gets its reference to the prosemirror element back as, while it is mounted in an editor-content, editor.element is not linked to it. So with me not doing that I probably don't need the beforeDestroy (I noticed the extra <div> but obviously need that for it to work, will have a think if there is another way to stop that).

However is that not slightly unexpected behaviour for the user, they would expect to be able to get the prosemirror element from editor.element.firstChild while it is mounted on the page?

Also is there possibly a memory leak in the current version, you are not exactly revising the original operation in the beforeDestroy(). If vue does not remove all event listeners or other references from the editor-content root element they will be held onto as you detach the root element from the dom and put it in the editor. Should it not be:

beforeDestroy() { 
  this.editor.element.appendChild(this.$el.firstChild)
}, 

@samwillis
Copy link
Author

Just a quick update on my work on a VueNodeViewRenderer, it turns out that as my VueRenderer required the editor to be mounted in an editor-content component it can't currently be used for NodeViews as the are created before the editor is mounted (it works for the Suggestion extension as they are not drawn until the editor is mounted and has focus).

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 vue.createApp. I think I prefer the former as it ensures that your components are all part of your app. (one of the big changes in Vue3 is there is now the concept of an App, you can't just mount a component anywhere without it being a in an app or an app itself)

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.

@oodavid
Copy link

oodavid commented Feb 10, 2021

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)

@samwillis
Copy link
Author

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.

@samwillis
Copy link
Author

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:

  1. Vue3 components can have multiple top level nodes (unlike Vue2 where each component must have a single top level node). This means they changed the rendering from Component -> Creates Element, to Component -> Renders into other element.

  2. There is (as far as I can see) no way to synchronously render a component, you have to wait for nextTick() to have access to the rendered DOM.

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 tip-tap-node-renderer high up, somewhere it is always mounted, in your Vue app. This component renders your components and uses the new Vue 3 'Teleport' component (https://v3.vuejs.org/guide/teleport.html) to render the dom into a custom location, ensuring the components are part of your app.
(you can optionally give it a name if you, for some reason, you have multiple vue apps on your page you can then provide the name to a VueRenderer or VueNodeViewRenderer)

<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 VueRenderer, for example with the Suggestion plugin, similar to: https://next.tiptap.dev/examples/community/

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 VueNodeViewRenderer with a Node:

  // 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.

@philippkuehn
Copy link
Contributor

I started to play around with Vue 3 today. Here is my implementation for a wrapper around Editor for now:

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?

@samwillis
Copy link
Author

samwillis commented Feb 22, 2021 via email

@philippkuehn
Copy link
Contributor

philippkuehn commented Feb 23, 2021

Hmm… with longer texts it is definitely slower than with Vue 2. But your version is also slow :(

@philippkuehn
Copy link
Contributor

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.mp4
import { 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
  }
}

@samwillis
Copy link
Author

How long is the text when you see the slow down? I haven’t seen that but then haven’t had massively long text.

@philippkuehn
Copy link
Contributor

philippkuehn commented Feb 25, 2021

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?

@samwillis
Copy link
Author

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 node-view-wrapper component which contains just a top level slot and no other elements (just checked and that works), and map the props from it back to the VueRenderer's root element. That way it feels to the developer like the existing api.

The slightly more complicated one is for the contentDOM in a VueNodeViewRenderer as we have to create the DOM element for prosemirror before we have a rendered component we have to place that el in the rendered component on the "nextTick". In my current implementation we have the same problem of the node-view-content creating an extra wrapping element. My thinking was that we could mark the location in the DOM using a temp "place holder" element from the node-view-content component, then place the contentDOM element after it (rather than inside it) on the nextTick, and then finally on another nextTick remove the temporary placeholder element and use its props to map to the contentDOM element attributes. I'm not sure how nicely this will play with Vue though, will need testing...

@philippkuehn
Copy link
Contributor

philippkuehn commented Feb 25, 2021

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 and paragraph. With Vue 2 this rendered markup is possible:

<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 ul and li. It's not possible to render allowed markup because li has to be a direct child of ul.

Some background information about why I'm using <node-view-wrapper> and <node-view-content> at all:

In tiptap 1 there was only the top element inside a node view (dom) and ref="content" (contentDOM). The problem with that was that we can't use complex node views with nested components within a single node view:

<div>
  <custom-component /> // contentDOM should be anywhere inside. not possible in tiptap 1
</div>

In tiptap 2 NodeViewContent is an individual component for each node view (with its own id that can be later found by document.querySelector(id)) that can be placed anywhere.

<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.

@samwillis
Copy link
Author

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 this.nodeRenderer.update() we are calling the update method on my TipTapNodeRenderer component instance. This seems to imediatly update the dom (without waiting for nextTick) and therefore make the rendered component dom available!

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)
  }
}

@philippkuehn
Copy link
Contributor

Oh that sounds great! I'm going to try this later today.

@samwillis
Copy link
Author

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
  },
})

@samwillis
Copy link
Author

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 data-NodeViewContent attribute, we may not need for a NodeViewContent component.

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
  }
}

@philippkuehn
Copy link
Contributor

philippkuehn commented Feb 25, 2021

Great news!

I think another reason for a <NodeViewContent> was that we are not allowed to mutate the contentDOM element. I'm not sure but I think something like this caused problems with ProseMirror:

<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 />

@samwillis
Copy link
Author

samwillis commented Feb 25, 2021

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:
It may be possible to use the provide/inject api to inject the two components but it won't really me any different from just importing them anyway: https://v3.vuejs.org/guide/component-provide-inject.html#working-with-reactivity

@samwillis
Copy link
Author

For reference this is the update method we are calling on the component interface, I haven't found any documentation for it yet but I think we are using it correctly.

https://github.com/vuejs/vue-next/blob/354966204e1116bd805d65a643109b13bca18185/packages/runtime-core/src/component.ts#L215-L218

@philippkuehn
Copy link
Contributor

philippkuehn commented Feb 25, 2021

It works! 🎉

I have a working draft with the exact same API (still needs some refactoring).

  • Editor.ts
  • EditorContent.ts
  • VueRenderer.ts
  • VueNodeViewRenderer.ts

No need for a TipTapNodeRenderer (it's part of EditorContent)!
I think I should move the vueRenderers code from Editor.ts to EditorContent.ts too.

// 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
  }
}

@samwillis
Copy link
Author

Brilliant! I will test it out tomorrow. We are nearly there!

Mostly as a note to self, I think __vueParentComponent may only be available when Vue is in dev mode for the dev tools. I think I added my own attribute to track it instead, need to check.

And I originally had the tiptapvuerenderer functionality in the editorContent and I had a problem when the editor wasn’t mounted but needed to create Dom elements (and so I split it out)... I will check what that was.

@philippkuehn
Copy link
Contributor

philippkuehn commented Feb 25, 2021

Mostly as a note to self, I think __vueParentComponent may only be available when Vue is in dev mode for the dev tools. I think I added my own attribute to track it instead, need to check.

Ah good catch! (https://github.com/vuejs/vue-next/blob/d0ea74556f74d8c503ffb7b70f41cbe2ce14db98/packages/runtime-core/src/renderer.ts#L774-L783)

And I originally had the tiptapvuerenderer functionality in the editorContent and I had a problem when the editor wasn’t mounted but needed to create Dom elements (and so I split it out)... I will check what that was.

Hmm, that makes sense. So maybe I'll leave it in the Editor.

@hanspagel
Copy link
Contributor

You both are amazing! 🙌 It’s such a joy to follow this conversation.

@samwillis
Copy link
Author

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...

@samwillis
Copy link
Author

Mostly as a note to self, I think __vueParentComponent may only be available when Vue is in dev mode for the dev tools. I think I added my own attribute to track it instead, need to check.

Ah good catch! (https://github.com/vuejs/vue-next/blob/d0ea74556f74d8c503ffb7b70f41cbe2ce14db98/packages/runtime-core/src/renderer.ts#L774-L783)

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

And I originally had the tiptapvuerenderer functionality in the EditorContent and I had a problem when the editor wasn’t mounted but needed to create Dom elements (and so I split it out)... I will check what that was.

Hmm, that makes sense. So maybe I'll leave it in the Editor.

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 tiptapVueRenderer component was that it could be mounted all the time (for example at the very top of your app) and always be available to render node views even if the editor isn't visible and there is no EditorContent mounted. I may have been wrong though.

Also I think if you do this It will destroy the node views when you hide the EditorContent:

<editor-content v-if="showEditor" :editor="editor"></editor-content>

Will have a play with it later today.

@philippkuehn
Copy link
Contributor

philippkuehn commented Feb 26, 2021

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 tiptapVueRenderer component was that it could be mounted all the time (for example at the very top of your app) and always be available to render node views even if the editor isn't visible and there is no EditorContent mounted. I may have been wrong though.

With that check no node views will be created until EditorContent is mounted

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
  }
}

Also I think if you do this It will destroy the node views when you hide the EditorContent:

jep you are right but that's why I added this to EditorContent to fix it:

onBeforeUnmount(() => {
  const editor = props.editor

  // destroy nodeviews before vue removes dom element
  if (editor.view?.docView) {
    editor.view.setProps({
      nodeViews: {},
    })
  }

  // ...
})

Toggling EditorContent works as expected with my version.

But you are right: I have to find an alternative solution for __vueParentComponent.

@philippkuehn
Copy link
Contributor

Thinking about how to release this code. There are several options:

  • release @tiptap/vue with vue-demi but I think I'm not a fan of it
  • release @tiptap/vue and @tiptap/vue-2 or @tiptap/vue-legacy
  • release @tiptap/vue (vue 2) and @tiptap/vue@next (vue 3) but I think that is a bit annoying to switch between branches to release both versions

@samwillis
Copy link
Author

samwillis commented Feb 26, 2021

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 @tiptap/vue-2 and @tiptap/vue-3?

@philippkuehn
Copy link
Contributor

Okay are are not done 😬

I've added a simple nodeview for paragraphs:

<template>
  <node-view-wrapper as="p">
    <node-view-content />
  </node-view-wrapper>
</template>

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?

@philippkuehn
Copy link
Contributor

Ah, sorry! Got it!

 const node = h(
   Teleport,
   {
     to: vueRenderer.teleportElement, 
+    key: vueRenderer.id,
   },
   h(
     vueRenderer.component as DefineComponent,
     {
       ref: vueRenderer.id,
       ...vueRenderer.props,
     },
   ),
 )

@samwillis
Copy link
Author

Yes unfortunately ☹️, see video, I have two editors (sharing my tiptapnoderenderer), the grey boxes are node views.

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

@samwillis
Copy link
Author

Ah! perfect

@philippkuehn
Copy link
Contributor

I also solved replacing __vueParentComponent. I'm using some internal vue APIs here which is maybe not ideal but it works!

So I think I can try to publish the first vue 3 version this evening 🎉

@philippkuehn
Copy link
Contributor

I have just published @tiptap/vue-2 and @tiptap/vue-3. This will finally close this issue. Thanks a lot for the help!

I will deprecate the packages @tiptap/vue and @tiptap/vue-starter-kit. Docs will be updated in the next few days.

For all further problems with the Vue 2 or 3 implementation, you can open new issues. Especially with the implementation of <node-view-wrapper /> and <node-view-content /> I am not satisfied yet. I think there will be changes to this.

@enjibby
Copy link

enjibby commented Mar 1, 2021

This is such an awesome thread! Thank you for working so hard on this implementation!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants