Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-nodeview-position-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tiptap/react': patch
'@tiptap/vue-3': patch
'@tiptap/vue-2': patch
---

Fix NodeView not re-rendering when a node's position changes without content or decoration changes (e.g. when a sibling node is moved within the same parent)
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { NodeViewWrapper } from '@tiptap/react'

export const RecommendationView = ({ node }) => {
export const RecommendationView = ({ node, getPos }) => {
return (
<NodeViewWrapper data-drag-handle>
<div className="title">Recommendation {node.attrs.id}</div>
<p>Test</p>
<small style={{ opacity: 0.5 }}>pos: {getPos()}</small>
</NodeViewWrapper>
)
}
13 changes: 7 additions & 6 deletions demos/src/Extensions/DragHandleWithNodeViews/React/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ export default () => {
extensions: [StarterKit, Recommendation],
content: `
<h1>
This is a very unique heading.
Drag to reorder — watch the position update instantly
</h1>
<p>
This is a unique paragraph. It’s so unique, it even has an ID attached to it.
</p>
<div class="node-recommendation" data-id="123"></div>
<p>
And this one, too.
Drag any recommendation block below to a new position. The <code>pos:</code> value should update immediately.
</p>
<div class="node-recommendation" data-id="1"></div>
<div class="node-recommendation" data-id="2"></div>
<div class="node-recommendation" data-id="3"></div>
<div class="node-recommendation" data-id="4"></div>
<div class="node-recommendation" data-id="5"></div>
`,
})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { mergeAttributes, Node } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'

import RecommendationView from './views/RecommendationView.vue'

export const Recommendation = Node.create({
name: 'recommendation',

group: 'block',

draggable: true,

addOptions() {
return {
HTMLAttributes: {
class: `node-${this.name}`,
},
}
},

addAttributes() {
return {
id: {
default: undefined,
parseHTML: element => element.getAttribute('data-id'),
renderHTML: attributes => ({
'data-id': attributes.id,
}),
},
}
},

parseHTML() {
return [
{
tag: `div.node-${this.name}`,
},
]
},

renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
},

addNodeView() {
return VueNodeViewRenderer(RecommendationView)
},
})

export default Recommendation
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<template>
<node-view-wrapper data-drag-handle class="node-recommendation">
<div class="title">Recommendation {{ node.attrs.id }}</div>
<p>Test</p>
<small style="opacity: 0.5">pos: {{ getPos() }}</small>
</node-view-wrapper>
</template>

<script>
import { nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3'

export default {
components: {
NodeViewWrapper,
},

props: nodeViewProps,
}
</script>
Empty file.
117 changes: 117 additions & 0 deletions demos/src/Extensions/DragHandleWithNodeViews/Vue/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<template>
<drag-handle v-if="editor" :editor="editor">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5" />
</svg>
</drag-handle>
<editor-content :editor="editor" />
</template>

<script>
import { DragHandle } from '@tiptap/extension-drag-handle-vue-3'
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'

import { Recommendation } from './extensions/recommendation/Recommendation.js'

export default {
components: {
EditorContent,
DragHandle,
},

data() {
return {
editor: null,
}
},

mounted() {
this.editor = new Editor({
extensions: [StarterKit, Recommendation],
content: `
<h1>
Drag to reorder — watch the position update instantly
</h1>
<p>
Drag any recommendation block below to a new position. The <code>pos:</code> value should update immediately.
</p>
<div class="node-recommendation" data-id="1"></div>
<div class="node-recommendation" data-id="2"></div>
<div class="node-recommendation" data-id="3"></div>
<div class="node-recommendation" data-id="4"></div>
<div class="node-recommendation" data-id="5"></div>
`,
})
},

beforeUnmount() {
this.editor?.destroy()
},
}
</script>

<style lang="scss">
.ProseMirror {
padding-inline: 4rem;

> * + * {
margin-top: 0.75em;
}

[data-id] {
border: 3px solid #0d0d0d;
border-radius: 0.5rem;
margin: 1rem 0;
position: relative;
margin-top: 1.5rem;
padding: 2rem 1rem 1rem;

&::before {
content: attr(data-id);
background-color: #0d0d0d;
font-size: 0.6rem;
letter-spacing: 1px;
font-weight: bold;
text-transform: uppercase;
color: #fff;
position: absolute;
top: 0;
padding: 0.25rem 0.75rem;
border-radius: 0 0 0.5rem 0.5rem;
}
}
}

.drag-handle {
align-items: center;
background: #f0f0f0;
border-radius: 0.25rem;
border: 1px solid rgba(0, 0, 0, 0.1);
cursor: grab;
display: flex;
height: 1.5rem;
justify-content: center;
width: 1.5rem;

svg {
width: 1.25rem;
height: 1.25rem;
}
}

.node-recommendation {
padding: 0.5rem;
border-radius: 0.5rem;
border: 0.15rem solid #000;

.title {
font-size: 0.875rem;
color: #777;
}

p {
margin: 0;
}
}
</style>
1 change: 1 addition & 0 deletions packages/core/src/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ export * as markdown from './markdown/index.js'
export * from './mergeAttributes.js'
export * from './mergeDeep.js'
export * from './minMax.js'
export * from './nodeViewPositionRegistry.js'
export * from './objectIncludes.js'
export * from './removeDuplicates.js'
70 changes: 70 additions & 0 deletions packages/core/src/utilities/nodeViewPositionRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Editor } from '../Editor.js'

/**
* Per-editor registry for centralized NodeView position-change checks.
* A single editor.on('update') listener + rAF is shared across all NodeViews
* for a given editor, keeping overhead bounded regardless of NodeView count.
*
* This is consumed by React, Vue 3, and Vue 2 NodeView renderers.
*/
interface PositionUpdateRegistry {
callbacks: Set<() => void>
rafId: number | null
handler: () => void
}

const positionUpdateRegistries = new WeakMap<Editor, PositionUpdateRegistry>()

/**
* Register a callback to be called (via a shared rAF) after every editor
* update transaction. If this is the first registration for the given editor,
* a new registry entry and a single `editor.on('update')` listener are created.
*/
export function schedulePositionCheck(editor: Editor, callback: () => void): void {
let registry = positionUpdateRegistries.get(editor)

if (!registry) {
const newRegistry: PositionUpdateRegistry = {
callbacks: new Set(),
rafId: null,
handler: () => {
if (newRegistry.rafId !== null) {
cancelAnimationFrame(newRegistry.rafId)
}
newRegistry.rafId = requestAnimationFrame(() => {
newRegistry.rafId = null
newRegistry.callbacks.forEach(cb => cb())
})
},
}

positionUpdateRegistries.set(editor, newRegistry)
editor.on('update', newRegistry.handler)
registry = newRegistry
}

registry.callbacks.add(callback)
}

/**
* Unregister a previously registered callback. When the last callback for an
* editor is removed, the shared listener and any pending rAF are also cleaned up.
*/
export function cancelPositionCheck(editor: Editor, callback: () => void): void {
const registry = positionUpdateRegistries.get(editor)

if (!registry) {
return
}

registry.callbacks.delete(callback)

if (registry.callbacks.size === 0) {
if (registry.rafId !== null) {
cancelAnimationFrame(registry.rafId)
}

editor.off('update', registry.handler)
positionUpdateRegistries.delete(editor)
}
}
Loading
Loading