From 6ff37322b23979ad95b9a72a26e3d477dc2d984e Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 22 Jun 2026 14:18:05 +0800 Subject: [PATCH] feat(editor): add image upload to slash command and build tiptap in make dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 斜杠命令图片上传 + Makefile dev 构建修复 1. 斜杠命令接入图片上传管线 - slash-command.ts: SlashCommand 扩展改为 Extension.create, 新增 onImageUpload 选项;原单一"图片"命令拆成两个: · "上传图片"(📤): 仅在 onImageUpload 可用时出现,触发原生文件选择器 (accept 限定 jpeg/png/gif/webp,与 upload 端点 MIME 一致), 上传成功后 setImage 插入,失败 console.error(与 FileHandler 一致) · "图片链接"(🖼): 保留原 window.prompt URL 方式 - index.ts: SlashCommand.configure({ onImageUpload }) 透传宿主回调, 复用 write.rs 已注入的 /api/upload 管线,无需 Rust 端改动 2. Makefile: make dev 不再忽略 tiptap 构建 - 根因: 原 dev target 只起 tailwindcss watch + dx serve,从不构建 tiptap 编辑器,导致 dev server 始终 serve 上次 make build 残留的 旧 editor.js(本次新增的"上传图片"命令在 dev 下永远不生效) - 新增 build-editor-incremental target (跳过 npm ci,直接 vite build,~1s) - dev 现在依赖 build-editor-incremental,启动前先增量构建编辑器 3. Makefile: clean 不再删 public/tiptap - public/tiptap 是跨子项目(npm)构建产物,删除后必须重跑 make build-editor (含 npm ci) 才能恢复;原 clean 会删它是陷阱(make clean && make dev 会导致编辑器直接消失)。现在 clean 只清 Rust 产物和缓存 --- Makefile | 11 +- libs/tiptap-editor/src/index.ts | 5 +- libs/tiptap-editor/src/slash-command.ts | 361 ++++++++++++++---------- 3 files changed, 221 insertions(+), 156 deletions(-) diff --git a/Makefile b/Makefile index 2710fc3..e216f68 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev build build-linux css css-watch clean build-editor highlight-css test +.PHONY: dev build build-linux css css-watch clean build-editor build-editor-incremental highlight-css test build: @$(MAKE) build-editor @@ -21,7 +21,13 @@ build-editor: @cd libs/tiptap-editor && npm ci --include=dev && npm run build @echo "Tiptap editor built." -dev: +# dev 用的增量构建:跳过 npm ci(假设 node_modules 已存在),仅 vite build。 +# 与 build-editor 分开,避免每次 make dev 都重装依赖。 +build-editor-incremental: + @cd libs/tiptap-editor && npm run build + +dev: build-editor-incremental + @echo "Building Tiptap editor (incremental)..." @echo "Starting tailwindcss watch and dx serve..." @tailwindcss -i input.css -o public/style.css --watch & \ TAILWIND_PID=$$!; \ @@ -40,5 +46,4 @@ test: clean: @cargo clean @rm -f public/style.css public/highlight.css - @rm -rf public/tiptap @rm -rf uploads/.cache diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index 90f1b05..83b9118 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -84,7 +84,10 @@ class TiptapEditorInstance { Image.configure({ allowBase64: true }), TaskList, TaskItem.configure({ nested: true }), - SlashCommand, + // 把宿主注入的图片上传回调透传给斜杠命令扩展,使 /上传图片 命令可用。 + SlashCommand.configure({ + onImageUpload: this.options.onImageUpload, + }), FileHandler.configure({ allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], onPaste: (editor, files) => { diff --git a/libs/tiptap-editor/src/slash-command.ts b/libs/tiptap-editor/src/slash-command.ts index 5dbabe6..20b4be1 100644 --- a/libs/tiptap-editor/src/slash-command.ts +++ b/libs/tiptap-editor/src/slash-command.ts @@ -9,113 +9,218 @@ interface CommandItem { command: (props: { editor: any; range: Range }) => void } -const COMMANDS: CommandItem[] = [ - { - title: '标题 1', - description: '大标题', - icon: 'H1', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run() - }, - }, - { - title: '标题 2', - description: '中标题', - icon: 'H2', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run() - }, - }, - { - title: '标题 3', - description: '小标题', - icon: 'H3', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run() - }, - }, - { - title: '无序列表', - description: '创建无序列表', - icon: '•', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleBulletList().run() - }, - }, - { - title: '有序列表', - description: '创建有序列表', - icon: '1.', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleOrderedList().run() - }, - }, - { - title: '任务列表', - description: '创建任务列表', - icon: '☑', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleTaskList().run() - }, - }, - { - title: '引用', - description: '插入引用块', - icon: '❝', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleBlockquote().run() - }, - }, - { - title: '代码块', - description: '插入代码块', - icon: '<>', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleCodeBlock().run() - }, - }, - { - title: '分割线', - description: '插入水平分割线', - icon: '—', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setHorizontalRule().run() - }, - }, - { - title: '表格', - description: '插入 3×3 表格', - icon: '▦', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() - }, - }, - { - title: '图片', - description: '插入图片', - icon: '🖼', - command: ({ editor, range }) => { - const url = window.prompt('输入图片 URL') - if (url) { - editor.chain().focus().deleteRange(range).setImage({ src: url }).run() - } - }, - }, - { - title: '链接', - description: '插入链接', - icon: '🔗', - command: ({ editor, range }) => { - const url = window.prompt('输入链接 URL') - if (url) { - editor.chain().focus().deleteRange(range).setLink({ href: url }).insertContent(url).run() - } - }, - }, -] +/** + * 斜杠命令扩展的选项。 + * + * `onImageUpload` 由宿主注入(参见 index.ts),用于把用户选择的图片文件 + * 上传到服务端并返回可访问的 URL。未提供时"上传图片"命令会被隐藏, + * 只保留"图片链接"(手动填 URL)。 + */ +export interface SlashCommandOptions { + onImageUpload?: (file: File) => Promise +} const SlashCommandPluginKey = new PluginKey('slashCommand') +/** + * 斜杠命令扩展。 + * + * `onImageUpload` 通过 `addOptions` 注入,"上传图片"命令据此决定是否出现。 + */ +export const SlashCommand = Extension.create({ + name: 'slashCommand', + + addOptions() { + return { + onImageUpload: undefined, + } + }, + + addProseMirrorPlugins() { + // 依据是否提供上传回调,决定可用命令集。 + const uploadFn = this.options.onImageUpload + const COMMANDS: CommandItem[] = [ + { + title: '标题 1', + description: '大标题', + icon: 'H1', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run() + }, + }, + { + title: '标题 2', + description: '中标题', + icon: 'H2', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run() + }, + }, + { + title: '标题 3', + description: '小标题', + icon: 'H3', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run() + }, + }, + { + title: '无序列表', + description: '创建无序列表', + icon: '•', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleBulletList().run() + }, + }, + { + title: '有序列表', + description: '创建有序列表', + icon: '1.', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run() + }, + }, + { + title: '任务列表', + description: '创建任务列表', + icon: '☑', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleTaskList().run() + }, + }, + { + title: '引用', + description: '插入引用块', + icon: '❝', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleBlockquote().run() + }, + }, + { + title: '代码块', + description: '插入代码块', + icon: '<>', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleCodeBlock().run() + }, + }, + { + title: '分割线', + description: '插入水平分割线', + icon: '—', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHorizontalRule().run() + }, + }, + { + title: '表格', + description: '插入 3×3 表格', + icon: '▦', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() + }, + }, + ] + + // 图片相关命令:上传命令仅在上传回调可用时才出现。 + if (uploadFn) { + COMMANDS.push({ + title: '上传图片', + description: '从本地选择并上传图片', + icon: '📤', + command: ({ editor, range }) => { + // 必须先删掉 /命令 文本,文件选择对话框会阻塞,关闭后 range 可能失效。 + editor.chain().focus().deleteRange(range).run() + const input = document.createElement('input') + input.type = 'file' + input.accept = 'image/jpeg,image/png,image/gif,image/webp' + input.addEventListener('change', () => { + const file = input.files?.[0] + if (!file) return + uploadFn(file) + .then((url) => { + editor.chain().focus().setImage({ src: url }).run() + }) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err) + console.error('[SlashCommand] Upload failed:', msg) + }) + }) + // click() 会立即触发原生文件选择器;回调在用户选择文件后异步执行。 + input.click() + }, + }) + } + + COMMANDS.push( + { + title: '图片链接', + description: '通过 URL 插入图片', + icon: '🖼', + command: ({ editor, range }) => { + const url = window.prompt('输入图片 URL') + if (url) { + editor.chain().focus().deleteRange(range).setImage({ src: url }).run() + } + }, + }, + { + title: '链接', + description: '插入链接', + icon: '🔗', + command: ({ editor, range }) => { + const url = window.prompt('输入链接 URL') + if (url) { + editor.chain().focus().deleteRange(range).setLink({ href: url }).insertContent(url).run() + } + }, + }, + ) + + return [ + Suggestion({ + pluginKey: SlashCommandPluginKey, + editor: this.editor, + char: '/', + items: ({ query }) => { + return COMMANDS.filter( + (item) => + item.title.toLowerCase().includes(query.toLowerCase()) || + item.description.toLowerCase().includes(query.toLowerCase()) + ) + }, + render() { + let popup: ReturnType | null = null + + return { + onStart(props) { + popup = createPopup(props) + }, + onUpdate(props) { + if (!popup) return + popup.updateItems(props.items) + popup.updatePosition() + }, + onKeyDown(props) { + if (!popup) return false + return popup.onKeyDown(props) + }, + onExit() { + if (popup) { + popup.destroy() + popup = null + } + }, + } + }, + command: ({ editor, range, props: item }) => { + item.command({ editor, range }) + }, + }), + ] + }, +}) + function createPopup(props: SuggestionProps) { const component = document.createElement('div') component.classList.add('slash-command') @@ -222,51 +327,3 @@ function createPopup(props: SuggestionProps) { }, } } - -export const SlashCommand = Extension.create({ - name: 'slashCommand', - - addProseMirrorPlugins() { - return [ - Suggestion({ - pluginKey: SlashCommandPluginKey, - editor: this.editor, - char: '/', - items: ({ query }) => { - return COMMANDS.filter( - (item) => - item.title.toLowerCase().includes(query.toLowerCase()) || - item.description.toLowerCase().includes(query.toLowerCase()) - ) - }, - render() { - let popup: ReturnType | null = null - - return { - onStart(props) { - popup = createPopup(props) - }, - onUpdate(props) { - if (!popup) return - popup.updateItems(props.items) - popup.updatePosition() - }, - onKeyDown(props) { - if (!popup) return false - return popup.onKeyDown(props) - }, - onExit() { - if (popup) { - popup.destroy() - popup = null - } - }, - } - }, - command: ({ editor, range, props: item }) => { - item.command({ editor, range }) - }, - }), - ] - }, -})