From 007e6f855b9c0fdf1295267d78999b5e1722b0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Ku=CC=88hn?= Date: Thu, 1 Apr 2021 15:19:31 +0200 Subject: [PATCH 1/4] add basic floating menu --- .../Extensions/FloatingMenu/React/index.jsx | 43 ++++++ .../Extensions/FloatingMenu/React/styles.scss | 5 + .../Extensions/FloatingMenu/Vue/index.vue | 73 ++++++++++ packages/extension-floating-menu/README.md | 14 ++ packages/extension-floating-menu/package.json | 31 +++++ .../src/floating-menu-plugin.ts | 131 ++++++++++++++++++ .../src/floating-menu.ts | 27 ++++ packages/extension-floating-menu/src/index.ts | 6 + packages/vue-2/src/FloatingMenu.ts | 39 ++++++ packages/vue-2/src/index.ts | 1 + 10 files changed, 370 insertions(+) create mode 100644 docs/src/demos/Extensions/FloatingMenu/React/index.jsx create mode 100644 docs/src/demos/Extensions/FloatingMenu/React/styles.scss create mode 100644 docs/src/demos/Extensions/FloatingMenu/Vue/index.vue create mode 100644 packages/extension-floating-menu/README.md create mode 100644 packages/extension-floating-menu/package.json create mode 100644 packages/extension-floating-menu/src/floating-menu-plugin.ts create mode 100644 packages/extension-floating-menu/src/floating-menu.ts create mode 100644 packages/extension-floating-menu/src/index.ts create mode 100644 packages/vue-2/src/FloatingMenu.ts 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 000000000..01787e1f5 --- /dev/null +++ b/docs/src/demos/Extensions/FloatingMenu/React/index.jsx @@ -0,0 +1,43 @@ +import React from 'react' +import { useEditor, EditorContent, BubbleMenu } from '@tiptap/react' +import { defaultExtensions } from '@tiptap/starter-kit' +import './styles.scss' + +export default () => { + const editor = useEditor({ + extensions: [ + ...defaultExtensions(), + ], + content: ` +

+ Hey, try to select some text here. There will popup a menu for selecting some inline styles. Remember: you have full control about content and styling of this menu. +

+ `, + }) + + 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 000000000..12c872982 --- /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 000000000..4954ea363 --- /dev/null +++ b/docs/src/demos/Extensions/FloatingMenu/Vue/index.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/packages/extension-floating-menu/README.md b/packages/extension-floating-menu/README.md new file mode 100644 index 000000000..3b68aecc6 --- /dev/null +++ b/packages/extension-floating-menu/README.md @@ -0,0 +1,14 @@ +# @tiptap/extension-floating-menu +[![Version](https://img.shields.io/npm/v/@tiptap/extension-floating-menu.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-floating-menu) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-floating-menu.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/extension-floating-menu.svg)](https://www.npmjs.com/package/@tiptap/extension-floating-menu) +[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](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 000000000..070f54636 --- /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 000000000..336c1d4b3 --- /dev/null +++ b/packages/extension-floating-menu/src/floating-menu-plugin.ts @@ -0,0 +1,131 @@ +import { Editor } 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 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, empty } = selection + const parent = this.element.offsetParent + const currentDom = view.domAtPos(anchor) + const currentElement = currentDom.node as Element + const isActive = currentElement.innerHTML === '
' + && currentElement.tagName === 'P' + && currentElement.parentNode === view.dom + + if (!empty || !parent || !isActive) { + this.hide() + + return + } + + const parentBox = parent.getBoundingClientRect() + const cursorCoords = view.coordsAtPos(anchor) + const top = cursorCoords.top - parentBox.top + + this.isActive = true + this.top = top + + 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`, + // bottom: `${this.bottom}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 000000000..8f4b1b121 --- /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 000000000..27bee69f2 --- /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/vue-2/src/FloatingMenu.ts b/packages/vue-2/src/FloatingMenu.ts new file mode 100644 index 000000000..08f74d36f --- /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 4233fd986..f5b9136a8 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' From c55ebc7ea6cab4cf471b0a59f83678e5b9106a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Ku=CC=88hn?= Date: Thu, 1 Apr 2021 15:33:37 +0200 Subject: [PATCH 2/4] improve default node detection for floating menu --- .../src/floating-menu-plugin.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/extension-floating-menu/src/floating-menu-plugin.ts b/packages/extension-floating-menu/src/floating-menu-plugin.ts index 336c1d4b3..98e3dad34 100644 --- a/packages/extension-floating-menu/src/floating-menu-plugin.ts +++ b/packages/extension-floating-menu/src/floating-menu-plugin.ts @@ -1,4 +1,4 @@ -import { Editor } from '@tiptap/core' +import { Editor, isNodeEmpty } from '@tiptap/core' import { EditorState, Plugin, PluginKey } from 'prosemirror-state' import { EditorView } from 'prosemirror-view' @@ -22,6 +22,8 @@ export class FloatingMenuView { public top = 0 + public left = 0 + public preventHide = false constructor({ @@ -73,13 +75,12 @@ export class FloatingMenuView { return } - const { anchor, empty } = selection + const { $anchor, anchor, empty } = selection const parent = this.element.offsetParent - const currentDom = view.domAtPos(anchor) - const currentElement = currentDom.node as Element - const isActive = currentElement.innerHTML === '
' - && currentElement.tagName === 'P' - && currentElement.parentNode === view.dom + 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() @@ -90,9 +91,11 @@ export class FloatingMenuView { 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() } @@ -103,9 +106,8 @@ export class FloatingMenuView { zIndex: 1, visibility: this.isActive ? 'visible' : 'hidden', opacity: this.isActive ? 1 : 0, - // left: `${this.left}px`, + left: `${this.left}px`, top: `${this.top}px`, - // bottom: `${this.bottom}px`, }) } From 3f1fa8139224081bfe1786edd734058489d4a7f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Ku=CC=88hn?= Date: Thu, 1 Apr 2021 15:47:00 +0200 Subject: [PATCH 3/4] add doc page for floating menu --- .../Extensions/FloatingMenu/Vue/index.vue | 2 +- .../docPages/api/extensions/floating-menu.md | 41 +++++++++++++++++++ docs/src/links.yaml | 2 + 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 docs/src/docPages/api/extensions/floating-menu.md diff --git a/docs/src/demos/Extensions/FloatingMenu/Vue/index.vue b/docs/src/demos/Extensions/FloatingMenu/Vue/index.vue index 4954ea363..8d249bf41 100644 --- a/docs/src/demos/Extensions/FloatingMenu/Vue/index.vue +++ b/docs/src/demos/Extensions/FloatingMenu/Vue/index.vue @@ -41,7 +41,7 @@ export default { ], content: `

- Hey, try to select some text here. There will popup a menu for selecting some inline styles. Remember: you have full control about content and styling of this menu. + This is an example of a medium-like editor. Enter a new line and some buttons will appear.

`, }) 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 000000000..ead688e0c --- /dev/null +++ b/docs/src/docPages/api/extensions/floating-menu.md @@ -0,0 +1,41 @@ +# Floating Menu +[![Version](https://img.shields.io/npm/v/@tiptap/extension-floating-menu.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-floating-menu) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-floating-menu.svg)](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 1bc8ef06c..11d266eef 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 From 3eac1ce319a6476bfc7b5c06a8e51b247d4b44b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Ku=CC=88hn?= Date: Thu, 1 Apr 2021 15:55:19 +0200 Subject: [PATCH 4/4] add floating menu to vue 3 and react --- .../Extensions/FloatingMenu/React/index.jsx | 32 +++++++++------ packages/react/package.json | 1 + packages/react/src/FloatingMenu.tsx | 29 +++++++++++++ packages/react/src/index.ts | 1 + packages/vue-2/package.json | 1 + packages/vue-3/package.json | 1 + packages/vue-3/src/FloatingMenu.ts | 41 +++++++++++++++++++ packages/vue-3/src/index.ts | 1 + 8 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 packages/react/src/FloatingMenu.tsx create mode 100644 packages/vue-3/src/FloatingMenu.ts diff --git a/docs/src/demos/Extensions/FloatingMenu/React/index.jsx b/docs/src/demos/Extensions/FloatingMenu/React/index.jsx index 01787e1f5..d01f0b0ec 100644 --- a/docs/src/demos/Extensions/FloatingMenu/React/index.jsx +++ b/docs/src/demos/Extensions/FloatingMenu/React/index.jsx @@ -1,5 +1,5 @@ import React from 'react' -import { useEditor, EditorContent, BubbleMenu } from '@tiptap/react' +import { useEditor, EditorContent, FloatingMenu } from '@tiptap/react' import { defaultExtensions } from '@tiptap/starter-kit' import './styles.scss' @@ -10,33 +10,39 @@ export default () => { ], content: `

- Hey, try to select some text here. There will popup a menu for selecting some inline styles. Remember: you have full control about content and styling of this menu. + This is an example of a medium-like editor. Enter a new line and some buttons will appear.

`, }) return (
- {editor && + {editor && - } + + }
) diff --git a/packages/react/package.json b/packages/react/package.json index 83462c8e4..be54f2674 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@tiptap/extension-bubble-menu": "^2.0.0-beta.3", + "@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 000000000..72da7c697 --- /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 493cb331e..6d1627afa 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 52ccccd5c..24af09e36 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.3", + "@tiptap/extension-floating-menu": "^2.0.0-beta.0", "prosemirror-view": "^1.18.2" } } diff --git a/packages/vue-3/package.json b/packages/vue-3/package.json index 6b7a639a8..ad674923e 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.3", + "@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 000000000..8a93315a2 --- /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 030301952..2350610af 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'