Remove tippy.js and replace with Floating UI (#5398)

* start experimenting with floating-ui

* add options to floating-ui bubble menu plugin & fix smaller issues

* add vue support for new floating-ui

* start experimenting with floating-ui

* adjust floating-menu plugin for floating-ui & update react component

* add vue support for floating-menu with floating-ui

* update tests for new floating-ui integration

* added changeset file

* move floating-ui dependency to peerDeps

* add install notice to changelog

* remove unnecessary code for destroying and removing component element in Vue suggestion.js

* remove unnecessary code for destroying and removing component element in React suggestion.js

* sync package-lock

* widen range for peerDeps again
This commit is contained in:
bdbch 2024-07-31 03:44:28 +02:00 committed by Dominik Biedebach
parent 0da439007a
commit 7eaa34d0d1
34 changed files with 2494 additions and 4476 deletions

View File

@ -0,0 +1,36 @@
---
"@tiptap/extension-floating-menu": major
"@tiptap/extension-bubble-menu": major
"@tiptap/extension-mention": major
"@tiptap/suggestion": major
"@tiptap/react": major
"@tiptap/vue-2": major
"@tiptap/vue-3": major
---
Removed tippy.js and replaced it with [Floating UI](https://floating-ui.com/) - a newer, more lightweight and customizable floating element library.
This change is breaking existing menu implementations and will require a manual migration.
**Affected packages:**
- `@tiptap/extension-floating-menu`
- `@tiptap/extension-bubble-menu`
- `@tiptap/extension-mention`
- `@tiptap/suggestion`
- `@tiptap/react`
- `@tiptap/vue-2`
- `@tiptap/vue-3`
Make sure to remove `tippyOptions` from the `FloatingMenu` and `BubbleMenu` components, and replace them with the new `options` object. Check our documentation to see how to migrate your existing menu implementations.
- [FloatingMenu](https://tiptap.dev/docs/editor/extensions/functionality/floatingmenu)
- [BubbleMenu](https://tiptap.dev/docs/editor/extensions/functionality/bubble-menu)
You'll also need to install `@floating-ui/dom` as a peer dependency to your project like this:
```bash
npm install @floating-ui/dom@^1.6.0
```
The new `options` object is compatible with all components that use these extensions.

View File

@ -23,7 +23,7 @@ react-dom
react-dom/client react-dom/client
shiki shiki
simplify-js simplify-js
tippy.js @floating-ui/dom
uuid uuid
y-webrtc y-webrtc
yjs yjs

View File

@ -20,10 +20,10 @@ context('/src/Examples/Community/React/', () => {
cy.get('.tiptap').type('{selectall}{backspace}@') cy.get('.tiptap').type('{selectall}{backspace}@')
// check if the mention autocomplete is visible // check if the mention autocomplete is visible
cy.get('.tippy-content .dropdown-menu').should('be.visible') cy.get('.dropdown-menu').should('be.visible')
// select the first user // select the first user
cy.get('.tippy-content .dropdown-menu button').first().then($el => { cy.get('.dropdown-menu button').first().then($el => {
const name = $el.text() const name = $el.text()
$el.click() $el.click()

View File

@ -1,8 +1,29 @@
import { ReactRenderer } from '@tiptap/react' import {
import tippy from 'tippy.js' computePosition,
flip,
shift,
} from '@floating-ui/dom'
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
import { MentionList } from './MentionList.jsx' import { MentionList } from './MentionList.jsx'
const updatePosition = (editor, element) => {
const virtualElement = {
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
}
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'absolute',
middleware: [shift(), flip()],
}).then(({ x, y, strategy }) => {
element.style.width = 'max-content'
element.style.position = strategy
element.style.left = `${x}px`
element.style.top = `${y}px`
})
}
export default { export default {
items: ({ query }) => { items: ({ query }) => {
return [ return [
@ -12,7 +33,6 @@ export default {
render: () => { render: () => {
let reactRenderer let reactRenderer
let popup
return { return {
onStart: props => { onStart: props => {
@ -26,15 +46,11 @@ export default {
editor: props.editor, editor: props.editor,
}) })
popup = tippy('body', { reactRenderer.element.style.position = 'absolute'
getReferenceClientRect: props.clientRect,
appendTo: () => document.body, document.body.appendChild(reactRenderer.element)
content: reactRenderer.element,
showOnCreate: true, updatePosition(props.editor, reactRenderer.element)
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
}, },
onUpdate(props) { onUpdate(props) {
@ -43,15 +59,13 @@ export default {
if (!props.clientRect) { if (!props.clientRect) {
return return
} }
updatePosition(props.editor, reactRenderer.element)
popup[0].setProps({
getReferenceClientRect: props.clientRect,
})
}, },
onKeyDown(props) { onKeyDown(props) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
popup[0].hide() reactRenderer.destroy()
reactRenderer.element.remove()
return true return true
} }
@ -60,8 +74,8 @@ export default {
}, },
onExit() { onExit() {
popup[0].destroy()
reactRenderer.destroy() reactRenderer.destroy()
reactRenderer.element.remove()
}, },
} }
}, },

View File

@ -20,10 +20,10 @@ context('/src/Examples/Community/Vue/', () => {
cy.get('.tiptap').type('{selectall}{backspace}@') cy.get('.tiptap').type('{selectall}{backspace}@')
// check if the mention autocomplete is visible // check if the mention autocomplete is visible
cy.get('.tippy-content .dropdown-menu').should('be.visible') cy.get('.dropdown-menu').should('be.visible')
// select the first user // select the first user
cy.get('.tippy-content .dropdown-menu button').first().then($el => { cy.get('.dropdown-menu button').first().then($el => {
const name = $el.text() const name = $el.text()
$el.click() $el.click()

View File

@ -1,8 +1,29 @@
import { VueRenderer } from '@tiptap/vue-3' import {
import tippy from 'tippy.js' computePosition,
flip,
shift,
} from '@floating-ui/dom'
import { posToDOMRect, VueRenderer } from '@tiptap/vue-3'
import MentionList from './MentionList.vue' import MentionList from './MentionList.vue'
const updatePosition = (editor, element) => {
const virtualElement = {
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
}
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'absolute',
middleware: [shift(), flip()],
}).then(({ x, y, strategy }) => {
element.style.width = 'max-content'
element.style.position = strategy
element.style.left = `${x}px`
element.style.top = `${y}px`
})
}
export default { export default {
items: ({ query }) => { items: ({ query }) => {
return [ return [
@ -12,7 +33,6 @@ export default {
render: () => { render: () => {
let component let component
let popup
return { return {
onStart: props => { onStart: props => {
@ -28,15 +48,11 @@ export default {
return return
} }
popup = tippy('body', { component.element.style.position = 'absolute'
getReferenceClientRect: props.clientRect,
appendTo: () => document.body, document.body.appendChild(component.element)
content: component.element,
showOnCreate: true, updatePosition(props.editor, component.element)
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
}, },
onUpdate(props) { onUpdate(props) {
@ -46,14 +62,13 @@ export default {
return return
} }
popup[0].setProps({ updatePosition(props.editor, component.element)
getReferenceClientRect: props.clientRect,
})
}, },
onKeyDown(props) { onKeyDown(props) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
popup[0].hide() component.destroy()
component.element.remove()
return true return true
} }
@ -62,8 +77,8 @@ export default {
}, },
onExit() { onExit() {
popup[0].destroy()
component.destroy() component.destroy()
component.element.remove()
}, },
} }
}, },

View File

@ -26,7 +26,7 @@ export default () => {
return ( return (
<> <>
{editor && <BubbleMenu className="bubble-menu" tippyOptions={{ duration: 100 }} editor={editor}> {editor && <BubbleMenu className="bubble-menu" editor={editor}>
<button <button
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''} className={editor.isActive('bold') ? 'is-active' : ''}
@ -47,7 +47,7 @@ export default () => {
</button> </button>
</BubbleMenu>} </BubbleMenu>}
{editor && <FloatingMenu className="floating-menu" tippyOptions={{ duration: 100 }} editor={editor}> {editor && <FloatingMenu className="floating-menu" editor={editor}>
<button <button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''} className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}

View File

@ -10,8 +10,8 @@ context('/src/Examples/Menus/React/', () => {
}) })
it('should show menu when the editor is empty', () => { it('should show menu when the editor is empty', () => {
cy.get('#app') cy.get('body')
.find('[data-tippy-root]') .find('.floating-menu')
}) })
it('should show menu when text is selected', () => { it('should show menu when text is selected', () => {
@ -19,8 +19,8 @@ context('/src/Examples/Menus/React/', () => {
.type('Test') .type('Test')
.type('{selectall}') .type('{selectall}')
cy.get('#app') cy.get('body')
.find('[data-tippy-root]') .find('.bubble-menu')
}) })
const marks = [ const marks = [
@ -44,8 +44,8 @@ context('/src/Examples/Menus/React/', () => {
.type('Test') .type('Test')
.type('{selectall}') .type('{selectall}')
cy.get('#app') cy.get('body')
.find('[data-tippy-root]') .find('.bubble-menu')
.contains(mark.button) .contains(mark.button)
.click() .click()

View File

@ -10,8 +10,8 @@ context('/src/Examples/Menus/Vue/', () => {
}) })
it('should show menu when the editor is empty', () => { it('should show menu when the editor is empty', () => {
cy.get('#app') cy.get('body')
.find('[data-tippy-root]') .find('.floating-menu')
}) })
it('should show menu when text is selected', () => { it('should show menu when text is selected', () => {
@ -19,8 +19,8 @@ context('/src/Examples/Menus/Vue/', () => {
.type('Test') .type('Test')
.type('{selectall}') .type('{selectall}')
cy.get('#app') cy.get('body')
.find('[data-tippy-root]') .find('.bubble-menu')
}) })
const marks = [ const marks = [
@ -44,8 +44,8 @@ context('/src/Examples/Menus/Vue/', () => {
.type('Test') .type('Test')
.type('{selectall}') .type('{selectall}')
cy.get('#app') cy.get('body')
.find('[data-tippy-root]') .find('.bubble-menu')
.contains(mark.button) .contains(mark.button)
.click() .click()

View File

@ -1,35 +1,35 @@
<template> <template>
<div v-if="editor"> <div v-if="editor">
<bubble-menu <bubble-menu
class="bubble-menu"
:tippy-options="{ duration: 100 }"
:editor="editor" :editor="editor"
> >
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }"> <div class="bubble-menu">
Bold <button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }">
</button> Bold
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }"> </button>
Italic <button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }">
</button> Italic
<button @click="editor.chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }"> </button>
Strike <button @click="editor.chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }">
</button> Strike
</button>
</div>
</bubble-menu> </bubble-menu>
<floating-menu <floating-menu
class="floating-menu"
:tippy-options="{ duration: 100 }"
:editor="editor" :editor="editor"
> >
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"> <div class="floating-menu">
H1 <button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }">
</button> H1
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"> </button>
H2 <button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }">
</button> H2
<button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }"> </button>
Bullet list <button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }">
</button> Bullet list
</button>
</div>
</floating-menu> </floating-menu>
</div> </div>

View File

@ -67,7 +67,7 @@ function EditorInstance({ shouldOptimizeRendering }) {
<div>Number of renders: <span id="render-count">{countRenderRef.current}</span></div> <div>Number of renders: <span id="render-count">{countRenderRef.current}</span></div>
</div> </div>
{currentEditorState && ( {currentEditorState && (
<BubbleMenu className="bubble-menu" tippyOptions={{ duration: 100 }} editor={editor}> <BubbleMenu className="bubble-menu" editor={editor}>
<button <button
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
className={currentEditorState.isBold ? 'is-active' : ''} className={currentEditorState.isBold ? 'is-active' : ''}

View File

@ -19,8 +19,8 @@ context('/src/Experiments/Commands/Vue/', () => {
items.forEach((item, i) => { items.forEach((item, i) => {
cy.get('.tiptap').type('{selectall}{backspace}/') cy.get('.tiptap').type('{selectall}{backspace}/')
cy.get('.tippy-content .dropdown-menu').should('exist') cy.get('.dropdown-menu').should('exist')
cy.get('.tippy-content .dropdown-menu button').eq(i).click() cy.get('.dropdown-menu button').eq(i).click()
cy.get('.tiptap').type(`I am a ${item.tag}`) cy.get('.tiptap').type(`I am a ${item.tag}`)
cy.get(`.tiptap ${item.tag}`).should('exist').should('have.text', `I am a ${item.tag}`) cy.get(`.tiptap ${item.tag}`).should('exist').should('have.text', `I am a ${item.tag}`)
}) })
@ -28,17 +28,17 @@ context('/src/Experiments/Commands/Vue/', () => {
it('should close the popup without any command via esc', () => { it('should close the popup without any command via esc', () => {
cy.get('.tiptap').type('{selectall}{backspace}/') cy.get('.tiptap').type('{selectall}{backspace}/')
cy.get('.tippy-content .dropdown-menu').should('exist') cy.get('.dropdown-menu').should('exist')
cy.get('.tiptap').type('{esc}') cy.get('.tiptap').type('{esc}')
cy.get('.tippy-content .dropdown-menu').should('not.exist') cy.get('.dropdown-menu').should('not.exist')
}) })
it('should open the popup when the cursor is after a slash', () => { it('should open the popup when the cursor is after a slash', () => {
cy.get('.tiptap').type('{selectall}{backspace}/') cy.get('.tiptap').type('{selectall}{backspace}/')
cy.get('.tippy-content .dropdown-menu').should('exist') cy.get('.dropdown-menu').should('exist')
cy.get('.tiptap').type('{leftArrow}') cy.get('.tiptap').type('{leftArrow}')
cy.get('.tippy-content .dropdown-menu').should('not.exist') cy.get('.dropdown-menu').should('not.exist')
cy.get('.tiptap').type('{rightArrow}') cy.get('.tiptap').type('{rightArrow}')
cy.get('.tippy-content .dropdown-menu').should('exist') cy.get('.dropdown-menu').should('exist')
}) })
}) })

View File

@ -1,8 +1,29 @@
import { VueRenderer } from '@tiptap/vue-3' import {
import tippy from 'tippy.js' computePosition,
flip,
shift,
} from '@floating-ui/dom'
import { posToDOMRect, VueRenderer } from '@tiptap/vue-3'
import CommandsList from './CommandsList.vue' import CommandsList from './CommandsList.vue'
const updatePosition = (editor, element) => {
const virtualElement = {
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
}
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'absolute',
middleware: [shift(), flip()],
}).then(({ x, y, strategy }) => {
element.style.width = 'max-content'
element.style.position = strategy
element.style.left = `${x}px`
element.style.top = `${y}px`
})
}
export default { export default {
items: ({ query }) => { items: ({ query }) => {
return [ return [
@ -55,7 +76,6 @@ export default {
render: () => { render: () => {
let component let component
let popup
return { return {
onStart: props => { onStart: props => {
@ -71,15 +91,11 @@ export default {
return return
} }
popup = tippy('body', { component.element.style.position = 'absolute'
getReferenceClientRect: props.clientRect,
appendTo: () => document.body, document.body.appendChild(component.element)
content: component.element,
showOnCreate: true, updatePosition(props.editor, component.element)
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
}, },
onUpdate(props) { onUpdate(props) {
@ -89,14 +105,13 @@ export default {
return return
} }
popup[0].setProps({ updatePosition(props.editor, component.element)
getReferenceClientRect: props.clientRect,
})
}, },
onKeyDown(props) { onKeyDown(props) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
popup[0].hide() component.destroy()
component.element.remove()
return true return true
} }
@ -105,8 +120,8 @@ export default {
}, },
onExit() { onExit() {
popup[0].destroy()
component.destroy() component.destroy()
component.element.remove()
}, },
} }
}, },

View File

@ -18,6 +18,7 @@ export default () => {
`, `,
}) })
const [showMenu, setShowMenu] = React.useState(true)
const [isEditable, setIsEditable] = React.useState(true) const [isEditable, setIsEditable] = React.useState(true)
useEffect(() => { useEffect(() => {
@ -28,7 +29,10 @@ export default () => {
return ( return (
<> <>
<button onClick={() => {
setShowMenu(old => !old)
editor.commands.focus()
} }>Toggle menu</button>
<div className="control-group"> <div className="control-group">
<label> <label>
<input type="checkbox" checked={isEditable} onChange={() => setIsEditable(!isEditable)} /> <input type="checkbox" checked={isEditable} onChange={() => setIsEditable(!isEditable)} />
@ -36,7 +40,7 @@ export default () => {
</label> </label>
</div> </div>
{editor && <BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}> {(editor && showMenu) && <BubbleMenu editor={editor} options={{ placement: 'bottom', offset: 8 }}>
<div className="bubble-menu"> <div className="bubble-menu">
<button <button
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}

View File

@ -6,11 +6,7 @@
Editable Editable
</label> </label>
</div> </div>
<bubble-menu <bubble-menu :editor="editor" v-if="editor">
:editor="editor"
:tippy-options="{ duration: 100 }"
v-if="editor"
>
<div class="bubble-menu"> <div class="bubble-menu">
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }"> <button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }">
Bold Bold

View File

@ -33,7 +33,7 @@ export default () => {
Editable Editable
</label> </label>
</div> </div>
{editor && <FloatingMenu editor={editor} tippyOptions={{ duration: 100 }}> {editor && <FloatingMenu editor={editor}>
<div className="floating-menu"> <div className="floating-menu">
<button <button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}

View File

@ -6,7 +6,7 @@
Editable Editable
</label> </label>
</div> </div>
<floating-menu :editor="editor" :tippy-options="{ duration: 100 }" v-if="editor"> <floating-menu :editor="editor" v-if="editor">
<div class="floating-menu"> <div class="floating-menu">
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"> <button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }">
H1 H1

View File

@ -1,8 +1,29 @@
import { ReactRenderer } from '@tiptap/react' import {
import tippy from 'tippy.js' computePosition,
flip,
shift,
} from '@floating-ui/dom'
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
import MentionList from './MentionList.jsx' import MentionList from './MentionList.jsx'
const updatePosition = (editor, element) => {
const virtualElement = {
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
}
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'absolute',
middleware: [shift(), flip()],
}).then(({ x, y, strategy }) => {
element.style.width = 'max-content'
element.style.position = strategy
element.style.left = `${x}px`
element.style.top = `${y}px`
})
}
export default { export default {
items: ({ query }) => { items: ({ query }) => {
return [ return [
@ -38,7 +59,6 @@ export default {
render: () => { render: () => {
let component let component
let popup
return { return {
onStart: props => { onStart: props => {
@ -51,15 +71,11 @@ export default {
return return
} }
popup = tippy('body', { component.element.style.position = 'absolute'
getReferenceClientRect: props.clientRect,
appendTo: () => document.body, document.body.appendChild(component.element)
content: component.element,
showOnCreate: true, updatePosition(props.editor, component.element)
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
}, },
onUpdate(props) { onUpdate(props) {
@ -69,14 +85,12 @@ export default {
return return
} }
popup[0].setProps({ updatePosition(props.editor, component.element)
getReferenceClientRect: props.clientRect,
})
}, },
onKeyDown(props) { onKeyDown(props) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
popup[0].hide() component.destroy()
return true return true
} }
@ -85,7 +99,6 @@ export default {
}, },
onExit() { onExit() {
popup[0].destroy()
component.destroy() component.destroy()
}, },
} }

View File

@ -1,8 +1,29 @@
import { VueRenderer } from '@tiptap/vue-3' import {
import tippy from 'tippy.js' computePosition,
flip,
shift,
} from '@floating-ui/dom'
import { posToDOMRect, VueRenderer } from '@tiptap/vue-3'
import MentionList from './MentionList.vue' import MentionList from './MentionList.vue'
const updatePosition = (editor, element) => {
const virtualElement = {
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
}
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'absolute',
middleware: [shift(), flip()],
}).then(({ x, y, strategy }) => {
element.style.width = 'max-content'
element.style.position = strategy
element.style.left = `${x}px`
element.style.top = `${y}px`
})
}
export default { export default {
items: ({ query }) => { items: ({ query }) => {
return [ return [
@ -12,7 +33,6 @@ export default {
render: () => { render: () => {
let component let component
let popup
return { return {
onStart: props => { onStart: props => {
@ -29,15 +49,11 @@ export default {
return return
} }
popup = tippy('body', { component.element.style.position = 'absolute'
getReferenceClientRect: props.clientRect,
appendTo: () => document.body, document.body.appendChild(component.element)
content: component.element,
showOnCreate: true, updatePosition(props.editor, component.element)
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
}, },
onUpdate(props) { onUpdate(props) {
@ -47,14 +63,12 @@ export default {
return return
} }
popup[0].setProps({ updatePosition(props.editor, component.element)
getReferenceClientRect: props.clientRect,
})
}, },
onKeyDown(props) { onKeyDown(props) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
popup[0].hide() component.destroy()
return true return true
} }
@ -63,7 +77,6 @@ export default {
}, },
onExit() { onExit() {
popup[0].destroy()
component.destroy() component.destroy()
}, },
} }

5874
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -68,8 +68,4 @@ img.ProseMirror-separator {
.ProseMirror-focused .ProseMirror-gapcursor { .ProseMirror-focused .ProseMirror-gapcursor {
display: block; display: block;
}
.tippy-box[data-animation=fade][data-state=hidden] {
opacity: 0
}` }`

View File

@ -28,9 +28,6 @@
"src", "src",
"dist" "dist"
], ],
"dependencies": {
"tippy.js": "^6.3.7"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ueberdosis/tiptap", "url": "https://github.com/ueberdosis/tiptap",
@ -38,12 +35,14 @@
}, },
"sideEffects": false, "sideEffects": false,
"devDependencies": { "devDependencies": {
"@tiptap/core": "^2.5.6", "@floating-ui/dom": "^1.0.0",
"@tiptap/pm": "^2.5.6" "@tiptap/core": "^2.5.7",
"@tiptap/pm": "^2.5.7"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.5.6", "@floating-ui/dom": "^1.0.0",
"@tiptap/pm": "^2.5.6" "@tiptap/core": "^2.5.7",
"@tiptap/pm": "^2.5.7"
}, },
"scripts": { "scripts": {
"clean": "rm -rf dist", "clean": "rm -rf dist",

View File

@ -1,9 +1,17 @@
import { import {
Editor, isNodeSelection, isTextSelection, posToDOMRect, type ArrowOptions,
type AutoPlacementOptions,
type FlipOptions,
type HideOptions,
type InlineOptions,
type Middleware, type OffsetOptions, type Placement, type ShiftOptions, type SizeOptions, type Strategy, arrow, autoPlacement, computePosition, flip, hide, inline, offset, shift,
size,
} from '@floating-ui/dom'
import {
Editor, isTextSelection, posToDOMRect,
} from '@tiptap/core' } from '@tiptap/core'
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state' import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
import { EditorView } from '@tiptap/pm/view' import { EditorView } from '@tiptap/pm/view'
import tippy, { Instance, Props } from 'tippy.js'
export interface BubbleMenuPluginProps { export interface BubbleMenuPluginProps {
/** /**
@ -25,12 +33,6 @@ export interface BubbleMenuPluginProps {
*/ */
element: HTMLElement element: HTMLElement
/**
* The options for the tippy.js instance.
* @see https://atomiks.github.io/tippyjs/v6/all-props/
*/
tippyOptions?: Partial<Props>
/** /**
* The delay in milliseconds before the menu should be updated. * The delay in milliseconds before the menu should be updated.
* This can be useful to prevent performance issues. * This can be useful to prevent performance issues.
@ -39,11 +41,19 @@ export interface BubbleMenuPluginProps {
*/ */
updateDelay?: number updateDelay?: number
/**
* The delay in milliseconds before the menu position should be updated on window resize.
* This can be useful to prevent performance issues.
* @type {number}
* @default 60
*/
resizeDelay?: number
/** /**
* A function that determines whether the menu should be shown or not. * A function that determines whether the menu should be shown or not.
* If this function returns `false`, the menu will be hidden, otherwise it will be shown. * If this function returns `false`, the menu will be hidden, otherwise it will be shown.
*/ */
shouldShow?: shouldShow:
| ((props: { | ((props: {
editor: Editor editor: Editor
view: EditorView view: EditorView
@ -53,6 +63,22 @@ export interface BubbleMenuPluginProps {
to: number to: number
}) => boolean) }) => boolean)
| null | null
/**
* FloatingUI options.
*/
options?: {
strategy?: Strategy
placement?: Placement
offset?: OffsetOptions | boolean
flip?: FlipOptions | boolean
shift?: ShiftOptions | boolean
arrow?: ArrowOptions | false
size?: SizeOptions | boolean
autoPlacement?: AutoPlacementOptions | boolean
hide?: HideOptions | boolean
inline?: InlineOptions | boolean
}
} }
export type BubbleMenuViewProps = BubbleMenuPluginProps & { export type BubbleMenuViewProps = BubbleMenuPluginProps & {
@ -68,14 +94,38 @@ export class BubbleMenuView {
public preventHide = false public preventHide = false
public tippy: Instance | undefined
public tippyOptions?: Partial<Props>
public updateDelay: number public updateDelay: number
public resizeDelay: number
private updateDebounceTimer: number | undefined private updateDebounceTimer: number | undefined
private resizeDebounceTimer: number | undefined
private floatingUIOptions: {
strategy: Strategy
placement: Placement
offset: OffsetOptions | boolean
flip: FlipOptions | boolean
shift: ShiftOptions | boolean
arrow: ArrowOptions | false
size: SizeOptions | boolean
autoPlacement: AutoPlacementOptions | boolean
hide: HideOptions | boolean
inline: InlineOptions | boolean
} = {
strategy: 'absolute',
placement: 'top',
offset: 8,
flip: {},
shift: {},
arrow: false,
size: false,
autoPlacement: false,
hide: false,
inline: false,
}
public shouldShow: Exclude<BubbleMenuPluginProps['shouldShow'], null> = ({ public shouldShow: Exclude<BubbleMenuPluginProps['shouldShow'], null> = ({
view, view,
state, state,
@ -104,18 +154,63 @@ export class BubbleMenuView {
return true return true
} }
get middlewares() {
const middlewares: Middleware[] = []
if (this.floatingUIOptions.flip) {
middlewares.push(flip(typeof this.floatingUIOptions.flip !== 'boolean' ? this.floatingUIOptions.flip : undefined))
}
if (this.floatingUIOptions.shift) {
middlewares.push(shift(typeof this.floatingUIOptions.shift !== 'boolean' ? this.floatingUIOptions.shift : undefined))
}
if (this.floatingUIOptions.offset) {
middlewares.push(offset(typeof this.floatingUIOptions.offset !== 'boolean' ? this.floatingUIOptions.offset : undefined))
}
if (this.floatingUIOptions.arrow) {
middlewares.push(arrow(this.floatingUIOptions.arrow))
}
if (this.floatingUIOptions.size) {
middlewares.push(size(typeof this.floatingUIOptions.size !== 'boolean' ? this.floatingUIOptions.size : undefined))
}
if (this.floatingUIOptions.autoPlacement) {
middlewares.push(autoPlacement(typeof this.floatingUIOptions.autoPlacement !== 'boolean' ? this.floatingUIOptions.autoPlacement : undefined))
}
if (this.floatingUIOptions.hide) {
middlewares.push(hide(typeof this.floatingUIOptions.hide !== 'boolean' ? this.floatingUIOptions.hide : undefined))
}
if (this.floatingUIOptions.inline) {
middlewares.push(inline(typeof this.floatingUIOptions.inline !== 'boolean' ? this.floatingUIOptions.inline : undefined))
}
return middlewares
}
constructor({ constructor({
editor, editor,
element, element,
view, view,
tippyOptions = {},
updateDelay = 250, updateDelay = 250,
resizeDelay = 60,
shouldShow, shouldShow,
options,
}: BubbleMenuViewProps) { }: BubbleMenuViewProps) {
this.editor = editor this.editor = editor
this.element = element this.element = element
this.view = view this.view = view
this.updateDelay = updateDelay this.updateDelay = updateDelay
this.resizeDelay = resizeDelay
this.floatingUIOptions = {
...this.floatingUIOptions,
...options,
}
if (shouldShow) { if (shouldShow) {
this.shouldShow = shouldShow this.shouldShow = shouldShow
@ -125,10 +220,21 @@ export class BubbleMenuView {
this.view.dom.addEventListener('dragstart', this.dragstartHandler) this.view.dom.addEventListener('dragstart', this.dragstartHandler)
this.editor.on('focus', this.focusHandler) this.editor.on('focus', this.focusHandler)
this.editor.on('blur', this.blurHandler) this.editor.on('blur', this.blurHandler)
this.tippyOptions = tippyOptions window.addEventListener('resize', () => {
// Detaches menu content from its current parent if (this.resizeDebounceTimer) {
this.element.remove() clearTimeout(this.resizeDebounceTimer)
this.element.style.visibility = 'visible' }
this.resizeDebounceTimer = window.setTimeout(() => {
this.updatePosition()
}, this.resizeDelay)
})
this.update(view, view.state)
if (this.getShouldShow()) {
this.show()
}
} }
mousedownHandler = () => { mousedownHandler = () => {
@ -158,33 +264,19 @@ export class BubbleMenuView {
this.hide() this.hide()
} }
tippyBlurHandler = (event: FocusEvent) => { updatePosition() {
this.blurHandler({ event }) const { selection } = this.editor.state
}
createTooltip() { const virtualElement = {
const { element: editorElement } = this.editor.options getBoundingClientRect: () => posToDOMRect(this.view, selection.from, selection.to),
const editorIsAttached = !!editorElement.parentElement
if (this.tippy || !editorIsAttached) {
return
} }
this.tippy = tippy(editorElement, { computePosition(virtualElement, this.element, { placement: this.floatingUIOptions.placement, strategy: this.floatingUIOptions.strategy, middleware: this.middlewares }).then(({ x, y, strategy }) => {
duration: 0, this.element.style.width = 'max-content'
getReferenceClientRect: null, this.element.style.position = strategy
content: this.element, this.element.style.left = `${x}px`
interactive: true, this.element.style.top = `${y}px`
trigger: 'manual',
placement: 'top',
hideOnClick: 'toggle',
...this.tippyOptions,
}) })
// maybe we have to hide tippy on its own blur event as well
if (this.tippy.popper.firstChild) {
(this.tippy.popper.firstChild as HTMLElement).addEventListener('blur', this.tippyBlurHandler)
}
} }
update(view: EditorView, oldState?: EditorState) { update(view: EditorView, oldState?: EditorState) {
@ -219,31 +311,36 @@ export class BubbleMenuView {
}, this.updateDelay) }, this.updateDelay)
} }
updateHandler = (view: EditorView, selectionChanged: boolean, docChanged: boolean, oldState?: EditorState) => { getShouldShow(oldState?: EditorState) {
const { state, composing } = view const { state } = this.view
const { selection } = state const { selection } = state
const { ranges } = selection
const from = Math.min(...ranges.map(range => range.$from.pos))
const to = Math.max(...ranges.map(range => range.$to.pos))
const shouldShow = this.shouldShow?.({
editor: this.editor,
view: this.view,
state,
oldState,
from,
to,
})
return shouldShow
}
updateHandler = (view: EditorView, selectionChanged: boolean, docChanged: boolean, oldState?: EditorState) => {
const { composing } = view
const isSame = !selectionChanged && !docChanged const isSame = !selectionChanged && !docChanged
if (composing || isSame) { if (composing || isSame) {
return return
} }
this.createTooltip() const shouldShow = this.getShouldShow(oldState)
// support for CellSelections
const { ranges } = selection
const from = Math.min(...ranges.map(range => range.$from.pos))
const to = Math.max(...ranges.map(range => range.$to.pos))
const shouldShow = this.shouldShow?.({
editor: this.editor,
view,
state,
oldState,
from,
to,
})
if (!shouldShow) { if (!shouldShow) {
this.hide() this.hide()
@ -251,47 +348,26 @@ export class BubbleMenuView {
return return
} }
this.tippy?.setProps({ this.updatePosition()
getReferenceClientRect:
this.tippyOptions?.getReferenceClientRect
|| (() => {
if (isNodeSelection(state.selection)) {
let node = view.nodeDOM(from) as HTMLElement
const nodeViewWrapper = node.dataset.nodeViewWrapper ? node : node.querySelector('[data-node-view-wrapper]')
if (nodeViewWrapper) {
node = nodeViewWrapper.firstChild as HTMLElement
}
if (node) {
return node.getBoundingClientRect()
}
}
return posToDOMRect(view, from, to)
}),
})
this.show() this.show()
} }
show() { show() {
this.tippy?.show() this.element.style.visibility = 'visible'
this.element.style.opacity = '1'
// attach from body
document.body.appendChild(this.element)
} }
hide() { hide() {
this.tippy?.hide() this.element.style.visibility = 'hidden'
this.element.style.opacity = '0'
// remove from body
this.element.remove()
} }
destroy() { destroy() {
if (this.tippy?.popper.firstChild) { this.hide()
(this.tippy.popper.firstChild as HTMLElement).removeEventListener(
'blur',
this.tippyBlurHandler,
)
}
this.tippy?.destroy()
this.element.removeEventListener('mousedown', this.mousedownHandler, { capture: true }) this.element.removeEventListener('mousedown', this.mousedownHandler, { capture: true })
this.view.dom.removeEventListener('dragstart', this.dragstartHandler) this.view.dom.removeEventListener('dragstart', this.dragstartHandler)
this.editor.off('focus', this.focusHandler) this.editor.off('focus', this.focusHandler)

View File

@ -21,7 +21,6 @@ export const BubbleMenu = Extension.create<BubbleMenuOptions>({
addOptions() { addOptions() {
return { return {
element: null, element: null,
tippyOptions: {},
pluginKey: 'bubbleMenu', pluginKey: 'bubbleMenu',
updateDelay: undefined, updateDelay: undefined,
shouldShow: null, shouldShow: null,
@ -38,7 +37,6 @@ export const BubbleMenu = Extension.create<BubbleMenuOptions>({
pluginKey: this.options.pluginKey, pluginKey: this.options.pluginKey,
editor: this.editor, editor: this.editor,
element: this.options.element, element: this.options.element,
tippyOptions: this.options.tippyOptions,
updateDelay: this.options.updateDelay, updateDelay: this.options.updateDelay,
shouldShow: this.options.shouldShow, shouldShow: this.options.shouldShow,
}), }),

View File

@ -29,15 +29,14 @@
"dist" "dist"
], ],
"devDependencies": { "devDependencies": {
"@tiptap/core": "^2.5.6", "@floating-ui/dom": "^1.0.0",
"@tiptap/pm": "^2.5.6" "@tiptap/core": "^2.5.7",
"@tiptap/pm": "^2.5.7"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.5.6", "@floating-ui/dom": "^1.0.0",
"@tiptap/pm": "^2.5.6" "@tiptap/core": "^2.5.7",
}, "@tiptap/pm": "^2.5.7"
"dependencies": {
"tippy.js": "^6.3.7"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -1,7 +1,15 @@
import {
type ArrowOptions,
type AutoPlacementOptions,
type FlipOptions,
type HideOptions,
type InlineOptions,
type Middleware, type OffsetOptions, type Placement, type ShiftOptions, type SizeOptions, type Strategy, arrow, autoPlacement, computePosition, flip, hide, inline, offset, shift,
size,
} from '@floating-ui/dom'
import { Editor, posToDOMRect } from '@tiptap/core' import { Editor, posToDOMRect } from '@tiptap/core'
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state' import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
import { EditorView } from '@tiptap/pm/view' import { EditorView } from '@tiptap/pm/view'
import tippy, { Instance, Props } from 'tippy.js'
export interface FloatingMenuPluginProps { export interface FloatingMenuPluginProps {
/** /**
@ -22,26 +30,36 @@ export interface FloatingMenuPluginProps {
*/ */
element: HTMLElement element: HTMLElement
/**
* The options for the tippy instance.
* @default {}
* @see https://atomiks.github.io/tippyjs/v6/all-props/
*/
tippyOptions?: Partial<Props>
/** /**
* A function that determines whether the menu should be shown or not. * A function that determines whether the menu should be shown or not.
* If this function returns `false`, the menu will be hidden, otherwise it will be shown. * If this function returns `false`, the menu will be hidden, otherwise it will be shown.
* @default null
*/ */
shouldShow?: shouldShow:
| ((props: { | ((props: {
editor: Editor editor: Editor
view: EditorView view: EditorView
state: EditorState state: EditorState
oldState?: EditorState oldState?: EditorState
from: number
to: number
}) => boolean) }) => boolean)
| null | null
/**
* FloatingUI options.
*/
options?: {
strategy?: Strategy
placement?: Placement
offset?: OffsetOptions | boolean
flip?: FlipOptions | boolean
shift?: ShiftOptions | boolean
arrow?: ArrowOptions | false
size?: SizeOptions | boolean
autoPlacement?: AutoPlacementOptions | boolean
hide?: HideOptions | boolean
inline?: InlineOptions | boolean
}
} }
export type FloatingMenuViewProps = FloatingMenuPluginProps & { export type FloatingMenuViewProps = FloatingMenuPluginProps & {
@ -60,10 +78,6 @@ export class FloatingMenuView {
public preventHide = false public preventHide = false
public tippy: Instance | undefined
public tippyOptions?: Partial<Props>
public shouldShow: Exclude<FloatingMenuPluginProps['shouldShow'], null> = ({ view, state }) => { public shouldShow: Exclude<FloatingMenuPluginProps['shouldShow'], null> = ({ view, state }) => {
const { selection } = state const { selection } = state
const { $anchor, empty } = selection const { $anchor, empty } = selection
@ -83,13 +97,80 @@ export class FloatingMenuView {
return true return true
} }
private floatingUIOptions: {
strategy: Strategy
placement: Placement
offset: OffsetOptions | boolean
flip: FlipOptions | boolean
shift: ShiftOptions | boolean
arrow: ArrowOptions | false
size: SizeOptions | boolean
autoPlacement: AutoPlacementOptions | boolean
hide: HideOptions | boolean
inline: InlineOptions | boolean
} = {
strategy: 'absolute',
placement: 'right',
offset: 8,
flip: {},
shift: {},
arrow: false,
size: false,
autoPlacement: false,
hide: false,
inline: false,
}
get middlewares() {
const middlewares: Middleware[] = []
if (this.floatingUIOptions.flip) {
middlewares.push(flip(typeof this.floatingUIOptions.flip !== 'boolean' ? this.floatingUIOptions.flip : undefined))
}
if (this.floatingUIOptions.shift) {
middlewares.push(shift(typeof this.floatingUIOptions.shift !== 'boolean' ? this.floatingUIOptions.shift : undefined))
}
if (this.floatingUIOptions.offset) {
middlewares.push(offset(typeof this.floatingUIOptions.offset !== 'boolean' ? this.floatingUIOptions.offset : undefined))
}
if (this.floatingUIOptions.arrow) {
middlewares.push(arrow(this.floatingUIOptions.arrow))
}
if (this.floatingUIOptions.size) {
middlewares.push(size(typeof this.floatingUIOptions.size !== 'boolean' ? this.floatingUIOptions.size : undefined))
}
if (this.floatingUIOptions.autoPlacement) {
middlewares.push(autoPlacement(typeof this.floatingUIOptions.autoPlacement !== 'boolean' ? this.floatingUIOptions.autoPlacement : undefined))
}
if (this.floatingUIOptions.hide) {
middlewares.push(hide(typeof this.floatingUIOptions.hide !== 'boolean' ? this.floatingUIOptions.hide : undefined))
}
if (this.floatingUIOptions.inline) {
middlewares.push(inline(typeof this.floatingUIOptions.inline !== 'boolean' ? this.floatingUIOptions.inline : undefined))
}
return middlewares
}
constructor({ constructor({
editor, element, view, tippyOptions = {}, shouldShow, editor, element, view, options, shouldShow,
}: FloatingMenuViewProps) { }: FloatingMenuViewProps) {
this.editor = editor this.editor = editor
this.element = element this.element = element
this.view = view this.view = view
this.floatingUIOptions = {
...this.floatingUIOptions,
...options,
}
if (shouldShow) { if (shouldShow) {
this.shouldShow = shouldShow this.shouldShow = shouldShow
} }
@ -97,10 +178,53 @@ export class FloatingMenuView {
this.element.addEventListener('mousedown', this.mousedownHandler, { capture: true }) this.element.addEventListener('mousedown', this.mousedownHandler, { capture: true })
this.editor.on('focus', this.focusHandler) this.editor.on('focus', this.focusHandler)
this.editor.on('blur', this.blurHandler) this.editor.on('blur', this.blurHandler)
this.tippyOptions = tippyOptions
// Detaches menu content from its current parent this.update(view, view.state)
this.element.remove()
this.element.style.visibility = 'visible' if (this.getShouldShow()) {
this.show()
}
}
getShouldShow(oldState?: EditorState) {
const { state } = this.view
const { selection } = state
const { ranges } = selection
const from = Math.min(...ranges.map(range => range.$from.pos))
const to = Math.max(...ranges.map(range => range.$to.pos))
const shouldShow = this.shouldShow?.({
editor: this.editor,
view: this.view,
state,
oldState,
from,
to,
})
return shouldShow
}
updateHandler = (view: EditorView, selectionChanged: boolean, docChanged: boolean, oldState?: EditorState) => {
const { composing } = view
const isSame = !selectionChanged && !docChanged
if (composing || isSame) {
return
}
const shouldShow = this.getShouldShow(oldState)
if (!shouldShow) {
this.hide()
return
}
this.updatePosition()
this.show()
} }
mousedownHandler = () => { mousedownHandler = () => {
@ -126,84 +250,44 @@ export class FloatingMenuView {
this.hide() this.hide()
} }
tippyBlurHandler = (event: FocusEvent) => { updatePosition() {
this.blurHandler({ event }) const { selection } = this.editor.state
}
createTooltip() { const virtualElement = {
const { element: editorElement } = this.editor.options getBoundingClientRect: () => posToDOMRect(this.view, selection.from, selection.to),
const editorIsAttached = !!editorElement.parentElement
if (this.tippy || !editorIsAttached) {
return
} }
this.tippy = tippy(editorElement, { computePosition(virtualElement, this.element, { placement: this.floatingUIOptions.placement, strategy: this.floatingUIOptions.strategy, middleware: this.middlewares }).then(({ x, y, strategy }) => {
duration: 0, this.element.style.width = 'max-content'
getReferenceClientRect: null, this.element.style.position = strategy
content: this.element, this.element.style.left = `${x}px`
interactive: true, this.element.style.top = `${y}px`
trigger: 'manual',
placement: 'right',
hideOnClick: 'toggle',
...this.tippyOptions,
}) })
// maybe we have to hide tippy on its own blur event as well
if (this.tippy.popper.firstChild) {
(this.tippy.popper.firstChild as HTMLElement).addEventListener('blur', this.tippyBlurHandler)
}
} }
update(view: EditorView, oldState?: EditorState) { update(view: EditorView, oldState?: EditorState) {
const { state } = view const selectionChanged = !oldState?.selection.eq(view.state.selection)
const { doc, selection } = state const docChanged = !oldState?.doc.eq(view.state.doc)
const { from, to } = selection
const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection)
if (isSame) { this.updateHandler(view, selectionChanged, docChanged, oldState)
return
}
this.createTooltip()
const shouldShow = this.shouldShow?.({
editor: this.editor,
view,
state,
oldState,
})
if (!shouldShow) {
this.hide()
return
}
this.tippy?.setProps({
getReferenceClientRect:
this.tippyOptions?.getReferenceClientRect || (() => posToDOMRect(view, from, to)),
})
this.show()
} }
show() { show() {
this.tippy?.show() this.element.style.visibility = 'visible'
this.element.style.opacity = '1'
// attach from body
document.body.appendChild(this.element)
} }
hide() { hide() {
this.tippy?.hide() this.element.style.visibility = 'hidden'
this.element.style.opacity = '0'
// remove from body
this.element.remove()
} }
destroy() { destroy() {
if (this.tippy?.popper.firstChild) { this.hide()
(this.tippy.popper.firstChild as HTMLElement).removeEventListener(
'blur',
this.tippyBlurHandler,
)
}
this.tippy?.destroy()
this.element.removeEventListener('mousedown', this.mousedownHandler, { capture: true }) this.element.removeEventListener('mousedown', this.mousedownHandler, { capture: true })
this.editor.off('focus', this.focusHandler) this.editor.off('focus', this.focusHandler)
this.editor.off('blur', this.blurHandler) this.editor.off('blur', this.blurHandler)

View File

@ -21,7 +21,7 @@ export const FloatingMenu = Extension.create<FloatingMenuOptions>({
addOptions() { addOptions() {
return { return {
element: null, element: null,
tippyOptions: {}, options: {},
pluginKey: 'floatingMenu', pluginKey: 'floatingMenu',
shouldShow: null, shouldShow: null,
} }
@ -37,7 +37,7 @@ export const FloatingMenu = Extension.create<FloatingMenuOptions>({
pluginKey: this.options.pluginKey, pluginKey: this.options.pluginKey,
editor: this.editor, editor: this.editor,
element: this.options.element, element: this.options.element,
tippyOptions: this.options.tippyOptions, options: this.options.options,
shouldShow: this.options.shouldShow, shouldShow: this.options.shouldShow,
}), }),
] ]

View File

@ -1,5 +1,6 @@
import { BubbleMenuPlugin, BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu' import { BubbleMenuPlugin, BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu'
import React, { useEffect, useState } from 'react' import React, { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useCurrentEditor } from './Context.js' import { useCurrentEditor } from './Context.js'
@ -10,23 +11,24 @@ export type BubbleMenuProps = Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>,
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
updateDelay?: number; updateDelay?: number;
resizeDelay?: number;
options?: BubbleMenuPluginProps['options'];
}; };
export const BubbleMenu = (props: BubbleMenuProps) => { export const BubbleMenu = (props: BubbleMenuProps) => {
const [element, setElement] = useState<HTMLDivElement | null>(null) const menuEl = useRef(document.createElement('div'))
const { editor: currentEditor } = useCurrentEditor() const { editor: currentEditor } = useCurrentEditor()
useEffect(() => { useEffect(() => {
if (!element) { menuEl.current.style.visibility = 'hidden'
return menuEl.current.style.position = 'absolute'
}
if (props.editor?.isDestroyed || currentEditor?.isDestroyed) { if (props.editor?.isDestroyed || currentEditor?.isDestroyed) {
return return
} }
const { const {
pluginKey = 'bubbleMenu', editor, tippyOptions = {}, updateDelay, shouldShow = null, pluginKey = 'bubbleMenu', editor, updateDelay, resizeDelay, shouldShow = null,
} = props } = props
const menuEditor = editor || currentEditor const menuEditor = editor || currentEditor
@ -38,20 +40,35 @@ export const BubbleMenu = (props: BubbleMenuProps) => {
const plugin = BubbleMenuPlugin({ const plugin = BubbleMenuPlugin({
updateDelay, updateDelay,
resizeDelay,
editor: menuEditor, editor: menuEditor,
element, element: menuEl.current,
pluginKey, pluginKey,
shouldShow, shouldShow,
tippyOptions, options: props.options,
}) })
menuEditor.registerPlugin(plugin) menuEditor.registerPlugin(plugin)
return () => menuEditor.unregisterPlugin(pluginKey)
}, [props.editor, currentEditor, element])
return ( return () => {
<div ref={setElement} className={props.className} style={{ visibility: 'hidden' }}> menuEditor.unregisterPlugin(pluginKey)
window.requestAnimationFrame(() => {
if (menuEl.current.parentNode) {
menuEl.current.parentNode.removeChild(menuEl.current)
}
})
}
}, [props.editor, currentEditor])
const portal = createPortal(
(
<div className={props.className}>
{props.children} {props.children}
</div> </div>
), menuEl.current,
)
return (
<>{portal}</>
) )
} }

View File

@ -1,7 +1,8 @@
import { FloatingMenuPlugin, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu' import { FloatingMenuPlugin, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
import React, { import React, {
useEffect, useState, useEffect, useRef,
} from 'react' } from 'react'
import { createPortal } from 'react-dom'
import { useCurrentEditor } from './Context.js' import { useCurrentEditor } from './Context.js'
@ -11,16 +12,16 @@ export type FloatingMenuProps = Omit<Optional<FloatingMenuPluginProps, 'pluginKe
editor: FloatingMenuPluginProps['editor'] | null; editor: FloatingMenuPluginProps['editor'] | null;
className?: string, className?: string,
children: React.ReactNode children: React.ReactNode
options?: FloatingMenuPluginProps['options']
} }
export const FloatingMenu = (props: FloatingMenuProps) => { export const FloatingMenu = (props: FloatingMenuProps) => {
const [element, setElement] = useState<HTMLDivElement | null>(null) const menuEl = useRef(document.createElement('div'))
const { editor: currentEditor } = useCurrentEditor() const { editor: currentEditor } = useCurrentEditor()
useEffect(() => { useEffect(() => {
if (!element) { menuEl.current.style.visibility = 'hidden'
return menuEl.current.style.position = 'absolute'
}
if (props.editor?.isDestroyed || currentEditor?.isDestroyed) { if (props.editor?.isDestroyed || currentEditor?.isDestroyed) {
return return
@ -29,7 +30,7 @@ export const FloatingMenu = (props: FloatingMenuProps) => {
const { const {
pluginKey = 'floatingMenu', pluginKey = 'floatingMenu',
editor, editor,
tippyOptions = {}, options,
shouldShow = null, shouldShow = null,
} = props } = props
@ -43,22 +44,34 @@ export const FloatingMenu = (props: FloatingMenuProps) => {
const plugin = FloatingMenuPlugin({ const plugin = FloatingMenuPlugin({
pluginKey, pluginKey,
editor: menuEditor, editor: menuEditor,
element, element: menuEl.current,
tippyOptions, options,
shouldShow, shouldShow,
}) })
menuEditor.registerPlugin(plugin) menuEditor.registerPlugin(plugin)
return () => menuEditor.unregisterPlugin(pluginKey) return () => {
menuEditor.unregisterPlugin(pluginKey)
window.requestAnimationFrame(() => {
if (menuEl.current.parentNode) {
menuEl.current.parentNode.removeChild(menuEl.current)
}
})
}
}, [ }, [
props.editor, props.editor,
currentEditor, currentEditor,
element,
]) ])
return ( const portal = createPortal(
<div ref={setElement} className={props.className} style={{ visibility: 'hidden' }}> (
<div className={props.className}>
{props.children} {props.children}
</div> </div>
), menuEl.current,
)
return (
<>{portal}</>
) )
} }

View File

@ -223,7 +223,7 @@ export function Suggestion<I = any, TSelected = any>({
}) })
}, },
decorationNode, decorationNode,
// virtual node for popper.js or tippy.js // virtual node for positioning
// this can be used for building popups without a DOM node // this can be used for building popups without a DOM node
clientRect: decorationNode clientRect: decorationNode
? () => { ? () => {

View File

@ -4,9 +4,10 @@ import Vue, { Component, CreateElement, PropType } from 'vue'
export interface BubbleMenuInterface extends Vue { export interface BubbleMenuInterface extends Vue {
pluginKey: BubbleMenuPluginProps['pluginKey'], pluginKey: BubbleMenuPluginProps['pluginKey'],
editor: BubbleMenuPluginProps['editor'], editor: BubbleMenuPluginProps['editor'],
tippyOptions: BubbleMenuPluginProps['tippyOptions'],
updateDelay: BubbleMenuPluginProps['updateDelay'], updateDelay: BubbleMenuPluginProps['updateDelay'],
resizeDelay: BubbleMenuPluginProps['resizeDelay'],
shouldShow: BubbleMenuPluginProps['shouldShow'], shouldShow: BubbleMenuPluginProps['shouldShow'],
options: BubbleMenuPluginProps['options'],
} }
export const BubbleMenu: Component = { export const BubbleMenu: Component = {
@ -27,9 +28,13 @@ export const BubbleMenu: Component = {
type: Number as PropType<BubbleMenuPluginProps['updateDelay']>, type: Number as PropType<BubbleMenuPluginProps['updateDelay']>,
}, },
tippyOptions: { options: {
type: Object as PropType<BubbleMenuPluginProps['tippyOptions']>, type: Object as PropType<BubbleMenuPluginProps['options']>,
default: () => ({}), default: {},
},
resizeDelay: {
type: Number as PropType<BubbleMenuPluginProps['resizeDelay']>,
}, },
shouldShow: { shouldShow: {
@ -46,14 +51,20 @@ export const BubbleMenu: Component = {
return return
} }
(this.$el as HTMLElement).style.visibility = 'hidden';
(this.$el as HTMLElement).style.position = 'absolute'
this.$el.remove()
this.$nextTick(() => { this.$nextTick(() => {
editor.registerPlugin(BubbleMenuPlugin({ editor.registerPlugin(BubbleMenuPlugin({
updateDelay: this.updateDelay, updateDelay: this.updateDelay,
resizeDelay: this.resizeDelay,
options: this.options,
editor, editor,
element: this.$el as HTMLElement, element: this.$el as HTMLElement,
pluginKey: this.pluginKey, pluginKey: this.pluginKey,
shouldShow: this.shouldShow, shouldShow: this.shouldShow,
tippyOptions: this.tippyOptions,
})) }))
}) })
}, },

View File

@ -3,7 +3,7 @@ import Vue, { Component, CreateElement, PropType } from 'vue'
export interface FloatingMenuInterface extends Vue { export interface FloatingMenuInterface extends Vue {
pluginKey: FloatingMenuPluginProps['pluginKey'], pluginKey: FloatingMenuPluginProps['pluginKey'],
tippyOptions: FloatingMenuPluginProps['tippyOptions'], options: FloatingMenuPluginProps['options'],
editor: FloatingMenuPluginProps['editor'], editor: FloatingMenuPluginProps['editor'],
shouldShow: FloatingMenuPluginProps['shouldShow'], shouldShow: FloatingMenuPluginProps['shouldShow'],
} }
@ -22,8 +22,8 @@ export const FloatingMenu: Component = {
required: true, required: true,
}, },
tippyOptions: { options: {
type: Object as PropType<FloatingMenuPluginProps['tippyOptions']>, type: Object as PropType<FloatingMenuPluginProps['options']>,
default: () => ({}), default: () => ({}),
}, },
@ -41,12 +41,17 @@ export const FloatingMenu: Component = {
return return
} }
(this.$el as HTMLElement).style.visibility = 'hidden';
(this.$el as HTMLElement).style.position = 'absolute'
this.$el.remove()
this.$nextTick(() => { this.$nextTick(() => {
editor.registerPlugin(FloatingMenuPlugin({ editor.registerPlugin(FloatingMenuPlugin({
pluginKey: this.pluginKey, pluginKey: this.pluginKey,
editor, editor,
element: this.$el as HTMLElement, element: this.$el as HTMLElement,
tippyOptions: this.tippyOptions, options: this.options,
shouldShow: this.shouldShow, shouldShow: this.shouldShow,
})) }))
}) })

View File

@ -6,6 +6,7 @@ import {
onMounted, onMounted,
PropType, PropType,
ref, ref,
Teleport,
} from 'vue' } from 'vue'
export const BubbleMenu = defineComponent({ export const BubbleMenu = defineComponent({
@ -27,8 +28,13 @@ export const BubbleMenu = defineComponent({
default: undefined, default: undefined,
}, },
tippyOptions: { resizeDelay: {
type: Object as PropType<BubbleMenuPluginProps['tippyOptions']>, type: Number as PropType<BubbleMenuPluginProps['resizeDelay']>,
default: undefined,
},
options: {
type: Object as PropType<BubbleMenuPluginProps['options']>,
default: () => ({}), default: () => ({}),
}, },
@ -43,20 +49,32 @@ export const BubbleMenu = defineComponent({
onMounted(() => { onMounted(() => {
const { const {
updateDelay,
editor, editor,
options,
pluginKey, pluginKey,
resizeDelay,
shouldShow, shouldShow,
tippyOptions, updateDelay,
} = props } = props
if (!root.value) {
return
}
root.value.style.visibility = 'hidden'
root.value.style.position = 'absolute'
// remove the element from the DOM
root.value.remove()
editor.registerPlugin(BubbleMenuPlugin({ editor.registerPlugin(BubbleMenuPlugin({
updateDelay,
editor, editor,
element: root.value as HTMLElement, element: root.value as HTMLElement,
options,
pluginKey, pluginKey,
resizeDelay,
shouldShow, shouldShow,
tippyOptions, updateDelay,
})) }))
}) })
@ -66,6 +84,6 @@ export const BubbleMenu = defineComponent({
editor.unregisterPlugin(pluginKey) editor.unregisterPlugin(pluginKey)
}) })
return () => h('div', { ref: root }, slots.default?.()) return () => h(Teleport, { to: 'body' }, h('div', { ref: root }, slots.default?.()))
}, },
}) })

View File

@ -1,3 +1,4 @@
import type { BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu'
import { FloatingMenuPlugin, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu' import { FloatingMenuPlugin, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
import { import {
defineComponent, defineComponent,
@ -6,6 +7,7 @@ import {
onMounted, onMounted,
PropType, PropType,
ref, ref,
Teleport,
} from 'vue' } from 'vue'
export const FloatingMenu = defineComponent({ export const FloatingMenu = defineComponent({
@ -24,8 +26,8 @@ export const FloatingMenu = defineComponent({
required: true, required: true,
}, },
tippyOptions: { options: {
type: Object as PropType<FloatingMenuPluginProps['tippyOptions']>, type: Object as PropType<BubbleMenuPluginProps['options']>,
default: () => ({}), default: () => ({}),
}, },
@ -42,15 +44,25 @@ export const FloatingMenu = defineComponent({
const { const {
pluginKey, pluginKey,
editor, editor,
tippyOptions, options,
shouldShow, shouldShow,
} = props } = props
if (!root.value) {
return
}
root.value.style.visibility = 'hidden'
root.value.style.position = 'absolute'
// remove the element from the DOM
root.value.remove()
editor.registerPlugin(FloatingMenuPlugin({ editor.registerPlugin(FloatingMenuPlugin({
pluginKey, pluginKey,
editor, editor,
element: root.value as HTMLElement, element: root.value as HTMLElement,
tippyOptions, options,
shouldShow, shouldShow,
})) }))
}) })
@ -61,6 +73,6 @@ export const FloatingMenu = defineComponent({
editor.unregisterPlugin(pluginKey) editor.unregisterPlugin(pluginKey)
}) })
return () => h('div', { ref: root }, slots.default?.()) return () => h(Teleport, { to: 'body' }, h('div', { ref: root }, slots.default?.()))
}, },
}) })