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:
1 parent
e7bef83655
commit
4878298592
11 files changed
+1069
-867
No files matched your search
+438
-431
File diff suppressed because it is too large.
Load diff
Vendored
+438
-431
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user