添加文本浮动菜单功能
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