添加基础菜单

This commit is contained in:
wux_labs
2024-12-20 21:35:42 +08:00
parent e0cf01c819
commit 7c78fb9e1a
11 changed files with 1009 additions and 0 deletions

View File

@@ -8,6 +8,13 @@ import { Footer } from "../components/Footer.ts";
import { Ribbon } from "./menus/toolbar/Ribbon.ts"; import { Ribbon } from "./menus/toolbar/Ribbon.ts";
import { Classic } from "./menus/toolbar/Classic.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); defineCustomElement('uai-editor-header', Header);
@@ -16,3 +23,11 @@ defineCustomElement('uai-editor-footer', Footer);
defineCustomElement('uai-editor-ribbon-menu', Ribbon); defineCustomElement('uai-editor-ribbon-menu', Ribbon);
defineCustomElement('uai-editor-classic-menu', Classic); 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);

View File

@@ -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<Props>;
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 = `<img src="${this.menuButtonOptions.icon}" width="${size}" />`
}
// 按钮文字描述
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);
}
}
}

View File

@@ -5,6 +5,12 @@ import { EditorEvents } from "@tiptap/core";
import { UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor.ts"; import { UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor.ts";
import menuIcon from "../../../assets/icons/menu.svg"; 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; classicMenu!: HTMLElement;
classicScrollableContainer!: HTMLElement; classicScrollableContainer!: HTMLElement;
// 基础菜单容器
classicMenuBaseScrollable!: ScrollableDiv;
classicMenuBaseGroup!: HTMLElement;
// 基础菜单
baseMenuUndo!: Undo;
baseMenuRedo!: Redo;
baseMenuFormatPainter!: FormatPainter;
baseMenuClearFormat!: ClearFormat;
constructor(defaultToolbarMenus: Record<string, any>[]) { constructor(defaultToolbarMenus: Record<string, any>[]) {
super(); super();
this.defaultToolbarMenus = defaultToolbarMenus; this.defaultToolbarMenus = defaultToolbarMenus;
@@ -57,6 +73,17 @@ export class Classic extends HTMLElement implements UAIEditorEventListener {
option.value = menu.value; option.value = menu.value;
selectMuenus.options.add(option); 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() { 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);
} }
} }

View File

@@ -5,6 +5,13 @@ import { EditorEvents } from "@tiptap/core";
import { UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor.ts"; import { UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor.ts";
import { t } from "i18next"; 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; ribbonMenu!: HTMLElement;
ribbonScrollableContainer!: HTMLElement; ribbonScrollableContainer!: HTMLElement;
// 基础菜单容器
ribbonMenuBaseScrollable!: ScrollableDiv;
ribbonMenuBaseGroup!: HTMLElement;
// 基础菜单
baseMenuUndo!: Undo;
baseMenuRedo!: Redo;
baseMenuFormatPainter!: FormatPainter;
baseMenuClearFormat!: ClearFormat;
constructor(defaultToolbarMenus: Record<string, any>[]) { constructor(defaultToolbarMenus: Record<string, any>[]) {
super(); super();
this.defaultToolbarMenus = defaultToolbarMenus; this.defaultToolbarMenus = defaultToolbarMenus;
@@ -54,6 +72,10 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener {
ribbonTabs.children[i].classList.remove("active"); ribbonTabs.children[i].classList.remove("active");
} }
tab.classList.add("active"); tab.classList.add("active");
this.ribbonMenuBaseScrollable.style.display = "none";
if (menu.value === "base") {
this.ribbonMenuBaseScrollable.style.display = "flex";
}
}) })
ribbonTabs.appendChild(tab); ribbonTabs.appendChild(tab);
}); });
@@ -69,6 +91,10 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener {
const level = i + 1 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.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() { 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);
} }
} }

View File

@@ -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 = "<strong><</strong>";
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 = "<strong>></strong>";
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";
}
// 触发宽度变化事件
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

334
src/core/UAIEditor.ts Normal file
View File

@@ -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<string, Record<string, string>>,
}
/**
* 定义内部编辑器类
*/
export class InnerEditor extends TipTap {
uaiEditor: UAIEditor;
constructor(uaiEditor: UAIEditor, options: Partial<EditorOptions> = {}) {
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 });
}
}

25
src/core/UAIExtensions.ts Normal file
View File

@@ -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;
}