feat(web-editor): integrate tiptap media extensions

This commit is contained in:
2026-04-05 17:26:36 +08:00
parent bb0a09d627
commit 60dbd1be9d
4 changed files with 1022 additions and 13 deletions
+5
View File
@@ -12,6 +12,11 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8", "@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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.4.2", "dexie": "^4.4.2",
@@ -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 (
<button
type="button"
className={cn(
"rounded-md border px-2 py-1 text-xs transition-colors",
active
? "border-primary/50 bg-primary/10 text-primary"
: "border-border bg-background text-foreground hover:border-primary/25 hover:bg-primary/5",
disabled && "cursor-not-allowed opacity-50"
)}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
);
}
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 (
<div>
<div className="flex flex-wrap gap-1 rounded-t-lg border border-input border-b-0 bg-muted/30 px-2 py-2">
<ToolbarButton
label="粗体"
disabled={!editor}
active={editor?.isActive("bold")}
onClick={() => editor?.chain().focus().toggleBold().run()}
/>
<ToolbarButton
label="斜体"
disabled={!editor}
active={editor?.isActive("italic")}
onClick={() => editor?.chain().focus().toggleItalic().run()}
/>
<ToolbarButton
label="标题"
disabled={!editor}
active={editor?.isActive("heading", { level: 2 })}
onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
/>
<ToolbarButton
label="无序列表"
disabled={!editor}
active={editor?.isActive("bulletList")}
onClick={() => editor?.chain().focus().toggleBulletList().run()}
/>
<ToolbarButton
label="链接"
disabled={!editor}
active={editor?.isActive("link")}
onClick={() => {
if (!editor) {
return;
}
const url = window.prompt("请输入链接地址");
if (!url) {
return;
}
editor.chain().focus().setLink({ href: url }).run();
}}
/>
<ToolbarButton
label="图片"
disabled={!editor}
onClick={() => {
if (!editor) {
return;
}
const url = window.prompt("请输入图片 URL");
if (!url) {
return;
}
editor.chain().focus().setImage({ src: url }).run();
}}
/>
<ToolbarButton
label="视频"
disabled={!editor}
onClick={() => {
if (!editor) {
return;
}
const url = window.prompt("请输入视频 URL(当前支持 YouTube");
if (!url) {
return;
}
editor.chain().focus().setYoutubeVideo({ src: url }).run();
}}
/>
</div>
<EditorContent editor={editor} />
</div>
);
}
+19 -13
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { TaskRichEditor } from "@/components/task-rich-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { LocalTaskPriority, LocalTaskStatus } from "@/services/local-db";
import { import {
createLocalTask, createLocalTask,
deleteLocalTask, deleteLocalTask,
@@ -9,7 +11,6 @@ import {
listLocalTasksByUser, listLocalTasksByUser,
updateLocalTask updateLocalTask
} from "@/services/local-task-repo"; } from "@/services/local-task-repo";
import type { LocalTaskPriority, LocalTaskStatus } from "@/services/local-db";
import type { WebSession } from "@/services/session-storage"; import type { WebSession } from "@/services/session-storage";
type TodoShellPageProps = { type TodoShellPageProps = {
@@ -18,6 +19,7 @@ type TodoShellPageProps = {
type TaskFormState = { type TaskFormState = {
title: string; title: string;
contentJson: string | null;
contentText: string; contentText: string;
priority: LocalTaskPriority; priority: LocalTaskPriority;
status: LocalTaskStatus; status: LocalTaskStatus;
@@ -26,6 +28,7 @@ type TaskFormState = {
const DEFAULT_FORM_STATE: TaskFormState = { const DEFAULT_FORM_STATE: TaskFormState = {
title: "", title: "",
contentJson: null,
contentText: "", contentText: "",
priority: "MEDIUM", priority: "MEDIUM",
status: "TODO", status: "TODO",
@@ -129,6 +132,7 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
setFormState({ setFormState({
title: selectedTask.title, title: selectedTask.title,
contentJson: selectedTask.contentJson,
contentText: selectedTask.contentText ?? "", contentText: selectedTask.contentText ?? "",
priority: selectedTask.priority, priority: selectedTask.priority,
status: selectedTask.status, status: selectedTask.status,
@@ -170,7 +174,7 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
id: selectedTaskId, id: selectedTaskId,
title: formState.title, title: formState.title,
contentText: formState.contentText || null, contentText: formState.contentText || null,
contentJson: null, contentJson: formState.contentJson,
priority: formState.priority, priority: formState.priority,
status: formState.status, status: formState.status,
ddlAt: parseDatetimeLocalValue(formState.ddlInput) ddlAt: parseDatetimeLocalValue(formState.ddlInput)
@@ -359,17 +363,19 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
<label className="block text-sm text-muted-foreground"> <label className="block text-sm text-muted-foreground">
<textarea <div className="mt-1">
className="mt-1 min-h-40 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-ring/30" <TaskRichEditor
value={formState.contentText} valueJson={formState.contentJson}
onChange={(event) => textFallback={formState.contentText}
setFormState((previous) => ({ onChange={(payload) =>
...previous, setFormState((previous) => ({
contentText: event.target.value ...previous,
})) contentJson: payload.json,
} contentText: payload.text
placeholder="输入任务详情(当前为本地文本版,富文本将在后续迭代接入)" }))
/> }
/>
</div>
</label> </label>
</div> </div>
)} )}
+816
View File
File diff suppressed because it is too large Load Diff