feat(editor): add image upload to slash command and build tiptap in make dev

斜杠命令图片上传 + Makefile dev 构建修复

1. 斜杠命令接入图片上传管线
   - slash-command.ts: SlashCommand 扩展改为 Extension.create<Options>,
     新增 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 产物和缓存
This commit is contained in:
xfy 2026-06-22 14:18:05 +08:00
parent 10ef52bede
commit 6ff37322b2
3 changed files with 221 additions and 156 deletions

View File

@ -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: build:
@$(MAKE) build-editor @$(MAKE) build-editor
@ -21,7 +21,13 @@ build-editor:
@cd libs/tiptap-editor && npm ci --include=dev && npm run build @cd libs/tiptap-editor && npm ci --include=dev && npm run build
@echo "Tiptap editor built." @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..." @echo "Starting tailwindcss watch and dx serve..."
@tailwindcss -i input.css -o public/style.css --watch & \ @tailwindcss -i input.css -o public/style.css --watch & \
TAILWIND_PID=$$!; \ TAILWIND_PID=$$!; \
@ -40,5 +46,4 @@ test:
clean: clean:
@cargo clean @cargo clean
@rm -f public/style.css public/highlight.css @rm -f public/style.css public/highlight.css
@rm -rf public/tiptap
@rm -rf uploads/.cache @rm -rf uploads/.cache

View File

@ -84,7 +84,10 @@ class TiptapEditorInstance {
Image.configure({ allowBase64: true }), Image.configure({ allowBase64: true }),
TaskList, TaskList,
TaskItem.configure({ nested: true }), TaskItem.configure({ nested: true }),
SlashCommand, // 把宿主注入的图片上传回调透传给斜杠命令扩展,使 /上传图片 命令可用。
SlashCommand.configure({
onImageUpload: this.options.onImageUpload,
}),
FileHandler.configure({ FileHandler.configure({
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
onPaste: (editor, files) => { onPaste: (editor, files) => {

View File

@ -9,7 +9,37 @@ interface CommandItem {
command: (props: { editor: any; range: Range }) => void command: (props: { editor: any; range: Range }) => void
} }
const COMMANDS: CommandItem[] = [ /**
*
*
* `onImageUpload` 宿 index.ts
* 访 URL"上传图片"
* "图片链接" URL
*/
export interface SlashCommandOptions {
onImageUpload?: (file: File) => Promise<string>
}
const SlashCommandPluginKey = new PluginKey('slashCommand')
/**
*
*
* `onImageUpload` `addOptions` "上传图片"
*/
export const SlashCommand = Extension.create<SlashCommandOptions>({
name: 'slashCommand',
addOptions() {
return {
onImageUpload: undefined,
}
},
addProseMirrorPlugins() {
// 依据是否提供上传回调,决定可用命令集。
const uploadFn = this.options.onImageUpload
const COMMANDS: CommandItem[] = [
{ {
title: '标题 1', title: '标题 1',
description: '大标题', description: '大标题',
@ -90,9 +120,42 @@ const COMMANDS: CommandItem[] = [
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() 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: '图片', title: '图片链接',
description: '插入图片', description: '通过 URL 插入图片',
icon: '🖼', icon: '🖼',
command: ({ editor, range }) => { command: ({ editor, range }) => {
const url = window.prompt('输入图片 URL') const url = window.prompt('输入图片 URL')
@ -112,9 +175,51 @@ const COMMANDS: CommandItem[] = [
} }
}, },
}, },
] )
const SlashCommandPluginKey = new PluginKey('slashCommand') return [
Suggestion<CommandItem>({
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<typeof createPopup> | 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<CommandItem>) { function createPopup(props: SuggestionProps<CommandItem>) {
const component = document.createElement('div') const component = document.createElement('div')
@ -222,51 +327,3 @@ function createPopup(props: SuggestionProps<CommandItem>) {
}, },
} }
} }
export const SlashCommand = Extension.create({
name: 'slashCommand',
addProseMirrorPlugins() {
return [
Suggestion<CommandItem>({
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<typeof createPopup> | 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 })
},
}),
]
},
})