feat(memo): add task list quick actions (#5983)
This commit is contained in:
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user