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:
parent
10ef52bede
commit
6ff37322b2
11
Makefile
11
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:
|
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
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -9,113 +9,218 @@ interface CommandItem {
|
|||||||
command: (props: { editor: any; range: Range }) => void
|
command: (props: { editor: any; range: Range }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMANDS: CommandItem[] = [
|
/**
|
||||||
{
|
* 斜杠命令扩展的选项。
|
||||||
title: '标题 1',
|
*
|
||||||
description: '大标题',
|
* `onImageUpload` 由宿主注入(参见 index.ts),用于把用户选择的图片文件
|
||||||
icon: 'H1',
|
* 上传到服务端并返回可访问的 URL。未提供时"上传图片"命令会被隐藏,
|
||||||
command: ({ editor, range }) => {
|
* 只保留"图片链接"(手动填 URL)。
|
||||||
editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run()
|
*/
|
||||||
},
|
export interface SlashCommandOptions {
|
||||||
},
|
onImageUpload?: (file: File) => Promise<string>
|
||||||
{
|
}
|
||||||
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()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const SlashCommandPluginKey = new PluginKey('slashCommand')
|
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',
|
||||||
|
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<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')
|
||||||
component.classList.add('slash-command')
|
component.classList.add('slash-command')
|
||||||
@ -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 })
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user