diff --git a/src/components/Footer.ts b/src/components/Footer.ts index 6481897..a2af5de 100644 --- a/src/components/Footer.ts +++ b/src/components/Footer.ts @@ -4,14 +4,22 @@ import { EditorEvents } from "@tiptap/core"; import { UAIEditorEventListener, UAIEditorOptions } from "../core/UAIEditor.ts"; +import { ToggleToc } from "./menus/toolbar/page/ToggleToc"; + /** * 编辑器底部状态栏 */ export class Footer extends HTMLElement implements UAIEditorEventListener { container!: HTMLElement; + // 文档大纲 + toggleToc!: ToggleToc; + constructor() { super(); + + // 创建状态栏菜单 + this.toggleToc = new ToggleToc({ menuType: "button", enable: true }); } /** @@ -20,6 +28,9 @@ export class Footer extends HTMLElement implements UAIEditorEventListener { * @param options */ onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + // 初始化状态栏菜单 + this.toggleToc.onCreate(event, options); + this.container = document.createElement("div"); this.container.classList.add("uai-footer"); this.appendChild(this.container); @@ -38,6 +49,11 @@ export class Footer extends HTMLElement implements UAIEditorEventListener { const statusBarRight = document.createElement("div"); statusBarRight.classList.add("uai-status-bar-right"); statusBar.appendChild(statusBarRight); + + this.toggleToc.classList.add("uai-status-bar-button"); + + // 状态栏中添加功能菜单按钮 + statusBarLeft.appendChild(this.toggleToc); } /** @@ -46,10 +62,10 @@ export class Footer extends HTMLElement implements UAIEditorEventListener { * @param options */ onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { - + this.toggleToc.onTransaction(event, options); } - onEditableChange(_editable: boolean) { - + onEditableChange(editable: boolean) { + this.toggleToc.onEditableChange(editable); } } \ No newline at end of file diff --git a/src/components/containers/TocContainer.ts b/src/components/containers/TocContainer.ts new file mode 100644 index 0000000..f9a5537 --- /dev/null +++ b/src/components/containers/TocContainer.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import closeIcon from "../../assets/icons/close.svg"; +import titleIcon from "../../assets/icons/toc.svg"; +import { t } from "i18next"; +import { UAIEditor, UAIEditorEventListener, UAIEditorOptions } from "../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; + +/** + * 定义文档大纲容器 + */ +export class TocContainer extends HTMLElement implements UAIEditorEventListener { + // 文档大纲容器 + container!: HTMLElement; + + // 文档大纲标题 + tocTitle!: HTMLElement; + + // 文档大纲内容 + content!: HTMLElement; + + constructor() { + super(); + // 初始化容器 + this.container = document.createElement("div"); + this.container.classList.add("uai-pannel-container"); + this.appendChild(this.container); + + // 初始化标题 + this.tocTitle = document.createElement("div"); + this.tocTitle.classList.add("uai-pannel-title"); + this.tocTitle.innerHTML = `  ${t('toc.title')}`; + this.container.appendChild(this.tocTitle); + + // 初始化关闭容器按钮 + const dialogClose = document.createElement("div"); + dialogClose.classList.add("uai-pannel-close"); + dialogClose.innerHTML = ``; + // 注册点击事件,点击关闭按钮关闭容器 + dialogClose.addEventListener("click", () => { + this.style.display = "none"; + }) + this.tocTitle.appendChild(dialogClose); + + this.content = document.createElement("div"); + this.content.classList.add("uai-toc-content"); + this.container.appendChild(this.content); + } + + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + } + + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + } + + onEditableChange(editable: boolean) { + } + + /** + * 文档大纲内容渲染 + * @param uaiEditor + */ + renderContents(uaiEditor: UAIEditor) { + this.content.innerHTML = ""; + // 获取编辑器的文档大纲内容 + let tableOfContents = uaiEditor.tableOfContents; + if (tableOfContents) { + // 循环处理所有大纲 + tableOfContents.forEach(content => { + const item = document.createElement("div"); + item.classList.add("uai-toc-item"); + // 根据大纲级别渲染大纲 + item.classList.add(`level-${content.level}`); + if (content.isActive) { + item.classList.add("active"); + } + // 大纲条目点击事件,跳转到文档中对应的位置 + item.addEventListener("click", () => { + if (!uaiEditor.innerEditor) { + return; + } + const activeHeading = tableOfContents.find( + (item: any) => 'isActive' in item && item.isActive, + ) + if (activeHeading && 'isActive' in activeHeading) { + activeHeading.isActive = false; + } + if ('isActive' in content) { + content.isActive = true; + } + const element = uaiEditor.innerEditor.view.dom.querySelector( + `[data-toc-id="${content.id}"`, + ) + const pageContainer = document.querySelector( + `.uai-main`, + ) + pageContainer?.scrollTo({ + top: (element as HTMLElement)?.offsetTop ?? 0 + 10, + }) + const pos = uaiEditor.innerEditor.view.posAtDOM(element as Node, 0); + const { tr } = uaiEditor.innerEditor.view.state; + tr.setSelection(new TextSelection(tr.doc.resolve(pos))); + uaiEditor.innerEditor.view.dispatch(tr); + // uaiEditor.innerEditor.view.focus(); + this.renderContents(uaiEditor); + }); + // 大纲文字内容 + const text = document.createElement("div"); + text.classList.add("uai-toc-text"); + text.innerHTML = content.textContent; + item.appendChild(text); + this.content.appendChild(item); + }) + } + } +} + diff --git a/src/components/index.ts b/src/components/index.ts index ebc1ad0..cdbde3e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,8 @@ import { Header } from "../components/Header.ts"; import { Editor } from "../components/Editor.ts"; import { Footer } from "../components/Footer.ts"; +import { TocContainer } from "../components/containers/TocContainer.ts"; + import { Ribbon } from "./menus/toolbar/Ribbon.ts"; import { Classic } from "./menus/toolbar/Classic.ts"; @@ -59,12 +61,17 @@ import { HardBreak } from "./menus/toolbar/insert/HardBreak.ts"; import { Emoji } from "./menus/toolbar/insert/Emoji.ts"; import { Symbol } from "./menus/toolbar/insert/Symbol.ts"; import { Math } from "./menus/toolbar/insert/Math.ts"; +import { Toc } from "./menus/toolbar/insert/Toc.ts"; + +import { ToggleToc } from "./menus/toolbar/page/ToggleToc.ts"; // 注册组件 defineCustomElement('uai-editor-header', Header); defineCustomElement('uai-editor-editor', Editor); defineCustomElement('uai-editor-footer', Footer); +defineCustomElement('uai-editor-toc-container', TocContainer); + defineCustomElement('uai-editor-ribbon-menu', Ribbon); defineCustomElement('uai-editor-classic-menu', Classic); @@ -119,3 +126,6 @@ defineCustomElement('uai-editor-insert-menu-hard-break', HardBreak); defineCustomElement('uai-editor-insert-menu-emoji', Emoji); defineCustomElement('uai-editor-insert-menu-symbol', Symbol); defineCustomElement('uai-editor-insert-menu-math', Math); +defineCustomElement('uai-editor-insert-menu-toc', Toc); + +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 9e4153c..fc0f98e 100644 --- a/src/components/menus/toolbar/Classic.ts +++ b/src/components/menus/toolbar/Classic.ts @@ -54,6 +54,9 @@ import { HardBreak } from "./insert/HardBreak.ts"; import { Emoji } from "./insert/Emoji.ts"; import { Symbol } from "./insert/Symbol.ts"; import { Math } from "./insert/Math.ts"; +import { Toc } from "./insert/Toc.ts"; + +import { ToggleToc } from "./page/ToggleToc.ts"; /** * 传统菜单栏 @@ -74,6 +77,10 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { classicMenuInsertScrollable!: ScrollableDiv; classicMenuInsertGroup!: HTMLElement; + // 页面菜单容器 + classicMenuPageScrollable!: ScrollableDiv; + classicMenuPageGroup!: HTMLElement; + // 基础菜单 baseMenuUndo!: Undo; baseMenuRedo!: Redo; @@ -119,6 +126,10 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { insertMenuEmoji!: Emoji; insertMenuSymbol!: Symbol; insertMenuMath!: Math; + insertMenuToc!: Toc; + + // 页面菜单 + pageMenuToggleToc!: ToggleToc; constructor(defaultToolbarMenus: Record[]) { super(); @@ -165,16 +176,21 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { const menu = selectMuenus.selectedOptions[0].value; this.classicMenuBaseScrollable.style.display = "none"; this.classicMenuInsertScrollable.style.display = "none"; + this.classicMenuPageScrollable.style.display = "none"; if (menu === "base") { this.classicMenuBaseScrollable.style.display = "flex"; } if (menu === "insert") { this.classicMenuInsertScrollable.style.display = "flex"; } + if (menu === "page") { + this.classicMenuPageScrollable.style.display = "flex"; + } }) // 创建分组菜单 this.createBaseMenu(event, options); this.createInsertMenu(event, options); + this.createPageMenu(event, options); this.classicMenuBaseScrollable.style.display = "flex"; } @@ -312,6 +328,12 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { this.insertMenuMath = new Math({ menuType: "button", enable: true, header: "classic", hideText: false }); this.eventComponents.push(this.insertMenuMath); + + this.insertMenuToc = new Toc({ menuType: "button", enable: true, header: "classic", hideText: false }); + this.eventComponents.push(this.insertMenuToc); + + this.pageMenuToggleToc = new ToggleToc({ menuType: "button", enable: true, header: "classic", hideText: false }); + this.eventComponents.push(this.pageMenuToggleToc); } /** * 创建基础菜单 @@ -401,5 +423,29 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { group2.appendChild(this.insertMenuEmoji); group2.appendChild(this.insertMenuSymbol); group2.appendChild(this.insertMenuMath); + + const group3 = document.createElement("div"); + group3.classList.add("uai-classic-virtual-group"); + this.classicMenuInsertGroup.appendChild(group3); + group3.appendChild(this.insertMenuToc); + } + + /** + * 创建页面菜单 + * @param event + * @param options + */ + createPageMenu(event: EditorEvents["create"], options: UAIEditorOptions) { + this.classicMenuPageGroup = document.createElement("div"); + this.classicMenuPageGroup.style.display = "flex"; + this.classicMenuPageScrollable = new ScrollableDiv(this.classicMenuPageGroup); + this.classicMenuPageScrollable.style.display = "none"; + this.classicMenu.appendChild(this.classicMenuPageScrollable); + this.classicMenuPageScrollable.onCreate(event, options); + + const group1 = document.createElement("div"); + group1.classList.add("uai-classic-virtual-group"); + this.classicMenuPageGroup.appendChild(group1); + group1.appendChild(this.pageMenuToggleToc); } } \ No newline at end of file diff --git a/src/components/menus/toolbar/Ribbon.ts b/src/components/menus/toolbar/Ribbon.ts index 86c5897..95e48dd 100644 --- a/src/components/menus/toolbar/Ribbon.ts +++ b/src/components/menus/toolbar/Ribbon.ts @@ -56,6 +56,9 @@ import { HardBreak } from "./insert/HardBreak.ts"; import { Emoji } from "./insert/Emoji.ts"; import { Symbol } from "./insert/Symbol.ts"; import { Math } from "./insert/Math.ts"; +import { Toc } from "./insert/Toc.ts"; + +import { ToggleToc } from "./page/ToggleToc.ts"; /** * 经典菜单栏 @@ -77,6 +80,10 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { ribbonMenuInsertScrollable!: ScrollableDiv; ribbonMenuInsertGroup!: HTMLElement; + // 页面菜单容器 + ribbonMenuPageScrollable!: ScrollableDiv; + ribbonMenuPageGroup!: HTMLElement; + // 基础菜单 baseMenuUndo!: Undo; baseMenuRedo!: Redo; @@ -122,6 +129,10 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { insertMenuEmoji!: Emoji; insertMenuSymbol!: Symbol; insertMenuMath!: Math; + insertMenuToc!: Toc; + + // 页面菜单 + pageMenuToggleToc!: ToggleToc; constructor(defaultToolbarMenus: Record[]) { super(); @@ -161,12 +172,16 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { tab.classList.add("active"); this.ribbonMenuBaseScrollable.style.display = "none"; this.ribbonMenuInsertScrollable.style.display = "none"; + this.ribbonMenuPageScrollable.style.display = "none"; if (menu.value === "base") { this.ribbonMenuBaseScrollable.style.display = "flex"; } if (menu.value === "insert") { this.ribbonMenuInsertScrollable.style.display = "flex"; } + if (menu.value === "page") { + this.ribbonMenuPageScrollable.style.display = "flex"; + } }) ribbonTabs.appendChild(tab); }); @@ -186,6 +201,7 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { // 创建分组菜单 this.createBaseMenu(event, options); this.createInsertMenu(event, options); + this.createPageMenu(event, options); this.ribbonMenuBaseScrollable.style.display = "flex"; } @@ -335,6 +351,12 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { this.insertMenuMath = new Math({ menuType: "button", enable: true, huge: true }); this.eventComponents.push(this.insertMenuMath); + + this.insertMenuToc = new Toc({ menuType: "button", enable: true, huge: true }); + this.eventComponents.push(this.insertMenuToc); + + this.pageMenuToggleToc = new ToggleToc({ menuType: "button", enable: true, huge: true, hideText: false }); + this.eventComponents.push(this.pageMenuToggleToc); } /** @@ -498,5 +520,29 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { group2.appendChild(this.insertMenuEmoji); group2.appendChild(this.insertMenuSymbol); group2.appendChild(this.insertMenuMath); + + const group3 = document.createElement("div"); + group3.classList.add("uai-ribbon-virtual-group"); + this.ribbonMenuInsertGroup.appendChild(group3); + group3.appendChild(this.insertMenuToc); + } + + /** + * 创建页面菜单 + * @param event + * @param options + */ + createPageMenu(_event: EditorEvents["create"], _options: UAIEditorOptions) { + this.ribbonMenuPageGroup = document.createElement("div"); + this.ribbonMenuPageGroup.classList.add("uai-ribbon-container"); + this.ribbonMenuPageGroup.style.display = "flex"; + this.ribbonMenuPageScrollable = new ScrollableDiv(this.ribbonMenuPageGroup); + this.ribbonMenuPageScrollable.style.display = "none"; + this.ribbonScrollableContainer.appendChild(this.ribbonMenuPageScrollable); + + const group1 = document.createElement("div"); + group1.classList.add("uai-ribbon-virtual-group"); + this.ribbonMenuPageGroup.appendChild(group1); + group1.appendChild(this.pageMenuToggleToc); } } \ No newline at end of file diff --git a/src/components/menus/toolbar/insert/Toc.ts b/src/components/menus/toolbar/insert/Toc.ts new file mode 100644 index 0000000..ba04b51 --- /dev/null +++ b/src/components/menus/toolbar/insert/Toc.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/toc.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; + +/** + * 插入菜单:插入大纲 + */ +export class Toc extends HTMLElement implements UAIEditorEventListener { + // 按钮选项 + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: false, + text: t('insert.toc'), + tooltip: t('insert.toc'), + } + + // 功能按钮 + 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().addTableOfContents().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.isEditable; + this.onEditableChange(disable); + } + } + + onEditableChange(editable: boolean) { + this.menuButtonOptions.enable = editable; + this.menuButton.onEditableChange(editable); + } +} + diff --git a/src/components/menus/toolbar/page/ToggleToc.ts b/src/components/menus/toolbar/page/ToggleToc.ts new file mode 100644 index 0000000..38ebd03 --- /dev/null +++ b/src/components/menus/toolbar/page/ToggleToc.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/toc.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions, InnerEditor } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; + +/** + * 页面菜单:打开文档大纲 + */ +export class ToggleToc extends HTMLElement implements UAIEditorEventListener { + // 按钮选项 + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: true, + text: t('toc.title'), + tooltip: t('toc.title'), + } + + // 功能按钮 + 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 display = (event.editor as InnerEditor).uaiEditor.tocContainer.style.display; + if (display != "none") { + // 如果当前已显示,则关闭文档大纲界面 + (event.editor as InnerEditor).uaiEditor.tocContainer.style.display = "none"; + } else { + // 如果当前未显示,则显示文档大纲界面 + (event.editor as InnerEditor).uaiEditor.toggleContainers.forEach(toggle => { + toggle.style.display = "none"; + }); + (event.editor as InnerEditor).uaiEditor.tocContainer.style.display = "block"; + } + } + }) + } + + /** + * 定义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/core/UAIEditor.ts b/src/core/UAIEditor.ts index 14c7808..a826354 100644 --- a/src/core/UAIEditor.ts +++ b/src/core/UAIEditor.ts @@ -7,8 +7,12 @@ import { } from "@tiptap/core"; import { DOMParser } from "@tiptap/pm/model"; + +import { TableOfContentData } from "@tiptap-pro/extension-table-of-contents"; + import "../components" import { Header } from "../components/Header.ts"; +import { TocContainer } from "../components/containers/TocContainer.ts"; import { Editor } from "../components/Editor.ts"; import { Footer } from "../components/Footer.ts"; import * as monaco from 'monaco-editor'; @@ -128,9 +132,11 @@ export class UAIEditor { toggleContainers: HTMLElement[] = []; header!: Header; + tocContainer!: TocContainer; editor!: Editor; footer!: Footer; source!: HTMLElement; + tableOfContents?: TableOfContentData; sourceEditor!: monaco.editor.IStandaloneCodeEditor; constructor(customOptions: UAIEditorOptions) { @@ -256,6 +262,11 @@ export class UAIEditor { this.center.style.height = "10vh"; this.center.style.flex = "1"; + this.tocContainer = new TocContainer(); + this.tocContainer.style.display = "none"; + this.center.appendChild(this.tocContainer); + this.toggleContainers.push(this.tocContainer); + this.editor = new Editor(); this.editor.classList.add("uai-main"); this.center.appendChild(this.editor); @@ -317,6 +328,7 @@ export class UAIEditor { this.options.onCreated(this); } this.header.onCreate(event, this.options); + this.tocContainer.onCreate(event, this.options); this.editor.onCreate(event, this.options); this.footer.onCreate(event, this.options); this.eventComponents.forEach(component => { @@ -328,9 +340,10 @@ export class UAIEditor { this.header.onTransaction(event, this.options); this.editor.onTransaction(event, this.options); this.footer.onTransaction(event, this.options); + this.tocContainer.onTransaction(event, this.options); this.eventComponents.forEach(component => { component.onTransaction(event, this.options); - }) + }); } switchEditor() { diff --git a/src/core/UAIExtensions.ts b/src/core/UAIExtensions.ts index a580ecd..99982b9 100644 --- a/src/core/UAIExtensions.ts +++ b/src/core/UAIExtensions.ts @@ -18,6 +18,7 @@ import { TextStyle } from "@tiptap/extension-text-style"; import { Underline } from "@tiptap/extension-underline"; import { Mathematics } from '@tiptap-pro/extension-mathematics'; +import { TableOfContents, getHierarchicalIndexes } from '@tiptap-pro/extension-table-of-contents' import BulletList from "../extensions/BulletList.ts"; import FontSize from "../extensions/FontSize.ts"; @@ -29,6 +30,8 @@ import OrderedList from "../extensions/OrderedList.ts"; import SelectFile from "../extensions/SelectFile.ts"; import Video from "../extensions/Video.ts"; import Audio from "../extensions/Audio.ts"; +import { uuid } from "../utils/UUID.ts"; +import Toc from "../extensions/Toc.ts"; /** * 定义编辑器的所有自定义扩展组件 @@ -69,6 +72,18 @@ export const allExtensions = (uaiEditor: UAIEditor, _options: UAIEditorOptions): }), Subscript, Superscript, + TableOfContents.configure({ + getIndex: getHierarchicalIndexes, + onUpdate: (content) => { + uaiEditor.tableOfContents = content; + uaiEditor.tocContainer.renderContents(uaiEditor); + }, + scrollParent: () => + document.querySelector( + ".uai-main", + ) as HTMLElement, + getId: () => uuid(), + }), TaskList, TaskItem.configure({ nested: true, @@ -77,6 +92,7 @@ export const allExtensions = (uaiEditor: UAIEditor, _options: UAIEditorOptions): types: ['heading', 'paragraph'], }), TextStyle, + Toc, Underline, Video, ]; diff --git a/src/extensions/SelectFile.ts b/src/extensions/SelectFile.ts index 71e8971..9f3c18c 100644 --- a/src/extensions/SelectFile.ts +++ b/src/extensions/SelectFile.ts @@ -5,7 +5,7 @@ import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; import { InnerEditor } from '../core/UAIEditor.ts'; import { Base64Uploader } from '../utils/FileUploader.ts'; -import { uuid } from '../utils/uuid.ts'; +import { uuid } from '../utils/UUID.ts'; import { Icons } from '../components/Icons.ts'; export type DecorationWaitingAction = { diff --git a/src/extensions/Toc.ts b/src/extensions/Toc.ts new file mode 100644 index 0000000..bbd036c --- /dev/null +++ b/src/extensions/Toc.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2024-present AI-Labs + +import { mergeAttributes, Node, NodeViewRendererProps } from '@tiptap/core' +import { InnerEditor } from '../core/UAIEditor' +import { TextSelection } from '@tiptap/pm/state' +import { t } from 'i18next' + +declare module '@tiptap/core' { + interface Commands { + addTableOfContents: { + addTableOfContents: () => ReturnType + } + } +} + +/** + * 定义文档大纲插件 + */ +export default Node.create({ + name: 'toc', + group: 'block', + atom: true, + addAttributes() { + return { + vnode: { + default: true, + }, + } + }, + parseHTML() { + return [{ tag: 'toc' }] + }, + renderHTML({ HTMLAttributes }) { + return ['toc', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)] + }, + addGlobalAttributes() { + return [ + { + types: ['heading'], + attributes: {}, + }, + ] + }, + addNodeView() { + return (props: NodeViewRendererProps) => { + const container = document.createElement('div'); + container.classList.add(`uai-node-view`); + + const content = document.createElement('div'); + content.classList.add("uai-node-container"); + content.classList.add("uai-hover-shadow"); + content.classList.add("uai-select-outline"); + content.classList.add("uai-node-toc"); + container.appendChild(content); + + const title = document.createElement("p"); + title.classList.add("uai-node-toc-head"); + title.innerHTML = t("toc.title"); + content.appendChild(title); + + let tableOfContents = (props.editor as InnerEditor).uaiEditor.tableOfContents; + + if (tableOfContents) { + const ul = document.createElement("ul"); + ul.classList.add("uai-node-toc-body"); + + tableOfContents.forEach(heading => { + const li = document.createElement("li"); + li.classList.add("uai-node-toc-item"); + li.classList.add(`level-${heading.level}`); + li.setAttribute("key", heading.id); + li.innerHTML = heading.textContent; + li.addEventListener("click", () => { + const element = props.editor.view.dom.querySelector(`[data-toc-id="${heading.id}"`) + if (element) { + element.scrollIntoView() + const pos = props.editor.view.posAtDOM(element, 0) + const { tr } = props.editor.view.state ?? {} + tr?.setSelection(new TextSelection(tr.doc.resolve(pos ?? 0))) + if (tr) { + props.editor.view.dispatch(tr) + props.editor.view.focus() + } + } + }); + ul.appendChild(li); + }) + content.appendChild(ul); + } + return { + dom: container + } + } + }, + addCommands() { + return { + addTableOfContents: + () => + ({ editor }) => { + return editor.chain().focus().insertContent({ type: this.name }).run() + }, + } + }, +}) diff --git a/src/utils/FileUploader.ts b/src/utils/FileUploader.ts new file mode 100644 index 0000000..ff109db --- /dev/null +++ b/src/utils/FileUploader.ts @@ -0,0 +1,24 @@ + +// Copyright (c) 2024-present AI-Labs + +/** + * 定义文件上传类 + */ +export type Uploader = (file: File, uploadUrl?: string, headers?: Record, formName?: string) => Promise>; + +/** + * 默认的Base64文件处理 + * @param file + * @param _uploadUrl + * @param _headers + * @param _formName + * @returns + */ +export const Base64Uploader: Uploader = (file: File, _uploadUrl?: string, _headers?: Record, _formName?: string): Promise> => { + let reader = new FileReader; + return new Promise((accept, fail) => { + reader.onload = () => accept({ code: 200, data: { src: reader.result, href: file.name } }); + reader.onerror = () => fail(reader.error); + setTimeout(() => reader.readAsDataURL(file), 1000); + }) +} \ No newline at end of file diff --git a/src/utils/Resize.ts b/src/utils/Resize.ts new file mode 100644 index 0000000..6e69a18 --- /dev/null +++ b/src/utils/Resize.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2024-present AI-Labs + +/** + * 调整对象大小 + * @param container + * @param root + * @param updateAttrs + */ +export const resize = (container: HTMLDivElement, root: HTMLElement, updateAttrs: (data: any) => void) => { + + const objResize = container.querySelector(".resize-obj") as HTMLElement, + minWidth = 10; + + let startX: number, startY: number, objWidth: number, startPosition: string; + + const onMousedown = (e: any) => { + e.preventDefault(); + + root.addEventListener('mousemove', onMousemove); + root.addEventListener('mouseup', onMouseup); + root.addEventListener('mouseleave', onMouseup); + + // 获取鼠标初始位置 + startX = e.clientX; + startY = e.clientY; + + objWidth = Number(objResize.getAttribute("data-with")) || objResize.clientWidth; + startPosition = e.target.getAttribute("data-position"); + }; + + const onMousemove = (event: MouseEvent) => { + // 获取鼠标最新位置的变化 + const distanceX = event.clientX - startX; + const distanceY = event.clientY - startY; + if (distanceX == 0 && distanceY == 0) { + return; + } + + // 判断是做缩小还是做放大 + var zoomIn = false; + var zoomSize = 0; + if (startPosition === "1") { + zoomIn = distanceX > 0 && distanceY > 0 ? false : true; + zoomSize = Math.max(Math.abs(distanceX), Math.abs(distanceY)); + } + if (startPosition === "2") { + zoomIn = distanceY > 0 ? false : true; + zoomSize = Math.abs(distanceY); + } + if (startPosition === "3") { + zoomIn = distanceX < 0 && distanceY > 0 ? false : true; + zoomSize = Math.max(Math.abs(distanceX), Math.abs(distanceY)); + } + if (startPosition === "4") { + zoomIn = distanceX > 0 ? false : true; + zoomSize = Math.abs(distanceX); + } + if (startPosition === "5") { + zoomIn = distanceX < 0 ? false : true; + zoomSize = Math.abs(distanceX); + } + if (startPosition === "6") { + zoomIn = distanceX > 0 && distanceY < 0 ? false : true; + zoomSize = Math.max(Math.abs(distanceX), Math.abs(distanceY)); + } + if (startPosition === "7") { + zoomIn = distanceY < 0 ? false : true; + zoomSize = Math.abs(distanceY); + } + if (startPosition === "8") { + zoomIn = distanceX < 0 && distanceY < 0 ? false : true; + zoomSize = Math.max(Math.abs(distanceX), Math.abs(distanceY)); + } + let newWidth = objWidth + zoomSize * (zoomIn ? 1 : -1); + + if (newWidth >= root.clientWidth) { + newWidth = root.clientWidth; + } + if (newWidth < minWidth) { + newWidth = minWidth; + } + + //及时修改节点宽度,拖动结束后再通知渲染视图 + objResize.style.width = `${newWidth}px`; + objResize.setAttribute("data-width", newWidth.toString()); + + objResize.parentElement!.removeAttribute("style"); + } + + const onMouseup = () => { + root.removeEventListener('mousemove', onMousemove); + root.removeEventListener('mouseup', onMouseup); + root.removeEventListener('mouseleave', onMouseup); + + const attrs = { width: `${objResize.getAttribute("data-width")}px` }; + updateAttrs(attrs) + }; + + for (let child of container.querySelector(".uai-resize")!.children) { + child.addEventListener("mousedown", onMousedown) + } +} \ No newline at end of file diff --git a/src/utils/UUID.ts b/src/utils/UUID.ts new file mode 100644 index 0000000..b579255 --- /dev/null +++ b/src/utils/UUID.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2024-present AI-Labs + +/** + * 生成UUID + * @returns + */ +export const uuid = () => { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c: any) => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +}