From 71a4e5e646a054223c39208982d895e246db7d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Ku=CC=88hn?= Date: Tue, 30 Mar 2021 11:36:51 +0200 Subject: [PATCH] add bubble menu from v1 --- .../src/demos/Extensions/BubbleMenu/index.vue | 85 +++++++ packages/extension-bubble-menu/README.md | 14 ++ packages/extension-bubble-menu/package.json | 31 +++ .../src/bubble-menu-plugin.ts | 238 ++++++++++++++++++ .../extension-bubble-menu/src/bubble-menu.ts | 29 +++ packages/extension-bubble-menu/src/index.ts | 5 + 6 files changed, 402 insertions(+) create mode 100644 docs/src/demos/Extensions/BubbleMenu/index.vue create mode 100644 packages/extension-bubble-menu/README.md create mode 100644 packages/extension-bubble-menu/package.json create mode 100644 packages/extension-bubble-menu/src/bubble-menu-plugin.ts create mode 100644 packages/extension-bubble-menu/src/bubble-menu.ts create mode 100644 packages/extension-bubble-menu/src/index.ts diff --git a/docs/src/demos/Extensions/BubbleMenu/index.vue b/docs/src/demos/Extensions/BubbleMenu/index.vue new file mode 100644 index 00000000..859cfcc1 --- /dev/null +++ b/docs/src/demos/Extensions/BubbleMenu/index.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/packages/extension-bubble-menu/README.md b/packages/extension-bubble-menu/README.md new file mode 100644 index 00000000..85864198 --- /dev/null +++ b/packages/extension-bubble-menu/README.md @@ -0,0 +1,14 @@ +# @tiptap/extension-underline +[![Version](https://img.shields.io/npm/v/@tiptap/extension-underline.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-underline) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-underline.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/extension-underline.svg)](https://www.npmjs.com/package/@tiptap/extension-underline) +[![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-bubble-menu/package.json b/packages/extension-bubble-menu/package.json new file mode 100644 index 00000000..95c6ef97 --- /dev/null +++ b/packages/extension-bubble-menu/package.json @@ -0,0 +1,31 @@ +{ + "name": "@tiptap/extension-bubble-menu", + "description": "bubble-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-bubble-menu.cjs.js", + "umd": "dist/tiptap-extension-bubble-menu.umd.js", + "module": "dist/tiptap-extension-bubble-menu.esm.js", + "unpkg": "dist/tiptap-extension-bubble-menu.bundle.umd.min.js", + "types": "dist/packages/extension-bubble-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-bubble-menu/src/bubble-menu-plugin.ts b/packages/extension-bubble-menu/src/bubble-menu-plugin.ts new file mode 100644 index 00000000..70fa3436 --- /dev/null +++ b/packages/extension-bubble-menu/src/bubble-menu-plugin.ts @@ -0,0 +1,238 @@ +import { Editor } from '@tiptap/core' +import { EditorState, Plugin, PluginKey } from 'prosemirror-state' +import { EditorView } from 'prosemirror-view' + +interface BubbleMenuSettings { + bottom: number; + isActive: boolean; + left: number; + top: number; +} +interface BubbleMenuPluginOptions { + editor: Editor; + element: HTMLElement; + keepInBounds: boolean; + onUpdate(menu: BubbleMenuSettings): void; +} +type DOMRectSide = 'bottom' | 'left' | 'right' | 'top'; + +function textRange(node: Node, from?: number, to?: number) { + const range = document.createRange() + range.setEnd( + node, + typeof to === 'number' ? to : (node.nodeValue || '').length, + ) + range.setStart(node, from || 0) + return range +} + +function singleRect(object: Range | Element, bias: number) { + const rects = object.getClientRects() + return !rects.length + ? object.getBoundingClientRect() + : rects[bias < 0 ? 0 : rects.length - 1] +} + +function coordsAtPos(view: EditorView, pos: number, end = false) { + const { node, offset } = view.domAtPos(pos) // view.docView.domFromPos(pos); + let side: DOMRectSide | null = null + let rect: DOMRect | null = null + if (node.nodeType === 3) { + const nodeValue = node.nodeValue || '' + if (end && offset < nodeValue.length) { + rect = singleRect(textRange(node, offset - 1, offset), -1) + side = 'right' + } else if (offset < nodeValue.length) { + rect = singleRect(textRange(node, offset, offset + 1), -1) + side = 'left' + } + } else if (node.firstChild) { + if (offset < node.childNodes.length) { + const child = node.childNodes[offset] + rect = singleRect( + child.nodeType === 3 ? textRange(child) : (child as Element), + -1, + ) + side = 'left' + } + if ((!rect || rect.top === rect.bottom) && offset) { + const child = node.childNodes[offset - 1] + rect = singleRect( + child.nodeType === 3 ? textRange(child) : (child as Element), + 1, + ) + side = 'right' + } + } else { + const element = node as Element + rect = element.getBoundingClientRect() + side = 'left' + } + + if (rect && side) { + const x = rect[side] + + return { + top: rect.top, + bottom: rect.bottom, + left: x, + right: x, + } + } + return { + top: 0, + bottom: 0, + left: 0, + right: 0, + } +} + +class Menu { + public options: BubbleMenuPluginOptions; + + public editorView: EditorView; + + public isActive = false; + + public left = 0; + + public bottom = 0; + + public top = 0; + + public preventHide = false; + + constructor({ + options, + editorView, + }: { + options: BubbleMenuPluginOptions; + editorView: EditorView; + }) { + this.options = { + ...{ + element: null, + keepInBounds: true, + onUpdate: () => false, + }, + ...options, + } + this.editorView = editorView + this.options.element.addEventListener('mousedown', this.mousedownHandler, { + capture: true, + }) + this.options.editor.on('focus', this.focusHandler) + this.options.editor.on('blur', this.blurHandler) + } + + mousedownHandler = () => { + this.preventHide = true + }; + + focusHandler = () => { + this.update(this.options.editor.view) + }; + + blurHandler = ({ event }: { event: FocusEvent }) => { + if (this.preventHide) { + this.preventHide = false + return + } + + this.hide(event) + }; + + update(view: EditorView, lastState?: EditorState) { + const { state } = view + + if (view.composing) { + return + } + + if ( + lastState + && lastState.doc.eq(state.doc) + && lastState.selection.eq(state.selection) + ) { + return + } + + if (state.selection.empty) { + this.hide() + return + } + + const { from, to } = state.selection + const start = coordsAtPos(view, from) + const end = coordsAtPos(view, to, true) + const parent = this.options.element.offsetParent + + if (!parent) { + this.hide() + return + } + + const box = parent.getBoundingClientRect() + const el = this.options.element.getBoundingClientRect() + const left = (start.left + end.left) / 2 - box.left + + this.left = Math.round( + this.options.keepInBounds + ? Math.min(box.width - el.width / 2, Math.max(left, el.width / 2)) + : left, + ) + this.bottom = Math.round(box.bottom - start.top) + this.top = Math.round(end.bottom - box.top) + this.isActive = true + + console.log({ + left: this.left, + top: this.top, + }) + + this.sendUpdate() + } + + sendUpdate() { + this.options.onUpdate({ + isActive: this.isActive, + left: this.left, + bottom: this.bottom, + top: this.top, + }) + } + + hide(event?: FocusEvent) { + if ( + event + && event.relatedTarget + && this.options.element.parentNode + && this.options.element.parentNode.contains(event.relatedTarget as Node) + ) { + return + } + + this.isActive = false + this.sendUpdate() + } + + destroy() { + this.options.element.removeEventListener( + 'mousedown', + this.mousedownHandler, + ) + this.options.editor.off('focus', this.focusHandler) + this.options.editor.off('blur', this.blurHandler) + } +} + +const BubbleMenuPlugin = (options: BubbleMenuPluginOptions) => { + return new Plugin({ + key: new PluginKey('menu_bubble'), + view(editorView) { + return new Menu({ editorView, options }) + }, + }) +} + +export { BubbleMenuPlugin } diff --git a/packages/extension-bubble-menu/src/bubble-menu.ts b/packages/extension-bubble-menu/src/bubble-menu.ts new file mode 100644 index 00000000..c2905784 --- /dev/null +++ b/packages/extension-bubble-menu/src/bubble-menu.ts @@ -0,0 +1,29 @@ +import { Extension } from '@tiptap/core' +import { BubbleMenuPlugin } from './bubble-menu-plugin' + +export interface BubbleMenuOptions { + element: HTMLElement, + keepInBounds: boolean, + onUpdate: () => void, +} + +export const BubbleMenu = Extension.create({ + name: 'bubbleMenu', + + defaultOptions: { + element: document.createElement('div'), + keepInBounds: true, + onUpdate: () => ({}), + }, + + addProseMirrorPlugins() { + return [ + BubbleMenuPlugin({ + editor: this.editor, + element: this.options.element, + keepInBounds: this.options.keepInBounds, + onUpdate: this.options.onUpdate, + }), + ] + }, +}) diff --git a/packages/extension-bubble-menu/src/index.ts b/packages/extension-bubble-menu/src/index.ts new file mode 100644 index 00000000..71fe7f0d --- /dev/null +++ b/packages/extension-bubble-menu/src/index.ts @@ -0,0 +1,5 @@ +import { BubbleMenu } from './bubble-menu' + +export * from './bubble-menu' + +export default BubbleMenu