diff --git a/src/components/index.ts b/src/components/index.ts index d7e609f..d23d21b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -72,6 +72,8 @@ import { DeleteColumn } from "./menus/toolbar/table/DeleteColumn.ts"; import { DeleteRow } from "./menus/toolbar/table/DeleteRow.ts"; import { DeleteTable } from "./menus/toolbar/table/DeleteTable.ts"; +import { Diagrams } from "./menus/toolbar/tools/Diagrams.ts"; + import { ToggleToc } from "./menus/toolbar/page/ToggleToc.ts"; // 注册组件 @@ -146,4 +148,6 @@ defineCustomElement('uai-editor-table-menu-add-row-before', AddRowBefore); defineCustomElement('uai-editor-table-menu-delete-row', DeleteRow); defineCustomElement('uai-editor-table-menu-delete-table', DeleteTable); +defineCustomElement('uai-editor-tools-menu-diagrams', Diagrams); + defineCustomElement('uai-editor-page-menu-toggle-toc', ToggleToc); diff --git a/src/components/menus/toolbar/Classic.ts b/src/components/menus/toolbar/Classic.ts index 7ee409c..38fc692 100644 --- a/src/components/menus/toolbar/Classic.ts +++ b/src/components/menus/toolbar/Classic.ts @@ -65,6 +65,8 @@ import { AddRowBefore } from "./table/AddRowBefore.ts"; import { DeleteRow } from "./table/DeleteRow.ts"; import { DeleteTable } from "./table/DeleteTable.ts"; +import { Diagrams } from "./tools/Diagrams.ts"; + import { ToggleToc } from "./page/ToggleToc.ts"; /** @@ -90,6 +92,10 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { classicMenuTableScrollable!: ScrollableDiv; classicMenuTableGroup!: HTMLElement; + // 工具菜单容器 + classicMenuToolScrollable!: ScrollableDiv; + classicMenuToolGroup!: HTMLElement; + // 页面菜单容器 classicMenuPageScrollable!: ScrollableDiv; classicMenuPageGroup!: HTMLElement; @@ -151,6 +157,9 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { tableMenuDeleteRow!: DeleteRow; tableMenuDeleteTable!: DeleteTable; + // 工具菜单 + toolsMenuDiagrams!: Diagrams; + // 页面菜单 pageMenuToggleToc!: ToggleToc; @@ -200,6 +209,7 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { this.classicMenuBaseScrollable.style.display = "none"; this.classicMenuInsertScrollable.style.display = "none"; this.classicMenuTableScrollable.style.display = "none"; + this.classicMenuToolScrollable.style.display = "none"; this.classicMenuPageScrollable.style.display = "none"; if (menu === "base") { this.classicMenuBaseScrollable.style.display = "flex"; @@ -210,6 +220,9 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { if (menu === "table") { this.classicMenuTableScrollable.style.display = "flex"; } + if (menu === "tools") { + this.classicMenuToolScrollable.style.display = "flex"; + } if (menu === "page") { this.classicMenuPageScrollable.style.display = "flex"; } @@ -218,6 +231,7 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { this.createBaseMenu(event, options); this.createInsertMenu(event, options); this.createTableMenu(event, options); + this.createToolMenu(event, options); this.createPageMenu(event, options); this.classicMenuBaseScrollable.style.display = "flex"; } @@ -384,6 +398,9 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { this.tableMenuDeleteTable = new DeleteTable({ menuType: "button", enable: true, header: "classic", hideText: false }); this.eventComponents.push(this.tableMenuDeleteTable); + this.toolsMenuDiagrams = new Diagrams({ menuType: "button", enable: true, header: "classic", hideText: false }); + this.eventComponents.push(this.toolsMenuDiagrams); + this.pageMenuToggleToc = new ToggleToc({ menuType: "button", enable: true, header: "classic", hideText: false }); this.eventComponents.push(this.pageMenuToggleToc); } @@ -516,6 +533,25 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { group3.appendChild(this.tableMenuDeleteTable); } + /** + * 创建工具菜单 + * @param event + * @param options + */ + createToolMenu(event: EditorEvents["create"], options: UAIEditorOptions) { + this.classicMenuToolGroup = document.createElement("div"); + this.classicMenuToolGroup.style.display = "flex"; + this.classicMenuToolScrollable = new ScrollableDiv(this.classicMenuToolGroup); + this.classicMenuToolScrollable.style.display = "none"; + this.classicMenu.appendChild(this.classicMenuToolScrollable); + this.classicMenuToolScrollable.onCreate(event, options); + + const group1 = document.createElement("div"); + group1.classList.add("uai-classic-virtual-group"); + this.classicMenuToolGroup.appendChild(group1); + group1.appendChild(this.toolsMenuDiagrams); + } + /** * 创建页面菜单 * @param event diff --git a/src/components/menus/toolbar/Ribbon.ts b/src/components/menus/toolbar/Ribbon.ts index 4594b87..96ace4a 100644 --- a/src/components/menus/toolbar/Ribbon.ts +++ b/src/components/menus/toolbar/Ribbon.ts @@ -67,6 +67,8 @@ import { AddRowBefore } from "./table/AddRowBefore.ts"; import { DeleteRow } from "./table/DeleteRow.ts"; import { DeleteTable } from "./table/DeleteTable.ts"; +import { Diagrams } from "./tools/Diagrams.ts"; + import { ToggleToc } from "./page/ToggleToc.ts"; /** @@ -93,6 +95,10 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { ribbonMenuTableScrollable!: ScrollableDiv; ribbonMenuTableGroup!: HTMLElement; + // 工具菜单容器 + ribbonMenuToolScrollable!: ScrollableDiv; + ribbonMenuToolGroup!: HTMLElement; + // 页面菜单容器 ribbonMenuPageScrollable!: ScrollableDiv; ribbonMenuPageGroup!: HTMLElement; @@ -154,6 +160,9 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { tableMenuDeleteRow!: DeleteRow; tableMenuDeleteTable!: DeleteTable; + // 工具菜单 + toolsMenuDiagrams!: Diagrams; + // 页面菜单 pageMenuToggleToc!: ToggleToc; @@ -196,6 +205,7 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { this.ribbonMenuBaseScrollable.style.display = "none"; this.ribbonMenuInsertScrollable.style.display = "none"; this.ribbonMenuTableScrollable.style.display = "none"; + this.ribbonMenuToolScrollable.style.display = "none"; this.ribbonMenuPageScrollable.style.display = "none"; if (menu.value === "base") { this.ribbonMenuBaseScrollable.style.display = "flex"; @@ -206,6 +216,9 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { if (menu.value === "table") { this.ribbonMenuTableScrollable.style.display = "flex"; } + if (menu.value === "tools") { + this.ribbonMenuToolScrollable.style.display = "flex"; + } if (menu.value === "page") { this.ribbonMenuPageScrollable.style.display = "flex"; } @@ -229,6 +242,7 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { this.createBaseMenu(event, options); this.createInsertMenu(event, options); this.createTableMenu(event, options); + this.createToolMenu(event, options); this.createPageMenu(event, options); this.ribbonMenuBaseScrollable.style.display = "flex"; } @@ -407,6 +421,9 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { this.tableMenuDeleteTable = new DeleteTable({ menuType: "button", enable: true, huge: true, hideText: false }); this.eventComponents.push(this.tableMenuDeleteTable); + this.toolsMenuDiagrams = new Diagrams({ menuType: "button", enable: true, huge: true, hideText: false }); + this.eventComponents.push(this.toolsMenuDiagrams); + this.pageMenuToggleToc = new ToggleToc({ menuType: "button", enable: true, huge: true, hideText: false }); this.eventComponents.push(this.pageMenuToggleToc); } @@ -621,6 +638,25 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { group3.appendChild(this.tableMenuDeleteTable); } + /** + * 创建工具菜单 + * @param event + * @param options + */ + createToolMenu(_event: EditorEvents["create"], _options: UAIEditorOptions) { + this.ribbonMenuToolGroup = document.createElement("div"); + this.ribbonMenuToolGroup.classList.add("uai-ribbon-container"); + this.ribbonMenuToolGroup.style.display = "flex"; + this.ribbonMenuToolScrollable = new ScrollableDiv(this.ribbonMenuToolGroup); + this.ribbonMenuToolScrollable.style.display = "none"; + this.ribbonScrollableContainer.appendChild(this.ribbonMenuToolScrollable); + + const group1 = document.createElement("div"); + group1.classList.add("uai-ribbon-virtual-group"); + this.ribbonMenuToolGroup.appendChild(group1); + group1.appendChild(this.toolsMenuDiagrams); + } + /** * 创建页面菜单 * @param event diff --git a/src/components/menus/toolbar/tools/Diagrams.ts b/src/components/menus/toolbar/tools/Diagrams.ts new file mode 100644 index 0000000..7e58a5e --- /dev/null +++ b/src/components/menus/toolbar/tools/Diagrams.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/diagrams.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions, InnerEditor } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; +import { FullScreenModal } from "../../../modals/FullScreenModal.ts"; +import DiagramEditor from "../../../../utils/DiagramEditor.ts"; +import { uuid } from "../../../../utils/UUID.ts"; + +/** + * 工具菜单:插入流程图 + */ +export class Diagrams extends HTMLElement implements UAIEditorEventListener { + // 按钮选项 + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: false, + text: t('tools.diagrams.text'), + tooltip: t('tools.diagrams.text'), + } + + // 功能按钮 + menuButton: MenuButton; + // 模态框 + modal: FullScreenModal; + + constructor(options: MenuButtonOptions) { + super(); + + // 初始化功能按钮选项 + this.menuButtonOptions = { ...this.menuButtonOptions, ...options }; + + // 创建功能按钮 + this.menuButton = new MenuButton(this.menuButtonOptions); + + // 创建模态框 + this.modal = new FullScreenModal(this.menuButtonOptions.text!); + } + + /** + * 定义创建方法 + * @param event + * @param options + */ + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + this.menuButton.onCreate(event, options); + this.appendChild(this.menuButton); + // 创建模态框容器 + const container = document.createElement("div"); + container.classList.add("uai-diagrams-container"); + container.classList.add("uai-diagrams-container-insert"); + this.modal.modalBody.appendChild(container); + const editor = event.editor; + + // 添加鼠标点击事件,弹出模态框 + this.addEventListener("click", () => { + if (this.menuButtonOptions.enable) { + container.innerHTML = ""; + const diagramEditor = new DiagramEditor({ + container: `.uai-diagrams-container-insert`, + }) + this.modal.show(); + diagramEditor.edit(''); + + // 添加回调消息监听 + const messageListener = (evt: MessageEvent) => { + if ( + evt?.type !== 'message' || + typeof evt?.data !== 'string' + ) { + return + } + + // 获取回调消息内容 + const { event, bounds, data } = JSON.parse(evt.data); + if (event === 'export') { + const { width, height } = bounds + const name = `diagrams-${uuid()}.svg` + // 将回调消息转换成图片 + const { size } = new Blob([data], { + type: 'image/svg+xml', + }) + const image = { + type: 'diagrams', + name, + size, + src: data, + width: `${width}`, + height, + content: data, + } + this.modal.hide(); + + // 在编辑器中插入图片 + diagramEditor.stopEditing(); + if (image?.type) { + editor.chain().focus().setImage(image, false).run() + } + window.removeEventListener('message', messageListener); + } + if (event === 'exit') { + // 如果回调消息是关闭,则关闭模态框 + this.modal.hide(); + window.removeEventListener('message', messageListener); + } + } + + // 移除、添加监听 + window.removeEventListener('message', messageListener); + window.addEventListener('message', messageListener); + this.modal.registerListener(messageListener); + } + }) + } + + /** + * 定义Transaction监听方法 + * @param event + * @param options + */ + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + this.menuButton.onTransaction(event, options); + if (this.menuButton.menuButton) { + var disable = event.editor.isEditable; + this.onEditableChange(disable); + } + } + + onEditableChange(editable: boolean) { + this.menuButtonOptions.enable = editable; + this.menuButton.onEditableChange(editable); + } +} + diff --git a/src/components/modals/FullScreenModal.ts b/src/components/modals/FullScreenModal.ts new file mode 100644 index 0000000..f68cce6 --- /dev/null +++ b/src/components/modals/FullScreenModal.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2024-present AI-Labs + +import { Modal } from "bootstrap"; +import closeIcon from "../../assets/icons/close.svg"; + +import 'bootstrap/dist/css/bootstrap.css'; + +/** + * 全屏的模态弹出框 + */ +export class FullScreenModal extends Modal { + + modalHeader!: HTMLElement; + modalBody!: HTMLElement; + modalFooter!: HTMLElement; + messageListener?: (evt: MessageEvent) => void + + constructor(title: string, options?: Partial) { + const modal = document.createElement("div"); + // 设置模态弹出框 + modal.classList.add("modal"); + modal.setAttribute("role", "dialog"); + modal.setAttribute("tabindex", "-1"); + modal.setAttribute("aria-labelledby", "exampleModalCenterTitle"); + + // 模态弹出框全屏样式 + const modalDialog = document.createElement("div"); + modalDialog.classList.add("modal-dialog"); + modalDialog.classList.add("modal-fullscreen"); + modalDialog.classList.add("modal-dialog-centered"); + modalDialog.setAttribute("role", "document"); + + // 模态弹出框内容 + const modalContent = document.createElement("div"); + modalContent.classList.add("modal-content"); + modalDialog.appendChild(modalContent); + + modal.appendChild(modalDialog); + super(modal, options); + + // 标题区 + this.modalHeader = document.createElement("div"); + this.modalHeader.classList.add("modal-header"); + + // 标题 + const titleDiv = document.createElement("div"); + titleDiv.innerHTML = `` + this.modalHeader.appendChild(titleDiv); + + // 关闭按钮 + const closeDiv = document.createElement("div"); + closeDiv.innerHTML = ``; + closeDiv.addEventListener("click", () => { + this.hide(); + if (this.messageListener) { + window.removeEventListener('message', this.messageListener); + } + }); + this.modalHeader.appendChild(closeDiv); + + modalContent.appendChild(this.modalHeader); + + // 主体区 + this.modalBody = document.createElement("div"); + this.modalBody.classList.add("modal-body"); + modalContent.appendChild(this.modalBody); + + } + + /** + * 添加回调监听 + * @param messageListener + */ + registerListener(messageListener: (evt: MessageEvent) => void) { + this.messageListener = messageListener; + } +} \ No newline at end of file diff --git a/src/utils/DiagramEditor.ts b/src/utils/DiagramEditor.ts new file mode 100644 index 0000000..c68043e --- /dev/null +++ b/src/utils/DiagramEditor.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2024-present AI-Labs + +// https://www.diagrams.com/doc/faq/embed-mode +class DiagramEditor { + frame: HTMLIFrameElement | undefined + container: string + domain = 'https://embed.diagrams.net' + // https://www.drawio.com/doc/faq/supported-url-parameters + params = { + ui: 'atlas', + proto: 'json', + libraries: 1, + configure: 1, + noSaveBtn: 1, + } + xml: string | null = null + format = 'xmlsvg' + data: any + + constructor({ + domain, + params, + container, + }: { + domain?: string + params?: Record + container: string + }) { + this.domain = domain ?? this.domain + this.params = { ...params, ...this.params } + this.container = container + } + + handleuseMessageEvent = (evt: MessageEvent) => { + if ( + this.frame !== undefined && + evt.source === this.frame.contentWindow && + evt.data.length > 0 + ) { + try { + const msg = JSON.parse(evt.data) + if (msg) { + this.handleuseMessage(msg) + } + } catch (e) { + console.error(e) + } + } + } + + // 编辑元素 + edit(src: string) { + let fmt = this.format + if (src.substring(0, 15) === 'data:image/png;') { + fmt = 'xmlpng' + } else if (src.substring(0, 19) === 'data:image/svg+xml;') { + fmt = 'xmlsvg' + } + this.startEditing(src, fmt) + return this + } + + // 创建 iframe + createFrame() { + const params = Object.keys(this.params) + .map((key) => `${key}=${this.params[key as keyof typeof this.params]}`) + .join('&') + const frame = document.createElement('iframe') + frame.setAttribute('class', 'uai-diagrams-iframe') + frame.setAttribute('src', `${this.domain}?${params}&lang=zh`) + return frame + } + + // diagrams页面和当前页面通信 + postMessage(msg: Record) { + if (this.frame) { + this.frame.contentWindow?.postMessage(JSON.stringify(msg), '*') + } + } + handleuseMessage(msg: { + event: string + data: any + xml: string + exit: boolean + }) { + if (msg.event === 'configure') { + this.configureEditor() + } else if (msg.event === 'init') { + this.initializeEditor() + } else if (msg.event === 'save') { + this.export(msg.data) + this.xml = msg.xml + if (msg.exit) { + msg.event = 'exit' + } else { + this.postMessage({ + action: 'status', + messageKey: 'allChangesSaved', + modified: false, + }) + } + } + if (msg.event === 'exit') { + if (this.format !== 'xml') { + if (this.xml) { + this.postMessage({ + action: 'export', + format: this.format, + xml: this.xml, + spinKey: 'export', + }) + } + } + this.stopEditing() + } + } + + // 开始编辑 + startEditing(data: any, format: string) { + if (!this.frame) { + window.addEventListener('message', this.handleuseMessageEvent) + this.format = format ?? this.format + this.data = data + this.frame = this.createFrame() + const container = document.querySelector(this.container) + container?.appendChild(this.frame) + } + } + + // 停止编辑 + stopEditing() { + window.removeEventListener('message', this.handleuseMessageEvent) + this.frame = undefined + } + + // 安装编辑器 + initializeEditor() { + this.postMessage({ + action: 'load', + autosave: 0, + saveAndExit: '1', + modified: 'unsavedChanges', + xml: this.data, + }) + } + + // 设置编辑器 + configureEditor() { + this.postMessage({ action: 'configure', config: this.params.configure }) + } + // 导出数据 + export(data: any) { + return data + } +} + +export default DiagramEditor