diff --git a/src/components/index.ts b/src/components/index.ts index a5cb631..46d9e13 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -33,6 +33,8 @@ import { Superscript } from "./menus/common/Superscript.ts"; import { FontColor } from "./menus/common/FontColor.ts"; import { Highlight } from "./menus/common/Highlight.ts"; +import { NodeDelete } from "./menus/common/NodeDelete.ts"; + import { Undo } from "./menus/toolbar/base/Undo.ts"; import { Redo } from "./menus/toolbar/base/Redo.ts"; import { FormatPainter } from "./menus/toolbar/base/FormatPainter.ts"; @@ -86,6 +88,10 @@ import { ExportImage } from "./menus/toolbar/export/ExportImage.ts"; import { TextSelectionBubbleMenu } from "./menus/bubble/TextSelectionBubbleMenu.ts"; +import { ImageBubbleMenu } from "./menus/bubble/ImageBubbleMenu.ts"; +import { ImageFlipX } from "./menus/bubble/image/ImageFlipX.ts"; +import { ImageFlipY } from "./menus/bubble/image/ImageFlipY.ts"; + // 注册组件 defineCustomElement('uai-editor-header', Header); defineCustomElement('uai-editor-editor', Editor); @@ -119,6 +125,8 @@ defineCustomElement('uai-editor-common-menu-superscript', Superscript); defineCustomElement('uai-editor-common-menu-font-color', FontColor); defineCustomElement('uai-editor-common-menu-highlight', Highlight); +defineCustomElement('uai-editor-common-menu-node-delete', NodeDelete); + defineCustomElement('uai-editor-base-menu-undo', Undo); defineCustomElement('uai-editor-base-menu-redo', Redo); defineCustomElement('uai-editor-base-menu-format-painter', FormatPainter); @@ -170,4 +178,8 @@ 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 +defineCustomElement('uai-editor-bubble-menu-text-selection', TextSelectionBubbleMenu); + +defineCustomElement('uai-editor-bubble-menu-image', ImageBubbleMenu); +defineCustomElement('uai-editor-bubble-menu-image-flip-x', ImageFlipX); +defineCustomElement('uai-editor-bubble-menu-image-flip-y', ImageFlipY); diff --git a/src/components/menus/bubble/ImageBubbleMenu.ts b/src/components/menus/bubble/ImageBubbleMenu.ts new file mode 100644 index 0000000..e7e64dc --- /dev/null +++ b/src/components/menus/bubble/ImageBubbleMenu.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { EditorEvents } from "@tiptap/core"; +import { UAIEditorEventListener, UAIEditor, UAIEditorOptions } from "../../../core/UAIEditor"; + +import { AlignLeft } from "../common/AlignLeft.ts"; +import { AlignCenter } from "../common/AlignCenter.ts"; +import { AlignRight } from "../common/AlignRight.ts"; + +import { ImageFlipX } from "./image/ImageFlipX.ts"; +import { ImageFlipY } from "./image/ImageFlipY.ts"; + +import { NodeDelete } from "../common/NodeDelete.ts"; + +/** + * 定义图片内容浮动菜单 + */ +export class ImageBubbleMenu extends HTMLElement implements UAIEditorEventListener { + uaiEditor!: UAIEditor; + + // 左对齐 + baseMenuAlignLeft!: AlignLeft; + // 居中对齐 + baseMenuAlignCenter!: AlignCenter; + // 右对齐 + baseMenuAlignRight!: AlignRight; + + // 垂直翻转 + imageMenuFlipX!: ImageFlipX; + // 水平翻转 + imageMenuFlipY!: ImageFlipY; + + commonMenuNodeDelete!: NodeDelete; + + constructor(uaiEditor: UAIEditor) { + super(); + this.uaiEditor = uaiEditor; + this.initMenus(); + + // 定义菜单容器 + const container = document.createElement("div"); + container.classList.add("uai-bubble-menu-container"); + + // 定义菜单组 + const group1 = document.createElement("div"); + group1.classList.add("uai-bubble-menu-virtual-group"); + container.appendChild(group1); + // 添加对齐功能 + group1.appendChild(this.baseMenuAlignLeft); + group1.appendChild(this.baseMenuAlignCenter); + group1.appendChild(this.baseMenuAlignRight); + + // 定义菜单组 + const group2 = document.createElement("div"); + group2.classList.add("uai-bubble-menu-virtual-group"); + container.appendChild(group2); + // 添加翻转功能 + group2.appendChild(this.imageMenuFlipX); + group2.appendChild(this.imageMenuFlipY); + + // 定义菜单组 + const group3 = document.createElement("div"); + group3.classList.add("uai-bubble-menu-virtual-group"); + container.appendChild(group3); + // 添加删除功能 + group3.appendChild(this.commonMenuNodeDelete); + + this.appendChild(container); + } + + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + + } + + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + + } + + onEditableChange(editable: boolean) { + + } + + /** + * 初始化功能菜单 + */ + initMenus() { + this.baseMenuAlignLeft = new AlignLeft({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuAlignLeft); + + this.baseMenuAlignCenter = new AlignCenter({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuAlignCenter); + + this.baseMenuAlignRight = new AlignRight({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.baseMenuAlignRight); + + this.imageMenuFlipX = new ImageFlipX({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.imageMenuFlipX); + + this.imageMenuFlipY = new ImageFlipY({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.imageMenuFlipY); + + this.commonMenuNodeDelete = new NodeDelete({ menuType: "button", enable: true }); + this.uaiEditor.eventComponents.push(this.commonMenuNodeDelete); + + } +} \ No newline at end of file diff --git a/src/components/menus/bubble/image/ImageFlipX.ts b/src/components/menus/bubble/image/ImageFlipX.ts new file mode 100644 index 0000000..ea2efad --- /dev/null +++ b/src/components/menus/bubble/image/ImageFlipX.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/image-flip-x.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; +import { getSelectionNode } from "../../../../extensions/Selection.ts"; + +/** + * 定义垂直翻转功能 + */ +export class ImageFlipX extends HTMLElement implements UAIEditorEventListener { + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: true, + text: '垂直翻转', + tooltip: '垂直翻转', + } + + menuButton: MenuButton; + + constructor(options: MenuButtonOptions) { + super(); + + // 初始化功能按钮选项 + this.menuButtonOptions = { ...this.menuButtonOptions, ...options }; + + // 创建功能按钮 + this.menuButton = new MenuButton(this.menuButtonOptions); + } + + /** + * 定义创建方法 + * @param event + * @param options + */ + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + this.menuButton.onCreate(event, options); + this.appendChild(this.menuButton); + + // 给按钮添加点击事件 + this.addEventListener("click", () => { + if (this.menuButtonOptions.enable) { + const image = getSelectionNode(event.editor); + const { flipX } = image?.attrs ?? {}; + if (image) { + // 如果文档内容是图片,则对图片进行垂直翻转 + event.editor.commands.updateAttributes(image.type, { + flipX: !flipX, + }) + } + } + }) + } + + /** + * 定义Transaction监听方法 + * @param event + * @param options + */ + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + this.menuButton.onTransaction(event, options); + if (this.menuButton.menuButton) { + if (this.menuButtonOptions.enable) { + // 判断图片是否有垂直翻转 + var active = event.editor.getAttributes('image')?.flipX; + if (active) { + // 如果有垂直翻转,则功能按钮是激活状态 + this.menuButton.menuButton.classList.add("active"); + } else { + // 如果没有垂直翻转,则功能按钮是非激活状态 + this.menuButton.menuButton.classList.remove("active"); + } + } + } + } + + onEditableChange(editable: boolean) { + this.menuButtonOptions.enable = editable; + this.menuButton.onEditableChange(editable); + } +} + diff --git a/src/components/menus/bubble/image/ImageFlipY.ts b/src/components/menus/bubble/image/ImageFlipY.ts new file mode 100644 index 0000000..616f00e --- /dev/null +++ b/src/components/menus/bubble/image/ImageFlipY.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/image-flip.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; +import { getSelectionNode } from "../../../../extensions/Selection.ts"; + +/** + * 定义水平翻转功能 + */ +export class ImageFlipY extends HTMLElement implements UAIEditorEventListener { + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: true, + text: '水平翻转', + tooltip: '水平翻转', + } + + menuButton: MenuButton; + + constructor(options: MenuButtonOptions) { + super(); + + // 初始化功能按钮选项 + this.menuButtonOptions = { ...this.menuButtonOptions, ...options }; + + // 创建功能按钮 + this.menuButton = new MenuButton(this.menuButtonOptions); + } + + /** + * 定义创建方法 + * @param event + * @param options + */ + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + this.menuButton.onCreate(event, options); + this.appendChild(this.menuButton); + + // 给按钮添加点击事件 + this.addEventListener("click", () => { + if (this.menuButtonOptions.enable) { + const image = getSelectionNode(event.editor); + const { flipY } = image?.attrs ?? {}; + if (image) { + // 如果文档内容是图片,则对图片进水平翻转 + event.editor.commands.updateAttributes(image.type, { + flipY: !flipY, + }) + } + } + }) + } + + /** + * 定义Transaction监听方法 + * @param event + * @param options + */ + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + this.menuButton.onTransaction(event, options); + if (this.menuButton.menuButton) { + if (this.menuButtonOptions.enable) { + // 判断图片是否有水平翻转 + var active = event.editor.getAttributes('image')?.flipY; + if (active) { + // 如果有水平翻转,则功能按钮是激活状态 + this.menuButton.menuButton.classList.add("active"); + } else { + // 如果没有水平翻转,则功能按钮是非激活状态 + this.menuButton.menuButton.classList.remove("active"); + } + } + } + } + + onEditableChange(editable: boolean) { + this.menuButtonOptions.enable = editable; + this.menuButton.onEditableChange(editable); + } +} + diff --git a/src/components/menus/common/NodeDelete.ts b/src/components/menus/common/NodeDelete.ts new file mode 100644 index 0000000..950550b --- /dev/null +++ b/src/components/menus/common/NodeDelete.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../MenuButton.ts"; +import icon from "../../../assets/icons/node-delete.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; + +/** + * 公共菜单:删除节点 + */ +export class NodeDelete extends HTMLElement implements UAIEditorEventListener { + // 按钮选项 + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: true, + text: t('bubbleMenu.delete'), + tooltip: t('bubbleMenu.delete'), + shortcut: "Backspace", + } + + // 功能按钮 + menuButton: MenuButton; + + constructor(options: MenuButtonOptions) { + super(); + + // 初始化功能按钮选项 + this.menuButtonOptions = { ...this.menuButtonOptions, ...options }; + + // 创建功能按钮 + this.menuButton = new MenuButton(this.menuButtonOptions); + } + + /** + * 定义创建方法 + * @param event + * @param options + */ + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + this.menuButton.onCreate(event, options); + this.appendChild(this.menuButton); + + // 定义按钮点击事件,删除节点 + this.addEventListener("click", () => { + if (this.menuButtonOptions.enable) { + event.editor.chain().focus().deleteSelectionNode().run(); + } + }) + } + + /** + * 定义Transaction监听方法 + * @param event + * @param options + */ + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + this.menuButton.onTransaction(event, options); + if (this.menuButton.menuButton) { + } + } + + onEditableChange(editable: boolean) { + this.menuButtonOptions.enable = editable; + this.menuButton.onEditableChange(editable); + } +} + diff --git a/src/core/UAIExtensions.ts b/src/core/UAIExtensions.ts index 36b21b7..12be8d8 100644 --- a/src/core/UAIExtensions.ts +++ b/src/core/UAIExtensions.ts @@ -33,6 +33,7 @@ import LineHeight from "../extensions/LineHeight.ts" import NodeAlign from "../extensions/NodeAlign.ts"; import OrderedList from "../extensions/OrderedList.ts"; import SelectFile from "../extensions/SelectFile.ts"; +import Selection from "../extensions/Selection.ts"; import Video from "../extensions/Video.ts"; import Audio from "../extensions/Audio.ts"; import { uuid } from "../utils/UUID.ts"; @@ -40,6 +41,7 @@ import Toc from "../extensions/Toc.ts"; import { BubbleMenuPluginOptions, BubbleMenuPlugin } from "../components/menus/bubble/BubbleMenuPlugin.ts"; import { TextSelectionBubbleMenu } from "../components/menus/bubble/TextSelectionBubbleMenu.ts"; +import { ImageBubbleMenu } from "../components/menus/bubble/ImageBubbleMenu.ts"; /** * 创建浮动菜单功能 @@ -105,6 +107,33 @@ const createTextSelectionBubbleMenu = (uaiEditor: UAIEditor) => { }) } +/** + * 创建图片内容的浮动菜单 + * @param uaiEditor + * @returns + */ +const createImageBubbleMenu = (uaiEditor: UAIEditor) => { + const container = new ImageBubbleMenu(uaiEditor); + + return createBubbleMenu("imageBubbleMenu", { + pluginKey: 'imageBubbleMenu', + element: container, + tippyOptions: { + appendTo: uaiEditor.editor.editorContainer, + arrow: false, + interactive: true, + hideOnClick: false, + placement: 'top', + }, + shouldShow: ({ editor }) => { + if (!editor.isEditable) { + return false; + } + return editor.isActive("image") + } + }) +} + /** * 定义编辑器的所有自定义扩展组件 * @param uaiEditor @@ -146,6 +175,7 @@ export const allExtensions = (uaiEditor: UAIEditor, _options: UAIEditorOptions): SelectFile.configure({ allowedMimeTypes: [] }), + Selection, Subscript, Superscript, Table.configure({ @@ -180,6 +210,7 @@ export const allExtensions = (uaiEditor: UAIEditor, _options: UAIEditorOptions): Underline, Video, createTextSelectionBubbleMenu(uaiEditor), + createImageBubbleMenu(uaiEditor), ]; return extensions; diff --git a/src/extensions/Selection.ts b/src/extensions/Selection.ts new file mode 100644 index 0000000..72b39a1 --- /dev/null +++ b/src/extensions/Selection.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2024-present AI-Labs + +import { type Editor, Extension, findParentNode } from '@tiptap/core' +import { type NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' + +const LIST_TYPE = ['table']; + +declare module '@tiptap/core' { + interface Commands { + setCurrentNodeSelection: { + setCurrentNodeSelection: () => ReturnType + } + deleteSelectionNode: { + deleteSelectionNode: () => ReturnType + } + } +} + +/** + * 定义内容选择扩展 + */ +export default Extension.create({ + name: 'selection', + addProseMirrorPlugins() { + const { editor } = this + + return [ + new Plugin({ + key: new PluginKey('selection'), + props: { + decorations(state) { + if (state.selection.empty) { + return null + } + + if (editor.isFocused) { + return null + } + + return DecorationSet.create(state.doc, [ + Decoration.inline(state.selection.from, state.selection.to, { + class: 'uai-text-selection', + }), + ]) + }, + }, + }), + ] + }, + addCommands() { + return { + setCurrentNodeSelection: + () => + ({ editor, chain }) => { + const parentNode = findParentNode((node) => + LIST_TYPE.includes(node.type.name), + )(editor.state.selection) + if (parentNode) { + return chain().setNodeSelection(parentNode.pos).run() + } + const { $anchor, node } = editor.state.selection as NodeSelection + const pos = node?.attrs?.vnode + ? $anchor.pos + : $anchor.pos - $anchor.parentOffset - 1 + return chain().setNodeSelection(pos).run() + }, + deleteSelectionNode: + () => + ({ editor, chain }) => { + const node = getSelectionNode(editor) + if (!node) { + return false + } + if (node.attrs.vnode) { + chain().focus().deleteSelection().run() + return true + } + if (editor.isActive('table')) { + chain().focus().deleteTable().run() + return true + } + return chain().focus().deleteNode(node.type.name).run() + }, + } + }, +}) + +/** + * 获取当前选中的节点 + * @param editor + * @returns + */ +export function getSelectionNode(editor: Editor) { + const { node } = editor.state.selection as NodeSelection + if (node) { + return node + } + const parentNode = findParentNode((node) => + LIST_TYPE.includes(node.type.name), + )(editor.state.selection) + const { $anchor } = editor.state.selection + if (parentNode) { + return $anchor.node(parentNode.depth) + } + editor.commands.selectParentNode() + return (editor.state.selection as NodeSelection).node +} + +/** + * 获取选中的文本 + * @param editor + * @returns + */ +export function getSelectionText(editor: Editor) { + const { from, to, empty } = editor.state.selection + if (empty) { + return '' + } + return editor.state.doc.textBetween(from, to, '') +}