Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 53 additions & 36 deletions src/renderer/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "../settings";
import {
eventToBinding,
findKeyBindingConflicts,
formatBinding,
keyBindingLabels,
isModifierKey,
Expand Down Expand Up @@ -104,6 +105,10 @@ const SettingsModal = ({
);
const [pendingBinding, setPendingBinding] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
const keyBindingConflicts = useMemo(
() => findKeyBindingConflicts(settings.keyBindings),
[settings.keyBindings]
);
const originalBindingRef = useRef<{
action: KeyBindingAction | null;
binding: string | null;
Expand Down Expand Up @@ -679,6 +684,7 @@ const SettingsModal = ({
).map((action, index, arr) => {
const isActive = listeningFor === action;
const isLast = index === arr.length - 1;
const conflicts = keyBindingConflicts[action] ?? [];
return (
<Fragment key={action}>
<Item
Expand All @@ -687,42 +693,53 @@ const SettingsModal = ({
>
<ItemContent>
<ItemTitle>{keyBindingLabels[action]}</ItemTitle>
<ItemDescription>
{action === "new"
? "Create a new markdown file."
: action === "open"
? "Open a markdown file."
: action === "save"
? "Save the current file."
: action === "saveAs"
? "Save the current file with a new name."
: action === "openSettings"
? "Open this settings dialog."
: action === "undo"
? "Undo the last change."
: action === "redo"
? "Redo the last undone change."
: action === "find"
? "Search for text in the current file."
: action === "bold"
? "Toggle bold markdown (**…**) for the selection or word."
: action === "italic"
? "Toggle italic markdown (*…*) for the selection or word."
: action === "link"
? "Insert a markdown link, or wrap the selection."
: action === "inlineCode"
? "Toggle inline code markdown (`…`) for the selection or word."
: action === "strikethrough"
? "Toggle strikethrough markdown (~~…~~) for the selection or word."
: action === "unorderedList"
? "Toggle a bulleted list for the selected lines."
: action === "orderedList"
? "Toggle a numbered list for the selected lines."
: action === "taskList"
? "Toggle a task checklist for the selected lines."
: action === "blockquote"
? "Toggle a blockquote for the selected lines."
: "Wrap the selected lines in a fenced code block."}
<ItemDescription className="line-clamp-none">
<span>
{action === "new"
? "Create a new markdown file."
: action === "open"
? "Open a markdown file."
: action === "save"
? "Save the current file."
: action === "saveAs"
? "Save the current file with a new name."
: action === "openSettings"
? "Open this settings dialog."
: action === "undo"
? "Undo the last change."
: action === "redo"
? "Redo the last undone change."
: action === "find"
? "Search for text in the current file."
: action === "bold"
? "Toggle bold markdown (**…**) for the selection or word."
: action === "italic"
? "Toggle italic markdown (*…*) for the selection or word."
: action === "link"
? "Insert a markdown link, or wrap the selection."
: action === "inlineCode"
? "Toggle inline code markdown (`…`) for the selection or word."
: action === "strikethrough"
? "Toggle strikethrough markdown (~~…~~) for the selection or word."
: action === "unorderedList"
? "Toggle a bulleted list for the selected lines."
: action === "orderedList"
? "Toggle a numbered list for the selected lines."
: action === "taskList"
? "Toggle a task checklist for the selected lines."
: action === "blockquote"
? "Toggle a blockquote for the selected lines."
: "Wrap the selected lines in a fenced code block."}
</span>
{conflicts.length > 0 ? (
<span className="mt-1 block text-destructive">
Conflicts with{" "}
{conflicts
.map((conflict) => keyBindingLabels[conflict])
.join(", ")}
.
</span>
) : null}
</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto flex-wrap justify-end">
Expand Down
37 changes: 35 additions & 2 deletions src/renderer/keybindings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { KeyBindingAction, KeyBindings } from "./settings";

const MOD_LABEL = navigator.platform.includes("Mac") ? "Cmd" : "Ctrl";
const getPlatform = (): string =>
typeof navigator === "undefined" ? "" : navigator.platform;

const MOD_LABEL = getPlatform().includes("Mac") ? "Cmd" : "Ctrl";

const order = ["mod", "ctrl", "alt", "shift"] as const;

Expand Down Expand Up @@ -108,7 +111,7 @@ export const formatBinding = (binding: string): string => {
* - Elsewhere, uses readable labels with "+" (e.g. "Ctrl+Shift+B")
*/
export const formatBindingShortcut = (binding: string): string => {
const isMac = navigator.platform.includes("Mac");
const isMac = getPlatform().includes("Mac");
const parts = normalizeBinding(binding).split("+").filter(Boolean);
const joiner = isMac ? "" : "+";

Expand Down Expand Up @@ -204,3 +207,33 @@ export const clampKeyBindings = (bindings: KeyBindings): KeyBindings => ({
redo: normalizeBinding(bindings.redo),
find: normalizeBinding(bindings.find),
});

export type KeyBindingConflicts = Partial<
Record<KeyBindingAction, KeyBindingAction[]>
>;

export const findKeyBindingConflicts = (
bindings: KeyBindings
): KeyBindingConflicts => {
const groups = new Map<string, KeyBindingAction[]>();

for (const action of Object.keys(bindings) as KeyBindingAction[]) {
const binding = normalizeBinding(bindings[action]);
if (!binding) {
continue;
}
groups.set(binding, [...(groups.get(binding) ?? []), action]);
}

const conflicts: KeyBindingConflicts = {};
for (const actions of groups.values()) {
if (actions.length <= 1) {
continue;
}
for (const action of actions) {
conflicts[action] = actions.filter((candidate) => candidate !== action);
}
}

return conflicts;
};
1 change: 1 addition & 0 deletions tests/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
import "./controllerShortcuts.test";
import "./keybindings.test";
import "./themeSettings.test";
40 changes: 40 additions & 0 deletions tests/keybindings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { strict as assert } from "assert";
import {
defaultKeyBindings,
KeyBindings,
} from "../src/renderer/settings";
import { findKeyBindingConflicts } from "../src/renderer/keybindings";

const runTest = (name: string, fn: () => void) => {
try {
fn();
console.log(`✓ ${name}`);
} catch (error) {
console.error(`✗ ${name}`);
console.error(error);
process.exitCode = 1;
}
};

runTest("keybinding conflicts are reported per action", () => {
const bindings: KeyBindings = {
...defaultKeyBindings,
bold: "mod+b",
italic: "cmd+b",
};

const conflicts = findKeyBindingConflicts(bindings);

assert.deepEqual(conflicts.bold, ["italic"]);
assert.deepEqual(conflicts.italic, ["bold"]);
});

runTest("unique keybindings have no conflicts", () => {
const conflicts = findKeyBindingConflicts(defaultKeyBindings);

assert.deepEqual(conflicts, {});
});

if (process.exitCode && process.exitCode !== 0) {
throw new Error("One or more tests failed.");
}
Loading