Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions web/src/components/MemoActionMenu/MemoActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import {
ArchiveRestoreIcon,
BookmarkMinusIcon,
BookmarkPlusIcon,
CheckCheckIcon,
CopyIcon,
Edit3Icon,
FileTextIcon,
LinkIcon,
ListChecksIcon,
ListRestartIcon,
MoreVerticalIcon,
TrashIcon,
} from "lucide-react";
Expand All @@ -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";

Expand All @@ -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 {
Expand All @@ -45,6 +53,8 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleCheckAllTaskListItemsClick,
handleUncheckAllTaskListItemsClick,
handleDeleteMemoClick,
confirmDeleteMemo,
} = useMemoActionHandlers({
Expand Down Expand Up @@ -97,6 +107,26 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
</DropdownMenuSub>
)}

{/* Task submenu (writable task memos) */}
{canMutateTasks && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ListChecksIcon className="w-4 h-auto" />
{t("memo.task-actions.title")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem disabled={!hasOpenTasks} onClick={handleCheckAllTaskListItemsClick}>
<CheckCheckIcon className="w-4 h-auto" />
{t("memo.task-actions.check-all")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!hasCompletedTasks} onClick={handleUncheckAllTaskListItemsClick}>
<ListRestartIcon className="w-4 h-auto" />
{t("memo.task-actions.uncheck-all")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}

{/* Write actions (non-readonly) */}
{!readonly && (
<>
Expand Down
36 changes: 36 additions & 0 deletions web/src/components/MemoActionMenu/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -121,6 +155,8 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleCheckAllTaskListItemsClick,
handleUncheckAllTaskListItemsClick,
handleDeleteMemoClick,
confirmDeleteMemo,
};
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/MemoActionMenu/index.ts
Original file line number Diff line number Diff line change
@@ -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";
12 changes: 0 additions & 12 deletions web/src/components/MemoActionMenu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,3 @@ export interface MemoActionMenuProps {
className?: string;
onEdit?: () => void;
}

export interface UseMemoActionHandlersReturn {
handleTogglePinMemoBtnClick: () => Promise<void>;
handleEditMemoClick: () => void;
handleToggleMemoStatusClick: () => Promise<void>;
handleCopyLink: () => void;
handleCopyContent: () => void;
handleDeleteMemoClick: () => void;
confirmDeleteMemo: () => Promise<void>;
handleRemoveCompletedTaskListItemsClick: () => void;
confirmRemoveCompletedTaskListItems: () => Promise<void>;
}
13 changes: 12 additions & 1 deletion web/src/components/MemoView/components/MemoBody.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<MemoBodyProps> = ({ 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 (
<>
Expand All @@ -38,7 +49,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
)}
>
<MemoContent
key={`${memo.name}-${memo.updateTime}`}
key={`${memo.name}-${contentRevision}`}
content={memo.content}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
Expand Down
6 changes: 6 additions & 0 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,12 @@
"show-less": "Show less",
"show-more": "Show more",
"to-do": "To-do",
"task-actions": {
"check-all": "Check all tasks",
"title": "Tasks",
"uncheck-all": "Uncheck all tasks",
"updated": "Tasks updated"
},
"view-detail": "View Detail",
"visibility": {
"disabled": "Public memos are disabled",
Expand Down
147 changes: 147 additions & 0 deletions web/src/utils/markdown-task-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { ListItem } from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
import { gfmFromMarkdown } from "mdast-util-gfm";
import { gfm } from "micromark-extension-gfm";
import { visit } from "unist-util-visit";

interface SourceRange {
start: number;
end: number;
}

interface MarkdownEdit extends SourceRange {
replacement: string;
}

interface ParsedTaskItem {
checked: boolean;
checkboxMarker: SourceRange;
}

interface LineInfo {
text: string;
startOffset: number;
endOffset: number;
}

const TASK_LINE_REGEXP = /^(\s*)((?:[-*+])|(?:\d+[.)]))(\s+)\[([ xX])\]/;

function getLineStarts(markdown: string): number[] {
const starts = [0];
for (let index = 0; index < markdown.length; index++) {
if (markdown[index] === "\n") {
starts.push(index + 1);
}
}
return starts;
}

function getLineInfo(markdown: string, lineStarts: number[], lineNumber: number): LineInfo | undefined {
const startOffset = lineStarts[lineNumber];
if (startOffset === undefined) {
return undefined;
}

const nextLineStart = lineStarts[lineNumber + 1];
const endOffset = nextLineStart === undefined ? markdown.length : nextLineStart - 1;
return {
text: markdown.slice(startOffset, endOffset),
startOffset,
endOffset,
};
}

function parseMarkdown(markdown: string) {
return fromMarkdown(markdown, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
});
}

function parseTaskItems(markdown: string): ParsedTaskItem[] {
let tree: ReturnType<typeof parseMarkdown>;
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<MarkdownEdit>((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);
}
38 changes: 38 additions & 0 deletions web/tests/markdown-task-actions.test.ts
Original file line number Diff line number Diff line change
@@ -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"),
);
});
});
Loading