feat(editor): set heading levels with number shortcuts

Ctrl or Cmd plus 1 through 6 sets the current rich-text paragraph to that heading. 0 restores body text. The desktop app keeps that 0 chord from also resetting the window zoom.
This commit is contained in:
tianma-if committed 2026-10-23 23:36:20 +08:00
1 parent 27936a3b25
commit 0f21f09444
8 files changed
+472

No files matched your search

@@ -0,0 +1,58 @@
const DIGIT_CODE = /^(?:Digit|Numpad)([0-6])$/;
/**
* Mod+0..6 heading chords. Keep the match identical to
* headingShortcutLevelFromEvent in apps/web/src/lib/editor-shortcuts.ts.
* Level 0 is body text. The rich-text editor claims it so the reset-zoom
* menu role does not also fire.
*/
export const headingLevelFromShortcutInput = (input) => {
if (!input || input.type !== "keyDown") return null;
if (input.isComposing || input.alt || input.shift || input.isAutoRepeat) return null;
if (!input.control && !input.meta) return null;
const codeLevel = DIGIT_CODE.exec(input.code ?? "");
if (codeLevel) return Number(codeLevel[1]);
if (/^[0-6]$/.test(input.key ?? "")) return Number(input.key);
return null;
};
export const shouldClaimParagraphShortcut = (input, richTextHeadingShortcutActive) =>
richTextHeadingShortcutActive === true && headingLevelFromShortcutInput(input) === 0;
export const createHeadingShortcutGuard = () => {
let richTextHeadingShortcutActive = false;
const guard = {
setActive(active) {
richTextHeadingShortcutActive = active === true;
},
reset() {
richTextHeadingShortcutActive = false;
},
isActive() {
return richTextHeadingShortcutActive;
},
handleInput(input, preventDefault, send) {
if (!shouldClaimParagraphShortcut(input, richTextHeadingShortcutActive)) return false;
preventDefault();
send(0);
return true;
},
attach(webContents) {
richTextHeadingShortcutActive = false;
webContents.on("before-input-event", (event, input) => {
guard.handleInput(
input,
() => event.preventDefault(),
(level) => webContents.send("desktop:heading-shortcut", level),
);
});
webContents.on("did-start-loading", () => {
richTextHeadingShortcutActive = false;
});
},
};
return guard;
};
@@ -0,0 +1,98 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { headingShortcutLevelFromEvent } from "../../../web/src/lib/editor-shortcuts.ts";
import {
createHeadingShortcutGuard,
headingLevelFromShortcutInput,
shouldClaimParagraphShortcut,
} from "./heading-shortcut.mjs";
const shortcut = (overrides = {}) => ({
type: "keyDown",
key: "0",
code: "Digit0",
isAutoRepeat: false,
isComposing: false,
shift: false,
control: true,
alt: false,
meta: false,
...overrides,
});
describe("desktop heading shortcut guard", () => {
test("matches the editor chord for the same physical key", () => {
for (const input of [
shortcut({ key: "1", code: "Digit1" }),
shortcut({ key: "&", code: "Digit1" }),
shortcut({ key: "6", code: "Numpad6", control: false, meta: true }),
shortcut({ key: "0", code: "Digit0", shift: true }),
shortcut({ key: "0", code: "Digit0", alt: true }),
shortcut({ key: "0", code: "Digit0", isComposing: true }),
shortcut({ key: "0", code: "Digit0", isAutoRepeat: true }),
shortcut({ key: "7", code: "Digit7" }),
shortcut({ control: false, meta: false }),
]) {
expect(headingLevelFromShortcutInput(input)).toBe(headingShortcutLevelFromEvent({
key: input.key,
code: input.code,
ctrlKey: input.control,
metaKey: input.meta,
altKey: input.alt,
shiftKey: input.shift,
isComposing: input.isComposing,
repeat: input.isAutoRepeat,
}));
}
expect(headingLevelFromShortcutInput(shortcut({ type: "keyUp" }))).toBeNull();
});
test("claims Mod+0 only while the rich text editor owns the chord", () => {
expect(shouldClaimParagraphShortcut(shortcut(), false)).toBe(false);
expect(shouldClaimParagraphShortcut(shortcut({ control: false, meta: true }), true)).toBe(true);
expect(shouldClaimParagraphShortcut(shortcut({ key: "2", code: "Digit2" }), true)).toBe(false);
expect(shouldClaimParagraphShortcut(shortcut({ shift: true }), true)).toBe(false);
});
test("stops the zoom shortcut and asks the editor to restore body text", () => {
const guard = createHeadingShortcutGuard();
const prevented = [];
const sent = [];
const webContents = {
listeners: {},
on(name, listener) { this.listeners[name] = listener; },
send(channel, level) { sent.push([channel, level]); },
};
guard.attach(webContents);
guard.setActive(true);
webContents.listeners["before-input-event"](
{ preventDefault: () => prevented.push("mod-0") },
shortcut(),
);
webContents.listeners["before-input-event"](
{ preventDefault: () => prevented.push("mod-1") },
shortcut({ key: "1", code: "Digit1" }),
);
expect(prevented).toEqual(["mod-0"]);
expect(sent).toEqual([["desktop:heading-shortcut", 0]]);
webContents.listeners["did-start-loading"]();
expect(guard.isActive()).toBe(false);
guard.setActive(true);
guard.reset();
expect(guard.isActive()).toBe(false);
});
test("wires the guard through the desktop window and preload", () => {
const indexSource = readFileSync(new URL("./index.mjs", import.meta.url), "utf8");
const preloadSource = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8");
expect(indexSource).toContain('ipcMain.on("desktop:rich-text-heading-shortcut"');
expect(indexSource).toContain("headingShortcutGuard.attach(mainWindow.webContents)");
expect(indexSource).toContain("headingShortcutGuard.reset()");
expect(preloadSource).toContain('ipcRenderer.sendSync("desktop:rich-text-heading-shortcut"');
expect(preloadSource).toContain('ipcRenderer.on("desktop:heading-shortcut"');
});
});
+9
View File
@@ -45,6 +45,7 @@ import { createRendererStartupGuard } from "./renderer-startup-guard.mjs";
import { waitForChildProcessSpawn } from "./child-process-start.mjs";
import { ScheduledTaskScheduler } from "./scheduled-task-scheduler.mjs";
import { desktopMenuCopy } from "./desktop-menu.mjs";
import { createHeadingShortcutGuard } from "./heading-shortcut.mjs";
import { buildEditContextMenuTemplate } from "./edit-context-menu.mjs";
import {
fetchTrustedWindowsUpdate,
@@ -78,6 +79,7 @@ import {
} from "./renderer-storage-migration.mjs";
const { autoUpdater } = electronUpdater;
const headingShortcutGuard = createHeadingShortcutGuard();
const linuxUpdateTestMode = process.platform === "linux"
&& process.env.GITHUB_ACTIONS === "true"
@@ -1517,6 +1519,7 @@ const createWindow = async () => {
}
});
attachEditContextMenu(mainWindow.webContents);
headingShortcutGuard.attach(mainWindow.webContents);
mainWindow.webContents.on("did-create-window", (childWindow) => attachEditContextMenu(childWindow.webContents));
mainWindow.on("hide", syncRendererHibernate);
mainWindow.on("hide", () => globalShortcut.setSuspended(false));
@@ -1561,6 +1564,7 @@ const createWindow = async () => {
void writeDiagnostic("renderer.loaded", { url: mainWindow?.webContents.getURL() || "" });
});
mainWindow.webContents.on("render-process-gone", (_event, details) => {
headingShortcutGuard.reset();
globalShortcut.setSuspended(false);
clearRendererUnresponsiveTimer();
void writeDiagnostic("renderer.gone", details);
@@ -1759,6 +1763,11 @@ const startApplication = async () => {
await nextSidecar.waitUntilReady();
return { ready: true, scope: nextScopeKey };
});
ipcMain.on("desktop:rich-text-heading-shortcut", (event, active) => {
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) return;
headingShortcutGuard.setActive(active);
event.returnValue = true;
});
ipcMain.on("desktop:renderer-ready", (event) => {
if (event.sender !== mainWindow?.webContents) return;
rendererReady = true;
+8
View File
@@ -107,6 +107,14 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
ipcRenderer.on("desktop:command", listener);
return () => ipcRenderer.removeListener("desktop:command", listener);
},
setRichTextHeadingShortcutActive: (active) => {
ipcRenderer.sendSync("desktop:rich-text-heading-shortcut", active === true);
},
onHeadingShortcut: (callback) => {
const listener = (_event, level) => callback(level);
ipcRenderer.on("desktop:heading-shortcut", listener);
return () => ipcRenderer.removeListener("desktop:heading-shortcut", listener);
},
onHibernatePrepare: (callback) => {
const listener = async () => {
try {
+61
View File
@@ -190,8 +190,10 @@ import {
import { ThemeBlock } from "./ThemeBlock";
import { EditorPhonePreview, PhonePreviewGlyph } from "./EditorPhonePreview";
import {
applyHeadingLevelShortcut,
applyPlainTextTab,
getAiSlashCommandStart,
headingShortcutLevelFromEvent,
preserveEmptyListIndentOnBackspace,
saveAndSyncEditor,
shouldOpenAiFromSpace,
@@ -686,6 +688,7 @@ const RichEditorPane = ({
const memoRef = useRef<MemoDetail | null>(memo);
const editSessionRef = useRef<MemoEditSession | null>(null);
const editorRef = useRef<Editor | null>(null);
const headingShortcutSurfaceRef = useRef({ markdown: false, plainText: false });
const editorCanvasInteractionVersionRef = useRef(0);
const persistResourceInsertionDraftRef = useRef<() => Promise<unknown>>(() => Promise.resolve());
const openAiAssistantRef = useRef<() => void>(() => undefined);
@@ -1273,6 +1276,17 @@ const RichEditorPane = ({
spellcheck: spellcheckEnabled ? "true" : "false",
},
handleKeyDown: (view, event) => {
if (view.editable) {
const headingLevel = headingShortcutLevelFromEvent(event);
if (
headingLevel !== null
&& applyHeadingLevelShortcut(view.state, view.dispatch, headingLevel)
) {
event.preventDefault();
return true;
}
}
const { selection } = view.state;
const currentNode = selection.$from.parent;
if (event.key === "Backspace" && preserveEmptyListIndentOnBackspace(view.state, view.dispatch)) {
@@ -1435,6 +1449,10 @@ const RichEditorPane = ({
onUnsafeRichTableEdit: setRichTableEditBlocked,
});
const useMarkdownSourceEditor = !useMobilePlainTextEditor && isMarkdownMode;
headingShortcutSurfaceRef.current = {
markdown: useMarkdownSourceEditor,
plainText: useMobilePlainTextEditor,
};
const applyPropertyFields = useCallback((source: string | null) => {
if (source === null || hydratingRef.current) return;
@@ -2691,6 +2709,49 @@ const RichEditorPane = ({
}
}, [editor, effectiveReadOnly, hydratedEditorMemoId, memo]);
useEffect(() => {
if (!isEditorReady(editor)) {
window.edgeeverDesktop?.setRichTextHeadingShortcutActive?.(false);
return;
}
const syncHeadingShortcutTarget = () => {
const surface = headingShortcutSurfaceRef.current;
const active = editor.isEditable
&& editor.isFocused
&& !surface.markdown
&& !surface.plainText;
window.edgeeverDesktop?.setRichTextHeadingShortcutActive?.(active);
};
editor.on("focus", syncHeadingShortcutTarget);
editor.on("blur", syncHeadingShortcutTarget);
syncHeadingShortcutTarget();
return () => {
editor.off("focus", syncHeadingShortcutTarget);
editor.off("blur", syncHeadingShortcutTarget);
window.edgeeverDesktop?.setRichTextHeadingShortcutActive?.(false);
};
}, [editor, effectiveReadOnly, hydratedEditorMemoId, memo, useMarkdownSourceEditor, useMobilePlainTextEditor]);
useEffect(() => {
const unsubscribe = window.edgeeverDesktop?.onHeadingShortcut?.((level) => {
if (level !== 0) return;
const surface = headingShortcutSurfaceRef.current;
if (surface.markdown || surface.plainText) return;
const current = editorRef.current;
if (!current || current.isDestroyed || !current.isEditable || !current.isFocused) return;
let editorView;
try {
editorView = current.view;
} catch {
return;
}
if (!editorView.editable) return;
applyHeadingLevelShortcut(current.state, editorView.dispatch, 0);
});
return () => unsubscribe?.();
}, []);
useEffect(() => {
if (!isEditorReady(editor) || !memo) {
return;
+157
View File
@@ -2,8 +2,10 @@ import { describe, expect, test } from "bun:test";
import { EditorState, TextSelection } from "@tiptap/pm/state";
import { Schema } from "@tiptap/pm/model";
import {
applyHeadingLevelShortcut,
applyPlainTextTab,
getAiSlashCommandStart,
headingShortcutLevelFromEvent,
preserveEmptyListIndentOnBackspace,
saveAndSyncEditor,
shouldOpenAiFromSpace,
@@ -235,3 +237,158 @@ describe("editor shortcut actions", () => {
expect(calls).toEqual(["save"]);
});
});
const headingSchema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { content: "inline*", group: "block" },
heading: {
content: "inline*",
group: "block",
attrs: { level: { default: 1 } },
},
codeBlock: { content: "text*", group: "block", code: true },
bulletList: { content: "listItem+", group: "block" },
listItem: { content: "paragraph block*" },
text: { group: "inline" },
},
marks: {
bold: {},
},
});
const headingState = (nodes, from, to = from) => {
const doc = headingSchema.node("doc", null, nodes);
return EditorState.create({ doc, selection: TextSelection.create(doc, from, to) });
};
const runHeading = (state, level) => {
let nextState = state;
let dispatched = false;
const handled = applyHeadingLevelShortcut(state, (transaction) => {
dispatched = true;
nextState = state.apply(transaction);
}, level);
return { handled, dispatched, state: nextState };
};
const keyEvent = (overrides) => ({
key: "1",
code: "Digit1",
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
isComposing: false,
keyCode: 49,
repeat: false,
...overrides,
});
describe("heading level shortcuts", () => {
test("reads Mod+0 through Mod+6 from the physical key", () => {
expect(headingShortcutLevelFromEvent(keyEvent({ key: "1", code: "Digit1" }))).toBe(1);
expect(headingShortcutLevelFromEvent(keyEvent({ key: "&", code: "Digit1" }))).toBe(1);
expect(headingShortcutLevelFromEvent(keyEvent({ key: "0", code: "Digit0", ctrlKey: false, metaKey: true }))).toBe(0);
expect(headingShortcutLevelFromEvent(keyEvent({ key: "3", code: "Numpad3" }))).toBe(3);
expect(headingShortcutLevelFromEvent(keyEvent({ key: "4", code: "" }))).toBe(4);
expect(headingShortcutLevelFromEvent(keyEvent({ shiftKey: true }))).toBeNull();
expect(headingShortcutLevelFromEvent(keyEvent({ altKey: true }))).toBeNull();
expect(headingShortcutLevelFromEvent(keyEvent({ ctrlKey: false }))).toBeNull();
expect(headingShortcutLevelFromEvent(keyEvent({ isComposing: true }))).toBeNull();
expect(headingShortcutLevelFromEvent(keyEvent({ keyCode: 229 }))).toBeNull();
expect(headingShortcutLevelFromEvent(keyEvent({ repeat: true }))).toBeNull();
expect(headingShortcutLevelFromEvent(keyEvent({ key: "7", code: "Digit7" }))).toBeNull();
});
test("turns an empty paragraph into a heading before text is typed", () => {
const result = runHeading(
headingState([headingSchema.node("paragraph")], 1),
2,
);
expect(result.handled).toBe(true);
expect(result.state.doc.child(0).type.name).toBe("heading");
expect(result.state.doc.child(0).attrs.level).toBe(2);
expect(result.state.doc.child(0).content.size).toBe(0);
expect(result.state.selection.from).toBe(1);
});
test("keeps existing text and marks when changing heading level", () => {
const bold = headingSchema.marks.bold.create();
const result = runHeading(headingState([
headingSchema.node("heading", { level: 1 }, [
headingSchema.text("Hello ", [bold]),
headingSchema.text("there"),
]),
], 3), 4);
const heading = result.state.doc.child(0);
expect(result.handled).toBe(true);
expect(heading.type.name).toBe("heading");
expect(heading.attrs.level).toBe(4);
expect(heading.textContent).toBe("Hello there");
expect(heading.child(0).marks.map((mark) => mark.type.name)).toEqual(["bold"]);
expect(heading.child(1).marks).toEqual([]);
});
test("restores body text and updates every selected paragraph", () => {
const restored = runHeading(headingState([
headingSchema.node("heading", { level: 3 }, headingSchema.text("Title")),
], 2), 0);
const both = runHeading(headingState([
headingSchema.node("paragraph", null, headingSchema.text("one")),
headingSchema.node("paragraph", null, headingSchema.text("two")),
headingSchema.node("paragraph", null, headingSchema.text("three")),
], 1, 6), 2);
expect(restored.state.doc.child(0).type.name).toBe("paragraph");
expect(restored.state.doc.textContent).toBe("Title");
expect(both.state.doc.child(0).type.name).toBe("heading");
expect(both.state.doc.child(0).attrs.level).toBe(2);
expect(both.state.doc.child(1).type.name).toBe("heading");
expect(both.state.doc.child(2).type.name).toBe("paragraph");
expect(both.state.doc.textContent).toBe("onetwothree");
});
test("leaves the document alone when the level is already applied", () => {
const state = headingState([
headingSchema.node("heading", { level: 2 }, headingSchema.text("Title")),
], 2);
const result = runHeading(state, 2);
expect(result.handled).toBe(false);
expect(result.dispatched).toBe(false);
expect(result.state.doc).toBe(state.doc);
});
test("does not convert code blocks or list item paragraphs", () => {
const code = runHeading(headingState([
headingSchema.node("paragraph", null, headingSchema.text("title")),
headingSchema.node("codeBlock", null, headingSchema.text("const n = 1")),
], 1, 12), 1);
const codeOnly = runHeading(headingState([
headingSchema.node("codeBlock", null, headingSchema.text("const n = 1")),
], 3), 1);
const list = headingSchema.node("doc", null, [
headingSchema.node("bulletList", null, [
headingSchema.node("listItem", null, [
headingSchema.node("paragraph", null, headingSchema.text("item")),
]),
]),
]);
const listResult = runHeading(
EditorState.create({ doc: list, selection: TextSelection.create(list, 3) }),
1,
);
expect(code.state.doc.child(0).type.name).toBe("heading");
expect(code.state.doc.child(0).textContent).toBe("title");
expect(code.state.doc.child(1).type.name).toBe("codeBlock");
expect(code.state.doc.child(1).textContent).toBe("const n = 1");
expect(codeOnly.handled).toBe(false);
expect(codeOnly.state.doc.child(0).type.name).toBe("codeBlock");
expect(listResult.handled).toBe(false);
expect(listResult.state.doc.child(0).child(0).child(0).type.name).toBe("paragraph");
});
});
+79
View File
@@ -268,3 +268,82 @@ export const shouldOpenAiFromSpace = ({
&& keyCode !== 229
&& selectionEmpty
&& isEmptyParagraph;
export type HeadingShortcutLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6;
const HEADING_SHORTCUT_TEXT_BLOCKS = new Set(["paragraph", "heading"]);
type HeadingShortcutKeyEvent = {
key: string;
code?: string;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
isComposing?: boolean;
keyCode?: number;
repeat?: boolean;
};
/**
* Typora-style heading chords: Mod+1..6 set that heading, Mod+0 sets body text.
* Shift stays free for the outline shortcut. Alt stays free for TipTap's toggle.
* The desktop main process mirrors this match for Mod+0 in heading-shortcut.mjs.
*/
export const headingShortcutLevelFromEvent = (
event: HeadingShortcutKeyEvent,
): HeadingShortcutLevel | null => {
if (event.isComposing || event.keyCode === 229 || event.repeat) return null;
if (event.altKey || event.shiftKey) return null;
if (!event.ctrlKey && !event.metaKey) return null;
const codeLevel = /^(?:Digit|Numpad)([0-6])$/.exec(event.code ?? "");
if (codeLevel) return Number(codeLevel[1]) as HeadingShortcutLevel;
if (/^[0-6]$/.test(event.key)) return Number(event.key) as HeadingShortcutLevel;
return null;
};
/**
* Sets every selected paragraph or heading to one level. Other blocks, including
* code blocks and list-item paragraphs the schema cannot replace, stay put.
* Pressing the current level again changes nothing.
*/
export const applyHeadingLevelShortcut = (
state: EditorState,
dispatch: ((transaction: Transaction) => void) | undefined,
level: HeadingShortcutLevel,
) => {
const target = level === 0 ? state.schema.nodes.paragraph : state.schema.nodes.heading;
if (!target) return false;
const positions: number[] = [];
for (const range of state.selection.ranges) {
state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
if (!node.isTextblock) return;
if (!HEADING_SHORTCUT_TEXT_BLOCKS.has(node.type.name)) return false;
const nextAttrs = level === 0 ? null : { ...node.attrs, level };
if (node.hasMarkup(target, nextAttrs)) return false;
const $pos = state.doc.resolve(pos);
if (node.type !== target && !$pos.parent.canReplaceWith($pos.index(), $pos.index() + 1, target)) {
return false;
}
positions.push(pos);
return false;
});
}
if (positions.length === 0) return false;
if (!dispatch) return true;
const transaction = state.tr;
for (const pos of positions) {
const mapped = transaction.mapping.map(pos);
const node = transaction.doc.nodeAt(mapped);
if (!node || !HEADING_SHORTCUT_TEXT_BLOCKS.has(node.type.name)) continue;
const nextAttrs = level === 0 ? null : { ...node.attrs, level };
if (node.hasMarkup(target, nextAttrs)) continue;
transaction.setNodeMarkup(mapped, target, nextAttrs);
}
dispatch(transaction.scrollIntoView());
return true;
};
+2
View File
@@ -104,6 +104,8 @@ interface EdgeEverDesktopBridge {
readResource(id: string): Promise<{ type: string; bytes: Uint8Array }>;
removeStagedResource(id: string): Promise<void>;
onCommand(callback: (command: string) => void): () => void;
setRichTextHeadingShortcutActive?(active: boolean): void;
onHeadingShortcut?(callback: (level: number) => void): () => void;
onHibernatePrepare?(callback: () => void | Promise<void>): () => void;
syncScheduledTasks(tasks: import("@edgeever/shared").ScheduledTask[]): Promise<{ scheduled: number }>;
onScheduledTask(callback: (payload: {