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:
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"');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Vendored
+2
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user