fix(editor): 改进列表层级交互 (#299)

This commit is contained in:
tianma-if committed 2026-08-26 02:51:04 +08:00
1 parent facf68614e
commit a36a555a83
5 files changed
+251 -10

No files matched your search

+12 -1
View File
@@ -162,7 +162,13 @@ import { NOTE_HTML_FULL_STYLES } from "@/lib/note-html-export-assets";
import { downloadNoteHtmlFile, getHtmlImageEmbedNoticeKind } from "@/lib/note-html-export";
import { openNotePrintPreview, serializeNoteDocumentForPrint } from "@/lib/note-print";
import type { NoteImageFormat } from "@/lib/note-image-export";
import { applyPlainTextTab, getAiSlashCommandStart, saveAndSyncEditor, shouldOpenAiFromSpace } from "@/lib/editor-shortcuts";
import {
applyPlainTextTab,
getAiSlashCommandStart,
preserveEmptyListIndentOnBackspace,
saveAndSyncEditor,
shouldOpenAiFromSpace,
} from "@/lib/editor-shortcuts";
import {
AI_SPACE_SHORTCUT_CHANGED_EVENT,
readAiSpaceShortcutPreference,
@@ -1308,6 +1314,11 @@ const RichEditorPane = ({
handleKeyDown: (view, event) => {
const { selection } = view.state;
const currentNode = selection.$from.parent;
if (event.key === "Backspace" && preserveEmptyListIndentOnBackspace(view.state, view.dispatch)) {
event.preventDefault();
return true;
}
if (event.key === "Tab" && applyPlainTextTab(view.state, view.dispatch, event.shiftKey)) {
event.preventDefault();
return true;
+19 -3
View File
@@ -25,6 +25,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { formatShortcutBinding, getActiveBlockValue, type ShortcutBinding } from "@/lib/app-helpers";
import { CODE_BLOCK_LANGUAGES, getCodeBlockLanguageValue } from "@/lib/code-block";
import { EditorTableMenu } from "@/components/EditorTableMenu";
import { wrapIndentedParagraphInList } from "@/lib/editor-shortcuts";
const EditorToolbarButton = ({
active = false,
@@ -113,6 +114,21 @@ const insertMermaidDiagram = (editor: Editor) => {
.run();
};
const toggleListAtSelection = (editor: Editor, listType: "bulletList" | "orderedList" | "taskList") => {
editor.commands.focus();
if (wrapIndentedParagraphInList(editor.state, editor.view.dispatch, listType)) {
return;
}
if (listType === "bulletList") {
editor.commands.toggleBulletList();
} else if (listType === "orderedList") {
editor.commands.toggleOrderedList();
} else {
editor.commands.toggleTaskList();
}
};
export const EditorToolbar = ({
editor,
readOnly,
@@ -365,7 +381,7 @@ export const EditorToolbar = ({
title={`${t("editorToolbar.bulletList")} · ${t("editorToolbar.listIndentHint")}`}
active={isActive("bulletList")}
disabled={disabled}
onClick={() => run((current) => current.chain().focus().toggleBulletList().run())}
onClick={() => run((current) => toggleListAtSelection(current, "bulletList"))}
>
<List className="h-4 w-4" />
</EditorToolbarButton>
@@ -373,7 +389,7 @@ export const EditorToolbar = ({
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())}
onClick={() => run((current) => toggleListAtSelection(current, "taskList"))}
>
<ListTodo className="h-4 w-4" />
</EditorToolbarButton>
@@ -381,7 +397,7 @@ export const EditorToolbar = ({
title={`${t("editorToolbar.orderedList")} · ${t("editorToolbar.listIndentHint")}`}
active={isActive("orderedList")}
disabled={disabled}
onClick={() => run((current) => current.chain().focus().toggleOrderedList().run())}
onClick={() => run((current) => toggleListAtSelection(current, "orderedList"))}
>
<ListOrdered className="h-4 w-4" />
</EditorToolbarButton>
@@ -43,6 +43,7 @@ import {
} from "@/lib/mobile-editor-standalone";
import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue";
import { createMarkdownImagePasteRule } from "@/lib/markdown-image-paste";
import { preserveEmptyListIndentOnBackspace, wrapIndentedParagraphInList } from "@/lib/editor-shortcuts";
import { ThemeBlock } from "./ThemeBlock";
import { EditorTagPicker } from "./EditorTagPicker";
import { listLocalTags } from "@/lib/local-mirror";
@@ -213,6 +214,13 @@ export const MobileStandaloneTiptapEditor = ({
content: emptyDoc(),
editorProps: {
attributes: getMobileEditorInputAttributes("edgeever-mobile-tiptap-content"),
handleKeyDown: (view, event) => {
if (event.key !== "Backspace" || !preserveEmptyListIndentOnBackspace(view.state, view.dispatch)) {
return false;
}
event.preventDefault();
return true;
},
},
onUpdate: ({ editor: activeEditor }) => {
contentJsonRef.current = activeEditor.getJSON() as TiptapDoc;
@@ -895,6 +903,7 @@ export const MobileStandaloneTiptapEditor = ({
const currentNotebookLabel =
notebookOptions.find((notebook) => notebook.id === memo?.notebookId)?.name ?? t("editor.notebookFallback");
const activeListItemType = editor?.isActive("taskItem") ? "taskItem" : "listItem";
const canWrapIndentedParagraph = Boolean(editor && wrapIndentedParagraphInList(editor.state, undefined));
const fallbackMarkdown = memo ? docToMarkdown(contentJsonRef.current) : "";
const runEditorCommand = (command: () => boolean) => {
@@ -942,15 +951,21 @@ export const MobileStandaloneTiptapEditor = ({
boldActive={Boolean(editor?.isActive("bold"))}
bulletListActive={Boolean(editor?.isActive("bulletList"))}
taskListActive={Boolean(editor?.isActive("taskList"))}
increaseListIndentAvailable={Boolean(editor?.can().chain().focus().sinkListItem(activeListItemType).run())}
increaseListIndentAvailable={canWrapIndentedParagraph || 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)}
onToggleTaskList={() => runEditorCommand(() => editor?.chain().focus().toggleTaskList().run() ?? false)}
onIncreaseListIndent={() => runEditorCommand(() => editor?.chain().focus().sinkListItem(activeListItemType).run() ?? false)}
onToggleBulletList={() => runEditorCommand(() => (
editor ? wrapIndentedParagraphInList(editor.state, editor.view.dispatch, "bulletList") || editor.commands.toggleBulletList() : false
))}
onToggleTaskList={() => runEditorCommand(() => (
editor ? wrapIndentedParagraphInList(editor.state, editor.view.dispatch, "taskList") || editor.commands.toggleTaskList() : false
))}
onIncreaseListIndent={() => runEditorCommand(() => (
editor ? wrapIndentedParagraphInList(editor.state, editor.view.dispatch) || editor.commands.sinkListItem(activeListItemType) : 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)}
+92 -1
View File
@@ -1,7 +1,14 @@
import { describe, expect, test } from "bun:test";
import { EditorState, TextSelection } from "@tiptap/pm/state";
import { Schema } from "@tiptap/pm/model";
import { applyPlainTextTab, getAiSlashCommandStart, saveAndSyncEditor, shouldOpenAiFromSpace } from "./editor-shortcuts.ts";
import {
applyPlainTextTab,
getAiSlashCommandStart,
preserveEmptyListIndentOnBackspace,
saveAndSyncEditor,
shouldOpenAiFromSpace,
wrapIndentedParagraphInList,
} from "./editor-shortcuts.ts";
const schema = new Schema({
nodes: {
@@ -29,6 +36,14 @@ const runTab = (state, shiftKey = false) => {
return { handled, state: nextState };
};
const runListBackspace = (state) => {
let nextState = state;
const handled = preserveEmptyListIndentOnBackspace(state, (transaction) => {
nextState = state.apply(transaction);
});
return { handled, state: nextState };
};
describe("editor shortcut actions", () => {
test("inserts a tab at the caret in a plain paragraph", () => {
const result = runTab(createState(["hello world"], 6));
@@ -75,6 +90,82 @@ describe("editor shortcut actions", () => {
expect(applyPlainTextTab(state, () => {}, false)).toBe(false);
});
test("removes an empty sibling list marker while preserving nested indentation", () => {
const doc = schema.node("doc", null, [
schema.node("bulletList", null, [
schema.node("listItem", null, [schema.node("paragraph", null, schema.text("first"))]),
schema.node("listItem", null, [schema.node("paragraph")]),
]),
]);
const result = runListBackspace(EditorState.create({
doc,
selection: TextSelection.create(doc, 12),
}));
expect(result.handled).toBe(true);
expect(result.state.doc.toJSON()).toEqual({
type: "doc",
content: [{
type: "bulletList",
content: [{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "first" }] },
{ type: "paragraph" },
],
}],
}],
});
expect(result.state.selection.$from.parent.type.name).toBe("paragraph");
expect(result.state.selection.$from.parent.content.size).toBe(0);
let nestedListState = result.state;
expect(wrapIndentedParagraphInList(result.state, undefined, "bulletList")).toBe(true);
expect(wrapIndentedParagraphInList(result.state, (transaction) => {
nestedListState = result.state.apply(transaction);
}, "bulletList")).toBe(true);
expect(nestedListState.doc.toJSON()).toEqual({
type: "doc",
content: [{
type: "bulletList",
content: [{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "first" }] },
{
type: "bulletList",
content: [{ type: "listItem", content: [{ type: "paragraph" }] }],
},
],
}],
}],
});
});
test("keeps the default Backspace behavior outside a removable sibling list item", () => {
const firstItemOnly = schema.node("doc", null, [
schema.node("bulletList", null, [
schema.node("listItem", null, [schema.node("paragraph")]),
]),
]);
const nonEmptySecondItem = schema.node("doc", null, [
schema.node("bulletList", null, [
schema.node("listItem", null, [schema.node("paragraph", null, schema.text("first"))]),
schema.node("listItem", null, [schema.node("paragraph", null, schema.text("second"))]),
]),
]);
expect(preserveEmptyListIndentOnBackspace(
EditorState.create({ doc: firstItemOnly, selection: TextSelection.create(firstItemOnly, 3) }),
() => {},
)).toBe(false);
expect(preserveEmptyListIndentOnBackspace(
EditorState.create({ doc: nonEmptySecondItem, selection: TextSelection.create(nonEmptySecondItem, 12) }),
() => {},
)).toBe(false);
});
test("recognizes /ai only at a text boundary", () => {
expect(getAiSlashCommandStart({ caretPosition: 2, insertedText: "i", textBefore: "/a" })).toBe(0);
expect(getAiSlashCommandStart({ caretPosition: 8, insertedText: "I", textBefore: "hello /a" })).toBe(6);
+109 -1
View File
@@ -1,4 +1,5 @@
import type { EditorState, Transaction } from "@tiptap/pm/state";
import { TextSelection, type EditorState, type Transaction } from "@tiptap/pm/state";
import { wrapInList } from "@tiptap/pm/schema-list";
const TAB_DELEGATED_NODE_TYPES = new Set([
"listItem",
@@ -96,6 +97,113 @@ export const applyPlainTextTab = (
return true;
};
/**
* Removes the marker from a newly-created empty list item while preserving
* its visual/content indentation inside the previous item. Typing a list
* marker in the resulting paragraph then creates a nested list, matching
* Typora's Backspace workflow.
*/
export const preserveEmptyListIndentOnBackspace = (
state: EditorState,
dispatch: ((transaction: Transaction) => void) | undefined,
) => {
const { selection } = state;
if (
!selection.empty
|| selection.$from.parent.type.name !== "paragraph"
|| selection.$from.parent.content.size !== 0
|| selection.$from.parentOffset !== 0
) {
return false;
}
let listItemDepth = -1;
for (let depth = selection.$from.depth - 1; depth > 0; depth -= 1) {
const nodeName = selection.$from.node(depth).type.name;
if (nodeName === "listItem" || nodeName === "taskItem") {
listItemDepth = depth;
break;
}
}
if (listItemDepth < 1) {
return false;
}
const listDepth = listItemDepth - 1;
const itemIndex = selection.$from.index(listDepth);
const currentItem = selection.$from.node(listItemDepth);
const list = selection.$from.node(listDepth);
if (
itemIndex === 0
|| currentItem.childCount !== 1
|| currentItem.firstChild !== selection.$from.parent
) {
return false;
}
const previousItem = list.child(itemIndex - 1);
if (previousItem.type !== currentItem.type) {
return false;
}
if (!dispatch) {
return true;
}
const currentItemStart = selection.$from.before(listItemDepth);
const previousItemStart = currentItemStart - previousItem.nodeSize;
const mergedItem = previousItem.copy(previousItem.content.append(currentItem.content));
const transaction = state.tr.replaceWith(
previousItemStart,
currentItemStart + currentItem.nodeSize,
mergedItem,
);
const paragraphCursor = previousItemStart + previousItem.content.size + 2;
transaction.setSelection(TextSelection.create(transaction.doc, paragraphCursor));
dispatch(transaction.scrollIntoView());
return true;
};
/**
* Turns a direct paragraph below a list item's leading paragraph into a
* nested list. This is the structural state produced by the Typora-style
* Backspace behavior above.
*/
export const wrapIndentedParagraphInList = (
state: EditorState,
dispatch: ((transaction: Transaction) => void) | undefined,
requestedListType?: string,
) => {
const { selection } = state;
if (
selection.$from.parent.type.name !== "paragraph"
|| selection.$to.parent !== selection.$from.parent
) {
return false;
}
let listItemDepth = -1;
for (let depth = selection.$from.depth - 1; depth > 0; depth -= 1) {
const nodeName = selection.$from.node(depth).type.name;
if (nodeName === "listItem" || nodeName === "taskItem") {
listItemDepth = depth;
break;
}
}
if (
listItemDepth < 1
|| selection.$from.index(listItemDepth) === 0
) {
return false;
}
const listTypeName = requestedListType ?? selection.$from.node(listItemDepth - 1).type.name;
const listType = state.schema.nodes[listTypeName];
return Boolean(listType && wrapInList(listType)(state, dispatch));
};
export const saveAndSyncEditor = async ({
hasUnsavedChanges,
save,