添加插入图片菜单

This commit is contained in:
wux_labs
2025-02-18 12:54:34 +08:00
parent adad3a656f
commit 6a49ef311a
8 changed files with 584 additions and 4 deletions
+2
View File
@@ -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);
+8 -2
View File
@@ -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<string, any>[]) {
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);
}
}
+8 -2
View File
@@ -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<string, any>[]) {
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);
}
}
@@ -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);
}
}
+7
View File
@@ -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<string, Record<string, string>>,
image?: {
uploadUrl?: string,
uploadHeaders?: (() => Record<string, any>) | Record<string, any>,
uploadFormName?: string,
uploader?: Uploader,
},
}
/**
+6
View File
@@ -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,
+160
View File
@@ -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<ReturnType> {
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 = `
<div class="uai-resize-wrapper" ${wrapperStyle}>
<img alt="${alt}" src="${src}" style="width: ${width}; height: ${height || 'auto'}; transform: ${transform};" class="resize-obj">
</div>
`
return {
dom: container,
}
}
container.innerHTML = `
<div class="uai-resize-wrapper" ${wrapperStyle}>
<div class="uai-resize">
<div class="uai-resize-btn-top-left" data-position="1" draggable="true"></div>
<div class="uai-resize-btn-top-center" data-position="2" draggable="true"></div>
<div class="uai-resize-btn-top-right" data-position="3" draggable="true"></div>
<div class="uai-resize-btn-left-center" data-position="4" draggable="true"></div>
<div class="uai-resize-btn-right-center" data-position="5" draggable="true"></div>
<div class="uai-resize-btn-bottom-left" data-position="6" draggable="true"></div>
<div class="uai-resize-btn-bottom-center" data-position="7" draggable="true"></div>
<div class="uai-resize-btn-bottom-right" data-position="8" draggable="true"></div>
</div>
<img alt="${alt}" src="${src}" style="width: ${width}; height: ${height || 'auto'}; transform: ${transform};" class="resize-obj">
</div>
`
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,
})
},
}
},
})
+302
View File
@@ -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<string, any>,
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
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<SelectFileOptions>({
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;
},
}
}),
]
},
})