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
shiki
simplify-js
tippy.js
@floating-ui/dom
uuid
y-webrtc
yjs

View File

@ -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()

View File

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

View File

@ -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()

View File

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

View File

@ -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' : ''}

View File

@ -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()

View File

@ -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()

View File

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

View File

@ -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' : ''}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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",

View File

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

View File

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

View File

@ -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",

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

View File

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

View File

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

View File

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

View File

@ -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
? () => {

View File

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

View File

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

View File

@ -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?.()))
},
})

View File

@ -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?.()))
},
})