diff --git a/src/components/index.ts b/src/components/index.ts index f4d1aed..b4b7d68 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,6 +8,13 @@ import { Footer } from "../components/Footer.ts"; import { Ribbon } from "./menus/toolbar/Ribbon.ts"; import { Classic } from "./menus/toolbar/Classic.ts"; +import { ScrollableDiv } from "./menus/toolbar/ScrollableDiv.ts"; +import { MenuButton } from "./menus/MenuButton.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"; +import { ClearFormat } from "./menus/toolbar/base/ClearFormat.ts"; // 注册组件 defineCustomElement('uai-editor-header', Header); @@ -16,3 +23,11 @@ defineCustomElement('uai-editor-footer', Footer); defineCustomElement('uai-editor-ribbon-menu', Ribbon); defineCustomElement('uai-editor-classic-menu', Classic); + +defineCustomElement('uai-editor-scrollable-div', ScrollableDiv); +defineCustomElement('uai-editor-menu-button', MenuButton); + +defineCustomElement('uai-editor-base-menu-undo', Undo); +defineCustomElement('uai-editor-base-menu-redo', Redo); +defineCustomElement('uai-editor-base-menu-format-painter', FormatPainter); +defineCustomElement('uai-editor-base-menu-clear-format', ClearFormat); diff --git a/src/components/menus/MenuButton.ts b/src/components/menus/MenuButton.ts new file mode 100644 index 0000000..f9a7397 --- /dev/null +++ b/src/components/menus/MenuButton.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { EditorEvents } from "@tiptap/core"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../core/UAIEditor.ts"; +import tippy, { Instance, Props } from "tippy.js"; + +/** + * 菜单按钮选项 + */ +export type MenuButtonOptions = { + menuType: "button", + enable: boolean, + className?: string, + header?: "ribbon" | "classic", + huge?: boolean, + icon?: string, + text?: string, + hideText?: boolean, + tooltip?: string, + shortcut?: string, + options?: string[] | number[] +} + +/** + * 功能菜单 + */ +export class MenuButton extends HTMLElement implements UAIEditorEventListener { + container!: HTMLElement; + menuButton!: HTMLElement; + menuButtonContent!: HTMLElement; + menuButtonArrow?: HTMLElement; + menuButtonOptions!: MenuButtonOptions; + tippyInstance!: Instance; + + constructor(options: MenuButtonOptions) { + super(); + + // 初始化功能菜单选项 + this.menuButtonOptions = options; + } + + /** + * 定义创建方法 + * @param event + * @param options + */ + onCreate(_event: EditorEvents["create"], _options: UAIEditorOptions) { + this.container = document.createElement("div"); + this.container.classList.add("uai-menu-button-wrap"); + this.appendChild(this.container); + + // 根据不同的菜单类型绘制不同的用户界面 + if (this.menuButtonOptions.menuType === "button") { + // 按钮 + this.createMenuButton() + } + + // 提示信息 + var tipsContent = this.menuButtonOptions.tooltip; + if (tipsContent) { + if (this.menuButtonOptions.shortcut) { + tipsContent += ` (${this.menuButtonOptions.shortcut})`; + } + this.tippyInstance = tippy(this.menuButton, { + appendTo: "parent", + content: tipsContent, + theme: 'uai-tips', + placement: "top", + // arrow: false, + // interactive: true, + }); + } + } + + onTransaction(_event: EditorEvents["transaction"], _options: UAIEditorOptions) { + + } + + onEditableChange(editable: boolean) { + this.menuButtonOptions.enable = editable; + if (editable) { + this.menuButton.style.pointerEvents = "auto"; + this.menuButton.style.opacity = "1"; + this.style.cursor = "pointer"; + } else { + this.menuButton.style.pointerEvents = "none"; + this.menuButton.style.opacity = "0.3"; + this.style.cursor = "not-allowed"; + } + } + + /** + * 创建按钮 + */ + createMenuButton() { + var size = 16; + this.menuButton = document.createElement("div"); + this.menuButton.classList.add("uai-menu-button"); + if (this.menuButtonOptions.huge) { + // 大按钮大小 + this.menuButton.classList.add("huge"); + size = 28; + } else { + // 小按钮大小 + size = 16; + } + // 传统样式设置,文字在图标右侧 + if (this.menuButtonOptions.header === "classic") { + this.menuButton.classList.add("classic-text"); + } + this.container.appendChild(this.menuButton); + + // 按钮内容 + this.menuButtonContent = document.createElement("div"); + this.menuButtonContent.classList.add("uai-button-content"); + this.menuButton.appendChild(this.menuButtonContent); + + // 按钮图标 + if (this.menuButtonOptions.icon) { + this.menuButtonContent.innerHTML = `` + } + + // 按钮文字描述 + if (!this.menuButtonOptions.hideText && this.menuButtonOptions.text) { + const menuButtonText = document.createElement("span"); + menuButtonText.classList.add("uai-button-text"); + menuButtonText.innerHTML = this.menuButtonOptions.text; + this.menuButtonContent.appendChild(menuButtonText); + } + } +} \ No newline at end of file diff --git a/src/components/menus/toolbar/Classic.ts b/src/components/menus/toolbar/Classic.ts index a704d94..47eb8fe 100644 --- a/src/components/menus/toolbar/Classic.ts +++ b/src/components/menus/toolbar/Classic.ts @@ -5,6 +5,12 @@ import { EditorEvents } from "@tiptap/core"; import { UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor.ts"; import menuIcon from "../../../assets/icons/menu.svg"; +import { ScrollableDiv } from "./ScrollableDiv"; + +import { Redo } from "./base/Redo"; +import { Undo } from "./base/Undo"; +import { FormatPainter } from "./base/FormatPainter"; +import { ClearFormat } from "./base/ClearFormat"; /** * 传统菜单栏 @@ -17,6 +23,16 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { classicMenu!: HTMLElement; classicScrollableContainer!: HTMLElement; + // 基础菜单容器 + classicMenuBaseScrollable!: ScrollableDiv; + classicMenuBaseGroup!: HTMLElement; + + // 基础菜单 + baseMenuUndo!: Undo; + baseMenuRedo!: Redo; + baseMenuFormatPainter!: FormatPainter; + baseMenuClearFormat!: ClearFormat; + constructor(defaultToolbarMenus: Record[]) { super(); this.defaultToolbarMenus = defaultToolbarMenus; @@ -57,6 +73,17 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { option.value = menu.value; selectMuenus.options.add(option); }); + // 添加事件,处理菜单分组切换、界面元素切换 + selectMuenus.addEventListener("change", () => { + const menu = selectMuenus.selectedOptions[0].value; + this.classicMenuBaseScrollable.style.display = "none"; + if (menu === "base") { + this.classicMenuBaseScrollable.style.display = "flex"; + } + }) + // 创建分组菜单 + this.createBaseMenu(event, options); + this.classicMenuBaseScrollable.style.display = "flex"; } /** @@ -80,5 +107,37 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { * 初始化菜单 */ initMenus() { + this.baseMenuUndo = new Undo({ menuType: "button", enable: true, hideText: false }); + this.eventComponents.push(this.baseMenuUndo); + + this.baseMenuRedo = new Redo({ menuType: "button", enable: true }); + this.eventComponents.push(this.baseMenuRedo); + + this.baseMenuFormatPainter = new FormatPainter({ menuType: "button", enable: true }); + this.eventComponents.push(this.baseMenuFormatPainter); + + this.baseMenuClearFormat = new ClearFormat({ menuType: "button", enable: true }); + this.eventComponents.push(this.baseMenuClearFormat); + } + /** + * 创建基础菜单 + * @param event + * @param options + */ + createBaseMenu(event: EditorEvents["create"], options: UAIEditorOptions) { + this.classicMenuBaseGroup = document.createElement("div"); + this.classicMenuBaseGroup.style.display = "flex"; + this.classicMenuBaseScrollable = new ScrollableDiv(this.classicMenuBaseGroup); + this.classicMenuBaseScrollable.style.display = "none"; + this.classicMenu.appendChild(this.classicMenuBaseScrollable); + this.classicMenuBaseScrollable.onCreate(event, options); + + const group1 = document.createElement("div"); + group1.classList.add("uai-classic-virtual-group"); + this.classicMenuBaseGroup.appendChild(group1); + group1.appendChild(this.baseMenuUndo); + group1.appendChild(this.baseMenuRedo); + group1.appendChild(this.baseMenuFormatPainter); + group1.appendChild(this.baseMenuClearFormat); } } \ No newline at end of file diff --git a/src/components/menus/toolbar/Ribbon.ts b/src/components/menus/toolbar/Ribbon.ts index f5f82b7..5168c3b 100644 --- a/src/components/menus/toolbar/Ribbon.ts +++ b/src/components/menus/toolbar/Ribbon.ts @@ -5,6 +5,13 @@ import { EditorEvents } from "@tiptap/core"; import { UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor.ts"; import { t } from "i18next"; +import { ScrollableDiv } from "./ScrollableDiv"; + +import { Redo } from "./base/Redo"; +import { Undo } from "./base/Undo"; + +import { FormatPainter } from "./base/FormatPainter"; +import { ClearFormat } from "./base/ClearFormat"; /** * 经典菜单栏 @@ -18,6 +25,17 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { ribbonMenu!: HTMLElement; ribbonScrollableContainer!: HTMLElement; + // 基础菜单容器 + ribbonMenuBaseScrollable!: ScrollableDiv; + ribbonMenuBaseGroup!: HTMLElement; + + // 基础菜单 + baseMenuUndo!: Undo; + baseMenuRedo!: Redo; + + baseMenuFormatPainter!: FormatPainter; + baseMenuClearFormat!: ClearFormat; + constructor(defaultToolbarMenus: Record[]) { super(); this.defaultToolbarMenus = defaultToolbarMenus; @@ -54,6 +72,10 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { ribbonTabs.children[i].classList.remove("active"); } tab.classList.add("active"); + this.ribbonMenuBaseScrollable.style.display = "none"; + if (menu.value === "base") { + this.ribbonMenuBaseScrollable.style.display = "flex"; + } }) ribbonTabs.appendChild(tab); }); @@ -69,6 +91,10 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { const level = i + 1 this.headingOptions.push({ label: `${t('base.heading.text')}`.replace("{level}", `${level}`), desc: `h${level}`, value: level, element: document.createElement("div") }) } + + // 创建分组菜单 + this.createBaseMenu(event, options); + this.ribbonMenuBaseScrollable.style.display = "flex"; } /** @@ -104,5 +130,46 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { * 初始化菜单 */ initMenus() { + this.baseMenuUndo = new Undo({ menuType: "button", enable: true }); + this.eventComponents.push(this.baseMenuUndo); + + this.baseMenuRedo = new Redo({ menuType: "button", enable: true }); + this.eventComponents.push(this.baseMenuRedo); + + this.baseMenuFormatPainter = new FormatPainter({ menuType: "button", enable: true }); + this.eventComponents.push(this.baseMenuFormatPainter); + + this.baseMenuClearFormat = new ClearFormat({ menuType: "button", enable: true }); + this.eventComponents.push(this.baseMenuClearFormat); + } + + /** + * 创建基础菜单 + * @param event + * @param options + */ + createBaseMenu(event: EditorEvents["create"], _options: UAIEditorOptions) { + this.ribbonMenuBaseGroup = document.createElement("div"); + this.ribbonMenuBaseGroup.classList.add("uai-ribbon-container"); + this.ribbonMenuBaseGroup.style.display = "flex"; + this.ribbonMenuBaseScrollable = new ScrollableDiv(this.ribbonMenuBaseGroup); + this.ribbonMenuBaseScrollable.style.display = "none"; + this.ribbonScrollableContainer.appendChild(this.ribbonMenuBaseScrollable); + + const group1 = document.createElement("div"); + group1.classList.add("uai-ribbon-virtual-group"); + this.ribbonMenuBaseGroup.appendChild(group1); + + const group1row1 = document.createElement("div"); + group1row1.classList.add("uai-ribbon-virtual-group-row"); + group1.appendChild(group1row1); + group1row1.appendChild(this.baseMenuUndo); + group1row1.appendChild(this.baseMenuRedo); + + const group1row2 = document.createElement("div"); + group1row2.classList.add("uai-ribbon-virtual-group-row"); + group1.appendChild(group1row2); + group1row2.appendChild(this.baseMenuFormatPainter); + group1row2.appendChild(this.baseMenuClearFormat); } } \ No newline at end of file diff --git a/src/components/menus/toolbar/ScrollableDiv.ts b/src/components/menus/toolbar/ScrollableDiv.ts new file mode 100644 index 0000000..705f68a --- /dev/null +++ b/src/components/menus/toolbar/ScrollableDiv.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { EditorEvents } from "@tiptap/core"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor.ts"; + +/** + * 可滚动容器 + */ +export class ScrollableDiv extends HTMLElement implements UAIEditorEventListener { + private container: HTMLElement; + scrollLeftBtn: HTMLElement; + content: HTMLElement; + scrollRightBtn: HTMLElement; + private scrollAmount: number = 50; // 每次滚动的像素数 + private transform: number = 0; // 当前transform值 + + constructor(content: HTMLElement) { + super(); + this.content = content; + + this.scrollLeftBtn = document.createElement("div"); + this.scrollLeftBtn.classList.add("uai-scrollable-control-button"); + this.scrollLeftBtn.innerHTML = "<"; + this.scrollLeftBtn.style.display = "none"; + this.container = document.createElement("div"); + this.container.appendChild(this.content); + // this.container.style.overflow = "hidden"; + // this.container.style.position = "relative"; + this.scrollRightBtn = document.createElement("div"); + this.scrollRightBtn.classList.add("uai-scrollable-control-button"); + this.scrollRightBtn.innerHTML = ">"; + this.scrollRightBtn.style.display = "none"; + + this.scrollLeftBtn.addEventListener('click', () => { + if (this.transform < 0) { + this.transform += Math.min(this.scrollAmount, 0 - this.transform); + } + this.content.style.transform = `translateX(${this.transform}px)`; + this.scrollButtonControl(); + }); + this.scrollRightBtn.addEventListener('click', () => { + const scrollWidth = this.content.scrollWidth + this.transform - this.scrollRightBtn.clientWidth - this.container.clientWidth; + if (scrollWidth > 0) { + this.transform -= Math.min(this.scrollAmount, scrollWidth); + } + this.content.style.transform = `translateX(${this.transform}px)`; + this.scrollButtonControl(); + }); + + this.classList.add("uai-classic-scrollable-menu") + this.appendChild(this.scrollLeftBtn) + this.appendChild(this.container) + this.appendChild(this.scrollRightBtn) + } + + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + if (entry.target === this.parentElement) { + this.scrollButtonControl(); + } + } + }); + + resizeObserver.observe(this.parentElement!); + } + + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + } + + onEditableChange(editable: boolean) { + + } + + scrollButtonControl() { + if (this.transform < 0) { + this.scrollLeftBtn.style.display = "flex"; + } else { + this.scrollLeftBtn.style.display = "none"; + } + if (this.parentElement!.scrollWidth + this.transform - this.scrollRightBtn.clientWidth < this.container.clientWidth) { + this.scrollRightBtn.style.display = "flex"; + } else { + this.scrollRightBtn.style.display = "none"; + } + // 触发宽度变化事件 + } +} diff --git a/src/components/menus/toolbar/base/ClearFormat.ts b/src/components/menus/toolbar/base/ClearFormat.ts new file mode 100644 index 0000000..57e69ce --- /dev/null +++ b/src/components/menus/toolbar/base/ClearFormat.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/clear-format.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; + +/** + * 基础菜单:清除格式 + */ +export class ClearFormat extends HTMLElement implements UAIEditorEventListener { + // 按钮选项 + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: true, + text: t('base.clearFormat'), + tooltip: t('base.clearFormat'), + } + + // 功能按钮 + 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().unsetAllMarks().run(); + } + }) + } + + /** + * 定义Transaction监听方法 + * @param event + * @param options + */ + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + this.menuButton.onTransaction(event, options); + } + + onEditableChange(editable: boolean) { + this.menuButtonOptions.enable = editable; + this.menuButton.onEditableChange(editable); + } +} + diff --git a/src/components/menus/toolbar/base/FormatPainter.ts b/src/components/menus/toolbar/base/FormatPainter.ts new file mode 100644 index 0000000..7df54df --- /dev/null +++ b/src/components/menus/toolbar/base/FormatPainter.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/format-painter.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; + +/** + * 基础菜单:格式刷 + */ +export class FormatPainter extends HTMLElement implements UAIEditorEventListener { + // 按钮选项 + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: true, + text: t('base.formatPainter.text'), + tooltip: t('base.formatPainter.text'), + } + + // 功能按钮 + 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) { + // TODO + } + }) + } + + /** + * 定义Transaction监听方法 + * @param event + * @param options + */ + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + this.menuButton.onTransaction(event, options); + if (this.menuButton.menuButton) { + var disable = !event.editor.state.selection.empty; + this.onEditableChange(disable); + } + } + + onEditableChange(editable: boolean) { + this.menuButtonOptions.enable = editable; + this.menuButton.onEditableChange(editable); + } +} + diff --git a/src/components/menus/toolbar/base/Redo.ts b/src/components/menus/toolbar/base/Redo.ts new file mode 100644 index 0000000..fa75da4 --- /dev/null +++ b/src/components/menus/toolbar/base/Redo.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/redo.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; + +/** + * 基础菜单:重做 + */ +export class Redo extends HTMLElement implements UAIEditorEventListener { + // 按钮选项 + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: true, + text: t('base.redo'), + tooltip: t('base.redo'), + shortcut: "Ctrl+Y / Ctrl+Shift+Z", + } + + // 功能按钮 + 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().redo().run(); + } + }) + } + + /** + * 定义Transaction监听方法 + * @param event + * @param options + */ + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions){ + this.menuButton.onTransaction(event, options); + if (this.menuButton.menuButton) { + // 根据当前是否有可重做操作决定按钮是否可用 + var disable = event.editor.can().chain().redo().run(); + this.onEditableChange(disable); + } + } + + onEditableChange(editable: boolean){ + this.menuButtonOptions.enable = editable; + this.menuButton.onEditableChange(editable); + } +} + diff --git a/src/components/menus/toolbar/base/Undo.ts b/src/components/menus/toolbar/base/Undo.ts new file mode 100644 index 0000000..3a9f036 --- /dev/null +++ b/src/components/menus/toolbar/base/Undo.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/undo.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; + +/** + * 基础菜单:撤销 + */ +export class Undo extends HTMLElement implements UAIEditorEventListener { + // 按钮选项 + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: true, + text: t('base.undo'), + tooltip: t('base.undo'), + shortcut: "Ctrl+Z", + } + + // 功能按钮 + 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().undo().run(); + } + }) + } + + /** + * 定义Transaction监听方法 + * @param event + * @param options + */ + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions){ + this.menuButton.onTransaction(event, options); + if (this.menuButton.menuButton) { + // 根据当前是否有可撤销操作决定按钮是否可用 + var disable = event.editor.can().chain().undo().run(); + this.onEditableChange(disable); + } + } + + onEditableChange(editable: boolean){ + this.menuButtonOptions.enable = editable; + this.menuButton.onEditableChange(editable); + } +} + diff --git a/src/core/UAIEditor.ts b/src/core/UAIEditor.ts new file mode 100644 index 0000000..48533ff --- /dev/null +++ b/src/core/UAIEditor.ts @@ -0,0 +1,334 @@ +// Copyright (c) 2024-present AI-Labs + +import { + Editor as TipTap, + EditorEvents, + EditorOptions, +} from "@tiptap/core"; + +import { DOMParser } from "@tiptap/pm/model"; +import "../components" +import { Header } from "../components/Header.ts"; +import { Editor } from "../components/Editor.ts"; +import { Footer } from "../components/Footer.ts"; +import * as monaco from 'monaco-editor'; +import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker.js?worker'; + +import "../styles"; + +import i18next from "i18next"; +import { zh } from "../i18n/zh.ts"; +import { Resource } from "i18next"; +import { allExtensions } from "./UAIExtensions.ts"; + +self.MonacoEnvironment = { + getWorker(_workerId, _label) { + return new HtmlWorker(); + }, +} + +/** + * 定义全局键盘事件监听 + */ +document.addEventListener('keydown', function (event) { + // 阻止 Ctrl + P 快捷键(打印) + if (event.ctrlKey && event.key === 'p') { + event.preventDefault(); + } + // 阻止 Ctrl + S 快捷键(保存) + if (event.ctrlKey && event.key === 's') { + event.preventDefault(); + } + // 阻止 Ctrl + Shift + I 快捷键(开发者工具) + if (event.ctrlKey && event.shiftKey && event.key === 'I') { + event.preventDefault(); + } +}); + +/** + * 定义全局编辑器监听接口 + */ +export interface UAIEditorEventListener { + onCreate: (event: EditorEvents['create'], options: UAIEditorOptions) => void; + onTransaction: (event: EditorEvents['transaction'], options: UAIEditorOptions) => void; + onEditableChange: (editable: boolean) => void; +} + +/** + * 定义数据字典类型 + */ +export type UAIEditorDict = { + label: string, + value: string | number, + order?: number +} + +/** + * 定义编辑器选项配置类型 + */ +export type UAIEditorOptions = { + element: string | Element + content?: string, + onCreated?: (editor: UAIEditor) => void, + dicts?: { + fontFamilies?: UAIEditorDict[], + fontSizes?: UAIEditorDict[], + lineHeights?: UAIEditorDict[], + symbols?: UAIEditorDict[], + emojis?: UAIEditorDict[], + }, + header?: "ribbon" | "classic", + theme?: "light" | "dark", + lang?: string, + i18n?: Record>, +} + +/** + * 定义内部编辑器类 + */ +export class InnerEditor extends TipTap { + + uaiEditor: UAIEditor; + + constructor(uaiEditor: UAIEditor, options: Partial = {}) { + super(options); + this.uaiEditor = uaiEditor; + } +} + +/** + * 定义编辑器主类 + */ +export class UAIEditor { + options: UAIEditorOptions; + innerEditor!: InnerEditor; + container!: HTMLElement; + center!: HTMLElement; + + eventComponents: UAIEditorEventListener[] = []; + toggleContainers: HTMLElement[] = []; + + header!: Header; + editor!: Editor; + footer!: Footer; + source!: HTMLElement; + sourceEditor!: monaco.editor.IStandaloneCodeEditor; + + constructor(customOptions: UAIEditorOptions) { + // 使用默认的选项初始化编辑器选项 + this.options = { + element: customOptions.element, + content: customOptions.content, + onCreated: customOptions.onCreated, + header: customOptions.header ?? "ribbon", + theme: customOptions.theme ?? "light", + lang: customOptions.lang ?? "zh", + i18n: customOptions.i18n, + dicts: { + fontFamilies: customOptions.dicts?.fontFamilies ?? [ + { label: "默认字体", value: "" }, + { label: "宋体", value: "SimSun" }, + { label: "黑体", value: "SimHei" }, + { label: "楷体", value: "KaiTi" }, + { label: "楷体_GB2312", value: "KaiTi_GB2312" }, + { label: '仿宋', value: 'FangSong' }, + { label: '仿宋_GB2312', value: 'FangSong_GB2312' }, + { label: '华文宋体', value: 'STSong' }, + { label: '华文仿宋', value: 'STFangsong' }, + { label: '方正仿宋简体', value: 'FZFangSong-Z02S' }, + { label: '方正小标宋', value: 'FZXiaoBiaoSong-B05S' }, + { label: '微软雅黑', value: 'Microsoft Yahei' }, + { label: 'Arial', value: 'Arial' }, + { label: 'Times New Roman', value: 'Times New Roman' }, + { label: 'Verdana', value: 'Verdana' }, + { label: 'Helvetica', value: 'Helvetica' }, + { label: 'Calibri', value: 'Calibri' }, + { label: 'Cambria', value: 'Cambria' }, + { label: 'Tahoma', value: 'Tahoma' }, + { label: 'Georgia', value: 'Georgia' }, + { label: 'Comic Sans MS', value: 'Comic Sans MS' }, + { label: 'Impact', value: 'Impact' }, + ], + fontSizes: customOptions.dicts?.fontSizes ?? [ + { label: "默认", value: "" }, + { label: "初号", value: "42pt", order: 20 }, + { label: "小初", value: "36pt", order: 19 }, + { label: "一号", value: "26pt", order: 16 }, + { label: "小一", value: "24pt", order: 15 }, + { label: "二号", value: "22pt", order: 14 }, + { label: "小二", value: "18pt", order: 11 }, + { label: "三号", value: "16pt", order: 10 }, + { label: "小三", value: "15pt", order: 9 }, + { label: "四号", value: "14pt", order: 7 }, + { label: "小四", value: "12pt", order: 4 }, + { label: "五号", value: "10.5pt" }, + { label: "小五", value: "9pt" }, + { label: '10', value: '10px', order: 1 }, + { label: '11', value: '11px', order: 2 }, + { label: '12', value: '12px', order: 3 }, + { label: '16', value: '16px', order: 5 }, + { label: '18', value: '18px', order: 6 }, + { label: '20', value: '20px', order: 8 }, + { label: '22', value: '22px' }, + { label: '24', value: '24px' }, + { label: '26', value: '26px', order: 12 }, + { label: '28', value: '28px', order: 13 }, + { label: '32', value: '32px' }, + { label: '36', value: '36px', order: 17 }, + { label: '42', value: '42px', order: 18 }, + { label: '48', value: '48px' }, + { label: '72', value: '72px', order: 21 }, + { label: '96', value: '96px', order: 22 } + ], + lineHeights: customOptions.dicts?.lineHeights ?? [ + { label: '单倍行距', value: 1 }, + { label: '1.5 倍行距', value: 1.5 }, + { label: '2 倍行距', value: 2 }, + { label: '2.5 倍行距', value: 2.5 }, + { label: '3 倍行距', value: 3 }, + ], + symbols: [ + { label: '普通文本', value: '‹›«»‘’“”‚„¡¿‥…‡‰‱‼⁈⁉⁇©®™§¶⁋', }, + { label: '货币符号', value: '$€¥£¢₠₡₢₣₤¤₿₥₦₧₨₩₪₫₭₮₯₰₱₲₳₴₵₶₷₸₹₺₻₼₽', }, + { label: '数学符号', value: '<>≤≥–—¯‾°−±÷⁄׃∫∑∞√∼≅≈≠≡∈∉∋∏∧∨¬∩∪∂∀∃∅∇∗∝∠¼½¾', }, + { label: '箭头', value: '←→↑↓⇐⇒⇑⇓⇠⇢⇡⇣⇤⇥⤒⤓↨' }, + { label: '拉丁语', value: 'ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſ', }, + ], + emojis: [ + { label: '表情与角色', value: '😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 😉 😊 😇 🥰 😍 🤩 😘 😗 😚 😙 😋 😛 😜 🤪 😝 🤑 🤗 🤭 🤫 🤔 🤐 🤨 😐 😑 😶 😏 😒 🙄 😬 🤥 😌 😔 😪 🤤 😴 😷 🤒 🤕 🤢 🤮 🤧 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 ☹️ 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 😈 👿 💀 ☠️ 💩 🤡 👹 👺 👻 👽 👾 🤖 👋 🤚 🖐️ ✋ 🖖 👌 🤏 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 👐 🤲 🤝 🙏 ✍️ 💅 🤳 💪 🦾 🦿 🦵 🦶 👂 🦻', }, + { label: '动物与自然', value: '🐵 🐒 🦍 🦧 🐶 🐕 🦮 🐕‍🦺 🐩 🐺 🦊 🦝 🐱 🐈 🦁 🐯 🐅 🐆 🐴 🐎 🦄 🦓 🦌 🐮 🐂 🐃 🐄 🐷 🐖 🐗 🐽 🐏 🐑 🐐 🐪 🐫 🦙 🦒 🐘 🦏 🦛 🐭 🐁 🐀 🐹 🐰 🐇 🐿️ 🦔 🦇 🐻 🐨 🐼 🦥 🦦 🦨 🦘 🦡 🐾 🦃 🐔 🐓 🐣 🐤 🐥 🐦 🐧 🕊️ 🦅 🦆 🦢 🦉 🦩 🦚 🦜 🐸 🐊 🐢 🦎 🐍 🐲 🐉 🦕 🦖 🐳 🐋 🐬 🐟 🐠 🐡 🦈 🐙 🐚 🐌 🦋 🐛 🐜 🐝 🐞 🦗 🕷️ 🕸️ 🦂 🦟 🦠 💐 🌸 💮 🏵️ 🌹 🥀 🌺 🌻 🌼 🌷 🌱 🌲 🌳 🌴 🌵 🌾 🌿 ☘️ 🍀 🍁 🍂 🍃', }, + { label: '食物与食品', value: '🥬 🥦 🧄 🧅 🍄 🥜 🌰 🍞 🥐 🥖 🥨 🥯 🥞 🧇 🧀 🍖 🍗 🥩 🥓 🍔 🍟 🍕 🌭 🥪 🌮 🌯 🥙 🧆 🥚 🍳 🥘 🍲 🥣 🥗 🍿 🧈 🧂 🥫 🍱 🍘 🍙 🍚 🍛 🍜 🍝 🍠 🍢 🍣 🍤 🍥 🥮 🍡 🥟 🥠 🥡 🦀 🦞 🦐 🦑 🦪 🍦 🍧 🍨 🍩 🍪 🎂 🍰 🧁 🥧 🍫 🍬 🍭 🍮 🍯 🍼 🥛 ☕ 🍵 🍶 🍾 🍷 🍸 🍹 🍺 🍻 🥂 🥃 🥤 🧃 🧉 🧊 🥢 🍽️ 🍴 🥄 🔪 🏺', }, + { label: '活动', value: '🎗️ 🎟️ 🎫 🎖️ 🏆 🏅 🥇 🥈 🥉 ⚽ ⚾ 🥎 🏀 🏐 🏈 🏉 🎾 🥏 🎳 🏏 🏑 🏒 🥍 🏓 🏸 🥊 🥋 🥅 ⛳ ⛸️ 🎣 🤿 🎽 🎿 🛷 🥌 🎯 🪀 🪁 🎱 🔮 🧿 🎮 🕹️ 🎰 🎲 🧩 🧸 ♠️ ♥️ ♦️ ♣️ ♟️ 🃏 🀄 🎴 🎭 🖼️ 🎨 🧵 🧶', }, + { label: '旅行与景点', value: '🚈 🚉 🚊 🚝 🚞 🚋 🚌 🚍 🚎 🚐 🚑 🚒 🚓 🚔 🚕 🚖 🚗 🚘 🚙 🚚 🚛 🚜 🏎️ 🏍️ 🛵 🦽 🦼 🛺 🚲 🛴 🛹 🚏 🛣️ 🛤️ 🛢️ ⛽ 🚨 🚥 🚦 🛑 🚧 ⚓ ⛵ 🛶 🚤 🛳️ ⛴️ 🛥️ 🚢 ✈️ 🛩️ 🛫 🛬 🪂 💺 🚁 🚟 🚠 🚡 🛰️ 🚀 🛸 🛎️ 🧳 ⌛ ⏳ ⌚ ⏰ ⏱️ ⏲️ 🕰️ 🕛 🕧 🕐 🕜 🕑 🕝 🕒 🕞 🕓 🕟 🕔 🕠 🕕 🕡 🕖 🕢 🕗 🕣 🕘 🕤 🕙 🕥 🕚 🕦 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘 🌙 🌚 🌛 🌜 🌡️ ☀️ 🌝 🌞 🪐 ⭐ 🌟 🌠 🌌 ☁️ ⛅ ⛈️ 🌤️ 🌥️ 🌦️ 🌧️ 🌨️ 🌩️ 🌪️ 🌫️ 🌬️ 🌀 🌈 🌂 ☂️ ☔ ⛱️ ⚡ ❄️ ☃️ ⛄ ☄️ 🔥 💧 🌊', }, + { label: '物品', value: '📃 📜 📄 📰 🗞️ 📑 🔖 🏷️ 💰 💴 💵 💶 💷 💸 💳 🧾 💹 ✉️ 📧 📨 📩 📤 📥 📦 📫 📪 📬 📭 📮 🗳️ ✏️ ✒️ 🖋️ 🖊️ 🖌️ 🖍️ 📝 💼 📁 📂 🗂️ 📅 📆 🗒️ 🗓️ 📇 📈 📉 📊 📋 📌 📍 📎 🖇️ 📏 📐 ✂️ 🗃️ 🗄️ 🗑️ 🔒 🔓 🔏 🔐 🔑 🗝️ 🔨 🪓 ⛏️ ⚒️ 🛠️ 🗡️ ⚔️ 🔫 🏹 🛡️ 🔧 🔩 ⚙️ 🗜️ ⚖️ 🦯 🔗 ⛓️ 🧰 🧲 ⚗️ 🧪 🧫 🧬 🔬 🔭 📡 💉 🩸 💊 🩹 🩺 🚪 🛏️ 🛋️ 🪑 🚽 🚿 🛁 🪒 🧴 🧷 🧹 🧺 🧻 🧼 🧽 🧯 🛒 🚬 ⚰️ ⚱️ 🗿', }, + { label: '符号', value: '➰ ➿ 〽️ ✳️ ✴️ ❇️ ©️ ®️ ™️ #️⃣ *️⃣ 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 🔠 🔡 🔢 🔣 🔤 🅰️ 🆎 🅱️ 🆑 🆒 🆓 ℹ️ 🆔 Ⓜ️ 🆕 🆖 🅾️ 🆗 🅿️ 🆘 🆙 🆚 🈁 🈂️ 🔴 🟠 🟡 🟢 🔵 🟣 🟤 ⚫ ⚪ 🟥 🟧 🟨 🟩 🟦 🟪 🟫 ⬛ ⬜ ◼️ ◻️ ◾ ◽ ▪️ ▫️ 🔶 🔷 🔸 🔹 🔺 🔻 💠 🔘 🔳 🔲', }, + { label: '旗帜', value: '🏁 🎌 🏴󠁧󠁢󠁥󠁮󠁧󠁿', }, + ], + }, + }; + const i18nConfig = this.options.i18n || {}; + const resources = { + zh: { translation: { ...zh, ...i18nConfig.zh } } + } as Resource; + i18next.init({ + lng: this.options.lang, resources, + }, (_err, _t) => { + this.initInnerEditor(); + }) + } + + /** + * 初始化编辑器 + */ + protected initInnerEditor() { + const rootEl = typeof this.options.element === "string" + ? document.querySelector(this.options.element) as Element : this.options.element; + this.container = document.createElement("div"); + this.container.classList.add("uai-editor-container"); + rootEl.appendChild(this.container); + + this.header = new Header(); + this.header.classList.add("uai-toolbar"); + this.header.classList.add("toolbar-ribbon"); + this.container.appendChild(this.header); + + this.center = document.createElement("div"); + this.center.style.display = "flex"; + this.center.style.height = "10vh"; + this.center.style.flex = "1"; + + this.editor = new Editor(); + this.editor.classList.add("uai-main"); + this.center.appendChild(this.editor); + this.container.appendChild(this.center); + + this.footer = new Footer(); + this.footer.classList.add("uai-footer"); + this.container.appendChild(this.footer); + + this.source = document.createElement("div"); + this.source.classList.add("uai-source-editor"); + this.sourceEditor = monaco.editor.create(this.source, { + value: '', // 编辑器初始显示文字 + language: 'html', // 语言 + autoIndent: "full", + automaticLayout: true, // 自动布局 + theme: 'vs', // 官方自带三种主题vs, hc-black, or vs-dark + minimap: { // 小地图 + enabled: true, + }, + fontSize: 14, + formatOnType: true, + formatOnPaste: true, + lineNumbersMinChars: 3, + wordWrap: 'on', + scrollbar: { + verticalScrollbarSize: 5, + horizontalScrollbarSize: 5, + }, + }) + this.container.appendChild(this.source); + + this.sourceEditor.onDidChangeModelContent(() => { + this.innerEditor.commands.setContent(this.sourceEditor.getValue()); + }) + + let content = this.options.content; + + this.innerEditor = new InnerEditor(this, { + element: this.editor.editorContainer, + content, + extensions: allExtensions(this, this.options), + onCreate: (props) => this.onCreate(props), + onTransaction: (props) => this.onTransaction(props), + onFocus: () => { }, + onBlur: () => { }, + onDestroy: () => { }, + editorProps: { + attributes: { + class: "uai-editor" + }, + } + }) + } + + protected onCreate(event: EditorEvents['create']) { + this.innerEditor.view.dom.style.height = "100%"; + if (this.options.onCreated) { + this.options.onCreated(this); + } + this.header.onCreate(event, this.options); + this.editor.onCreate(event, this.options); + this.footer.onCreate(event, this.options); + this.eventComponents.forEach(component => { + component.onCreate(event, this.options); + }) + } + + protected onTransaction(event: EditorEvents['transaction']) { + this.header.onTransaction(event, this.options); + this.editor.onTransaction(event, this.options); + this.footer.onTransaction(event, this.options); + this.eventComponents.forEach(component => { + component.onTransaction(event, this.options); + }) + } + + switchEditor() { + this.container.appendChild(this.center); + this.container.appendChild(this.footer); + try { + this.container.removeChild(this.source); + } catch (error) { + } + } + + switchSource() { + this.container.removeChild(this.center); + this.container.removeChild(this.footer); + this.container.appendChild(this.source); + this.sourceEditor.setValue(this.innerEditor.getHTML()); + this.sourceEditor.getAction('editor.action.formatDocument')!.run(); + this.sourceEditor.getModel()?.updateOptions({ tabSize: 2 }); + } +} \ No newline at end of file diff --git a/src/core/UAIExtensions.ts b/src/core/UAIExtensions.ts new file mode 100644 index 0000000..4308cf9 --- /dev/null +++ b/src/core/UAIExtensions.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { Extensions } from "@tiptap/core"; +import { UAIEditor, UAIEditorOptions } from "./UAIEditor"; + +import { StarterKit } from "@tiptap/starter-kit"; + + +/** + * 定义编辑器的所有自定义扩展组件 + * @param uaiEditor + * @param _options + * @returns + */ +export const allExtensions = (uaiEditor: UAIEditor, _options: UAIEditorOptions): Extensions => { + let extensions: Extensions = [ + StarterKit.configure({ + bulletList: false, + orderedList: false, + }), + ]; + + return extensions; +}