添加插入图片菜单

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
+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;
},
}
}),
]
},
})