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] 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 00000000..01787e1f --- /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 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..4954ea36 --- /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 00000000..3b68aecc --- /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 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..336c1d4b --- /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 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/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'