feat(web-editor): integrate tiptap media extensions
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
onChange={(payload) =>
|
||||||
setFormState((previous) => ({
|
setFormState((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
contentText: event.target.value
|
contentJson: payload.json,
|
||||||
|
contentText: payload.text
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="输入任务详情(当前为本地文本版,富文本将在后续迭代接入)"
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Generated
+816
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user