feat: add task lists across clients

This commit is contained in:
tianma-if committed 2026-08-10 12:31:26 +08:00
1 parent 0e3ebf7771
commit a257e53220
28 files changed
+1124 -810

No files matched your search

@@ -98,8 +98,8 @@ enum BuiltInMemoTemplates {
titleEN: "Weekly Review & Status",
descriptionZH: "梳理本周核心产出、风险卡点与下周关键优先级。",
descriptionEN: "Summarize weekly highlights, blockers, and next week's key priorities.",
contentMarkdownZH: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n1. [ ] \n2. [ ] \n3. [ ] \n\n## 💡 总结与思考\n\n- \n",
contentMarkdownEN: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n1. [ ] \n2. [ ] \n3. [ ] \n\n## 💡 Reflection & Insights\n\n- \n"
contentMarkdownZH: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 总结与思考\n\n- \n",
contentMarkdownEN: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 Reflection & Insights\n\n- \n"
),
Item(
id: "reading",
@@ -121,12 +121,19 @@ enum EditorContentCodec {
let src = attrs["src"] as? String ?? ""
let alt = attrs["alt"] as? String ?? ""
if !src.isEmpty { lines.append("![\(alt)](\(src))") }
case "bulletList", "orderedList":
case "bulletList", "orderedList", "taskList":
if let items = dict["content"] as? [Any] {
for item in items {
guard let itemDict = item as? [String: Any] else { continue }
let text = blockText(itemDict)
if !text.isEmpty { lines.append("- \(text)") }
if !text.isEmpty {
if type == "taskList" {
let checked = (itemDict["attrs"] as? [String: Any])?["checked"] as? Bool ?? false
lines.append("- [\(checked ? "x" : " ")] \(text)")
} else {
lines.append("- \(text)")
}
}
}
}
case "blockquote":
@@ -66,6 +66,16 @@ enum TipTapContentSource: Sendable {
return true
}
let markdownHasTaskList = markdown.range(
of: #"(?m)^\s*[-*+]\s+\[[ xX]\]\s*"#,
options: .regularExpression
) != nil
let jsonHasTaskList = json.contains(#""type":"taskList""#)
|| json.contains(#""type": "taskList""#)
if markdownHasTaskList && !jsonHasTaskList {
return true
}
let md = structureScore(markdown: markdown)
let js = structureScore(json: json)
return md >= 2 && md > js
@@ -90,7 +100,7 @@ enum TipTapContentSource: Sendable {
if json.contains("\"heading\"") { score += 3 }
if json.contains("\"table\"") || json.contains("\"tableRow\"") { score += 2 }
if json.contains("\"codeBlock\"") { score += 3 }
if json.contains("\"bulletList\"") || json.contains("\"orderedList\"") { score += 2 }
if json.contains("\"bulletList\"") || json.contains("\"orderedList\"") || json.contains("\"taskList\"") { score += 2 }
if json.contains("\"blockquote\"") { score += 1 }
if json.contains("\"bold\"") || json.contains("\"italic\"") { score += 1 }
if json.contains("\"type\":\"image\"") || json.contains("\"type\": \"image\"") { score += 1 }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -8,6 +8,7 @@
"@tiptap/core": "3.27.1",
"@tiptap/extension-code-block": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-mathematics": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-table": "3.27.1",
@@ -171,7 +172,7 @@
"@tiptap/extension-link": ["@tiptap/extension-link@3.29.2", "", { "dependencies": { "linkifyjs": "^4.3.3" }, "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA=="],
"@tiptap/extension-list": ["@tiptap/extension-list@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A=="],
"@tiptap/extension-list": ["@tiptap/extension-list@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ=="],
"@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ=="],
@@ -469,6 +470,8 @@
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"@tiptap/starter-kit/@tiptap/extension-list": ["@tiptap/extension-list@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A=="],
"cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
+1
View File
@@ -12,6 +12,7 @@
"@tiptap/core": "3.27.1",
"@tiptap/extension-code-block": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-mathematics": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-table": "3.27.1",
+15 -2
View File
@@ -3,6 +3,7 @@ import "katex/dist/katex.min.css";
import { Editor, mergeAttributes, Node } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import Placeholder from "@tiptap/extension-placeholder";
import CodeBlock from "@tiptap/extension-code-block";
import { TableKit } from "@tiptap/extension-table";
@@ -608,6 +609,8 @@ function buildExtensions(placeholder: string) {
StarterKit.configure({
codeBlock: false,
}),
TaskList,
TaskItem.configure({ nested: true }),
MergeDivider,
...createEdgeEverMathematics(),
CodeBlock.configure({
@@ -847,6 +850,8 @@ function getEditorSearchMatches(ed: Editor, query: string): EditorSearchMatch[]
return matches;
}
const activeListItemType = () => editor.isActive("taskItem") ? "taskItem" : "listItem";
function setToolbarVisible(visible: boolean) {
toolbarEl.classList.toggle("editor-mode", visible);
toolbarEl.innerHTML = "";
@@ -863,15 +868,20 @@ function setToolbarVisible(visible: boolean) {
label: "•",
run: () => editor.chain().focus().toggleBulletList().run(),
},
{
id: "task",
label: "☑",
run: () => editor.chain().focus().toggleTaskList().run(),
},
{
id: "indent",
label: "⇥",
run: () => editor.chain().focus().sinkListItem("listItem").run(),
run: () => editor.chain().focus().sinkListItem(activeListItemType()).run(),
},
{
id: "outdent",
label: "⇤",
run: () => editor.chain().focus().liftListItem("listItem").run(),
run: () => editor.chain().focus().liftListItem(activeListItemType()).run(),
},
{
id: "quote",
@@ -893,6 +903,7 @@ function setToolbarVisible(visible: boolean) {
image: ["插入图片", "Insert image"],
bold: ["粗体", "Bold"],
bullet: ["项目符号列表", "Bullet list"],
task: ["任务清单", "Task list"],
indent: ["增加列表缩进", "Increase list indent"],
outdent: ["减少列表缩进", "Decrease list indent"],
quote: ["引用", "Block quote"],
@@ -913,6 +924,7 @@ function refreshToolbarState() {
const active: Record<string, boolean> = {
bold: editor.isActive("bold"),
bullet: editor.isActive("bulletList"),
task: editor.isActive("taskList"),
quote: editor.isActive("blockquote"),
};
toolbarEl.querySelectorAll<HTMLButtonElement>("button[data-action]").forEach((button) => {
@@ -1059,6 +1071,7 @@ const api: EdgeEverEditorAPI = {
const map: Record<string, () => void> = {
bold: () => editor.chain().focus().toggleBold().run(),
bulletList: () => editor.chain().focus().toggleBulletList().run(),
taskList: () => editor.chain().focus().toggleTaskList().run(),
blockquote: () => editor.chain().focus().toggleBlockquote().run(),
horizontalRule: () => editor.chain().focus().setHorizontalRule().run(),
heading2: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
+42
View File
@@ -177,6 +177,48 @@ html[data-theme="dark"] body {
pointer-events: none;
}
.ProseMirror ul[data-type="taskList"] {
margin: 0 0 1rem;
padding-left: 0;
list-style: none;
}
.ProseMirror ul[data-type="taskList"] li[data-type="taskItem"] {
display: flex;
align-items: flex-start;
gap: 9px;
margin: 4px 0;
}
.ProseMirror ul[data-type="taskList"] li[data-type="taskItem"] > label {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
margin-top: 3px;
user-select: none;
}
.ProseMirror ul[data-type="taskList"] li[data-type="taskItem"] > label input {
width: 18px;
height: 18px;
margin: 0;
accent-color: #16a06e;
}
.ProseMirror ul[data-type="taskList"] li[data-type="taskItem"] > div {
min-width: 0;
flex: 1 1 auto;
}
.ProseMirror ul[data-type="taskList"] li[data-type="taskItem"] > div > p {
margin-bottom: 0;
}
.ProseMirror ul[data-type="taskList"] ul[data-type="taskList"] {
margin: 4px 0 0;
padding-left: 24px;
}
.edgeever-prose img,
.ProseMirror img {
max-width: 100%;
@@ -117,6 +117,24 @@ final class TipTapContentSourceTests: XCTestCase {
XCTAssertFalse(TipTapContentSource.resolve(mode: .viewer, documentJSON: flatJSON, markdown: markdown).useJSON)
}
func testEditorRecoversTaskListMissingFromLegacyJSON() {
let markdown = "- [ ] Pending\n- [x] Complete\n"
let legacyJSON = #"{"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Pending"}]}]}]}]}"#
let decision = TipTapContentSource.resolve(mode: .editor, documentJSON: legacyJSON, markdown: markdown)
XCTAssertFalse(decision.useJSON)
XCTAssertEqual(decision.payload, markdown)
}
func testNativeMarkdownFallbackPreservesTaskState() {
let json = #"{"type":"doc","content":[{"type":"taskList","content":[{"type":"taskItem","attrs":{"checked":false},"content":[{"type":"paragraph","content":[{"type":"text","text":"Pending"}]}]},{"type":"taskItem","attrs":{"checked":true},"content":[{"type":"paragraph","content":[{"type":"text","text":"Complete"}]}]}]}]}"#
XCTAssertEqual(
EditorContentCodec.markdownFromTipTapJSON(json),
"- [ ] Pending\n\n- [x] Complete\n"
)
}
/// Live WKWebView: packaged TipTap must turn Markdown into real DOM structure.
@MainActor
func testPackagedEditorSetMarkdownRendersHeadingsListsAndBold() async throws {
+1
View File
@@ -21,6 +21,7 @@
"@tanstack/react-query-persist-client": "^5.101.2",
"@tiptap/extension-code-block": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/react": "3.27.1",
@@ -6,6 +6,7 @@ import { renderMermaidSVG, THEMES } from "beautiful-mermaid";
import Image from "@tiptap/extension-image";
import CodeBlock from "@tiptap/extension-code-block";
import Placeholder from "@tiptap/extension-placeholder";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { TableKit } from "@tiptap/extension-table";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
@@ -404,6 +405,8 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
autofocus: autoFocus ? "end" : false,
extensions: [
StarterKit.configure({ codeBlock: false, link: { openOnClick: false } }),
TaskList,
TaskItem.configure({ nested: true }),
MergeDivider,
...createEdgeEverMathematics(),
mermaidCodeBlockExtension,
@@ -703,6 +706,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
selector: ({ editor: activeEditor }) =>
(activeEditor?.isActive("bold") ? MOBILE_EDITOR_ACTIVE_FLAGS.bold : 0) |
(activeEditor?.isActive("bulletList") ? MOBILE_EDITOR_ACTIVE_FLAGS.bulletList : 0) |
(activeEditor?.isActive("taskList") ? MOBILE_EDITOR_ACTIVE_FLAGS.taskList : 0) |
(activeEditor?.isActive("blockquote") ? MOBILE_EDITOR_ACTIVE_FLAGS.blockquote : 0),
});
@@ -729,17 +733,20 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
image: <ImagePlusIcon />,
bold: <BoldIcon />,
bulletList: <ListIcon />,
taskList: <ListTodoIcon />,
increaseListIndent: <ListIndentIncreaseIcon />,
decreaseListIndent: <ListIndentDecreaseIcon />,
blockquote: <QuoteIcon />,
horizontalRule: <MinusIcon />,
};
const activeListItemType = editor?.isActive("taskItem") ? "taskItem" : "listItem";
const toolbarHandlers: Record<MobileEditorToolbarActionId, () => void> = {
image: () => void insertImage(),
bold: () => editor?.chain().focus().toggleBold().run(),
bulletList: () => editor?.chain().focus().toggleBulletList().run(),
increaseListIndent: () => editor?.chain().focus().sinkListItem("listItem").run(),
decreaseListIndent: () => editor?.chain().focus().liftListItem("listItem").run(),
taskList: () => editor?.chain().focus().toggleTaskList().run(),
increaseListIndent: () => editor?.chain().focus().sinkListItem(activeListItemType).run(),
decreaseListIndent: () => editor?.chain().focus().liftListItem(activeListItemType).run(),
blockquote: () => editor?.chain().focus().toggleBlockquote().run(),
horizontalRule: () => editor?.chain().focus().setHorizontalRule().run(),
};
@@ -754,9 +761,9 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
key={action.id}
active={action.activeFlag > 0 && Boolean(toolbarState & action.activeFlag)}
disabled={(action.id === "increaseListIndent"
&& !Boolean(editor?.can().chain().focus().sinkListItem("listItem").run()))
&& !Boolean(editor?.can().chain().focus().sinkListItem(activeListItemType).run()))
|| (action.id === "decreaseListIndent"
&& !Boolean(editor?.can().chain().focus().liftListItem("listItem").run()))}
&& !Boolean(editor?.can().chain().focus().liftListItem(activeListItemType).run()))}
icon={toolbarIcons[action.id]}
label={getMobileEditorToolbarActionLabel(action.id, props.locale)}
onRun={toolbarHandlers[action.id]}
@@ -850,6 +857,15 @@ const ListIcon = () => (
</EditorIcon>
);
const ListTodoIcon = () => (
<EditorIcon size={18} strokeWidth={2.1}>
<rect height="6" rx="1" width="6" x="3" y="3" />
<path d="m4.5 6 1 1 2-2M13 6h8" />
<rect height="6" rx="1" width="6" x="3" y="15" />
<path d="M13 18h8" />
</EditorIcon>
);
const ListIndentIncreaseIcon = () => (
<EditorIcon size={18} strokeWidth={2.1}>
<path d="M4 5h16M4 12h10M4 19h16" />
@@ -1790,6 +1806,13 @@ const getEditorStyles = (theme: "light" | "dark", options?: { viewer?: boolean }
.edgeever-editor-content h1 { margin: 0.7em 0 0.4em; font-size: 1.6rem; }
.edgeever-editor-content h2 { margin: 0.85em 0 0.35em; font-size: 1.35rem; }
.edgeever-editor-content h3 { margin: 0.75em 0 0.3em; font-size: 1.15rem; }
.edgeever-editor-content ul[data-type="taskList"] { margin: 0 0 var(--editor-paragraph-spacing); padding-left: 0; list-style: none; }
.edgeever-editor-content ul[data-type="taskList"] li[data-type="taskItem"] { display: flex; align-items: flex-start; gap: 9px; margin: 4px 0; }
.edgeever-editor-content ul[data-type="taskList"] li[data-type="taskItem"] > label { display: inline-flex; flex: 0 0 auto; align-items: center; margin-top: 3px; user-select: none; }
.edgeever-editor-content ul[data-type="taskList"] li[data-type="taskItem"] > label input { width: 18px; height: 18px; margin: 0; accent-color: #16a06e; }
.edgeever-editor-content ul[data-type="taskList"] li[data-type="taskItem"] > div { min-width: 0; flex: 1 1 auto; }
.edgeever-editor-content ul[data-type="taskList"] li[data-type="taskItem"] > div > p { margin-bottom: 0; }
.edgeever-editor-content ul[data-type="taskList"] ul[data-type="taskList"] { margin: 4px 0 0; padding-left: 24px; }
.edgeever-editor-content blockquote { margin-left: 0; max-width: 100%; padding-left: 14px; border-left: 3px solid #5eead4; color: ${theme === "dark" ? "#cbd5e1" : "#475569"}; }
.edgeever-editor-content pre { max-width: 100%; overflow-x: auto; border-radius: 10px; padding: 14px 90px 14px 14px; background: #0f172a; color: #e2e8f0; font-size: 0.9rem; }
.edgeever-editor-content code { border-radius: 4px; padding: 2px 4px; background: ${theme === "dark" ? "#1e293b" : "#f1f5f9"}; font-size: 0.9em; }
+3
View File
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import type { Mark } from "@tiptap/pm/model";
import StarterKit from "@tiptap/starter-kit";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import Placeholder from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import { useTranslation } from "react-i18next";
@@ -1029,6 +1030,8 @@ const RichEditorPane = ({
codeBlock: false,
link: { openOnClick: false },
}),
TaskList,
TaskItem.configure({ nested: true }),
EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
MergeDivider,
...createEdgeEverMathematics(),
@@ -9,6 +9,7 @@ import {
Strikethrough,
Code2,
List,
ListTodo,
ListOrdered,
Quote,
SquareCode,
@@ -354,6 +355,14 @@ export const EditorToolbar = ({
>
<List className="h-4 w-4" />
</EditorToolbarButton>
<EditorToolbarButton
title={`${t("editorToolbar.taskList")} · ${t("editorToolbar.listIndentHint")}`}
active={isActive("taskList")}
disabled={!canRun((current) => current.can().chain().focus().toggleTaskList().run())}
onClick={() => run((current) => current.chain().focus().toggleTaskList().run())}
>
<ListTodo className="h-4 w-4" />
</EditorToolbarButton>
<EditorToolbarButton
title={`${t("editorToolbar.orderedList")} · ${t("editorToolbar.listIndentHint")}`}
active={isActive("orderedList")}
@@ -1,4 +1,4 @@
import { Bold, Check, ChevronDown, ImagePlus, List, ListIndentDecrease, ListIndentIncrease, Minus, Quote } from "lucide-react";
import { Bold, Check, ChevronDown, ImagePlus, List, ListIndentDecrease, ListIndentIncrease, ListTodo, Minus, Quote } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
MOBILE_EDITOR_TOOLBAR_ACTIONS,
@@ -56,6 +56,7 @@ export const MobileEditorToolbar = ({
disabled,
boldActive,
bulletListActive,
taskListActive,
increaseListIndentAvailable,
decreaseListIndentAvailable,
blockquoteActive,
@@ -63,6 +64,7 @@ export const MobileEditorToolbar = ({
onPickImage,
onToggleBold,
onToggleBulletList,
onToggleTaskList,
onIncreaseListIndent,
onDecreaseListIndent,
onToggleBlockquote,
@@ -71,6 +73,7 @@ export const MobileEditorToolbar = ({
disabled: boolean;
boldActive: boolean;
bulletListActive: boolean;
taskListActive: boolean;
increaseListIndentAvailable: boolean;
decreaseListIndentAvailable: boolean;
blockquoteActive: boolean;
@@ -78,6 +81,7 @@ export const MobileEditorToolbar = ({
onPickImage: () => void;
onToggleBold: () => void;
onToggleBulletList: () => void;
onToggleTaskList: () => void;
onIncreaseListIndent: () => void;
onDecreaseListIndent: () => void;
onToggleBlockquote: () => void;
@@ -87,6 +91,7 @@ export const MobileEditorToolbar = ({
image: <ImagePlus aria-hidden="true" size={18} strokeWidth={2} />,
bold: <Bold aria-hidden="true" size={17} strokeWidth={2.4} />,
bulletList: <List aria-hidden="true" size={18} strokeWidth={2.2} />,
taskList: <ListTodo aria-hidden="true" size={18} strokeWidth={2.1} />,
increaseListIndent: <ListIndentIncrease aria-hidden="true" size={18} strokeWidth={2.1} />,
decreaseListIndent: <ListIndentDecrease aria-hidden="true" size={18} strokeWidth={2.1} />,
blockquote: <Quote aria-hidden="true" size={17} strokeWidth={2.2} />,
@@ -96,6 +101,7 @@ export const MobileEditorToolbar = ({
image: onPickImage,
bold: onToggleBold,
bulletList: onToggleBulletList,
taskList: onToggleTaskList,
increaseListIndent: onIncreaseListIndent,
decreaseListIndent: onDecreaseListIndent,
blockquote: onToggleBlockquote,
@@ -104,6 +110,7 @@ export const MobileEditorToolbar = ({
const activeStates: Partial<Record<MobileEditorToolbarActionId, boolean>> = {
bold: boldActive,
bulletList: bulletListActive,
taskList: taskListActive,
blockquote: blockquoteActive,
};
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { mergeAttributes } from "@tiptap/core";
import Placeholder from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
@@ -187,6 +188,8 @@ export const MobileStandaloneTiptapEditor = ({
const editor = useEditor({
extensions: [
StarterKit.configure({ codeBlock: false }),
TaskList,
TaskItem.configure({ nested: true }),
EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
MergeDivider,
...createEdgeEverMathematics(),
@@ -878,6 +881,7 @@ export const MobileStandaloneTiptapEditor = ({
!memo || !editor || saveState === "loading" || saveState === "compressing" || saveState === "uploading" || saveState === "leaving";
const currentNotebookLabel =
notebookOptions.find((notebook) => notebook.id === memo?.notebookId)?.name ?? t("editor.notebookFallback");
const activeListItemType = editor?.isActive("taskItem") ? "taskItem" : "listItem";
const fallbackMarkdown = memo ? docToMarkdown(contentJsonRef.current) : "";
const runEditorCommand = (command: () => boolean) => {
@@ -925,15 +929,17 @@ export const MobileStandaloneTiptapEditor = ({
disabled={editorActionDisabled}
boldActive={Boolean(editor?.isActive("bold"))}
bulletListActive={Boolean(editor?.isActive("bulletList"))}
increaseListIndentAvailable={Boolean(editor?.can().chain().focus().sinkListItem("listItem").run())}
decreaseListIndentAvailable={Boolean(editor?.can().chain().focus().liftListItem("listItem").run())}
taskListActive={Boolean(editor?.isActive("taskList"))}
increaseListIndentAvailable={Boolean(editor?.can().chain().focus().sinkListItem(activeListItemType).run())}
decreaseListIndentAvailable={Boolean(editor?.can().chain().focus().liftListItem(activeListItemType).run())}
blockquoteActive={Boolean(editor?.isActive("blockquote"))}
locale={locale}
onPickImage={() => imageInputRef.current?.click()}
onToggleBold={() => runEditorCommand(() => editor?.chain().focus().toggleBold().run() ?? false)}
onToggleBulletList={() => runEditorCommand(() => editor?.chain().focus().toggleBulletList().run() ?? false)}
onIncreaseListIndent={() => runEditorCommand(() => editor?.chain().focus().sinkListItem("listItem").run() ?? false)}
onDecreaseListIndent={() => runEditorCommand(() => editor?.chain().focus().liftListItem("listItem").run() ?? false)}
onToggleTaskList={() => runEditorCommand(() => editor?.chain().focus().toggleTaskList().run() ?? false)}
onIncreaseListIndent={() => runEditorCommand(() => editor?.chain().focus().sinkListItem(activeListItemType).run() ?? false)}
onDecreaseListIndent={() => runEditorCommand(() => editor?.chain().focus().liftListItem(activeListItemType).run() ?? false)}
onToggleBlockquote={() => runEditorCommand(() => editor?.chain().focus().toggleBlockquote().run() ?? false)}
onSetHorizontalRule={() => runEditorCommand(() => editor?.chain().focus().setHorizontalRule().run() ?? false)}
/>
@@ -1,6 +1,7 @@
import "katex/dist/katex.min.css";
import { Node, mergeAttributes } from "@tiptap/core";
import Image from "@tiptap/extension-image";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { TableKit } from "@tiptap/extension-table";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
@@ -79,6 +80,8 @@ const SharedDocument = ({ share, token }: { share: PublicMemoShare; token: strin
const editor = useEditor({
extensions: [
StarterKit.configure({ codeBlock: false, link: { openOnClick: true } }),
TaskList,
TaskItem.configure({ nested: true }),
EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
MergeDivider,
...createEdgeEverMathematics(),
+41
View File
@@ -472,6 +472,47 @@ textarea {
margin: 0.25rem 0;
}
.ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.ProseMirror ul[data-type="taskList"] li[data-type="taskItem"] {
display: flex;
align-items: flex-start;
gap: 0.55rem;
}
.ProseMirror ul[data-type="taskList"] li[data-type="taskItem"] > label {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
margin-top: 0.2rem;
user-select: none;
}
.ProseMirror ul[data-type="taskList"] li[data-type="taskItem"] > label input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: var(--brand-green);
cursor: pointer;
}
.ProseMirror ul[data-type="taskList"] li[data-type="taskItem"] > div {
min-width: 0;
flex: 1 1 auto;
}
.ProseMirror ul[data-type="taskList"] li[data-type="taskItem"] > div > p {
margin-bottom: 0;
}
.ProseMirror ul[data-type="taskList"] ul[data-type="taskList"] {
margin: 0.25rem 0 0;
padding-left: 1.5rem;
}
.ProseMirror blockquote {
margin: 0 0 1rem;
border-left: 3px solid var(--brand-green);
+40
View File
@@ -96,6 +96,46 @@
margin: 1mm 0;
}
.edgeever-print-content ul[data-type="taskList"] {
padding-left: 0;
list-style: none;
}
.edgeever-print-content li[data-type="taskItem"] {
display: flex;
align-items: flex-start;
gap: 2mm;
break-inside: avoid-page;
}
.edgeever-print-content li[data-type="taskItem"] > label {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
margin-top: 0.8mm;
}
.edgeever-print-content li[data-type="taskItem"] > label input {
width: 4mm;
height: 4mm;
margin: 0;
accent-color: #16a06e;
}
.edgeever-print-content li[data-type="taskItem"] > div {
min-width: 0;
flex: 1 1 auto;
}
.edgeever-print-content li[data-type="taskItem"] > div > p {
margin-bottom: 0;
}
.edgeever-print-content ul[data-type="taskList"] ul[data-type="taskList"] {
margin: 1mm 0 0;
padding-left: 6mm;
}
.edgeever-print-content blockquote {
break-inside: avoid-page;
margin: 4mm 0;
+3
View File
@@ -24,6 +24,7 @@
"@tanstack/react-query": "latest",
"@tiptap/core": "3.27.1",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-placeholder": "latest",
"@tiptap/extension-table": "3.27.1",
"@tiptap/react": "latest",
@@ -116,6 +117,7 @@
"@tanstack/react-query-persist-client": "^5.101.2",
"@tiptap/extension-code-block": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/react": "3.27.1",
@@ -215,6 +217,7 @@
"dependencies": {
"@tiptap/core": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-mathematics": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/markdown": "3.27.1",
+1
View File
@@ -120,6 +120,7 @@
"@tanstack/react-query": "latest",
"@tiptap/core": "3.27.1",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-placeholder": "latest",
"@tiptap/extension-table": "3.27.1",
"@tiptap/react": "latest",
+1
View File
@@ -14,6 +14,7 @@
"dependencies": {
"@tiptap/core": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-mathematics": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/markdown": "3.27.1",
+45
View File
@@ -127,6 +127,51 @@ describe("Nested list Markdown conversion", () => {
});
});
describe("Markdown task list conversion", () => {
const markdown = [
"- [ ] Pending task",
"- [x] Completed task",
" - [ ] Nested task",
].join("\n");
test("preserves checked state and nesting through a Markdown round trip", () => {
const doc = markdownToDoc(markdown);
const taskList = doc.content[0];
expect(taskList?.type).toBe("taskList");
expect(taskList?.content?.[0]).toMatchObject({
type: "taskItem",
attrs: { checked: false },
});
expect(taskList?.content?.[1]).toMatchObject({
type: "taskItem",
attrs: { checked: true },
});
expect(taskList?.content?.[1]?.content?.[1]?.type).toBe("taskList");
expect(docToMarkdown(doc)).toBe(markdown);
});
test("recovers task semantics retained only in the Markdown compatibility copy", () => {
const legacyDoc = {
type: "doc",
content: [{
type: "bulletList",
content: [{
type: "listItem",
content: [{ type: "paragraph", content: [{ type: "text", text: "Pending task" }] }],
}],
}],
};
const resolved = resolveMemoContentDoc(legacyDoc, "- [ ] Pending task");
expect(resolved.content[0]?.type).toBe("taskList");
expect(resolved.content[0]?.content?.[0]).toMatchObject({
type: "taskItem",
attrs: { checked: false },
});
});
});
describe("Mermaid Markdown conversion", () => {
const markdown = "```mermaid\nflowchart LR\n A --> B\n```";
+7 -1
View File
@@ -1,4 +1,5 @@
import Image from "@tiptap/extension-image";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { TableKit } from "@tiptap/extension-table";
import { Markdown, MarkdownManager } from "@tiptap/markdown";
import StarterKit from "@tiptap/starter-kit";
@@ -71,6 +72,8 @@ export const emptyDoc = (): TiptapDoc => ({
const markdownManager = new MarkdownManager({
extensions: [
StarterKit,
TaskList,
TaskItem.configure({ nested: true }),
TableKit,
Image,
MergeDivider,
@@ -113,6 +116,7 @@ export const resolveMemoContentDoc = (
if (
!contentMarkdown?.trim() ||
docContainsNodeType(currentDoc, "table") ||
docContainsNodeType(currentDoc, "taskList") ||
docContainsNodeType(currentDoc, "edgeeverThemeBlock") ||
docContainsNodeType(currentDoc, MERGE_DIVIDER_NODE_TYPE) ||
docContainsNodeType(currentDoc, BLOCK_MATH_NODE_TYPE) ||
@@ -125,8 +129,10 @@ export const resolveMemoContentDoc = (
// Some older saves left an empty JSON document behind while retaining the
// real body in Markdown. Treat that as a compatibility case too; otherwise
// the editor can show the Markdown body while list excerpts see an empty
// JSON document. Also recover merge dividers when only Markdown still has them.
// JSON document. Also recover task lists and merge dividers when only Markdown
// still retains their semantics.
return docContainsNodeType(markdownDoc, "table")
|| docContainsNodeType(markdownDoc, "taskList")
|| docContainsNodeType(markdownDoc, MERGE_DIVIDER_NODE_TYPE)
|| docContainsNodeType(markdownDoc, BLOCK_MATH_NODE_TYPE)
|| docContainsNodeType(markdownDoc, INLINE_MATH_NODE_TYPE)
+2 -1
View File
@@ -808,6 +808,7 @@ export const enUS = {
strike: "Strikethrough",
inlineCode: "Inline code",
bulletList: "Bulleted list",
taskList: "Task list",
orderedList: "Numbered list",
increaseListIndent: "Increase list level (Tab)",
decreaseListIndent: "Decrease list level (Shift + Tab)",
@@ -1117,7 +1118,7 @@ export const enUS = {
weeklyReview: {
title: "Weekly Review & Status",
description: "Summarize weekly highlights, blockers, and next week's key priorities.",
contentMarkdown: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n1. [ ] \n2. [ ] \n3. [ ] \n\n## 💡 Reflection & Insights\n\n- \n",
contentMarkdown: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 Reflection & Insights\n\n- \n",
},
reading: {
title: "Reading Note Card",
+2 -1
View File
@@ -808,6 +808,7 @@ export const zhCN = {
strike: "删除线",
inlineCode: "行内代码",
bulletList: "无序列表",
taskList: "任务清单",
orderedList: "有序列表",
increaseListIndent: "增加列表层级(Tab)",
decreaseListIndent: "减少列表层级(Shift + Tab)",
@@ -1117,7 +1118,7 @@ export const zhCN = {
weeklyReview: {
title: "周报与进展复盘",
description: "梳理本周核心产出、风险卡点与下周关键优先级。",
contentMarkdown: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n1. [ ] \n2. [ ] \n3. [ ] \n\n## 💡 总结与思考\n\n- \n",
contentMarkdown: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 总结与思考\n\n- \n",
},
reading: {
title: "深度阅读卡片",
@@ -16,6 +16,7 @@ describe("mobile editor contract", () => {
"image",
"bold",
"bulletList",
"taskList",
"increaseListIndent",
"decreaseListIndent",
"blockquote",
@@ -31,6 +32,7 @@ describe("mobile editor contract", () => {
expect(getMobileEditorPlaceholder("en-US")).toBe("Start writing...");
expect(getMobileEditorToolbarLabel("zh-CN")).toBe("编辑器工具栏");
expect(getMobileEditorToolbarActionLabel("bulletList", "en-US")).toBe("Bullet list");
expect(getMobileEditorToolbarActionLabel("taskList", "zh-CN")).toBe("任务清单");
expect(getMobileEditorToolbarActionLabel("increaseListIndent", "zh-CN")).toBe("增加列表层级(Tab)");
expect(getMobileEditorToolbarActionLabel("decreaseListIndent", "en-US")).toBe("Decrease list level (Shift + Tab)");
expect(getMobileEditorImageScaleLabel("zh-CN")).toBe("图片显示尺寸");
+5
View File
@@ -6,6 +6,7 @@ export type MobileEditorToolbarActionId =
| "image"
| "bold"
| "bulletList"
| "taskList"
| "increaseListIndent"
| "decreaseListIndent"
| "blockquote"
@@ -13,6 +14,7 @@ export type MobileEditorToolbarActionId =
export const MOBILE_EDITOR_ACTIVE_FLAGS = {
bold: 1,
taskList: 2,
bulletList: 8,
blockquote: 16,
} as const;
@@ -21,6 +23,7 @@ export const MOBILE_EDITOR_TOOLBAR_ACTIONS = [
{ id: "image", activeFlag: 0 },
{ id: "bold", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.bold },
{ id: "bulletList", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.bulletList },
{ id: "taskList", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.taskList },
{ id: "increaseListIndent", activeFlag: 0 },
{ id: "decreaseListIndent", activeFlag: 0 },
{ id: "blockquote", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.blockquote },
@@ -38,6 +41,7 @@ const MOBILE_EDITOR_COPY = {
image: "上传图片",
bold: "加粗",
bulletList: "无序列表",
taskList: "任务清单",
increaseListIndent: "增加列表层级(Tab)",
decreaseListIndent: "减少列表层级(Shift + Tab)",
blockquote: "引用",
@@ -58,6 +62,7 @@ const MOBILE_EDITOR_COPY = {
image: "Upload image",
bold: "Bold",
bulletList: "Bullet list",
taskList: "Task list",
increaseListIndent: "Increase list level (Tab)",
decreaseListIndent: "Decrease list level (Shift + Tab)",
blockquote: "Quote",