添加文本浮动菜单功能
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 { ExportMarkdown } from "./menus/toolbar/export/ExportMarkdown.ts";
|
||||||
import { ExportImage } from "./menus/toolbar/export/ExportImage.ts";
|
import { ExportImage } from "./menus/toolbar/export/ExportImage.ts";
|
||||||
|
|
||||||
|
import { TextSelectionBubbleMenu } from "./menus/bubble/TextSelectionBubbleMenu.ts";
|
||||||
|
|
||||||
// 注册组件
|
// 注册组件
|
||||||
defineCustomElement('uai-editor-header', Header);
|
defineCustomElement('uai-editor-header', Header);
|
||||||
defineCustomElement('uai-editor-editor', Editor);
|
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-pdf', ExportPdf);
|
||||||
defineCustomElement('uai-editor-export-menu-markdown', ExportMarkdown);
|
defineCustomElement('uai-editor-export-menu-markdown', ExportMarkdown);
|
||||||
defineCustomElement('uai-editor-export-menu-image', ExportImage);
|
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
|
// Copyright (c) 2024-present AI-Labs
|
||||||
|
|
||||||
// @ ts-nocheck
|
// @ ts-nocheck
|
||||||
import { Extensions } from "@tiptap/core";
|
import { Extension, Extensions, getTextBetween } from "@tiptap/core";
|
||||||
import { UAIEditor, UAIEditorOptions } from "./UAIEditor";
|
import { UAIEditor, UAIEditorOptions } from "./UAIEditor";
|
||||||
|
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
@@ -38,6 +38,73 @@ import Audio from "../extensions/Audio.ts";
|
|||||||
import { uuid } from "../utils/UUID.ts";
|
import { uuid } from "../utils/UUID.ts";
|
||||||
import Toc from "../extensions/Toc.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
|
* @param uaiEditor
|
||||||
@@ -112,6 +179,7 @@ export const allExtensions = (uaiEditor: UAIEditor, _options: UAIEditorOptions):
|
|||||||
Toc,
|
Toc,
|
||||||
Underline,
|
Underline,
|
||||||
Video,
|
Video,
|
||||||
|
createTextSelectionBubbleMenu(uaiEditor),
|
||||||
];
|
];
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
|
|||||||
Reference in New Issue
Block a user