添加文本浮动菜单功能
This commit is contained in:
		@@ -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);
 | 
			
		||||
							
								
								
									
										170
									
								
								src/components/menus/bubble/BubbleMenuPlugin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								src/components/menus/bubble/BubbleMenuPlugin.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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<Props>;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 用来判断当前浮动菜单是否显示
 | 
			
		||||
     */
 | 
			
		||||
    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<Props>;
 | 
			
		||||
 | 
			
		||||
    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 }),
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										130
									
								
								src/components/menus/bubble/TextSelectionBubbleMenu.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								src/components/menus/bubble/TextSelectionBubbleMenu.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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<BubbleMenuPluginOptions>({
 | 
			
		||||
        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;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user