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,