mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-07 01:12:56 +08:00
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:
parent
0da439007a
commit
7eaa34d0d1
36
.changeset/dirty-bats-look.md
Normal file
36
.changeset/dirty-bats-look.md
Normal 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.
|
@ -23,7 +23,7 @@ react-dom
|
||||
react-dom/client
|
||||
shiki
|
||||
simplify-js
|
||||
tippy.js
|
||||
@floating-ui/dom
|
||||
uuid
|
||||
y-webrtc
|
||||
yjs
|
||||
|
@ -20,10 +20,10 @@ context('/src/Examples/Community/React/', () => {
|
||||
cy.get('.tiptap').type('{selectall}{backspace}@')
|
||||
|
||||
// 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
|
||||
cy.get('.tippy-content .dropdown-menu button').first().then($el => {
|
||||
cy.get('.dropdown-menu button').first().then($el => {
|
||||
const name = $el.text()
|
||||
|
||||
$el.click()
|
||||
|
@ -1,8 +1,29 @@
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import tippy from 'tippy.js'
|
||||
import {
|
||||
computePosition,
|
||||
flip,
|
||||
shift,
|
||||
} from '@floating-ui/dom'
|
||||
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
|
||||
|
||||
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 {
|
||||
items: ({ query }) => {
|
||||
return [
|
||||
@ -12,7 +33,6 @@ export default {
|
||||
|
||||
render: () => {
|
||||
let reactRenderer
|
||||
let popup
|
||||
|
||||
return {
|
||||
onStart: props => {
|
||||
@ -26,15 +46,11 @@ export default {
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: reactRenderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
reactRenderer.element.style.position = 'absolute'
|
||||
|
||||
document.body.appendChild(reactRenderer.element)
|
||||
|
||||
updatePosition(props.editor, reactRenderer.element)
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
@ -43,15 +59,13 @@ export default {
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
updatePosition(props.editor, reactRenderer.element)
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
reactRenderer.destroy()
|
||||
reactRenderer.element.remove()
|
||||
|
||||
return true
|
||||
}
|
||||
@ -60,8 +74,8 @@ export default {
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
reactRenderer.destroy()
|
||||
reactRenderer.element.remove()
|
||||
},
|
||||
}
|
||||
},
|
||||
|
@ -20,10 +20,10 @@ context('/src/Examples/Community/Vue/', () => {
|
||||
cy.get('.tiptap').type('{selectall}{backspace}@')
|
||||
|
||||
// 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
|
||||
cy.get('.tippy-content .dropdown-menu button').first().then($el => {
|
||||
cy.get('.dropdown-menu button').first().then($el => {
|
||||
const name = $el.text()
|
||||
|
||||
$el.click()
|
||||
|
@ -1,8 +1,29 @@
|
||||
import { VueRenderer } from '@tiptap/vue-3'
|
||||
import tippy from 'tippy.js'
|
||||
import {
|
||||
computePosition,
|
||||
flip,
|
||||
shift,
|
||||
} from '@floating-ui/dom'
|
||||
import { posToDOMRect, VueRenderer } from '@tiptap/vue-3'
|
||||
|
||||
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 {
|
||||
items: ({ query }) => {
|
||||
return [
|
||||
@ -12,7 +33,6 @@ export default {
|
||||
|
||||
render: () => {
|
||||
let component
|
||||
let popup
|
||||
|
||||
return {
|
||||
onStart: props => {
|
||||
@ -28,15 +48,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
component.element.style.position = 'absolute'
|
||||
|
||||
document.body.appendChild(component.element)
|
||||
|
||||
updatePosition(props.editor, component.element)
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
@ -46,14 +62,13 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
updatePosition(props.editor, component.element)
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
component.destroy()
|
||||
component.element.remove()
|
||||
|
||||
return true
|
||||
}
|
||||
@ -62,8 +77,8 @@ export default {
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
component.element.remove()
|
||||
},
|
||||
}
|
||||
},
|
||||
|
@ -26,7 +26,7 @@ export default () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{editor && <BubbleMenu className="bubble-menu" tippyOptions={{ duration: 100 }} editor={editor}>
|
||||
{editor && <BubbleMenu className="bubble-menu" editor={editor}>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={editor.isActive('bold') ? 'is-active' : ''}
|
||||
@ -47,7 +47,7 @@ export default () => {
|
||||
</button>
|
||||
</BubbleMenu>}
|
||||
|
||||
{editor && <FloatingMenu className="floating-menu" tippyOptions={{ duration: 100 }} editor={editor}>
|
||||
{editor && <FloatingMenu className="floating-menu" editor={editor}>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
|
||||
|
@ -10,8 +10,8 @@ context('/src/Examples/Menus/React/', () => {
|
||||
})
|
||||
|
||||
it('should show menu when the editor is empty', () => {
|
||||
cy.get('#app')
|
||||
.find('[data-tippy-root]')
|
||||
cy.get('body')
|
||||
.find('.floating-menu')
|
||||
})
|
||||
|
||||
it('should show menu when text is selected', () => {
|
||||
@ -19,8 +19,8 @@ context('/src/Examples/Menus/React/', () => {
|
||||
.type('Test')
|
||||
.type('{selectall}')
|
||||
|
||||
cy.get('#app')
|
||||
.find('[data-tippy-root]')
|
||||
cy.get('body')
|
||||
.find('.bubble-menu')
|
||||
})
|
||||
|
||||
const marks = [
|
||||
@ -44,8 +44,8 @@ context('/src/Examples/Menus/React/', () => {
|
||||
.type('Test')
|
||||
.type('{selectall}')
|
||||
|
||||
cy.get('#app')
|
||||
.find('[data-tippy-root]')
|
||||
cy.get('body')
|
||||
.find('.bubble-menu')
|
||||
.contains(mark.button)
|
||||
.click()
|
||||
|
||||
|
@ -10,8 +10,8 @@ context('/src/Examples/Menus/Vue/', () => {
|
||||
})
|
||||
|
||||
it('should show menu when the editor is empty', () => {
|
||||
cy.get('#app')
|
||||
.find('[data-tippy-root]')
|
||||
cy.get('body')
|
||||
.find('.floating-menu')
|
||||
})
|
||||
|
||||
it('should show menu when text is selected', () => {
|
||||
@ -19,8 +19,8 @@ context('/src/Examples/Menus/Vue/', () => {
|
||||
.type('Test')
|
||||
.type('{selectall}')
|
||||
|
||||
cy.get('#app')
|
||||
.find('[data-tippy-root]')
|
||||
cy.get('body')
|
||||
.find('.bubble-menu')
|
||||
})
|
||||
|
||||
const marks = [
|
||||
@ -44,8 +44,8 @@ context('/src/Examples/Menus/Vue/', () => {
|
||||
.type('Test')
|
||||
.type('{selectall}')
|
||||
|
||||
cy.get('#app')
|
||||
.find('[data-tippy-root]')
|
||||
cy.get('body')
|
||||
.find('.bubble-menu')
|
||||
.contains(mark.button)
|
||||
.click()
|
||||
|
||||
|
@ -1,35 +1,35 @@
|
||||
<template>
|
||||
<div v-if="editor">
|
||||
<bubble-menu
|
||||
class="bubble-menu"
|
||||
:tippy-options="{ duration: 100 }"
|
||||
:editor="editor"
|
||||
>
|
||||
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }">
|
||||
Bold
|
||||
</button>
|
||||
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }">
|
||||
Italic
|
||||
</button>
|
||||
<button @click="editor.chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }">
|
||||
Strike
|
||||
</button>
|
||||
<div class="bubble-menu">
|
||||
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }">
|
||||
Bold
|
||||
</button>
|
||||
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }">
|
||||
Italic
|
||||
</button>
|
||||
<button @click="editor.chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }">
|
||||
Strike
|
||||
</button>
|
||||
</div>
|
||||
</bubble-menu>
|
||||
|
||||
<floating-menu
|
||||
class="floating-menu"
|
||||
:tippy-options="{ duration: 100 }"
|
||||
:editor="editor"
|
||||
>
|
||||
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }">
|
||||
H1
|
||||
</button>
|
||||
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }">
|
||||
H2
|
||||
</button>
|
||||
<button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }">
|
||||
Bullet list
|
||||
</button>
|
||||
<div class="floating-menu">
|
||||
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }">
|
||||
H1
|
||||
</button>
|
||||
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }">
|
||||
H2
|
||||
</button>
|
||||
<button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }">
|
||||
Bullet list
|
||||
</button>
|
||||
</div>
|
||||
</floating-menu>
|
||||
</div>
|
||||
|
||||
|
@ -67,7 +67,7 @@ function EditorInstance({ shouldOptimizeRendering }) {
|
||||
<div>Number of renders: <span id="render-count">{countRenderRef.current}</span></div>
|
||||
</div>
|
||||
{currentEditorState && (
|
||||
<BubbleMenu className="bubble-menu" tippyOptions={{ duration: 100 }} editor={editor}>
|
||||
<BubbleMenu className="bubble-menu" editor={editor}>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={currentEditorState.isBold ? 'is-active' : ''}
|
||||
|
@ -19,8 +19,8 @@ context('/src/Experiments/Commands/Vue/', () => {
|
||||
|
||||
items.forEach((item, i) => {
|
||||
cy.get('.tiptap').type('{selectall}{backspace}/')
|
||||
cy.get('.tippy-content .dropdown-menu').should('exist')
|
||||
cy.get('.tippy-content .dropdown-menu button').eq(i).click()
|
||||
cy.get('.dropdown-menu').should('exist')
|
||||
cy.get('.dropdown-menu button').eq(i).click()
|
||||
cy.get('.tiptap').type(`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', () => {
|
||||
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('.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', () => {
|
||||
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('.tippy-content .dropdown-menu').should('not.exist')
|
||||
cy.get('.dropdown-menu').should('not.exist')
|
||||
cy.get('.tiptap').type('{rightArrow}')
|
||||
cy.get('.tippy-content .dropdown-menu').should('exist')
|
||||
cy.get('.dropdown-menu').should('exist')
|
||||
})
|
||||
})
|
||||
|
@ -1,8 +1,29 @@
|
||||
import { VueRenderer } from '@tiptap/vue-3'
|
||||
import tippy from 'tippy.js'
|
||||
import {
|
||||
computePosition,
|
||||
flip,
|
||||
shift,
|
||||
} from '@floating-ui/dom'
|
||||
import { posToDOMRect, VueRenderer } from '@tiptap/vue-3'
|
||||
|
||||
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 {
|
||||
items: ({ query }) => {
|
||||
return [
|
||||
@ -55,7 +76,6 @@ export default {
|
||||
|
||||
render: () => {
|
||||
let component
|
||||
let popup
|
||||
|
||||
return {
|
||||
onStart: props => {
|
||||
@ -71,15 +91,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
component.element.style.position = 'absolute'
|
||||
|
||||
document.body.appendChild(component.element)
|
||||
|
||||
updatePosition(props.editor, component.element)
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
@ -89,14 +105,13 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
updatePosition(props.editor, component.element)
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
component.destroy()
|
||||
component.element.remove()
|
||||
|
||||
return true
|
||||
}
|
||||
@ -105,8 +120,8 @@ export default {
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
component.element.remove()
|
||||
},
|
||||
}
|
||||
},
|
||||
|
@ -18,6 +18,7 @@ export default () => {
|
||||
`,
|
||||
})
|
||||
|
||||
const [showMenu, setShowMenu] = React.useState(true)
|
||||
const [isEditable, setIsEditable] = React.useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@ -28,7 +29,10 @@ export default () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<button onClick={() => {
|
||||
setShowMenu(old => !old)
|
||||
editor.commands.focus()
|
||||
} }>Toggle menu</button>
|
||||
<div className="control-group">
|
||||
<label>
|
||||
<input type="checkbox" checked={isEditable} onChange={() => setIsEditable(!isEditable)} />
|
||||
@ -36,7 +40,7 @@ export default () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{editor && <BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
|
||||
{(editor && showMenu) && <BubbleMenu editor={editor} options={{ placement: 'bottom', offset: 8 }}>
|
||||
<div className="bubble-menu">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
|
@ -6,11 +6,7 @@
|
||||
Editable
|
||||
</label>
|
||||
</div>
|
||||
<bubble-menu
|
||||
:editor="editor"
|
||||
:tippy-options="{ duration: 100 }"
|
||||
v-if="editor"
|
||||
>
|
||||
<bubble-menu :editor="editor" v-if="editor">
|
||||
<div class="bubble-menu">
|
||||
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }">
|
||||
Bold
|
||||
|
@ -33,7 +33,7 @@ export default () => {
|
||||
Editable
|
||||
</label>
|
||||
</div>
|
||||
{editor && <FloatingMenu editor={editor} tippyOptions={{ duration: 100 }}>
|
||||
{editor && <FloatingMenu editor={editor}>
|
||||
<div className="floating-menu">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
|
@ -6,7 +6,7 @@
|
||||
Editable
|
||||
</label>
|
||||
</div>
|
||||
<floating-menu :editor="editor" :tippy-options="{ duration: 100 }" v-if="editor">
|
||||
<floating-menu :editor="editor" v-if="editor">
|
||||
<div class="floating-menu">
|
||||
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }">
|
||||
H1
|
||||
|
@ -1,8 +1,29 @@
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import tippy from 'tippy.js'
|
||||
import {
|
||||
computePosition,
|
||||
flip,
|
||||
shift,
|
||||
} from '@floating-ui/dom'
|
||||
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
|
||||
|
||||
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 {
|
||||
items: ({ query }) => {
|
||||
return [
|
||||
@ -38,7 +59,6 @@ export default {
|
||||
|
||||
render: () => {
|
||||
let component
|
||||
let popup
|
||||
|
||||
return {
|
||||
onStart: props => {
|
||||
@ -51,15 +71,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
component.element.style.position = 'absolute'
|
||||
|
||||
document.body.appendChild(component.element)
|
||||
|
||||
updatePosition(props.editor, component.element)
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
@ -69,14 +85,12 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
updatePosition(props.editor, component.element)
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
component.destroy()
|
||||
|
||||
return true
|
||||
}
|
||||
@ -85,7 +99,6 @@ export default {
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
|
@ -1,8 +1,29 @@
|
||||
import { VueRenderer } from '@tiptap/vue-3'
|
||||
import tippy from 'tippy.js'
|
||||
import {
|
||||
computePosition,
|
||||
flip,
|
||||
shift,
|
||||
} from '@floating-ui/dom'
|
||||
import { posToDOMRect, VueRenderer } from '@tiptap/vue-3'
|
||||
|
||||
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 {
|
||||
items: ({ query }) => {
|
||||
return [
|
||||
@ -12,7 +33,6 @@ export default {
|
||||
|
||||
render: () => {
|
||||
let component
|
||||
let popup
|
||||
|
||||
return {
|
||||
onStart: props => {
|
||||
@ -29,15 +49,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
component.element.style.position = 'absolute'
|
||||
|
||||
document.body.appendChild(component.element)
|
||||
|
||||
updatePosition(props.editor, component.element)
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
@ -47,14 +63,12 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
updatePosition(props.editor, component.element)
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
component.destroy()
|
||||
|
||||
return true
|
||||
}
|
||||
@ -63,7 +77,6 @@ export default {
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
|
5874
package-lock.json
generated
5874
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -68,8 +68,4 @@ img.ProseMirror-separator {
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tippy-box[data-animation=fade][data-state=hidden] {
|
||||
opacity: 0
|
||||
}`
|
||||
|
@ -28,9 +28,6 @@
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
@ -38,12 +35,14 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "^2.5.6",
|
||||
"@tiptap/pm": "^2.5.6"
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "^2.5.7",
|
||||
"@tiptap/pm": "^2.5.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.5.6",
|
||||
"@tiptap/pm": "^2.5.6"
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "^2.5.7",
|
||||
"@tiptap/pm": "^2.5.7"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
|
@ -1,9 +1,17 @@
|
||||
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'
|
||||
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
import tippy, { Instance, Props } from 'tippy.js'
|
||||
|
||||
export interface BubbleMenuPluginProps {
|
||||
/**
|
||||
@ -25,12 +33,6 @@ export interface BubbleMenuPluginProps {
|
||||
*/
|
||||
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.
|
||||
* This can be useful to prevent performance issues.
|
||||
@ -39,11 +41,19 @@ export interface BubbleMenuPluginProps {
|
||||
*/
|
||||
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.
|
||||
* If this function returns `false`, the menu will be hidden, otherwise it will be shown.
|
||||
*/
|
||||
shouldShow?:
|
||||
shouldShow:
|
||||
| ((props: {
|
||||
editor: Editor
|
||||
view: EditorView
|
||||
@ -53,6 +63,22 @@ export interface BubbleMenuPluginProps {
|
||||
to: number
|
||||
}) => boolean)
|
||||
| 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 & {
|
||||
@ -68,14 +94,38 @@ export class BubbleMenuView {
|
||||
|
||||
public preventHide = false
|
||||
|
||||
public tippy: Instance | undefined
|
||||
|
||||
public tippyOptions?: Partial<Props>
|
||||
|
||||
public updateDelay: number
|
||||
|
||||
public resizeDelay: number
|
||||
|
||||
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> = ({
|
||||
view,
|
||||
state,
|
||||
@ -104,18 +154,63 @@ export class BubbleMenuView {
|
||||
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({
|
||||
editor,
|
||||
element,
|
||||
view,
|
||||
tippyOptions = {},
|
||||
updateDelay = 250,
|
||||
resizeDelay = 60,
|
||||
shouldShow,
|
||||
options,
|
||||
}: BubbleMenuViewProps) {
|
||||
this.editor = editor
|
||||
this.element = element
|
||||
this.view = view
|
||||
this.updateDelay = updateDelay
|
||||
this.resizeDelay = resizeDelay
|
||||
|
||||
this.floatingUIOptions = {
|
||||
...this.floatingUIOptions,
|
||||
...options,
|
||||
}
|
||||
|
||||
if (shouldShow) {
|
||||
this.shouldShow = shouldShow
|
||||
@ -125,10 +220,21 @@ export class BubbleMenuView {
|
||||
this.view.dom.addEventListener('dragstart', this.dragstartHandler)
|
||||
this.editor.on('focus', this.focusHandler)
|
||||
this.editor.on('blur', this.blurHandler)
|
||||
this.tippyOptions = tippyOptions
|
||||
// Detaches menu content from its current parent
|
||||
this.element.remove()
|
||||
this.element.style.visibility = 'visible'
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.resizeDebounceTimer) {
|
||||
clearTimeout(this.resizeDebounceTimer)
|
||||
}
|
||||
|
||||
this.resizeDebounceTimer = window.setTimeout(() => {
|
||||
this.updatePosition()
|
||||
}, this.resizeDelay)
|
||||
})
|
||||
|
||||
this.update(view, view.state)
|
||||
|
||||
if (this.getShouldShow()) {
|
||||
this.show()
|
||||
}
|
||||
}
|
||||
|
||||
mousedownHandler = () => {
|
||||
@ -158,33 +264,19 @@ export class BubbleMenuView {
|
||||
this.hide()
|
||||
}
|
||||
|
||||
tippyBlurHandler = (event: FocusEvent) => {
|
||||
this.blurHandler({ event })
|
||||
}
|
||||
updatePosition() {
|
||||
const { selection } = this.editor.state
|
||||
|
||||
createTooltip() {
|
||||
const { element: editorElement } = this.editor.options
|
||||
const editorIsAttached = !!editorElement.parentElement
|
||||
|
||||
if (this.tippy || !editorIsAttached) {
|
||||
return
|
||||
const virtualElement = {
|
||||
getBoundingClientRect: () => posToDOMRect(this.view, selection.from, selection.to),
|
||||
}
|
||||
|
||||
this.tippy = tippy(editorElement, {
|
||||
duration: 0,
|
||||
getReferenceClientRect: null,
|
||||
content: this.element,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'top',
|
||||
hideOnClick: 'toggle',
|
||||
...this.tippyOptions,
|
||||
computePosition(virtualElement, this.element, { placement: this.floatingUIOptions.placement, strategy: this.floatingUIOptions.strategy, middleware: this.middlewares }).then(({ x, y, strategy }) => {
|
||||
this.element.style.width = 'max-content'
|
||||
this.element.style.position = strategy
|
||||
this.element.style.left = `${x}px`
|
||||
this.element.style.top = `${y}px`
|
||||
})
|
||||
|
||||
// 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) {
|
||||
@ -219,31 +311,36 @@ export class BubbleMenuView {
|
||||
}, this.updateDelay)
|
||||
}
|
||||
|
||||
updateHandler = (view: EditorView, selectionChanged: boolean, docChanged: boolean, oldState?: EditorState) => {
|
||||
const { state, composing } = view
|
||||
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
|
||||
}
|
||||
|
||||
this.createTooltip()
|
||||
|
||||
// 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,
|
||||
})
|
||||
const shouldShow = this.getShouldShow(oldState)
|
||||
|
||||
if (!shouldShow) {
|
||||
this.hide()
|
||||
@ -251,47 +348,26 @@ export class BubbleMenuView {
|
||||
return
|
||||
}
|
||||
|
||||
this.tippy?.setProps({
|
||||
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.updatePosition()
|
||||
this.show()
|
||||
}
|
||||
|
||||
show() {
|
||||
this.tippy?.show()
|
||||
this.element.style.visibility = 'visible'
|
||||
this.element.style.opacity = '1'
|
||||
// attach from body
|
||||
document.body.appendChild(this.element)
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.tippy?.hide()
|
||||
this.element.style.visibility = 'hidden'
|
||||
this.element.style.opacity = '0'
|
||||
// remove from body
|
||||
this.element.remove()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.tippy?.popper.firstChild) {
|
||||
(this.tippy.popper.firstChild as HTMLElement).removeEventListener(
|
||||
'blur',
|
||||
this.tippyBlurHandler,
|
||||
)
|
||||
}
|
||||
this.tippy?.destroy()
|
||||
this.hide()
|
||||
this.element.removeEventListener('mousedown', this.mousedownHandler, { capture: true })
|
||||
this.view.dom.removeEventListener('dragstart', this.dragstartHandler)
|
||||
this.editor.off('focus', this.focusHandler)
|
||||
|
@ -21,7 +21,6 @@ export const BubbleMenu = Extension.create<BubbleMenuOptions>({
|
||||
addOptions() {
|
||||
return {
|
||||
element: null,
|
||||
tippyOptions: {},
|
||||
pluginKey: 'bubbleMenu',
|
||||
updateDelay: undefined,
|
||||
shouldShow: null,
|
||||
@ -38,7 +37,6 @@ export const BubbleMenu = Extension.create<BubbleMenuOptions>({
|
||||
pluginKey: this.options.pluginKey,
|
||||
editor: this.editor,
|
||||
element: this.options.element,
|
||||
tippyOptions: this.options.tippyOptions,
|
||||
updateDelay: this.options.updateDelay,
|
||||
shouldShow: this.options.shouldShow,
|
||||
}),
|
||||
|
@ -29,15 +29,14 @@
|
||||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "^2.5.6",
|
||||
"@tiptap/pm": "^2.5.6"
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "^2.5.7",
|
||||
"@tiptap/pm": "^2.5.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.5.6",
|
||||
"@tiptap/pm": "^2.5.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"tippy.js": "^6.3.7"
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "^2.5.7",
|
||||
"@tiptap/pm": "^2.5.7"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -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 { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
import tippy, { Instance, Props } from 'tippy.js'
|
||||
|
||||
export interface FloatingMenuPluginProps {
|
||||
/**
|
||||
@ -22,26 +30,36 @@ export interface FloatingMenuPluginProps {
|
||||
*/
|
||||
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.
|
||||
* If this function returns `false`, the menu will be hidden, otherwise it will be shown.
|
||||
* @default null
|
||||
*/
|
||||
shouldShow?:
|
||||
shouldShow:
|
||||
| ((props: {
|
||||
editor: Editor
|
||||
view: EditorView
|
||||
state: EditorState
|
||||
oldState?: EditorState
|
||||
from: number
|
||||
to: number
|
||||
}) => boolean)
|
||||
| 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 & {
|
||||
@ -60,10 +78,6 @@ export class FloatingMenuView {
|
||||
|
||||
public preventHide = false
|
||||
|
||||
public tippy: Instance | undefined
|
||||
|
||||
public tippyOptions?: Partial<Props>
|
||||
|
||||
public shouldShow: Exclude<FloatingMenuPluginProps['shouldShow'], null> = ({ view, state }) => {
|
||||
const { selection } = state
|
||||
const { $anchor, empty } = selection
|
||||
@ -83,13 +97,80 @@ export class FloatingMenuView {
|
||||
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({
|
||||
editor, element, view, tippyOptions = {}, shouldShow,
|
||||
editor, element, view, options, shouldShow,
|
||||
}: FloatingMenuViewProps) {
|
||||
this.editor = editor
|
||||
this.element = element
|
||||
this.view = view
|
||||
|
||||
this.floatingUIOptions = {
|
||||
...this.floatingUIOptions,
|
||||
...options,
|
||||
}
|
||||
|
||||
if (shouldShow) {
|
||||
this.shouldShow = shouldShow
|
||||
}
|
||||
@ -97,10 +178,53 @@ export class FloatingMenuView {
|
||||
this.element.addEventListener('mousedown', this.mousedownHandler, { capture: true })
|
||||
this.editor.on('focus', this.focusHandler)
|
||||
this.editor.on('blur', this.blurHandler)
|
||||
this.tippyOptions = tippyOptions
|
||||
// Detaches menu content from its current parent
|
||||
this.element.remove()
|
||||
this.element.style.visibility = 'visible'
|
||||
|
||||
this.update(view, view.state)
|
||||
|
||||
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 = () => {
|
||||
@ -126,84 +250,44 @@ export class FloatingMenuView {
|
||||
this.hide()
|
||||
}
|
||||
|
||||
tippyBlurHandler = (event: FocusEvent) => {
|
||||
this.blurHandler({ event })
|
||||
}
|
||||
updatePosition() {
|
||||
const { selection } = this.editor.state
|
||||
|
||||
createTooltip() {
|
||||
const { element: editorElement } = this.editor.options
|
||||
const editorIsAttached = !!editorElement.parentElement
|
||||
|
||||
if (this.tippy || !editorIsAttached) {
|
||||
return
|
||||
const virtualElement = {
|
||||
getBoundingClientRect: () => posToDOMRect(this.view, selection.from, selection.to),
|
||||
}
|
||||
|
||||
this.tippy = tippy(editorElement, {
|
||||
duration: 0,
|
||||
getReferenceClientRect: null,
|
||||
content: this.element,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'right',
|
||||
hideOnClick: 'toggle',
|
||||
...this.tippyOptions,
|
||||
computePosition(virtualElement, this.element, { placement: this.floatingUIOptions.placement, strategy: this.floatingUIOptions.strategy, middleware: this.middlewares }).then(({ x, y, strategy }) => {
|
||||
this.element.style.width = 'max-content'
|
||||
this.element.style.position = strategy
|
||||
this.element.style.left = `${x}px`
|
||||
this.element.style.top = `${y}px`
|
||||
})
|
||||
|
||||
// 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) {
|
||||
const { state } = view
|
||||
const { doc, selection } = state
|
||||
const { from, to } = selection
|
||||
const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection)
|
||||
const selectionChanged = !oldState?.selection.eq(view.state.selection)
|
||||
const docChanged = !oldState?.doc.eq(view.state.doc)
|
||||
|
||||
if (isSame) {
|
||||
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()
|
||||
this.updateHandler(view, selectionChanged, docChanged, oldState)
|
||||
}
|
||||
|
||||
show() {
|
||||
this.tippy?.show()
|
||||
this.element.style.visibility = 'visible'
|
||||
this.element.style.opacity = '1'
|
||||
// attach from body
|
||||
document.body.appendChild(this.element)
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.tippy?.hide()
|
||||
this.element.style.visibility = 'hidden'
|
||||
this.element.style.opacity = '0'
|
||||
// remove from body
|
||||
this.element.remove()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.tippy?.popper.firstChild) {
|
||||
(this.tippy.popper.firstChild as HTMLElement).removeEventListener(
|
||||
'blur',
|
||||
this.tippyBlurHandler,
|
||||
)
|
||||
}
|
||||
this.tippy?.destroy()
|
||||
this.hide()
|
||||
this.element.removeEventListener('mousedown', this.mousedownHandler, { capture: true })
|
||||
this.editor.off('focus', this.focusHandler)
|
||||
this.editor.off('blur', this.blurHandler)
|
||||
|
@ -21,7 +21,7 @@ export const FloatingMenu = Extension.create<FloatingMenuOptions>({
|
||||
addOptions() {
|
||||
return {
|
||||
element: null,
|
||||
tippyOptions: {},
|
||||
options: {},
|
||||
pluginKey: 'floatingMenu',
|
||||
shouldShow: null,
|
||||
}
|
||||
@ -37,7 +37,7 @@ export const FloatingMenu = Extension.create<FloatingMenuOptions>({
|
||||
pluginKey: this.options.pluginKey,
|
||||
editor: this.editor,
|
||||
element: this.options.element,
|
||||
tippyOptions: this.options.tippyOptions,
|
||||
options: this.options.options,
|
||||
shouldShow: this.options.shouldShow,
|
||||
}),
|
||||
]
|
||||
|
@ -1,5 +1,6 @@
|
||||
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'
|
||||
|
||||
@ -10,23 +11,24 @@ export type BubbleMenuProps = Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>,
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
updateDelay?: number;
|
||||
resizeDelay?: number;
|
||||
options?: BubbleMenuPluginProps['options'];
|
||||
};
|
||||
|
||||
export const BubbleMenu = (props: BubbleMenuProps) => {
|
||||
const [element, setElement] = useState<HTMLDivElement | null>(null)
|
||||
const menuEl = useRef(document.createElement('div'))
|
||||
const { editor: currentEditor } = useCurrentEditor()
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
menuEl.current.style.visibility = 'hidden'
|
||||
menuEl.current.style.position = 'absolute'
|
||||
|
||||
if (props.editor?.isDestroyed || currentEditor?.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
pluginKey = 'bubbleMenu', editor, tippyOptions = {}, updateDelay, shouldShow = null,
|
||||
pluginKey = 'bubbleMenu', editor, updateDelay, resizeDelay, shouldShow = null,
|
||||
} = props
|
||||
|
||||
const menuEditor = editor || currentEditor
|
||||
@ -38,20 +40,35 @@ export const BubbleMenu = (props: BubbleMenuProps) => {
|
||||
|
||||
const plugin = BubbleMenuPlugin({
|
||||
updateDelay,
|
||||
resizeDelay,
|
||||
editor: menuEditor,
|
||||
element,
|
||||
element: menuEl.current,
|
||||
pluginKey,
|
||||
shouldShow,
|
||||
tippyOptions,
|
||||
options: props.options,
|
||||
})
|
||||
|
||||
menuEditor.registerPlugin(plugin)
|
||||
return () => menuEditor.unregisterPlugin(pluginKey)
|
||||
}, [props.editor, currentEditor, element])
|
||||
|
||||
return (
|
||||
<div ref={setElement} className={props.className} style={{ visibility: 'hidden' }}>
|
||||
return () => {
|
||||
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}
|
||||
</div>
|
||||
), menuEl.current,
|
||||
)
|
||||
|
||||
return (
|
||||
<>{portal}</>
|
||||
)
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { FloatingMenuPlugin, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
|
||||
import React, {
|
||||
useEffect, useState,
|
||||
useEffect, useRef,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { useCurrentEditor } from './Context.js'
|
||||
|
||||
@ -11,16 +12,16 @@ export type FloatingMenuProps = Omit<Optional<FloatingMenuPluginProps, 'pluginKe
|
||||
editor: FloatingMenuPluginProps['editor'] | null;
|
||||
className?: string,
|
||||
children: React.ReactNode
|
||||
options?: FloatingMenuPluginProps['options']
|
||||
}
|
||||
|
||||
export const FloatingMenu = (props: FloatingMenuProps) => {
|
||||
const [element, setElement] = useState<HTMLDivElement | null>(null)
|
||||
const menuEl = useRef(document.createElement('div'))
|
||||
const { editor: currentEditor } = useCurrentEditor()
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
menuEl.current.style.visibility = 'hidden'
|
||||
menuEl.current.style.position = 'absolute'
|
||||
|
||||
if (props.editor?.isDestroyed || currentEditor?.isDestroyed) {
|
||||
return
|
||||
@ -29,7 +30,7 @@ export const FloatingMenu = (props: FloatingMenuProps) => {
|
||||
const {
|
||||
pluginKey = 'floatingMenu',
|
||||
editor,
|
||||
tippyOptions = {},
|
||||
options,
|
||||
shouldShow = null,
|
||||
} = props
|
||||
|
||||
@ -43,22 +44,34 @@ export const FloatingMenu = (props: FloatingMenuProps) => {
|
||||
const plugin = FloatingMenuPlugin({
|
||||
pluginKey,
|
||||
editor: menuEditor,
|
||||
element,
|
||||
tippyOptions,
|
||||
element: menuEl.current,
|
||||
options,
|
||||
shouldShow,
|
||||
})
|
||||
|
||||
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,
|
||||
currentEditor,
|
||||
element,
|
||||
])
|
||||
|
||||
return (
|
||||
<div ref={setElement} className={props.className} style={{ visibility: 'hidden' }}>
|
||||
const portal = createPortal(
|
||||
(
|
||||
<div className={props.className}>
|
||||
{props.children}
|
||||
</div>
|
||||
), menuEl.current,
|
||||
)
|
||||
|
||||
return (
|
||||
<>{portal}</>
|
||||
)
|
||||
}
|
||||
|
@ -223,7 +223,7 @@ export function Suggestion<I = any, TSelected = any>({
|
||||
})
|
||||
},
|
||||
decorationNode,
|
||||
// virtual node for popper.js or tippy.js
|
||||
// virtual node for positioning
|
||||
// this can be used for building popups without a DOM node
|
||||
clientRect: decorationNode
|
||||
? () => {
|
||||
|
@ -4,9 +4,10 @@ import Vue, { Component, CreateElement, PropType } from 'vue'
|
||||
export interface BubbleMenuInterface extends Vue {
|
||||
pluginKey: BubbleMenuPluginProps['pluginKey'],
|
||||
editor: BubbleMenuPluginProps['editor'],
|
||||
tippyOptions: BubbleMenuPluginProps['tippyOptions'],
|
||||
updateDelay: BubbleMenuPluginProps['updateDelay'],
|
||||
resizeDelay: BubbleMenuPluginProps['resizeDelay'],
|
||||
shouldShow: BubbleMenuPluginProps['shouldShow'],
|
||||
options: BubbleMenuPluginProps['options'],
|
||||
}
|
||||
|
||||
export const BubbleMenu: Component = {
|
||||
@ -27,9 +28,13 @@ export const BubbleMenu: Component = {
|
||||
type: Number as PropType<BubbleMenuPluginProps['updateDelay']>,
|
||||
},
|
||||
|
||||
tippyOptions: {
|
||||
type: Object as PropType<BubbleMenuPluginProps['tippyOptions']>,
|
||||
default: () => ({}),
|
||||
options: {
|
||||
type: Object as PropType<BubbleMenuPluginProps['options']>,
|
||||
default: {},
|
||||
},
|
||||
|
||||
resizeDelay: {
|
||||
type: Number as PropType<BubbleMenuPluginProps['resizeDelay']>,
|
||||
},
|
||||
|
||||
shouldShow: {
|
||||
@ -46,14 +51,20 @@ export const BubbleMenu: Component = {
|
||||
return
|
||||
}
|
||||
|
||||
(this.$el as HTMLElement).style.visibility = 'hidden';
|
||||
(this.$el as HTMLElement).style.position = 'absolute'
|
||||
|
||||
this.$el.remove()
|
||||
|
||||
this.$nextTick(() => {
|
||||
editor.registerPlugin(BubbleMenuPlugin({
|
||||
updateDelay: this.updateDelay,
|
||||
resizeDelay: this.resizeDelay,
|
||||
options: this.options,
|
||||
editor,
|
||||
element: this.$el as HTMLElement,
|
||||
pluginKey: this.pluginKey,
|
||||
shouldShow: this.shouldShow,
|
||||
tippyOptions: this.tippyOptions,
|
||||
}))
|
||||
})
|
||||
},
|
||||
|
@ -3,7 +3,7 @@ import Vue, { Component, CreateElement, PropType } from 'vue'
|
||||
|
||||
export interface FloatingMenuInterface extends Vue {
|
||||
pluginKey: FloatingMenuPluginProps['pluginKey'],
|
||||
tippyOptions: FloatingMenuPluginProps['tippyOptions'],
|
||||
options: FloatingMenuPluginProps['options'],
|
||||
editor: FloatingMenuPluginProps['editor'],
|
||||
shouldShow: FloatingMenuPluginProps['shouldShow'],
|
||||
}
|
||||
@ -22,8 +22,8 @@ export const FloatingMenu: Component = {
|
||||
required: true,
|
||||
},
|
||||
|
||||
tippyOptions: {
|
||||
type: Object as PropType<FloatingMenuPluginProps['tippyOptions']>,
|
||||
options: {
|
||||
type: Object as PropType<FloatingMenuPluginProps['options']>,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
@ -41,12 +41,17 @@ export const FloatingMenu: Component = {
|
||||
return
|
||||
}
|
||||
|
||||
(this.$el as HTMLElement).style.visibility = 'hidden';
|
||||
(this.$el as HTMLElement).style.position = 'absolute'
|
||||
|
||||
this.$el.remove()
|
||||
|
||||
this.$nextTick(() => {
|
||||
editor.registerPlugin(FloatingMenuPlugin({
|
||||
pluginKey: this.pluginKey,
|
||||
editor,
|
||||
element: this.$el as HTMLElement,
|
||||
tippyOptions: this.tippyOptions,
|
||||
options: this.options,
|
||||
shouldShow: this.shouldShow,
|
||||
}))
|
||||
})
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
onMounted,
|
||||
PropType,
|
||||
ref,
|
||||
Teleport,
|
||||
} from 'vue'
|
||||
|
||||
export const BubbleMenu = defineComponent({
|
||||
@ -27,8 +28,13 @@ export const BubbleMenu = defineComponent({
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
tippyOptions: {
|
||||
type: Object as PropType<BubbleMenuPluginProps['tippyOptions']>,
|
||||
resizeDelay: {
|
||||
type: Number as PropType<BubbleMenuPluginProps['resizeDelay']>,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
options: {
|
||||
type: Object as PropType<BubbleMenuPluginProps['options']>,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
@ -43,20 +49,32 @@ export const BubbleMenu = defineComponent({
|
||||
|
||||
onMounted(() => {
|
||||
const {
|
||||
updateDelay,
|
||||
editor,
|
||||
options,
|
||||
pluginKey,
|
||||
resizeDelay,
|
||||
shouldShow,
|
||||
tippyOptions,
|
||||
updateDelay,
|
||||
} = 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({
|
||||
updateDelay,
|
||||
editor,
|
||||
element: root.value as HTMLElement,
|
||||
options,
|
||||
pluginKey,
|
||||
resizeDelay,
|
||||
shouldShow,
|
||||
tippyOptions,
|
||||
updateDelay,
|
||||
}))
|
||||
})
|
||||
|
||||
@ -66,6 +84,6 @@ export const BubbleMenu = defineComponent({
|
||||
editor.unregisterPlugin(pluginKey)
|
||||
})
|
||||
|
||||
return () => h('div', { ref: root }, slots.default?.())
|
||||
return () => h(Teleport, { to: 'body' }, h('div', { ref: root }, slots.default?.()))
|
||||
},
|
||||
})
|
||||
|
@ -1,3 +1,4 @@
|
||||
import type { BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu'
|
||||
import { FloatingMenuPlugin, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
|
||||
import {
|
||||
defineComponent,
|
||||
@ -6,6 +7,7 @@ import {
|
||||
onMounted,
|
||||
PropType,
|
||||
ref,
|
||||
Teleport,
|
||||
} from 'vue'
|
||||
|
||||
export const FloatingMenu = defineComponent({
|
||||
@ -24,8 +26,8 @@ export const FloatingMenu = defineComponent({
|
||||
required: true,
|
||||
},
|
||||
|
||||
tippyOptions: {
|
||||
type: Object as PropType<FloatingMenuPluginProps['tippyOptions']>,
|
||||
options: {
|
||||
type: Object as PropType<BubbleMenuPluginProps['options']>,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
@ -42,15 +44,25 @@ export const FloatingMenu = defineComponent({
|
||||
const {
|
||||
pluginKey,
|
||||
editor,
|
||||
tippyOptions,
|
||||
options,
|
||||
shouldShow,
|
||||
} = 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({
|
||||
pluginKey,
|
||||
editor,
|
||||
element: root.value as HTMLElement,
|
||||
tippyOptions,
|
||||
options,
|
||||
shouldShow,
|
||||
}))
|
||||
})
|
||||
@ -61,6 +73,6 @@ export const FloatingMenu = defineComponent({
|
||||
editor.unregisterPlugin(pluginKey)
|
||||
})
|
||||
|
||||
return () => h('div', { ref: root }, slots.default?.())
|
||||
return () => h(Teleport, { to: 'body' }, h('div', { ref: root }, slots.default?.()))
|
||||
},
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user