From c38d30fb1561e0f97faba6a563a6a188d06dfa53 Mon Sep 17 00:00:00 2001 From: wux_labs Date: Sat, 4 Jan 2025 21:41:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=87=E6=9C=AC=E6=B5=AE?= =?UTF-8?q?=E5=8A=A8=E8=8F=9C=E5=8D=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/index.ts | 4 + .../menus/bubble/BubbleMenuPlugin.ts | 170 ++++++++++++++++++ .../menus/bubble/TextSelectionBubbleMenu.ts | 130 ++++++++++++++ src/core/UAIExtensions.ts | 70 +++++++- 4 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 src/components/menus/bubble/BubbleMenuPlugin.ts create mode 100644 src/components/menus/bubble/TextSelectionBubbleMenu.ts diff --git a/src/components/index.ts b/src/components/index.ts index 53b2deb..a5cb631 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -84,6 +84,8 @@ import { ExportPdf } from "./menus/toolbar/export/ExportPdf.ts"; import { ExportMarkdown } from "./menus/toolbar/export/ExportMarkdown.ts"; import { ExportImage } from "./menus/toolbar/export/ExportImage.ts"; +import { TextSelectionBubbleMenu } from "./menus/bubble/TextSelectionBubbleMenu.ts"; + // 注册组件 defineCustomElement('uai-editor-header', Header); defineCustomElement('uai-editor-editor', Editor); @@ -167,3 +169,5 @@ defineCustomElement('uai-editor-export-menu-odt', ExportOdt); defineCustomElement('uai-editor-export-menu-pdf', ExportPdf); defineCustomElement('uai-editor-export-menu-markdown', ExportMarkdown); defineCustomElement('uai-editor-export-menu-image', ExportImage); + +defineCustomElement('uai-editor-bubble-menu-text-selection', TextSelectionBubbleMenu); \ No newline at end of file diff --git a/src/components/menus/bubble/BubbleMenuPlugin.ts b/src/components/menus/bubble/BubbleMenuPlugin.ts new file mode 100644 index 0000000..ea2da3e --- /dev/null +++ b/src/components/menus/bubble/BubbleMenuPlugin.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2024-present AI-Labs + +import { + Editor, isNodeSelection, posToDOMRect, +} from '@tiptap/core' +import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state' +import { EditorView } from '@tiptap/pm/view' +import tippy, { Instance, Props } from 'tippy.js' + +export type BubbleMenuPluginOptions = { + /** + * 插件Key值 + * @type {PluginKey | string} + * @default 'bubbleMenu' + */ + pluginKey: PluginKey | string + + /** + * 编辑器实例 + */ + editor?: Editor; + + /** + * 显示浮动菜单的元素 + * @type {HTMLElement} + * @default null + */ + element: HTMLElement; + + view?: EditorView; + + /** + * 浮动菜单选项实例 + */ + tippyOptions?: Partial; + + /** + * 用来判断当前浮动菜单是否显示 + */ + shouldShow?: ((props: { + editor: Editor + view: EditorView + state: EditorState + oldState?: EditorState + from: number + to: number + }) => boolean); +} + +export class BubbleMenuView { + public editor: Editor; + + public element: HTMLElement; + + public view: EditorView; + + public tippy: Instance | undefined; + + public tippyOptions?: Partial; + + public shouldShow?: (props: { + editor: Editor + view: EditorView + state: EditorState + oldState?: EditorState + from: number + to: number + }) => boolean; + + constructor({ + editor, + element, + view, + tippyOptions = {}, + shouldShow, + }: BubbleMenuPluginOptions) { + this.editor = editor!; + this.element = element; + this.view = view!; + this.tippyOptions = tippyOptions; + this.shouldShow = shouldShow; + + this.element.remove(); + this.element.style.visibility = 'visible'; + } + + createTooltip() { + const { element: editorElement } = this.editor.options; + const editorIsAttached = !!editorElement.parentElement; + + if (this.tippy || !editorIsAttached) { + return; + } + + this.tippy = tippy(editorElement, { + duration: 0, + getReferenceClientRect: null, + content: this.element, + interactive: true, + trigger: 'manual', + ...this.tippyOptions, + }); + } + + update(view: EditorView, oldState?: EditorState) { + const selectionChanged = !oldState?.selection.eq(view.state.selection); + const docChanged = !oldState?.doc.eq(view.state.doc); + + const { state, composing } = view; + const { selection } = state; + + const isSame = !selectionChanged && !docChanged; + + if (composing || isSame) { + return; + } + + this.createTooltip(); + + 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) { + this.tippy?.hide(); + return; + } + + this.tippy?.setProps({ + 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.tippy?.show(); + } +} + +export const BubbleMenuPlugin = (options: BubbleMenuPluginOptions) => { + return new Plugin({ + key: + typeof options.pluginKey === 'string' ? new PluginKey(options.pluginKey) : options.pluginKey, + view: view => new BubbleMenuView({ view, ...options }), + }); +} diff --git a/src/components/menus/bubble/TextSelectionBubbleMenu.ts b/src/components/menus/bubble/TextSelectionBubbleMenu.ts new file mode 100644 index 0000000..a03dee0 --- /dev/null +++ b/src/components/menus/bubble/TextSelectionBubbleMenu.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { EditorEvents } from "@tiptap/core"; +import { UAIEditor, UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor"; +import { Bold } from "../common/Bold"; +import { FontSizeDecrease } from "../common/FontSizeDecrease"; +import { FontSizeIncrease } from "../common/FontSizeIncrease"; +import { Italic } from "../common/Italic"; +import { Strike } from "../common/Strike"; +import { Subscript } from "../common/Subscript"; +import { Superscript } from "../common/Superscript"; +import { Underline } from "../common/Underline"; +import { FontColor } from "../common/FontColor"; +import { Highlight } from "../common/Highlight"; + +/** + * 定义选中文本浮动菜单 + */ +export class TextSelectionBubbleMenu extends HTMLElement implements UAIEditorEventListener { + uaiEditor!: UAIEditor; + + // 设置字体大小 + baseMenuFontSizeIncrease!: FontSizeIncrease; + baseMenuFontSizeDecrease!: FontSizeDecrease; + + // 设置字体样式 + baseMenuBold!: Bold; + baseMenuItalic!: Italic; + baseMenuUnderline!: Underline; + baseMenuStrike!: Strike; + baseMenuSubscript!: Subscript; + baseMenuSuperscript!: Superscript; + + // 设置颜色 + baseMenuFontColor!: FontColor; + baseMenuHighlight!: Highlight; + + constructor(uaiEditor: UAIEditor) { + super(); + this.uaiEditor = uaiEditor; + this.initMenus(); + + // 定义菜单容器 + const container = document.createElement("div"); + container.classList.add("uai-bubble-menu-container"); + + const group0 = document.createElement("div"); + group0.classList.add("uai-bubble-menu-virtual-group"); + container.appendChild(group0); + + // 定义菜单组 + const group1 = document.createElement("div"); + group1.classList.add("uai-bubble-menu-virtual-group"); + container.appendChild(group1); + // 添加字体样式功能 + group1.appendChild(this.baseMenuBold); + group1.appendChild(this.baseMenuItalic); + group1.appendChild(this.baseMenuUnderline); + group1.appendChild(this.baseMenuStrike); + + // 定义菜单组 + const group2 = document.createElement("div"); + group2.classList.add("uai-bubble-menu-virtual-group"); + container.appendChild(group2); + // 添加字体大小功能 + group2.appendChild(this.baseMenuFontSizeIncrease); + group2.appendChild(this.baseMenuFontSizeDecrease); + + group2.appendChild(this.baseMenuSubscript); + group2.appendChild(this.baseMenuSuperscript); + + // 定义菜单组 + const group4 = document.createElement("div"); + group4.classList.add("uai-bubble-menu-virtual-group"); + container.appendChild(group4); + // 添加设置颜色 + group4.appendChild(this.baseMenuFontColor); + group4.appendChild(this.baseMenuHighlight); + + this.appendChild(container); + } + + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + + } + + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + + } + + onEditableChange(editable: boolean) { + + } + + /** + * 初始化功能菜单 + */ + initMenus() { + this.baseMenuFontSizeIncrease = new FontSizeIncrease({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuFontSizeIncrease); + + this.baseMenuFontSizeDecrease = new FontSizeDecrease({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuFontSizeDecrease); + + this.baseMenuBold = new Bold({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuBold); + + this.baseMenuItalic = new Italic({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuItalic); + + this.baseMenuUnderline = new Underline({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuUnderline); + + this.baseMenuStrike = new Strike({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuStrike); + + this.baseMenuSubscript = new Subscript({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuSubscript); + + this.baseMenuSuperscript = new Superscript({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuSuperscript); + + this.baseMenuFontColor = new FontColor({ menuType: "color", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuFontColor); + + this.baseMenuHighlight = new Highlight({ menuType: "color", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuHighlight); + } +} \ No newline at end of file diff --git a/src/core/UAIExtensions.ts b/src/core/UAIExtensions.ts index 7d9053b..36b21b7 100644 --- a/src/core/UAIExtensions.ts +++ b/src/core/UAIExtensions.ts @@ -1,7 +1,7 @@ // Copyright (c) 2024-present AI-Labs // @ ts-nocheck -import { Extensions } from "@tiptap/core"; +import { Extension, Extensions, getTextBetween } from "@tiptap/core"; import { UAIEditor, UAIEditorOptions } from "./UAIEditor"; import { StarterKit } from "@tiptap/starter-kit"; @@ -38,6 +38,73 @@ import Audio from "../extensions/Audio.ts"; import { uuid } from "../utils/UUID.ts"; import Toc from "../extensions/Toc.ts"; +import { BubbleMenuPluginOptions, BubbleMenuPlugin } from "../components/menus/bubble/BubbleMenuPlugin.ts"; +import { TextSelectionBubbleMenu } from "../components/menus/bubble/TextSelectionBubbleMenu.ts"; + +/** + * 创建浮动菜单功能 + * @param name + * @param options + * @returns + */ +function createBubbleMenu(name: string, options: BubbleMenuPluginOptions) { + return Extension.create({ + name: name, + addOptions() { + return { + ...options + } + }, + addProseMirrorPlugins() { + if (!this.options.element) { + return [] + } + + return [ + BubbleMenuPlugin({ + pluginKey: this.options.pluginKey, + editor: this.editor, + element: this.options.element, + tippyOptions: this.options.tippyOptions, + shouldShow: this.options.shouldShow, + }), + ] + }, + }) +} + +/** + * 创建文本内容的浮动菜单 + * @param uaiEditor + * @returns + */ +const createTextSelectionBubbleMenu = (uaiEditor: UAIEditor) => { + const container = new TextSelectionBubbleMenu(uaiEditor); + + return createBubbleMenu("textSelectionBubbleMenu", { + pluginKey: 'textSelectionBubbleMenu', + element: container, + tippyOptions: { + appendTo: uaiEditor.editor.editorContainer, + arrow: false, + interactive: true, + hideOnClick: false, + placement: 'top', + }, + shouldShow: ({ editor }) => { + if (!editor.isEditable) { + return false; + } + const { state: { selection } } = editor; + return !selection.empty && getTextBetween(editor.state.doc, { + from: selection.from, + to: selection.to + }).trim().length > 0 + && !editor.isActive("image"); + } + }) +} + /** * 定义编辑器的所有自定义扩展组件 * @param uaiEditor @@ -112,6 +179,7 @@ export const allExtensions = (uaiEditor: UAIEditor, _options: UAIEditorOptions): Toc, Underline, Video, + createTextSelectionBubbleMenu(uaiEditor), ]; return extensions;