feat(mobile): add undo and redo to phone editors

Android web, the Android app, and iOS share the same toolbar. Loading a note clears that replacement from history so the first undo does not wipe the document.
This commit is contained in:
tianma-if committed 2026-09-28 15:15:47 +08:00
1 parent e7bef83655
commit 4878298592
11 files changed
+1069 -867

No files matched your search

File diff suppressed because it is too large. Load diff
+438 -431
View File
File diff suppressed because it is too large. Load diff
+23 -1
View File
@@ -29,6 +29,7 @@ import {
type TiptapDoc,
type DiagramDocument,
} from "@edgeever/shared";
import { clearMobileEditorUndoHistory } from "@edgeever/shared/mobile-editor";
import {
type NoteImageTheme,
type NoteImageFontStyle,
@@ -1116,6 +1117,8 @@ function setToolbarVisible(visible: boolean) {
toolbarEl.innerHTML = "";
if (!visible) return;
const actions: Array<{ id: string; label: string; run: () => void }> = [
{ id: "undo", label: "↩", run: () => editor.chain().focus().undo().run() },
{ id: "redo", label: "↪", run: () => editor.chain().focus().redo().run() },
{
id: "image",
label: "▧+",
@@ -1159,6 +1162,8 @@ function setToolbarVisible(visible: boolean) {
btn.textContent = action.label;
btn.dataset.action = action.id;
const labels: Record<string, [string, string]> = {
undo: ["撤销", "Undo"],
redo: ["重做", "Redo"],
image: ["插入图片", "Insert image"],
bold: ["粗体", "Bold"],
bullet: ["项目符号列表", "Bullet list"],
@@ -1186,8 +1191,21 @@ function refreshToolbarState() {
task: editor.isActive("taskList"),
quote: editor.isActive("blockquote"),
};
const historyEnabled = (command: "undo" | "redo") => {
try {
return editor.can().chain().focus()[command]().run();
} catch {
return false;
}
};
const enabled: Record<string, boolean> = {
undo: historyEnabled("undo"),
redo: historyEnabled("redo"),
};
toolbarEl.querySelectorAll<HTMLButtonElement>("button[data-action]").forEach((button) => {
button.classList.toggle("is-active", active[button.dataset.action ?? ""] ?? false);
const actionId = button.dataset.action ?? "";
button.classList.toggle("is-active", active[actionId] ?? false);
if (actionId in enabled) button.disabled = !enabled[actionId];
});
}
@@ -1393,6 +1411,8 @@ const api: EdgeEverEditorAPI = {
// Keep editability. Do NOT focus("end") here — native re-pushes content on SwiftUI
// updates while typing; focusing would yank the caret to the document bottom mid-edit.
editor.setEditable(mode === "editor");
clearMobileEditorUndoHistory(editor);
refreshToolbarState();
suppressChange = false;
void afterContentSet((document.documentElement.dataset.theme as "light" | "dark") || "light");
},
@@ -1411,6 +1431,8 @@ const api: EdgeEverEditorAPI = {
editor.commands.setContent({ type: "doc", content: [{ type: "paragraph" }] });
}
editor.setEditable(mode === "editor");
clearMobileEditorUndoHistory(editor);
refreshToolbarState();
suppressChange = false;
void afterContentSet((document.documentElement.dataset.theme as "light" | "dark") || "light");
},
+10
View File
@@ -140,6 +140,16 @@ html[data-theme="dark"] body {
color: var(--toolbar-active-fg);
}
#toolbar button:disabled {
opacity: 0.38;
}
#toolbar button:disabled:active {
border-color: transparent;
background: transparent;
color: var(--toolbar-fg);
}
/* Scrollable editor body — only this region pans, toolbar stays put. */
#editor {
flex: 1 1 auto;
@@ -64,6 +64,7 @@ import {
import {
MOBILE_EDITOR_ACTIVE_FLAGS,
MOBILE_EDITOR_TOOLBAR_ACTIONS,
clearMobileEditorUndoHistory,
getMobileEditorInputAttributes,
getMobileEditorImageScaleLabel,
getMobileEditorImageWidthPresetLabel,
@@ -869,6 +870,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
// composer reset). Callers already own persistence for that state, so an
// emitted update would create a delayed stale write during screen teardown.
editor.commands.setContent(next, { emitUpdate: false });
clearMobileEditorUndoHistory(editor);
} catch {
// Ignore malformed payloads from the native bridge.
}
@@ -1449,6 +1451,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
const incoming = JSON.stringify(next);
if (current !== incoming) {
editor.commands.setContent(next, { emitUpdate: false });
clearMobileEditorUndoHistory(editor);
}
}, [editor, isViewer, props.baseUrl, props.content, props.locale]);
@@ -1563,6 +1566,20 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
(activeEditor?.isActive("taskList") ? MOBILE_EDITOR_ACTIVE_FLAGS.taskList : 0) |
(activeEditor?.isActive("blockquote") ? MOBILE_EDITOR_ACTIVE_FLAGS.blockquote : 0),
});
const historyState = useEditorState({
editor,
selector: ({ editor: activeEditor }) => {
if (!activeEditor) return 0;
const available = (command: "undo" | "redo") => {
try {
return activeEditor.can().chain().focus()[command]().run();
} catch {
return false;
}
};
return (available("undo") ? 1 : 0) | (available("redo") ? 2 : 0);
},
});
const requestOpenAiForSelection = () => {
if (openAiForSelection()) {
setAiSelectionHint(false);
@@ -1600,6 +1617,8 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
};
const toolbarIcons: Record<MobileEditorToolbarActionId, ReactNode> = {
undo: <UndoIcon />,
redo: <RedoIcon />,
image: <ImagePlusIcon />,
bold: <BoldIcon />,
bulletList: <ListIcon />,
@@ -1611,6 +1630,8 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
};
const activeListItemType = editor?.isActive("taskItem") ? "taskItem" : "listItem";
const toolbarHandlers: Record<MobileEditorToolbarActionId, () => void> = {
undo: () => editor?.chain().focus().undo().run(),
redo: () => editor?.chain().focus().redo().run(),
image: () => void insertImage(),
bold: () => editor?.chain().focus().toggleBold().run(),
bulletList: () => editor?.chain().focus().toggleBulletList().run(),
@@ -1630,7 +1651,9 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
<ToolbarButton
key={action.id}
active={action.activeFlag > 0 && Boolean(toolbarState & action.activeFlag)}
disabled={(action.id === "increaseListIndent"
disabled={(action.id === "undo" && ((historyState ?? 0) & 1) === 0)
|| (action.id === "redo" && ((historyState ?? 0) & 2) === 0)
|| (action.id === "increaseListIndent"
&& !Boolean(editor?.can().chain().focus().sinkListItem(activeListItemType).run()))
|| (action.id === "decreaseListIndent"
&& !Boolean(editor?.can().chain().focus().liftListItem(activeListItemType).run()))}
@@ -2066,6 +2089,20 @@ const EditorIcon = ({ children, size, strokeWidth }: { children: ReactNode; size
// Keep the same Lucide paths as the PWA toolbar without pulling the full icon
// barrel into the standalone DOM bundle (which adds roughly 1.8 MB in Metro).
const UndoIcon = () => (
<EditorIcon size={18} strokeWidth={2}>
<path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" />
</EditorIcon>
);
const RedoIcon = () => (
<EditorIcon size={18} strokeWidth={2}>
<path d="m15 14 5-5-5-5" />
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13" />
</EditorIcon>
);
const ImagePlusIcon = () => (
<EditorIcon size={18} strokeWidth={2}>
<path d="M16 5h6" />
@@ -1,4 +1,4 @@
import { Bold, Check, ChevronDown, ImagePlus, List, ListIndentDecrease, ListIndentIncrease, ListTodo, Minus, Quote } from "lucide-react";
import { Bold, Check, ChevronDown, ImagePlus, List, ListIndentDecrease, ListIndentIncrease, ListTodo, Minus, Quote, Redo2, Undo2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
MOBILE_EDITOR_TOOLBAR_ACTIONS,
@@ -54,6 +54,8 @@ const MobileEditorHeaderInner = ({
export const MobileEditorToolbar = ({
disabled,
undoAvailable,
redoAvailable,
boldActive,
bulletListActive,
taskListActive,
@@ -61,6 +63,8 @@ export const MobileEditorToolbar = ({
decreaseListIndentAvailable,
blockquoteActive,
locale,
onUndo,
onRedo,
onPickImage,
onToggleBold,
onToggleBulletList,
@@ -71,6 +75,8 @@ export const MobileEditorToolbar = ({
onSetHorizontalRule,
}: {
disabled: boolean;
undoAvailable: boolean;
redoAvailable: boolean;
boldActive: boolean;
bulletListActive: boolean;
taskListActive: boolean;
@@ -78,6 +84,8 @@ export const MobileEditorToolbar = ({
decreaseListIndentAvailable: boolean;
blockquoteActive: boolean;
locale: MobileEditorLocale;
onUndo: () => void;
onRedo: () => void;
onPickImage: () => void;
onToggleBold: () => void;
onToggleBulletList: () => void;
@@ -88,6 +96,8 @@ export const MobileEditorToolbar = ({
onSetHorizontalRule: () => void;
}) => {
const icons: Record<MobileEditorToolbarActionId, ReactNode> = {
undo: <Undo2 aria-hidden="true" size={18} strokeWidth={2} />,
redo: <Redo2 aria-hidden="true" size={18} strokeWidth={2} />,
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} />,
@@ -98,6 +108,8 @@ export const MobileEditorToolbar = ({
horizontalRule: <Minus aria-hidden="true" size={18} strokeWidth={2.4} />,
};
const handlers: Record<MobileEditorToolbarActionId, () => void> = {
undo: onUndo,
redo: onRedo,
image: onPickImage,
bold: onToggleBold,
bulletList: onToggleBulletList,
@@ -128,6 +140,8 @@ export const MobileEditorToolbar = ({
title={label}
aria-pressed={activeStates[id]}
disabled={disabled
|| (id === "undo" && !undoAvailable)
|| (id === "redo" && !redoAvailable)
|| (id === "increaseListIndent" && !increaseListIndentAvailable)
|| (id === "decreaseListIndent" && !decreaseListIndentAvailable)}
onPointerDown={(event) => event.preventDefault()}
@@ -12,7 +12,7 @@ import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
import { NEW_IMAGE_WIDTH_PERCENT } from "@edgeever/shared/image-display";
import { insertUploadedResources } from "@/lib/resource-insertion";
import { getResourceInsertionTarget } from "@/lib/resource-insertion-target";
import { getMobileEditorInputAttributes, getMobileEditorPlaceholder } from "@edgeever/shared/mobile-editor";
import { clearMobileEditorUndoHistory, getMobileEditorInputAttributes, getMobileEditorPlaceholder } from "@edgeever/shared/mobile-editor";
import { EdgeEverLink } from "@edgeever/shared/editor-link";
import { createInlineFieldExtension } from "@/components/editor/InlineField";
import {
@@ -789,6 +789,7 @@ export const MobileStandaloneTiptapEditor = ({
tagsTextRef.current = draft.tagsText || "";
contentJsonRef.current = draft.contentJson || emptyDoc();
editor.commands.setContent(contentJsonRef.current, { emitUpdate: false });
clearMobileEditorUndoHistory(editor);
dirtyRef.current = true;
setSaveStateStable("local-draft");
scheduleMetadataSave();
@@ -802,6 +803,7 @@ export const MobileStandaloneTiptapEditor = ({
tagsTextRef.current = queuedTagsText;
contentJsonRef.current = queuedPayload.contentJson || emptyDoc();
editor.commands.setContent(contentJsonRef.current, { emitUpdate: false });
clearMobileEditorUndoHistory(editor);
lastSavedSnapshotRef.current = JSON.stringify({
title: queuedTitle,
tagsText: queuedTagsText,
@@ -817,6 +819,7 @@ export const MobileStandaloneTiptapEditor = ({
tagsTextRef.current = nextTagsText;
contentJsonRef.current = nextContentJson;
editor.commands.setContent(nextContentJson, { emitUpdate: false });
clearMobileEditorUndoHistory(editor);
lastSavedSnapshotRef.current = JSON.stringify({
title: nextTitle,
tagsText: nextTagsText,
@@ -933,6 +936,14 @@ export const MobileStandaloneTiptapEditor = ({
const currentNotebookLabel =
notebookOptions.find((notebook) => notebook.id === memo?.notebookId)?.name ?? t("editor.notebookFallback");
const activeListItemType = editor?.isActive("taskItem") ? "taskItem" : "listItem";
const historyAvailable = (command: "undo" | "redo") => {
if (!editor || editor.isDestroyed) return false;
try {
return editor.can().chain().focus()[command]().run();
} catch {
return false;
}
};
const canWrapIndentedParagraph = Boolean(editor && wrapIndentedParagraphInList(editor.state, undefined));
const fallbackMarkdown = memo ? docToMarkdown(contentJsonRef.current) : "";
@@ -978,6 +989,8 @@ export const MobileStandaloneTiptapEditor = ({
<MobileEditorToolbar
disabled={editorActionDisabled}
undoAvailable={historyAvailable("undo")}
redoAvailable={historyAvailable("redo")}
boldActive={Boolean(editor?.isActive("bold"))}
bulletListActive={Boolean(editor?.isActive("bulletList"))}
taskListActive={Boolean(editor?.isActive("taskList"))}
@@ -985,6 +998,8 @@ export const MobileStandaloneTiptapEditor = ({
decreaseListIndentAvailable={Boolean(editor?.can().chain().focus().liftListItem(activeListItemType).run())}
blockquoteActive={Boolean(editor?.isActive("blockquote"))}
locale={locale}
onUndo={() => runEditorCommand(() => editor?.chain().focus().undo().run() ?? false)}
onRedo={() => runEditorCommand(() => editor?.chain().focus().redo().run() ?? false)}
onPickImage={() => imageInputRef.current?.click()}
onToggleBold={() => runEditorCommand(() => editor?.chain().focus().toggleBold().run() ?? false)}
onToggleBulletList={() => runEditorCommand(() => (
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { clearMobileEditorUndoHistory } from "@edgeever/shared/mobile-editor";
const createEditor = () => {
const editor = new Editor({
extensions: [StarterKit],
content: { type: "doc", content: [{ type: "paragraph" }] },
});
// Headless Bun has no DOM, so Editor.createView never reconfigures plugins.
// Mount the same plugin set a browser view installs before the first paint.
editor.view.updateState(editor.state.reconfigure({
plugins: editor.extensionManager.plugins,
}));
return editor;
};
describe("mobile editor undo history", () => {
test("drops a programmatic document load and keeps later typing undoable", () => {
const editor = createEditor();
editor.commands.setContent({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "loaded" }] }],
}, { emitUpdate: false });
expect(editor.can().undo()).toBe(true);
clearMobileEditorUndoHistory(editor);
expect(editor.getText()).toBe("loaded");
expect(editor.can().undo()).toBe(false);
editor.view.dispatch(editor.state.tr.insertText("!"));
expect(editor.getText()).toBe("loaded!");
expect(editor.can().undo()).toBe(true);
expect(editor.commands.undo()).toBe(true);
expect(editor.getText()).toBe("loaded");
expect(editor.can().undo()).toBe(false);
editor.destroy();
});
});
@@ -309,6 +309,7 @@ input {
.mobile-editor-tool-button:disabled {
border-color: transparent;
opacity: 0.38;
}
.mobile-editor-tool-button:disabled:active {
+6 -1
View File
@@ -11,8 +11,10 @@ import {
} from "./mobile-editor.ts";
describe("mobile editor contract", () => {
test("keeps the core toolbar compact and ordered by editing frequency", () => {
test("keeps undo and redo at the start of the mobile toolbar", () => {
expect(MOBILE_EDITOR_TOOLBAR_ACTIONS.map(({ id }) => id)).toEqual([
"undo",
"redo",
"image",
"bold",
"bulletList",
@@ -31,6 +33,9 @@ describe("mobile editor contract", () => {
expect(getMobileEditorPlaceholder("zh-CN")).toBe("开始记录...");
expect(getMobileEditorPlaceholder("en-US")).toBe("Start writing...");
expect(getMobileEditorToolbarLabel("zh-CN")).toBe("编辑器工具栏");
expect(getMobileEditorToolbarActionLabel("undo", "zh-CN")).toBe("撤销");
expect(getMobileEditorToolbarActionLabel("redo", "en-US")).toBe("Redo");
expect(getMobileEditorToolbarActionLabel("undo", "ja")).toBe("元に戻す");
expect(getMobileEditorToolbarActionLabel("bulletList", "en-US")).toBe("Bullet list");
expect(getMobileEditorToolbarActionLabel("taskList", "zh-CN")).toBe("任务清单");
expect(getMobileEditorToolbarActionLabel("increaseListIndent", "zh-CN")).toBe("增加列表层级(Tab)");
+44
View File
@@ -1,8 +1,12 @@
import type { Editor } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import type { ImageWidthPresetId } from "./image-display";
export type MobileEditorLocale = "zh-CN" | "en-US" | "ja";
export type MobileEditorToolbarActionId =
| "undo"
| "redo"
| "image"
| "bold"
| "bulletList"
@@ -20,6 +24,8 @@ export const MOBILE_EDITOR_ACTIVE_FLAGS = {
} as const;
export const MOBILE_EDITOR_TOOLBAR_ACTIONS = [
{ id: "undo", activeFlag: 0 },
{ id: "redo", activeFlag: 0 },
{ id: "image", activeFlag: 0 },
{ id: "bold", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.bold },
{ id: "bulletList", activeFlag: MOBILE_EDITOR_ACTIVE_FLAGS.bulletList },
@@ -38,6 +44,8 @@ const MOBILE_EDITOR_COPY = {
placeholder: "开始记录...",
toolbar: "编辑器工具栏",
actions: {
undo: "撤销",
redo: "重做",
image: "上传图片",
bold: "加粗",
bulletList: "无序列表",
@@ -59,6 +67,8 @@ const MOBILE_EDITOR_COPY = {
placeholder: "Start writing...",
toolbar: "Editor toolbar",
actions: {
undo: "Undo",
redo: "Redo",
image: "Upload image",
bold: "Bold",
bulletList: "Bullet list",
@@ -80,6 +90,8 @@ const MOBILE_EDITOR_COPY = {
placeholder: "書き始める...",
toolbar: "エディタのツールバー",
actions: {
undo: "元に戻す",
redo: "やり直す",
image: "画像をアップロード",
bold: "太字",
bulletList: "箇条書き",
@@ -118,6 +130,38 @@ export const getMobileEditorImageWidthPresetLabel = (
locale: MobileEditorLocale
): string => MOBILE_EDITOR_COPY[locale].imageSizes[preset];
/**
* Opening a note, restoring a draft, or applying a template replaces the
* document. That replacement must not become an undo step, or the first undo
* would wipe the note back to the previous document.
*/
export const clearMobileEditorUndoHistory = (editor: Editor): void => {
try {
const state = editor.state;
editor.view.updateState(EditorState.create({
doc: state.doc,
plugins: state.plugins,
schema: state.schema,
selection: state.selection,
}));
// updateState does not emit a transaction, so toolbar subscribers would
// keep the pre-reset undo flag. A no-step transaction refreshes them
// without recording a new history event or a save.
editor.view.dispatch(editor.state.tr.setMeta("addToHistory", false).setMeta("preventUpdate", true));
} catch {
try {
const state = editor.state;
editor.view.updateState(EditorState.create({
doc: state.doc,
plugins: state.plugins,
schema: state.schema,
}));
} catch {
// The view is not mounted yet. The next user edit still starts a new history.
}
}
};
export const getMobileEditorInputAttributes = (className: string): Record<string, string> => ({
autocapitalize: "sentences",
autocomplete: "on",