Skip to content
Merged
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
4 changes: 3 additions & 1 deletion apps/roam/src/components/canvas/Tldraw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ const TldrawCanvas = ({ title }: { title: string }) => {
if (cancelled) return;

if (!ready) {
console.warn("Plugin timer timeout — proceeding with canvas mount anyway.");
console.warn(
"Plugin timer timeout — proceeding with canvas mount anyway.",
);
// Optional: dispatchToastEvent({ id: 'tldraw-plugin-timer-timeout', title: 'Timed out waiting for plugin init', severity: 'warning' })
}

Expand Down
15 changes: 14 additions & 1 deletion apps/roam/src/components/canvas/uiOverrides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
useValue,
useToasts,
} from "tldraw";
import { IKeyCombo } from "@blueprintjs/core";
import { DiscourseNode } from "~/utils/getDiscourseNodes";
import { getNewDiscourseNodeText } from "~/utils/formatUtils";
import createDiscourseNode from "~/utils/createDiscourseNode";
Expand All @@ -45,6 +46,9 @@ import { AddReferencedNodeType } from "./DiscourseRelationShape/DiscourseRelatio
import { dispatchToastEvent } from "./ToastListener";
import { getRelationColor } from "./DiscourseRelationShape/DiscourseRelationUtil";
import DiscourseGraphPanel from "./DiscourseToolPanel";
import { convertComboToTldrawFormat } from "~/utils/keyboardShortcutUtils";
import { DISCOURSE_TOOL_SHORTCUT_KEY } from "~/data/userSettings";
import { getSetting } from "~/utils/extensionSettings";

const convertToDiscourseNode = async ({
text,
Expand Down Expand Up @@ -326,11 +330,20 @@ export const createUiOverrides = ({
setConvertToDialogOpen: (open: boolean) => void;
}): TLUiOverrides => ({
tools: (editor, tools) => {
// Get the custom keyboard shortcut for the discourse tool
const discourseToolCombo = getSetting(DISCOURSE_TOOL_SHORTCUT_KEY, {
key: "",
modifiers: 0,
}) as IKeyCombo;

// For discourse tool, just use the key directly since we don't allow modifiers
const discourseToolShortcut = discourseToolCombo?.key?.toUpperCase() || "";

tools["discourse-tool"] = {
id: "discourse-tool",
icon: "none",
label: "tool.discourse-tool" as TLUiTranslationKey,
kbd: "",
kbd: discourseToolShortcut,
readonlyOk: true,
onSelect: () => {
editor.setCurrentTool("discourse-tool");
Expand Down
13 changes: 12 additions & 1 deletion apps/roam/src/components/settings/HomePersonalSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
hideDiscourseFloatingMenu,
} from "~/components/DiscourseFloatingMenu";
import { NodeSearchMenuTriggerSetting } from "../DiscourseNodeSearchMenu";
import { AUTO_CANVAS_RELATIONS_KEY } from "~/data/userSettings";
import {
AUTO_CANVAS_RELATIONS_KEY,
DISCOURSE_TOOL_SHORTCUT_KEY,
} from "~/data/userSettings";
import KeyboardShortcutInput from "./KeyboardShortcutInput";

const HomePersonalSettings = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
const extensionAPI = onloadArgs.extensionAPI;
Expand All @@ -39,6 +43,13 @@ const HomePersonalSettings = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
/>
<NodeSearchMenuTriggerSetting onloadArgs={onloadArgs} />
</Label>
<KeyboardShortcutInput
onloadArgs={onloadArgs}
settingKey={DISCOURSE_TOOL_SHORTCUT_KEY}
label="Discourse Tool Keyboard Shortcut"
description="Set a single key to activate the Discourse Tool in tldraw. Only single keys (no modifiers) are supported. Leave empty for no shortcut."
placeholder="Click to set single key..."
/>
<Checkbox
defaultChecked={
extensionAPI.settings.get("discourse-context-overlay") as boolean
Expand Down
162 changes: 162 additions & 0 deletions apps/roam/src/components/settings/KeyboardShortcutInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React, { useState, useCallback, useMemo, useRef } from "react";
import { OnloadArgs } from "roamjs-components/types";
import {
InputGroup,
Button,
getKeyCombo,
IKeyCombo,
Label,
} from "@blueprintjs/core";
import Description from "roamjs-components/components/Description";
import { DISCOURSE_TOOL_SHORTCUT_KEY } from "~/data/userSettings";

type KeyboardShortcutInputProps = {
onloadArgs: OnloadArgs;
settingKey: string;
label: string;
description: string;
placeholder?: string;
};

// Reuse the keyboard combo utilities from NodeMenuTriggerComponent
const isMac = () => {
const platform =
typeof navigator !== "undefined" ? navigator.platform : undefined;
return platform == null ? false : /Mac|iPod|iPhone|iPad/.test(platform);
};

const MODIFIER_BIT_MASKS = {
alt: 1,
ctrl: 2,
meta: 4,
shift: 8,
};

const ALIASES: { [key: string]: string } = {
cmd: "meta",
command: "meta",
escape: "esc",
minus: "-",
mod: isMac() ? "meta" : "ctrl",
option: "alt",
plus: "+",
return: "enter",
win: "meta",
};

const normalizeKeyCombo = (combo: string) => {
const keys = combo.replace(/\s/g, "").split("+");
return keys.map((key) => {
const keyName = ALIASES[key] != null ? ALIASES[key] : key;
return keyName === "meta" ? (isMac() ? "cmd" : "win") : keyName;
});
};

const getModifiersFromCombo = (comboKey: IKeyCombo) => {
if (!comboKey) return [];
return [
comboKey.modifiers & MODIFIER_BIT_MASKS.alt && "alt",
comboKey.modifiers & MODIFIER_BIT_MASKS.ctrl && "ctrl",
comboKey.modifiers & MODIFIER_BIT_MASKS.shift && "shift",
comboKey.modifiers & MODIFIER_BIT_MASKS.meta && "meta",
].filter(Boolean);
};

const KeyboardShortcutInput = ({
onloadArgs,
settingKey,
label,
description,
placeholder = "Click to set shortcut...",
}: KeyboardShortcutInputProps) => {
const extensionAPI = onloadArgs.extensionAPI;
const inputRef = useRef<HTMLInputElement>(null);
const [isActive, setIsActive] = useState(false);
const [comboKey, setComboKey] = useState<IKeyCombo>(
() =>
(extensionAPI.settings.get(settingKey) as IKeyCombo) || {
modifiers: 0,
key: "",
},
);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Allow focus navigation & cancel without intercepting
if (e.key === "Tab") return;
if (e.key === "Escape") {
inputRef.current?.blur();
return;
}
e.stopPropagation();
e.preventDefault();
// For discourse tool, only allow single keys without modifiers
if (settingKey === DISCOURSE_TOOL_SHORTCUT_KEY) {
// Ignore modifier keys
if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
return;
}

// Only allow single character keys
if (e.key.length === 1) {
const comboObj = { key: e.key.toLowerCase(), modifiers: 0 };
setComboKey(comboObj);
extensionAPI.settings
.set(settingKey, comboObj)
.catch(() => console.error("Failed to set setting"));
}
return;
}

// For other shortcuts, use the full Blueprint logic
const comboObj = getKeyCombo(e.nativeEvent);
if (!comboObj.key) return;

setComboKey({ key: comboObj.key, modifiers: comboObj.modifiers });
extensionAPI.settings
.set(settingKey, comboObj)
.catch(() => console.error("Failed to set setting"));
},
[extensionAPI, settingKey],
);

const shortcut = useMemo(() => {
if (!comboKey.key) return "";

const modifiers = getModifiersFromCombo(comboKey);
const comboString = [...modifiers, comboKey.key].join("+");
return normalizeKeyCombo(comboString).join("+");
}, [comboKey]);

const handleClear = useCallback(() => {
setComboKey({ modifiers: 0, key: "" });
extensionAPI.settings
.set(settingKey, { modifiers: 0, key: "" })
.catch(() => console.error("Failed to set setting"));
}, [extensionAPI, settingKey]);

return (
<Label>
{label}
<Description description={description} />
<InputGroup
inputRef={inputRef}
placeholder={isActive ? "Press keys ..." : placeholder}
value={shortcut}
onKeyDown={handleKeyDown}
onFocus={() => setIsActive(true)}
onBlur={() => setIsActive(false)}
rightElement={
<Button
hidden={!comboKey.key}
icon="remove"
onClick={handleClear}
minimal
/>
}
/>
</Label>
);
};

export default KeyboardShortcutInput;
1 change: 1 addition & 0 deletions apps/roam/src/data/userSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export const HIDE_METADATA_KEY = "hide-metadata";
export const DEFAULT_FILTERS_KEY = "default-filters";
export const QUERY_BUILDER_SETTINGS_KEY = "query-builder-settings";
export const AUTO_CANVAS_RELATIONS_KEY = "auto-canvas-relations";
export const DISCOURSE_TOOL_SHORTCUT_KEY = "discourse-tool-shortcut";
56 changes: 56 additions & 0 deletions apps/roam/src/utils/keyboardShortcutUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { IKeyCombo } from "@blueprintjs/core";

/**
* Convert Blueprint IKeyCombo to tldraw keyboard shortcut format
*
* tldraw format examples:
* - "?C" = Ctrl+C
* - "$!X" = Shift+Ctrl+X
* - "!3" = F3
* - "^A" = Alt+A
* - "@S" = Cmd+S (Mac) / Win+S (Windows)
*/
export const convertComboToTldrawFormat = (
combo: IKeyCombo | undefined,
): string => {
if (!combo || !combo.key) return "";

const modifiers = [];
if (combo.modifiers & 2) modifiers.push("?"); // Ctrl
if (combo.modifiers & 8) modifiers.push("$"); // Shift
if (combo.modifiers & 1) modifiers.push("^"); // Alt
if (combo.modifiers & 4) modifiers.push("@"); // Meta/Cmd

return modifiers.join("") + combo.key.toUpperCase();
};

/**
* Convert tldraw keyboard shortcut format to Blueprint IKeyCombo
* This is useful for testing and validation
*/
export const convertTldrawFormatToCombo = (shortcut: string): IKeyCombo => {
if (!shortcut) return { modifiers: 0, key: "" };

let modifiers = 0;
let key = shortcut;

// Extract modifiers
if (shortcut.includes("?")) {
modifiers |= 2; // Ctrl
key = key.replace("?", "");
}
if (shortcut.includes("$")) {
modifiers |= 8; // Shift
key = key.replace("$", "");
}
if (shortcut.includes("^")) {
modifiers |= 1; // Alt
key = key.replace("^", "");
}
if (shortcut.includes("@")) {
modifiers |= 4; // Meta/Cmd
key = key.replace("@", "");
}

return { modifiers, key: key.toLowerCase() };
};