diff --git a/src/components/index.ts b/src/components/index.ts index ec89ebe..06d47e7 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -52,6 +52,7 @@ import { CodeBlock } from "./menus/toolbar/base/CodeBlock.ts"; import { Print } from "./menus/toolbar/base/Print.ts"; import { Link } from "./menus/toolbar/insert/Link.ts"; +import { Image } from "./menus/toolbar/insert/Image.ts"; // 注册组件 defineCustomElement('uai-editor-header', Header); @@ -105,3 +106,4 @@ defineCustomElement('uai-editor-base-menu-codeblock', CodeBlock); defineCustomElement('uai-editor-base-menu-print', Print); defineCustomElement('uai-editor-insert-menu-link', Link); +defineCustomElement('uai-editor-insert-menu-image', Image); diff --git a/src/components/menus/toolbar/Classic.ts b/src/components/menus/toolbar/Classic.ts index 077a8a8..42fa3ab 100644 --- a/src/components/menus/toolbar/Classic.ts +++ b/src/components/menus/toolbar/Classic.ts @@ -5,7 +5,7 @@ import { EditorEvents } from "@tiptap/core"; import { UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor.ts"; import menuIcon from "../../../assets/icons/menu.svg"; -import { ScrollableDiv } from "./ScrollableDiv"; +import { ScrollableDiv } from "./ScrollableDiv.ts"; import { FontSizeIncrease } from "../common/FontSizeIncrease.ts"; import { FontSizeDecrease } from "../common/FontSizeDecrease.ts"; @@ -46,7 +46,8 @@ import { BlockQuote } from "./base/BlockQuote.ts"; import { CodeBlock } from "./base/CodeBlock.ts"; import { Print } from "./base/Print.ts"; -import { Link } from "./insert/Link"; +import { Link } from "./insert/Link.ts"; +import { Image } from "./insert/Image.ts"; /** * 传统菜单栏 @@ -105,6 +106,7 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { // 插入菜单 insertMenuLink!: Link; + insertMenuImage!: Image; constructor(defaultToolbarMenus: Record[]) { super(); @@ -277,6 +279,9 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { this.insertMenuLink = new Link({ menuType: "button", enable: true, header: "classic", hideText: false }); this.eventComponents.push(this.insertMenuLink); + + this.insertMenuImage = new Image({ menuType: "button", enable: true, header: "classic", hideText: false }); + this.eventComponents.push(this.insertMenuImage); } /** * 创建基础菜单 @@ -355,5 +360,6 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { group1.classList.add("uai-classic-virtual-group"); this.classicMenuInsertGroup.appendChild(group1); group1.appendChild(this.insertMenuLink); + group1.appendChild(this.insertMenuImage); } } \ No newline at end of file diff --git a/src/components/menus/toolbar/Ribbon.ts b/src/components/menus/toolbar/Ribbon.ts index eabe5e8..b062ae1 100644 --- a/src/components/menus/toolbar/Ribbon.ts +++ b/src/components/menus/toolbar/Ribbon.ts @@ -5,7 +5,7 @@ import { EditorEvents } from "@tiptap/core"; import { UAIEditorEventListener, UAIEditorOptions } from "../../../core/UAIEditor.ts"; import { t } from "i18next"; -import { ScrollableDiv } from "./ScrollableDiv"; +import { ScrollableDiv } from "./ScrollableDiv.ts"; import { Icons } from "../../Icons.ts"; import { FontSizeIncrease } from "../common/FontSizeIncrease.ts"; @@ -48,7 +48,8 @@ import { CodeBlock } from "./base/CodeBlock.ts"; import { Print } from "./base/Print.ts"; -import { Link } from "./insert/Link"; +import { Link } from "./insert/Link.ts"; +import { Image } from "./insert/Image.ts"; /** * 经典菜单栏 @@ -108,6 +109,7 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { // 插入菜单 insertMenuLink!: Link; + insertMenuImage!: Image; constructor(defaultToolbarMenus: Record[]) { super(); @@ -300,6 +302,9 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { this.insertMenuLink = new Link({ menuType: "button", enable: true, huge: true }); this.eventComponents.push(this.insertMenuLink); + + this.insertMenuImage = new Image({ menuType: "button", enable: true, huge: true }); + this.eventComponents.push(this.insertMenuImage); } /** @@ -452,5 +457,6 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { group1.classList.add("uai-ribbon-virtual-group"); this.ribbonMenuInsertGroup.appendChild(group1); group1.appendChild(this.insertMenuLink); + group1.appendChild(this.insertMenuImage); } } \ No newline at end of file diff --git a/src/components/menus/toolbar/insert/Image.ts b/src/components/menus/toolbar/insert/Image.ts new file mode 100644 index 0000000..de593f4 --- /dev/null +++ b/src/components/menus/toolbar/insert/Image.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/image.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; + +/** + * 插入菜单:插入图片 + */ +export class Image extends HTMLElement implements UAIEditorEventListener { + // 按钮选项 + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: false, + text: t('insert.image'), + tooltip: t('insert.image'), + } + + // 功能按钮 + menuButton: MenuButton; + // 文件选择 + fileInput: HTMLInputElement; + + constructor(options: MenuButtonOptions) { + super(); + + // 初始化功能按钮选项 + this.menuButtonOptions = { ...this.menuButtonOptions, ...options }; + + // 创建功能按钮 + this.menuButton = new MenuButton(this.menuButtonOptions); + + // 初始化文件选择 + this.fileInput = document.createElement("input"); + this.fileInput.type = "file"; + this.fileInput.multiple = true; + this.fileInput.accept = "image/*"; + } + + /** + * 定义创建方法 + * @param event + * @param options + */ + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + this.menuButton.onCreate(event, options); + this.appendChild(this.menuButton); + + // this.fileInput.addEventListener("change", () => { + // const files = this.fileInput.files; + // if (files && files.length > 0) { + // for (let file of files) { + // event.editor.commands.uploadImage(file); + // } + // } + // (this.fileInput as any).value = ""; + // }); + + // 定义按钮点击事件,插入图片 + this.addEventListener("click", () => { + if (this.menuButtonOptions.enable) { + event.editor.chain().focus().selectFiles('image', true).run() + // this.fileInput.click(); + } + }) + } + + /** + * 定义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/core/UAIEditor.ts b/src/core/UAIEditor.ts index 48533ff..babb88a 100644 --- a/src/core/UAIEditor.ts +++ b/src/core/UAIEditor.ts @@ -20,6 +20,7 @@ import i18next from "i18next"; import { zh } from "../i18n/zh.ts"; import { Resource } from "i18next"; import { allExtensions } from "./UAIExtensions.ts"; +import { Uploader } from "../utils/FileUploader.ts"; self.MonacoEnvironment = { getWorker(_workerId, _label) { @@ -81,6 +82,12 @@ export type UAIEditorOptions = { theme?: "light" | "dark", lang?: string, i18n?: Record>, + image?: { + uploadUrl?: string, + uploadHeaders?: (() => Record) | Record, + uploadFormName?: string, + uploader?: Uploader, + }, } /** diff --git a/src/core/UAIExtensions.ts b/src/core/UAIExtensions.ts index 0ea7593..36bad88 100644 --- a/src/core/UAIExtensions.ts +++ b/src/core/UAIExtensions.ts @@ -19,10 +19,12 @@ import { Underline } from "@tiptap/extension-underline"; import BulletList from "../extensions/BulletList.ts"; import FontSize from "../extensions/FontSize.ts"; +import Image from "../extensions/Image.ts"; import Indent from "../extensions/Indent.ts"; import LineHeight from "../extensions/LineHeight.ts" import NodeAlign from "../extensions/NodeAlign.ts"; import OrderedList from "../extensions/OrderedList.ts"; +import SelectFile from "../extensions/SelectFile.ts"; /** * 定义编辑器的所有自定义扩展组件 @@ -45,11 +47,15 @@ export const allExtensions = (uaiEditor: UAIEditor, _options: UAIEditorOptions): Highlight.configure({ multicolor: true }), + Image, Indent, LineHeight, Link, NodeAlign, OrderedList, + SelectFile.configure({ + allowedMimeTypes: [] + }), Subscript, Superscript, TaskList, diff --git a/src/extensions/Image.ts b/src/extensions/Image.ts new file mode 100644 index 0000000..cef6eff --- /dev/null +++ b/src/extensions/Image.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2024-present AI-Labs + +import { NodeViewRendererProps } from '@tiptap/core' +import Image from '@tiptap/extension-image' +import { resize } from '../utils/resize.ts' + +declare module '@tiptap/core' { + interface Commands { + setImage: { + /** + * 设置图片 + * @param options + * @param replace + * @returns + */ + setImage: (options: any, replace?: any) => ReturnType + } + } +} +export default Image.extend({ + atom: true, + addAttributes() { + return { + vnode: { + default: true, + }, + id: { + default: null, + }, + type: { + default: 'image', + }, + name: { + default: null, + }, + size: { + default: null, + }, + file: { + default: null, + }, + src: { + default: null, + }, + content: { + default: null, + }, + width: { + default: '500px', + }, + height: { + default: 'auto', + }, + left: { + default: 0, + }, + top: { + default: 0, + }, + angle: { + default: null, + }, + draggable: { + default: false, + }, + rotatable: { + default: false, + }, + equalProportion: { + default: true, + }, + flipX: { + default: false, + }, + flipY: { + default: false, + }, + uploaded: { + default: false, + }, + error: { + default: false, + }, + previewType: { + default: 'image', + }, + } + }, + parseHTML() { + return [{ tag: 'img' }] + }, + addNodeView() { + return (props: NodeViewRendererProps) => { + const container = document.createElement('div'); + const { src, width, height, nodeAlign, alt, flipX, flipY } = props.node.attrs; + container.classList.add(`uai-node-view`); + container.style.justifyContent = nodeAlign; + + const wrapperStyle = width.indexOf("%") > 0 ? `style="width: ${width};"` : ""; + + var transform = "none"; + + if (flipX || flipY) { + transform = `rotateX(${flipX ? '180' : '0'}deg) rotateY(${flipY ? '180' : '0'}deg)` + } + + if (!this.editor.isEditable) { + container.innerHTML = ` +
+ ${alt} +
+ ` + return { + dom: container, + } + } + + container.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
+ ${alt} +
+ ` + resize(container, this.editor.view.dom, (attrs) => this.editor.commands.updateAttributes("image", attrs)); + return { + dom: container, + } + } + }, + addCommands() { + return { + setImage: + ( + options: { src: string; alt?: string; title?: string }, + replace?: boolean, + ) => + ({ commands, editor }) => { + if (replace) { + return commands.insertContent({ + type: this.name, + attrs: options, + }) + } + return commands.insertContentAt(editor.state.selection.anchor, { + type: this.name, + attrs: options, + }) + }, + } + }, +}) diff --git a/src/extensions/SelectFile.ts b/src/extensions/SelectFile.ts new file mode 100644 index 0000000..249dc03 --- /dev/null +++ b/src/extensions/SelectFile.ts @@ -0,0 +1,302 @@ +// Copyright (c) 2024-present AI-Labs + +import { Node, mergeAttributes } from '@tiptap/core'; +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 { Icons } from '../components/Icons.ts'; + +export type DecorationWaitingAction = { + type: "add" | "remove"; + id: string; + pos: number; +} +const key = new PluginKey("uai-waiting-plugin"); +const actionKey = "uai_waiting_action"; + +export const createDecoration = (action: { pos: number, id: string }) => { + const placeholder = document.createElement("div"); + placeholder.classList.add("uai-loader-placeholder"); + placeholder.innerHTML = Icons.Loading; + return Decoration.widget(action.pos, placeholder, { id: action.id }); +} + +export interface SelectFileOptions { + allowedMimeTypes: string[], + HTMLAttributes: Record, +} + +declare module '@tiptap/core' { + interface Commands { + setFile: { + setFile: (options: any) => ReturnType + } + insertFile: { + insertFile: (options: any) => ReturnType + } + selectFiles: { + selectFiles: (type: any, autoType: any) => ReturnType + } + } +} + +/** + * 指定可选文件的类型 + */ +const mimeTypes: any = { + image: [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'image/apng', + ], +} + +/** + * 判断是否接受选择的文件类型 + * @param options + * @param type + * @returns + */ +const getAccept = (options: SelectFileOptions, type: string) => { + const accept = options.allowedMimeTypes + if (type === 'file' && accept.length === 0) { + return '' + } + if (!type || !['image', 'video', 'audio'].includes(type)) { + return accept.toString() + } + let acceptArray = [...accept] + if (acceptArray.includes(`${type}/*`) || accept.length === 0) { + acceptArray = mimeTypes[type] + } else if (acceptArray.filter((item) => item.startsWith(type)).length > 0) { + acceptArray = accept.filter((item: any) => mimeTypes[type].includes(item)) + } else { + acceptArray = ['notAllow'] + } + return acceptArray.length === 0 ? '' : acceptArray.toString() +} + +/** + * 定义文件选择扩展 + */ +export default Node.create({ + name: 'file', + group: 'block', + addAttributes() { + return { + vnode: { + default: true, + }, + id: { + default: null, + }, + file: { + default: null, + }, + url: { + default: null, + }, + name: { + default: null, + }, + type: { + default: null, + }, + size: { + default: null, + }, + uploaded: { + default: false, + }, + previewType: { + default: null, + }, + width: { + default: null, + }, + height: { + default: 200, + }, + } + }, + parseHTML() { + return [{ tag: 'file' }] + }, + renderHTML({ HTMLAttributes }) { + return [ + 'file', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + ] + }, + addCommands() { + return { + insertFile: + ({ file, autoType }) => + ({ editor }) => { + const { type, name, size } = file; + let previewType: string | null = null; + const id = uuid(); + let uploader = Base64Uploader; + let editorOptions = (editor as InnerEditor).uaiEditor.options; + const { state: { tr }, view } = this.editor; + if (!tr.selection.empty) tr.deleteSelection(); + view.dispatch(tr.setMeta(actionKey, { + type: "add", + id, + pos: tr.selection.from, + })); + + // 图片 + if (type.startsWith('image/') && mimeTypes.image.includes(type)) { + previewType = 'image'; + uploader = editorOptions.image?.uploader ?? Base64Uploader; + uploader(file, editorOptions.image?.uploadUrl, editorOptions.image?.uploadHeaders, editorOptions.image?.uploadFormName || "image") + .then(json => { + view.dispatch(tr.setMeta(actionKey, { type: "remove", id })); + this.editor.commands.insertContentAt(tr.selection.from, { + type: autoType ? (previewType ?? 'file') : 'file', + attrs: { + [previewType === 'file' ? 'url' : 'src']: json.data.src, + name, + type, + size, + file, + previewType, + }, + }); + }); + } + return true; + }, + selectFiles: + (type, autoType = false) => + ({ editor }) => { + const accept = getAccept(this.options, type) + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.multiple = true; + fileInput.accept = accept; + fileInput.click(); + fileInput.addEventListener("change", () => { + for (const file of fileInput.files ?? []) { + editor.chain().focus().insertFile({ file, autoType }).run(); + } + }); + return true; + } + } + }, + addProseMirrorPlugins() { + const editor = this.editor; + return [ + new Plugin({ + key: key, + state: { + init: () => DecorationSet.empty, + apply: (tr, set) => { + const action = tr.getMeta(actionKey) as DecorationWaitingAction; + if (action) { + // update decorations position + let removed = false; + const newSet = set.map(tr.mapping, tr.doc, { + onRemove: (_) => { + removed = true; + } + }); + + if (!removed) { + set = newSet; + } + + // add decoration + if (action.type === "add") { + set = set.add(tr.doc, [createDecoration(action)]); + } + // remove decoration + else if (action.type === "remove") { + set = set.remove(set.find(void 0, void 0, + spec => spec.id == action.id)); + } + } + return set; + } + }, + + props: { + decorations(state) { + return this.getState(state); + }, + + handlePaste: (_, event) => { + const items = Array.from(event.clipboardData?.items || []); + let isImagePasted = false; + let autoType = true; + for (const item of items) { + if (item.type.indexOf("image") === 0) { + const file = item.getAsFile(); + if (file) { + event.preventDefault(); + isImagePasted = true; + + editor.chain().focus().insertFile({ file, autoType }).run(); + } + } + } + return isImagePasted; + }, + + handleDOMEvents: { + drop(view, event) { + let autoType = true; + const hasFiles = event.dataTransfer && + event.dataTransfer.files && + event.dataTransfer.files.length + + if (!hasFiles) return false + + const images = Array + .from(event.dataTransfer.files) + .filter(file => (/image/i).test(file.type)) + + if (images.length === 0) return false + + event.preventDefault() + + const { state: { tr, doc }, dispatch } = view + const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }) + dispatch(tr.setSelection(TextSelection.create(doc, coordinates!.pos)).scrollIntoView()) + + images.forEach(image => { + editor.chain().focus().insertFile({ image, autoType }).run(); + }) + + return true + } + }, + + transformPastedHTML(html) { + const parser = new DOMParser(); + const document = parser.parseFromString(html, 'text/html'); + const workspace = document.documentElement.querySelector('body'); + if (workspace?.children) { + const imgNodes = document.documentElement.querySelectorAll('p > img'); + for (const image of imgNodes) { + const imageParent = image.parentNode; + const position = Array.prototype.indexOf.call(workspace.children, imageParent); + image.parentElement!.prepend(image); + workspace.insertBefore(image, workspace.children[position]); + } + return workspace.innerHTML; + } + return html; + }, + } + }), + ] + }, +})