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