feat(memo): add task list quick actions (#5983)

pull/5959/merge
boojack 4 days ago committed by GitHub
parent e564c1a993
commit 648b3bd812
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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) => {
</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 && (
<>

@ -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,
};

@ -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";

@ -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>;
}

@ -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<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 (
<>
@ -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}

@ -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",

@ -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);
}

@ -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…
Cancel
Save