From 648b3bd812e5bf2b1abb9f8b3529efefe4734e7c Mon Sep 17 00:00:00 2001 From: boojack Date: Wed, 27 May 2026 09:13:55 +0800 Subject: [PATCH] feat(memo): add task list quick actions (#5983) --- .../MemoActionMenu/MemoActionMenu.tsx | 30 ++++ web/src/components/MemoActionMenu/hooks.ts | 36 +++++ web/src/components/MemoActionMenu/index.ts | 2 +- web/src/components/MemoActionMenu/types.ts | 12 -- .../MemoView/components/MemoBody.tsx | 13 +- web/src/locales/en.json | 6 + web/src/utils/markdown-task-actions.ts | 147 ++++++++++++++++++ web/tests/markdown-task-actions.test.ts | 38 +++++ 8 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 web/src/utils/markdown-task-actions.ts create mode 100644 web/tests/markdown-task-actions.test.ts diff --git a/web/src/components/MemoActionMenu/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx index aaa6d386..999ef7fb 100644 --- a/web/src/components/MemoActionMenu/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu/MemoActionMenu.tsx @@ -3,10 +3,13 @@ import { ArchiveRestoreIcon, BookmarkMinusIcon, BookmarkPlusIcon, + CheckCheckIcon, CopyIcon, Edit3Icon, FileTextIcon, LinkIcon, + ListChecksIcon, + ListRestartIcon, MoreVerticalIcon, TrashIcon, } from "lucide-react"; @@ -24,6 +27,7 @@ import { } from "@/components/ui/dropdown-menu"; import { State } from "@/types/proto/api/v1/common_pb"; import { useTranslate } from "@/utils/i18n"; +import { countTasks } from "@/utils/markdown-manipulation"; import { useMemoActionHandlers } from "./hooks"; import type { MemoActionMenuProps } from "./types"; @@ -37,6 +41,10 @@ const MemoActionMenu = (props: MemoActionMenuProps) => { // Derived state const isComment = Boolean(memo.parent); const isArchived = memo.state === State.ARCHIVED; + const taskStats = countTasks(memo.content); + const canMutateTasks = !readonly && !isArchived && taskStats.total > 0; + const hasOpenTasks = taskStats.completed < taskStats.total; + const hasCompletedTasks = taskStats.completed > 0; // Action handlers const { @@ -45,6 +53,8 @@ const MemoActionMenu = (props: MemoActionMenuProps) => { handleToggleMemoStatusClick, handleCopyLink, handleCopyContent, + handleCheckAllTaskListItemsClick, + handleUncheckAllTaskListItemsClick, handleDeleteMemoClick, confirmDeleteMemo, } = useMemoActionHandlers({ @@ -97,6 +107,26 @@ const MemoActionMenu = (props: MemoActionMenuProps) => { )} + {/* Task submenu (writable task memos) */} + {canMutateTasks && ( + + + + {t("memo.task-actions.title")} + + + + + {t("memo.task-actions.check-all")} + + + + {t("memo.task-actions.uncheck-all")} + + + + )} + {/* Write actions (non-readonly) */} {!readonly && ( <> diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts index c5cbbf28..d80f6444 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -12,6 +12,7 @@ import { ROUTES } from "@/router/routes"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; +import { checkAllTasks, uncheckAllTasks } from "@/utils/markdown-task-actions"; interface UseMemoActionHandlersOptions { memo: Memo; @@ -34,6 +35,31 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use queryClient.invalidateQueries({ queryKey: userKeys.stats() }); }, [queryClient]); + const updateMemoContent = useCallback( + async (nextContent: string, context: string) => { + if (nextContent === memo.content) { + return; + } + + try { + await updateMemo({ + update: { + name: memo.name, + content: nextContent, + }, + updateMask: ["content", "update_time"], + }); + toast.success(t("memo.task-actions.updated")); + } catch (error: unknown) { + handleError(error, toast.error, { + context, + fallbackMessage: "An error occurred", + }); + } + }, + [memo.content, memo.name, t, updateMemo], + ); + const handleTogglePinMemoBtnClick = useCallback(async () => { try { await updateMemo({ @@ -94,6 +120,14 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use toast.success(t("message.succeed-copy-content")); }, [memo.content, t]); + const handleCheckAllTaskListItemsClick = useCallback(async () => { + await updateMemoContent(checkAllTasks(memo.content), "Check memo task list items"); + }, [memo.content, updateMemoContent]); + + const handleUncheckAllTaskListItemsClick = useCallback(async () => { + await updateMemoContent(uncheckAllTasks(memo.content), "Uncheck memo task list items"); + }, [memo.content, updateMemoContent]); + const handleDeleteMemoClick = useCallback(() => { setDeleteDialogOpen(true); }, [setDeleteDialogOpen]); @@ -121,6 +155,8 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use handleToggleMemoStatusClick, handleCopyLink, handleCopyContent, + handleCheckAllTaskListItemsClick, + handleUncheckAllTaskListItemsClick, handleDeleteMemoClick, confirmDeleteMemo, }; diff --git a/web/src/components/MemoActionMenu/index.ts b/web/src/components/MemoActionMenu/index.ts index 6a045be6..a837c5db 100644 --- a/web/src/components/MemoActionMenu/index.ts +++ b/web/src/components/MemoActionMenu/index.ts @@ -1,3 +1,3 @@ export { useMemoActionHandlers } from "./hooks"; export { default, default as MemoActionMenu } from "./MemoActionMenu"; -export type { MemoActionMenuProps, UseMemoActionHandlersReturn } from "./types"; +export type { MemoActionMenuProps } from "./types"; diff --git a/web/src/components/MemoActionMenu/types.ts b/web/src/components/MemoActionMenu/types.ts index 9133f95e..442c3422 100644 --- a/web/src/components/MemoActionMenu/types.ts +++ b/web/src/components/MemoActionMenu/types.ts @@ -6,15 +6,3 @@ export interface MemoActionMenuProps { className?: string; onEdit?: () => void; } - -export interface UseMemoActionHandlersReturn { - handleTogglePinMemoBtnClick: () => Promise; - handleEditMemoClick: () => void; - handleToggleMemoStatusClick: () => Promise; - handleCopyLink: () => void; - handleCopyContent: () => void; - handleDeleteMemoClick: () => void; - confirmDeleteMemo: () => Promise; - handleRemoveCompletedTaskListItemsClick: () => void; - confirmRemoveCompletedTaskListItems: () => Promise; -} diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx index 33839b19..f760e35a 100644 --- a/web/src/components/MemoView/components/MemoBody.tsx +++ b/web/src/components/MemoView/components/MemoBody.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { AttachmentListView, LocationDisplayView, RelationListView } from "@/components/MemoMetadata"; import { cn } from "@/lib/utils"; import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; @@ -22,12 +23,22 @@ const BlurOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => { ); }; +const getContentRevision = (content: string) => { + let hash = 2166136261; + for (let i = 0; i < content.length; i++) { + hash ^= content.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return `${content.length}-${hash >>> 0}`; +}; + const MemoBody: React.FC = ({ compact }) => { const { memo, parentPage, showBlurredContent, blurred, readonly, openEditor, openPreview, toggleBlurVisibility } = useMemoViewContext(); const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview }); const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); + const contentRevision = useMemo(() => getContentRevision(memo.content), [memo.content]); return ( <> @@ -38,7 +49,7 @@ const MemoBody: React.FC = ({ compact }) => { )} > ; + try { + tree = parseMarkdown(markdown); + } catch { + return []; + } + + const lineStarts = getLineStarts(markdown); + const tasks: ParsedTaskItem[] = []; + + visit(tree, "listItem", (node: ListItem) => { + if (typeof node.checked !== "boolean") { + return; + } + + const startLine = node.position ? node.position.start.line - 1 : undefined; + if (startLine === undefined) { + return; + } + + const lineInfo = getLineInfo(markdown, lineStarts, startLine); + if (!lineInfo) { + return; + } + + const match = lineInfo.text.match(TASK_LINE_REGEXP); + if (!match || match.index !== 0) { + return; + } + + const markerStart = lineInfo.startOffset + match[1].length + match[2].length + match[3].length + 1; + + tasks.push({ + checked: node.checked, + checkboxMarker: { + start: markerStart, + end: markerStart + 1, + }, + }); + }); + + return tasks; +} + +function applyMarkdownEdits(markdown: string, edits: MarkdownEdit[]): string { + if (edits.length === 0) { + return markdown; + } + + const sortedEdits = [...edits].sort((a, b) => a.start - b.start); + let previousEnd = 0; + for (const edit of sortedEdits) { + if (edit.start < 0 || edit.end < edit.start || edit.end > markdown.length || edit.start < previousEnd) { + return markdown; + } + previousEnd = edit.end; + } + + let nextMarkdown = markdown; + for (let index = sortedEdits.length - 1; index >= 0; index--) { + const edit = sortedEdits[index]; + nextMarkdown = `${nextMarkdown.slice(0, edit.start)}${edit.replacement}${nextMarkdown.slice(edit.end)}`; + } + return nextMarkdown; +} + +function setAllTaskMarkers(markdown: string, checked: boolean): string { + const marker = checked ? "x" : " "; + const edits = parseTaskItems(markdown) + .filter((task) => task.checked !== checked) + .map((task) => ({ + start: task.checkboxMarker.start, + end: task.checkboxMarker.end, + replacement: marker, + })); + + return applyMarkdownEdits(markdown, edits); +} + +export function uncheckAllTasks(markdown: string): string { + return setAllTaskMarkers(markdown, false); +} + +export function checkAllTasks(markdown: string): string { + return setAllTaskMarkers(markdown, true); +} diff --git a/web/tests/markdown-task-actions.test.ts b/web/tests/markdown-task-actions.test.ts new file mode 100644 index 00000000..bf5874b0 --- /dev/null +++ b/web/tests/markdown-task-actions.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { checkAllTasks, uncheckAllTasks } from "@/utils/markdown-task-actions"; + +describe("checkAllTasks", () => { + it("checks every unchecked task while preserving source formatting", () => { + const markdown = ["Intro", "- [ ] first", "* [x] second", " + [ ] nested", "1. [ ] ordered", "Outro"].join("\n"); + + expect(checkAllTasks(markdown)).toBe(["Intro", "- [x] first", "* [x] second", " + [x] nested", "1. [x] ordered", "Outro"].join("\n")); + }); + + it("returns the original string when no checkbox markers need changing", () => { + const markdown = ["Intro", "- [x] first", "Outro"].join("\n"); + + expect(checkAllTasks(markdown)).toBe(markdown); + }); +}); + +describe("uncheckAllTasks", () => { + it("unchecks every checked task while preserving source formatting", () => { + const markdown = ["Intro", "- [x] first", "* [X] second", " + [ ] nested", "1. [x] ordered", "Outro"].join("\n"); + + expect(uncheckAllTasks(markdown)).toBe(["Intro", "- [ ] first", "* [ ] second", " + [ ] nested", "1. [ ] ordered", "Outro"].join("\n")); + }); + + it("returns the original string when no checkbox markers need changing", () => { + const markdown = ["Intro", "- [ ] first", "Outro"].join("\n"); + + expect(uncheckAllTasks(markdown)).toBe(markdown); + }); + + it("ignores task-looking text inside fenced and inline code", () => { + const markdown = ["```", "- [x] not a task", "```", "", "Inline `- [x] not a task` text", "", "- [x] real task"].join("\n"); + + expect(uncheckAllTasks(markdown)).toBe( + ["```", "- [x] not a task", "```", "", "Inline `- [x] not a task` text", "", "- [ ] real task"].join("\n"), + ); + }); +});