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"),
+ );
+ });
+});