From 0f21f09444e69ae89f191f49e2902487f92cc136 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 23 Oct 2026 23:36:20 +0800 Subject: [PATCH] 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. --- apps/desktop/src/main/heading-shortcut.mjs | 58 +++++++ .../src/main/heading-shortcut.test.mjs | 98 +++++++++++ apps/desktop/src/main/index.mjs | 9 + apps/desktop/src/preload/index.cjs | 8 + apps/web/src/components/EditorPane.tsx | 61 +++++++ apps/web/src/lib/editor-shortcuts.test.mjs | 157 ++++++++++++++++++ apps/web/src/lib/editor-shortcuts.ts | 79 +++++++++ apps/web/src/vite-env.d.ts | 2 + 8 files changed, 472 insertions(+) create mode 100644 apps/desktop/src/main/heading-shortcut.mjs create mode 100644 apps/desktop/src/main/heading-shortcut.test.mjs diff --git a/apps/desktop/src/main/heading-shortcut.mjs b/apps/desktop/src/main/heading-shortcut.mjs new file mode 100644 index 00000000..7ab2237f --- /dev/null +++ b/apps/desktop/src/main/heading-shortcut.mjs @@ -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; +}; diff --git a/apps/desktop/src/main/heading-shortcut.test.mjs b/apps/desktop/src/main/heading-shortcut.test.mjs new file mode 100644 index 00000000..1ddb4d93 --- /dev/null +++ b/apps/desktop/src/main/heading-shortcut.test.mjs @@ -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"'); + }); +}); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 9e49c95d..7c908117 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -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; diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index b70020ba..1a85e74d 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -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 { diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 1ddebac4..571db7f5 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -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(memo); const editSessionRef = useRef(null); const editorRef = useRef(null); + const headingShortcutSurfaceRef = useRef({ markdown: false, plainText: false }); const editorCanvasInteractionVersionRef = useRef(0); const persistResourceInsertionDraftRef = useRef<() => Promise>(() => 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; diff --git a/apps/web/src/lib/editor-shortcuts.test.mjs b/apps/web/src/lib/editor-shortcuts.test.mjs index 13aec368..cd39b71f 100644 --- a/apps/web/src/lib/editor-shortcuts.test.mjs +++ b/apps/web/src/lib/editor-shortcuts.test.mjs @@ -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"); + }); +}); diff --git a/apps/web/src/lib/editor-shortcuts.ts b/apps/web/src/lib/editor-shortcuts.ts index cd9671c9..25813e46 100644 --- a/apps/web/src/lib/editor-shortcuts.ts +++ b/apps/web/src/lib/editor-shortcuts.ts @@ -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; +}; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 52745b89..5c14eee0 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -104,6 +104,8 @@ interface EdgeEverDesktopBridge { readResource(id: string): Promise<{ type: string; bytes: Uint8Array }>; removeStagedResource(id: string): Promise; onCommand(callback: (command: string) => void): () => void; + setRichTextHeadingShortcutActive?(active: boolean): void; + onHeadingShortcut?(callback: (level: number) => void): () => void; onHibernatePrepare?(callback: () => void | Promise): () => void; syncScheduledTasks(tasks: import("@edgeever/shared").ScheduledTask[]): Promise<{ scheduled: number }>; onScheduledTask(callback: (payload: {