添加文本浮动菜单功能

This commit is contained in:
wux_labs
2025-01-04 21:41:32 +08:00
parent fb1f11ffdf
commit c38d30fb15
4 changed files with 373 additions and 1 deletions

View File

@@ -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);

View 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 }),
});
}

View 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);
}
}

View File

@@ -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;