xfy c2c7b46958 fix(tiptap-editor): use insertContentAt with drop position in onDrop handler
Previously dropped images were inserted at the current cursor position
instead of the actual drop position. Now uses insertContentAt(pos, ...)
to place images at the correct location.
2026-06-05 15:21:38 +08:00

168 lines
4.5 KiB
TypeScript

import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import { TableKit } from '@tiptap/extension-table'
import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { TaskList, TaskItem } from '@tiptap/extension-list'
import { FileHandler } from '@tiptap/extension-file-handler'
import { SlashCommand } from './slash-command'
import './style.css'
export interface EditorOptions {
content?: string
placeholder?: string
onUpdate?: (markdown: string) => void
onFocus?: () => void
onBlur?: () => void
editable?: boolean
// 新增:图片上传回调
onImageUpload?: (file: File) => Promise<string>
}
class TiptapEditorInstance {
private editor: Editor | null = null
private container: HTMLElement
private options: EditorOptions
constructor(container: HTMLElement, options: EditorOptions = {}) {
this.container = container
this.options = options
this.init()
}
private init() {
const el = document.createElement('div')
el.className = 'tiptap-editor'
this.container.appendChild(el)
this.editor = new Editor({
element: el,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Markdown.configure({
html: false,
}),
TableKit,
Image.configure({ allowBase64: true }),
Link.configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
}),
TaskList,
TaskItem.configure({ nested: true }),
SlashCommand,
FileHandler.configure({
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
onPaste: (editor, files) => {
if (this.options.onImageUpload) {
files.forEach((file) => {
this.options.onImageUpload!(file)
.then((url) => {
editor.chain().focus().setImage({ src: url }).run()
})
.catch((err) => {
console.error('[TiptapEditor] Upload failed:', err)
})
})
}
},
onDrop: (editor, files, pos) => {
if (this.options.onImageUpload) {
files.forEach((file) => {
this.options.onImageUpload!(file)
.then((url) => {
editor.chain().focus().insertContentAt(pos, {
type: 'image',
attrs: { src: url }
}).run()
})
.catch((err) => {
console.error('[TiptapEditor] Upload failed:', err)
})
})
}
},
}),
],
content: this.options.content || '',
editable: this.options.editable !== false,
autofocus: false,
onUpdate: ({ editor }) => {
if (this.options.onUpdate) {
this.options.onUpdate(editor.getMarkdown())
}
},
onFocus: () => {
if (this.options.onFocus) {
this.options.onFocus()
}
},
onBlur: () => {
if (this.options.onBlur) {
this.options.onBlur()
}
},
})
}
getMarkdown(): string {
return this.editor?.getMarkdown() || ''
}
setMarkdown(content: string): void {
this.editor?.commands.setContent(content, false, { contentType: 'markdown' })
}
getHTML(): string {
return this.editor?.getHTML() || ''
}
focus(): void {
this.editor?.commands.focus()
}
blur(): void {
this.editor?.commands.blur()
}
isEmpty(): boolean {
return this.editor?.isEmpty ?? true
}
destroy(): void {
this.editor?.destroy()
this.editor = null
this.container.innerHTML = ''
}
}
const TiptapEditor = {
_instances: new Map<string, TiptapEditorInstance>(),
create(containerId: string, options: EditorOptions = {}): TiptapEditorInstance | null {
const container = document.getElementById(containerId)
if (!container) {
console.error(`[TiptapEditor] Container not found: #${containerId}`)
return null
}
const existing = this._instances.get(containerId)
if (existing) {
existing.destroy()
}
const instance = new TiptapEditorInstance(container, options)
this._instances.set(containerId, instance)
return instance
},
}
export default TiptapEditor