From 60dbd1be9d36adfcdc522f870bc88770bc2e7401 Mon Sep 17 00:00:00 2001 From: Yaosanqi137 Date: Sun, 5 Apr 2026 17:26:36 +0800 Subject: [PATCH] feat(web-editor): integrate tiptap media extensions --- apps/web/package.json | 5 + apps/web/src/components/task-rich-editor.tsx | 182 +++++ apps/web/src/pages/todo-shell-page.tsx | 32 +- pnpm-lock.yaml | 816 +++++++++++++++++++ 4 files changed, 1022 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/task-rich-editor.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 45d4755..68aa1db 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,11 @@ "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/geist": "^5.2.8", + "@tiptap/extension-image": "^3.22.2", + "@tiptap/extension-link": "^3.22.2", + "@tiptap/extension-youtube": "^3.22.2", + "@tiptap/react": "^3.22.2", + "@tiptap/starter-kit": "^3.22.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dexie": "^4.4.2", diff --git a/apps/web/src/components/task-rich-editor.tsx b/apps/web/src/components/task-rich-editor.tsx new file mode 100644 index 0000000..53ca2b2 --- /dev/null +++ b/apps/web/src/components/task-rich-editor.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo } from "react"; +import type { JSONContent } from "@tiptap/react"; +import { EditorContent, useEditor } from "@tiptap/react"; +import Image from "@tiptap/extension-image"; +import Link from "@tiptap/extension-link"; +import StarterKit from "@tiptap/starter-kit"; +import Youtube from "@tiptap/extension-youtube"; +import { cn } from "@/lib/utils"; + +type TaskRichEditorProps = { + valueJson: string | null; + textFallback: string; + onChange: (payload: { json: string | null; text: string }) => void; +}; + +type ToolbarButtonProps = { + label: string; + disabled?: boolean; + active?: boolean; + onClick: () => void; +}; + +function ToolbarButton({ label, disabled = false, active = false, onClick }: ToolbarButtonProps) { + return ( + + ); +} + +function resolveEditorContent( + valueJson: string | null, + textFallback: string +): JSONContent | string { + if (valueJson) { + try { + return JSON.parse(valueJson) as JSONContent; + } catch { + return textFallback; + } + } + + return textFallback; +} + +export function TaskRichEditor({ valueJson, textFallback, onChange }: TaskRichEditorProps) { + const content = useMemo( + () => resolveEditorContent(valueJson, textFallback), + [valueJson, textFallback] + ); + + const editor = useEditor({ + extensions: [ + StarterKit, + Link.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + HTMLAttributes: { + rel: "noopener noreferrer", + target: "_blank" + } + }), + Image, + Youtube.configure({ + controls: true + }) + ], + content, + editorProps: { + attributes: { + class: + "min-h-40 rounded-b-lg border border-t-0 border-input bg-background px-3 py-2 text-sm text-foreground outline-none" + } + }, + onUpdate({ editor: currentEditor }) { + const nextJson = JSON.stringify(currentEditor.getJSON()); + const nextText = currentEditor.getText(); + onChange({ json: nextJson, text: nextText }); + } + }); + + useEffect(() => { + if (!editor) { + return; + } + + editor.commands.setContent(content, { emitUpdate: false }); + }, [content, editor]); + + return ( +
+
+ editor?.chain().focus().toggleBold().run()} + /> + editor?.chain().focus().toggleItalic().run()} + /> + editor?.chain().focus().toggleHeading({ level: 2 }).run()} + /> + editor?.chain().focus().toggleBulletList().run()} + /> + { + if (!editor) { + return; + } + + const url = window.prompt("请输入链接地址"); + if (!url) { + return; + } + + editor.chain().focus().setLink({ href: url }).run(); + }} + /> + { + if (!editor) { + return; + } + + const url = window.prompt("请输入图片 URL"); + if (!url) { + return; + } + + editor.chain().focus().setImage({ src: url }).run(); + }} + /> + { + if (!editor) { + return; + } + + const url = window.prompt("请输入视频 URL(当前支持 YouTube)"); + if (!url) { + return; + } + + editor.chain().focus().setYoutubeVideo({ src: url }).run(); + }} + /> +
+ +
+ ); +} diff --git a/apps/web/src/pages/todo-shell-page.tsx b/apps/web/src/pages/todo-shell-page.tsx index 74e19c7..64d9034 100644 --- a/apps/web/src/pages/todo-shell-page.tsx +++ b/apps/web/src/pages/todo-shell-page.tsx @@ -1,7 +1,9 @@ import { useEffect, useState } from "react"; import { useLiveQuery } from "dexie-react-hooks"; +import { TaskRichEditor } from "@/components/task-rich-editor"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import type { LocalTaskPriority, LocalTaskStatus } from "@/services/local-db"; import { createLocalTask, deleteLocalTask, @@ -9,7 +11,6 @@ import { listLocalTasksByUser, updateLocalTask } from "@/services/local-task-repo"; -import type { LocalTaskPriority, LocalTaskStatus } from "@/services/local-db"; import type { WebSession } from "@/services/session-storage"; type TodoShellPageProps = { @@ -18,6 +19,7 @@ type TodoShellPageProps = { type TaskFormState = { title: string; + contentJson: string | null; contentText: string; priority: LocalTaskPriority; status: LocalTaskStatus; @@ -26,6 +28,7 @@ type TaskFormState = { const DEFAULT_FORM_STATE: TaskFormState = { title: "", + contentJson: null, contentText: "", priority: "MEDIUM", status: "TODO", @@ -129,6 +132,7 @@ export function TodoShellPage({ session }: TodoShellPageProps) { setFormState({ title: selectedTask.title, + contentJson: selectedTask.contentJson, contentText: selectedTask.contentText ?? "", priority: selectedTask.priority, status: selectedTask.status, @@ -170,7 +174,7 @@ export function TodoShellPage({ session }: TodoShellPageProps) { id: selectedTaskId, title: formState.title, contentText: formState.contentText || null, - contentJson: null, + contentJson: formState.contentJson, priority: formState.priority, status: formState.status, ddlAt: parseDatetimeLocalValue(formState.ddlInput) @@ -359,17 +363,19 @@ export function TodoShellPage({ session }: TodoShellPageProps) {