diff --git a/docs/src/demos/Extensions/FloatingMenu/React/index.jsx b/docs/src/demos/Extensions/FloatingMenu/React/index.jsx
new file mode 100644
index 00000000..d01f0b0e
--- /dev/null
+++ b/docs/src/demos/Extensions/FloatingMenu/React/index.jsx
@@ -0,0 +1,49 @@
+import React from 'react'
+import { useEditor, EditorContent, FloatingMenu } from '@tiptap/react'
+import { defaultExtensions } from '@tiptap/starter-kit'
+import './styles.scss'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ ...defaultExtensions(),
+ ],
+ content: `
+
+ This is an example of a medium-like editor. Enter a new line and some buttons will appear.
+
+ `,
+ })
+
+ return (
+
+ {editor &&
+
+
+
+
+ }
+
+
+ )
+}
diff --git a/docs/src/demos/Extensions/FloatingMenu/React/styles.scss b/docs/src/demos/Extensions/FloatingMenu/React/styles.scss
new file mode 100644
index 00000000..12c87298
--- /dev/null
+++ b/docs/src/demos/Extensions/FloatingMenu/React/styles.scss
@@ -0,0 +1,5 @@
+.ProseMirror {
+ > * + * {
+ margin-top: 0.75em;
+ }
+}
diff --git a/docs/src/demos/Extensions/FloatingMenu/Vue/index.vue b/docs/src/demos/Extensions/FloatingMenu/Vue/index.vue
new file mode 100644
index 00000000..8d249bf4
--- /dev/null
+++ b/docs/src/demos/Extensions/FloatingMenu/Vue/index.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/src/docPages/api/extensions/floating-menu.md b/docs/src/docPages/api/extensions/floating-menu.md
new file mode 100644
index 00000000..ead688e0
--- /dev/null
+++ b/docs/src/docPages/api/extensions/floating-menu.md
@@ -0,0 +1,41 @@
+# Floating Menu
+[](https://www.npmjs.com/package/@tiptap/extension-floating-menu)
+[](https://npmcharts.com/compare/@tiptap/extension-floating-menu?minimal=true)
+
+This extension will make a contextual menu appear near a selection of text.
+
+## Installation
+```bash
+# with npm
+npm install @tiptap/extension-floating-menu
+# with Yarn
+yarn add @tiptap/extension-floating-menu
+```
+
+## Settings
+| Option | Type | Default | Description |
+| ------------ | ------------- | --------- | ----------------------------- |
+| element | `HTMLElement` | `null` | The DOM element of your menu. |
+
+## Source code
+[packages/extension-floating-menu/](https://github.com/ueberdosis/tiptap-next/blob/main/packages/extension-floating-menu/)
+
+## Using Vanilla JavaScript
+```js
+import { Editor } from '@tiptap/core'
+import FloatingMenu from '@tiptap/extension-floating-menu'
+
+new Editor({
+ extensions: [
+ FloatingMenu.configure({
+ element: document.querySelector('.menu'),
+ }),
+ ],
+})
+```
+
+## Using a framework
+
diff --git a/docs/src/links.yaml b/docs/src/links.yaml
index 1bc8ef06..11d266ee 100644
--- a/docs/src/links.yaml
+++ b/docs/src/links.yaml
@@ -207,6 +207,8 @@
# type: pro
- title: Dropcursor
link: /api/extensions/dropcursor
+ - title: FloatingMenu
+ link: /api/extensions/floating-menu
- title: Focus
link: /api/extensions/focus
- title: FontFamily
diff --git a/packages/extension-floating-menu/README.md b/packages/extension-floating-menu/README.md
new file mode 100644
index 00000000..3b68aecc
--- /dev/null
+++ b/packages/extension-floating-menu/README.md
@@ -0,0 +1,14 @@
+# @tiptap/extension-floating-menu
+[](https://www.npmjs.com/package/@tiptap/extension-floating-menu)
+[](https://npmcharts.com/compare/tiptap?minimal=true)
+[](https://www.npmjs.com/package/@tiptap/extension-floating-menu)
+[](https://github.com/sponsors/ueberdosis)
+
+## Introduction
+tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
+
+## Offical Documentation
+Documentation can be found on the [tiptap website](https://tiptap.dev).
+
+## License
+tiptap is open-sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap-next/blob/main/LICENSE.md).
diff --git a/packages/extension-floating-menu/package.json b/packages/extension-floating-menu/package.json
new file mode 100644
index 00000000..070f5463
--- /dev/null
+++ b/packages/extension-floating-menu/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@tiptap/extension-floating-menu",
+ "description": "floating-menu extension for tiptap",
+ "version": "2.0.0-beta.0",
+ "homepage": "https://tiptap.dev",
+ "keywords": [
+ "tiptap",
+ "tiptap extension"
+ ],
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "main": "dist/tiptap-extension-floating-menu.cjs.js",
+ "umd": "dist/tiptap-extension-floating-menu.umd.js",
+ "module": "dist/tiptap-extension-floating-menu.esm.js",
+ "unpkg": "dist/tiptap-extension-floating-menu.bundle.umd.min.js",
+ "types": "dist/packages/extension-floating-menu/src/index.d.ts",
+ "files": [
+ "src",
+ "dist"
+ ],
+ "peerDependencies": {
+ "@tiptap/core": "^2.0.0-beta.1"
+ },
+ "dependencies": {
+ "prosemirror-state": "^1.3.4",
+ "prosemirror-view": "^1.18.2"
+ }
+}
diff --git a/packages/extension-floating-menu/src/floating-menu-plugin.ts b/packages/extension-floating-menu/src/floating-menu-plugin.ts
new file mode 100644
index 00000000..98e3dad3
--- /dev/null
+++ b/packages/extension-floating-menu/src/floating-menu-plugin.ts
@@ -0,0 +1,133 @@
+import { Editor, isNodeEmpty } from '@tiptap/core'
+import { EditorState, Plugin, PluginKey } from 'prosemirror-state'
+import { EditorView } from 'prosemirror-view'
+
+export interface FloatingMenuPluginProps {
+ editor: Editor,
+ element: HTMLElement,
+}
+
+export type FloatingMenuViewProps = FloatingMenuPluginProps & {
+ view: EditorView,
+}
+
+export class FloatingMenuView {
+ public editor: Editor
+
+ public element: HTMLElement
+
+ public view: EditorView
+
+ public isActive = false
+
+ public top = 0
+
+ public left = 0
+
+ public preventHide = false
+
+ constructor({
+ editor,
+ element,
+ view,
+ }: FloatingMenuViewProps) {
+ this.editor = editor
+ this.element = element
+ this.view = view
+ this.element.addEventListener('mousedown', this.mousedownHandler, { capture: true })
+ this.editor.on('focus', this.focusHandler)
+ this.editor.on('blur', this.blurHandler)
+ this.render()
+ }
+
+ mousedownHandler = () => {
+ this.preventHide = true
+ }
+
+ focusHandler = () => {
+ // we use `setTimeout` to make sure `selection` is already updated
+ setTimeout(() => this.update(this.editor.view))
+ }
+
+ blurHandler = ({ event }: { event: FocusEvent }) => {
+ if (this.preventHide) {
+ this.preventHide = false
+
+ return
+ }
+
+ if (
+ event?.relatedTarget
+ && this.element.parentNode?.contains(event.relatedTarget as Node)
+ ) {
+ return
+ }
+
+ this.hide()
+ }
+
+ update(view: EditorView, oldState?: EditorState) {
+ const { state, composing } = view
+ const { doc, selection } = state
+ const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection)
+
+ if (composing || isSame) {
+ return
+ }
+
+ const { $anchor, anchor, empty } = selection
+ const parent = this.element.offsetParent
+ const isRootDepth = $anchor.depth === 1
+ const isDefaultNodeType = $anchor.parent.type === state.doc.type.contentMatch.defaultType
+ const isDefaultNodeEmpty = isNodeEmpty(selection.$anchor.parent)
+ const isActive = isRootDepth && isDefaultNodeType && isDefaultNodeEmpty
+
+ if (!empty || !parent || !isActive) {
+ this.hide()
+
+ return
+ }
+
+ const parentBox = parent.getBoundingClientRect()
+ const cursorCoords = view.coordsAtPos(anchor)
+ const top = cursorCoords.top - parentBox.top
+ const left = cursorCoords.left - parentBox.left
+
+ this.isActive = true
+ this.top = top
+ this.left = left
+
+ this.render()
+ }
+
+ render() {
+ Object.assign(this.element.style, {
+ position: 'absolute',
+ zIndex: 1,
+ visibility: this.isActive ? 'visible' : 'hidden',
+ opacity: this.isActive ? 1 : 0,
+ left: `${this.left}px`,
+ top: `${this.top}px`,
+ })
+ }
+
+ hide() {
+ this.isActive = false
+ this.render()
+ }
+
+ destroy() {
+ this.element.removeEventListener('mousedown', this.mousedownHandler)
+ this.editor.off('focus', this.focusHandler)
+ this.editor.off('blur', this.blurHandler)
+ }
+}
+
+export const FloatingMenuPluginKey = new PluginKey('menuFloating')
+
+export const FloatingMenuPlugin = (options: FloatingMenuPluginProps) => {
+ return new Plugin({
+ key: FloatingMenuPluginKey,
+ view: view => new FloatingMenuView({ view, ...options }),
+ })
+}
diff --git a/packages/extension-floating-menu/src/floating-menu.ts b/packages/extension-floating-menu/src/floating-menu.ts
new file mode 100644
index 00000000..8f4b1b12
--- /dev/null
+++ b/packages/extension-floating-menu/src/floating-menu.ts
@@ -0,0 +1,27 @@
+import { Extension } from '@tiptap/core'
+import { FloatingMenuPlugin, FloatingMenuPluginProps } from './floating-menu-plugin'
+
+export type FloatingMenuOptions = Omit & {
+ element: HTMLElement | null,
+}
+
+export const FloatingMenu = Extension.create({
+ name: 'bubbleMenu',
+
+ defaultOptions: {
+ element: null,
+ },
+
+ addProseMirrorPlugins() {
+ if (!this.options.element) {
+ return []
+ }
+
+ return [
+ FloatingMenuPlugin({
+ editor: this.editor,
+ element: this.options.element,
+ }),
+ ]
+ },
+})
diff --git a/packages/extension-floating-menu/src/index.ts b/packages/extension-floating-menu/src/index.ts
new file mode 100644
index 00000000..27bee69f
--- /dev/null
+++ b/packages/extension-floating-menu/src/index.ts
@@ -0,0 +1,6 @@
+import { FloatingMenu } from './floating-menu'
+
+export * from './floating-menu'
+export * from './floating-menu-plugin'
+
+export default FloatingMenu
diff --git a/packages/react/package.json b/packages/react/package.json
index fc11ece3..e717cf09 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -28,6 +28,7 @@
},
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.0.0-beta.4",
+ "@tiptap/extension-floating-menu": "^2.0.0-beta.0",
"prosemirror-view": "^1.18.2"
},
"devDependencies": {
diff --git a/packages/react/src/FloatingMenu.tsx b/packages/react/src/FloatingMenu.tsx
new file mode 100644
index 00000000..72da7c69
--- /dev/null
+++ b/packages/react/src/FloatingMenu.tsx
@@ -0,0 +1,29 @@
+import React, { useEffect, useRef } from 'react'
+import { FloatingMenuPlugin, FloatingMenuPluginKey, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
+
+export type FloatingMenuProps = Omit & {
+ className?: string,
+}
+
+export const FloatingMenu: React.FC = props => {
+ const element = useRef(null)
+
+ useEffect(() => {
+ const { editor } = props
+
+ editor.registerPlugin(FloatingMenuPlugin({
+ editor,
+ element: element.current as HTMLElement,
+ }))
+
+ return () => {
+ editor.unregisterPlugin(FloatingMenuPluginKey)
+ }
+ }, [])
+
+ return (
+
+ {props.children}
+
+ )
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 493cb331..6d1627af 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -1,6 +1,7 @@
export * from '@tiptap/core'
export * from './BubbleMenu'
export { Editor } from './Editor'
+export * from './FloatingMenu'
export * from './useEditor'
export * from './ReactRenderer'
export * from './ReactNodeViewRenderer'
diff --git a/packages/vue-2/package.json b/packages/vue-2/package.json
index ccc9dd4d..e1f84d25 100644
--- a/packages/vue-2/package.json
+++ b/packages/vue-2/package.json
@@ -27,6 +27,7 @@
},
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.0.0-beta.4",
+ "@tiptap/extension-floating-menu": "^2.0.0-beta.0",
"prosemirror-view": "^1.18.2"
}
}
diff --git a/packages/vue-2/src/FloatingMenu.ts b/packages/vue-2/src/FloatingMenu.ts
new file mode 100644
index 00000000..08f74d36
--- /dev/null
+++ b/packages/vue-2/src/FloatingMenu.ts
@@ -0,0 +1,39 @@
+import Vue, { PropType } from 'vue'
+import { FloatingMenuPlugin, FloatingMenuPluginKey, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
+
+export const FloatingMenu = Vue.extend({
+ name: 'FloatingMenu',
+
+ props: {
+ editor: {
+ type: Object as PropType,
+ required: true,
+ },
+ },
+
+ watch: {
+ editor: {
+ immediate: true,
+ handler(editor: FloatingMenuPluginProps['editor']) {
+ if (!editor) {
+ return
+ }
+
+ this.$nextTick(() => {
+ editor.registerPlugin(FloatingMenuPlugin({
+ editor,
+ element: this.$el as HTMLElement,
+ }))
+ })
+ },
+ },
+ },
+
+ render(createElement) {
+ return createElement('div', {}, this.$slots.default)
+ },
+
+ beforeDestroy() {
+ this.editor.unregisterPlugin(FloatingMenuPluginKey)
+ },
+})
diff --git a/packages/vue-2/src/index.ts b/packages/vue-2/src/index.ts
index 4233fd98..f5b9136a 100644
--- a/packages/vue-2/src/index.ts
+++ b/packages/vue-2/src/index.ts
@@ -2,6 +2,7 @@ export * from '@tiptap/core'
export * from './BubbleMenu'
export { Editor } from './Editor'
export * from './EditorContent'
+export * from './FloatingMenu'
export * from './VueRenderer'
export * from './VueNodeViewRenderer'
export * from './NodeViewWrapper'
diff --git a/packages/vue-3/package.json b/packages/vue-3/package.json
index 6c8a71bf..80280e0f 100644
--- a/packages/vue-3/package.json
+++ b/packages/vue-3/package.json
@@ -26,6 +26,7 @@
},
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.0.0-beta.4",
+ "@tiptap/extension-floating-menu": "^2.0.0-beta.0",
"prosemirror-state": "^1.3.4",
"prosemirror-view": "^1.18.2",
"vue": "^3.0.0"
diff --git a/packages/vue-3/src/FloatingMenu.ts b/packages/vue-3/src/FloatingMenu.ts
new file mode 100644
index 00000000..8a93315a
--- /dev/null
+++ b/packages/vue-3/src/FloatingMenu.ts
@@ -0,0 +1,41 @@
+import {
+ h,
+ ref,
+ PropType,
+ onMounted,
+ onBeforeUnmount,
+ defineComponent,
+} from 'vue'
+import {
+ FloatingMenuPlugin,
+ FloatingMenuPluginKey,
+ FloatingMenuPluginProps,
+} from '@tiptap/extension-floating-menu'
+
+export const FloatingMenu = defineComponent({
+ name: 'FloatingMenu',
+
+ props: {
+ editor: {
+ type: Object as PropType,
+ required: true,
+ },
+ },
+
+ setup({ editor }, { slots }) {
+ const root = ref(null)
+
+ onMounted(() => {
+ editor.registerPlugin(FloatingMenuPlugin({
+ editor,
+ element: root.value as HTMLElement,
+ }))
+ })
+
+ onBeforeUnmount(() => {
+ editor.unregisterPlugin(FloatingMenuPluginKey)
+ })
+
+ return () => h('div', { ref: root }, slots.default?.())
+ },
+})
diff --git a/packages/vue-3/src/index.ts b/packages/vue-3/src/index.ts
index 03030195..2350610a 100644
--- a/packages/vue-3/src/index.ts
+++ b/packages/vue-3/src/index.ts
@@ -2,6 +2,7 @@ export * from '@tiptap/core'
export * from './BubbleMenu'
export { Editor } from './Editor'
export * from './EditorContent'
+export * from './FloatingMenu'
export * from './useEditor'
export * from './VueRenderer'
export * from './VueNodeViewRenderer'