From a36a555a836d74a32f2e164cd93fb9a573cf7294 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 26 Aug 2026 02:51:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E6=94=B9=E8=BF=9B=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=B1=82=E7=BA=A7=E4=BA=A4=E4=BA=92=20(#299)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/components/EditorPane.tsx | 13 ++- apps/web/src/components/EditorToolbar.tsx | 22 +++- .../MobileStandaloneTiptapEditor.tsx | 23 +++- apps/web/src/lib/editor-shortcuts.test.mjs | 93 ++++++++++++++- apps/web/src/lib/editor-shortcuts.ts | 110 +++++++++++++++++- 5 files changed, 251 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index d5818dda..72b7795e 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -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; diff --git a/apps/web/src/components/EditorToolbar.tsx b/apps/web/src/components/EditorToolbar.tsx index 9de5e716..613af869 100644 --- a/apps/web/src/components/EditorToolbar.tsx +++ b/apps/web/src/components/EditorToolbar.tsx @@ -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"))} > @@ -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"))} > @@ -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"))} > diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx index 4a0f0b21..5b328c15 100644 --- a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx +++ b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx @@ -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)} diff --git a/apps/web/src/lib/editor-shortcuts.test.mjs b/apps/web/src/lib/editor-shortcuts.test.mjs index 81e9c69c..13aec368 100644 --- a/apps/web/src/lib/editor-shortcuts.test.mjs +++ b/apps/web/src/lib/editor-shortcuts.test.mjs @@ -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); diff --git a/apps/web/src/lib/editor-shortcuts.ts b/apps/web/src/lib/editor-shortcuts.ts index d626b7c4..cd9671c9 100644 --- a/apps/web/src/lib/editor-shortcuts.ts +++ b/apps/web/src/lib/editor-shortcuts.ts @@ -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,