fix(editor): 改进列表层级交互 (#299)
This commit is contained in:
1 parent
facf68614e
commit
a36a555a83
5 files changed
+251
-10
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user