diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx new file mode 100644 index 000000000..74edf8d63 --- /dev/null +++ b/apps/obsidian/src/components/NodeSearchFooter.tsx @@ -0,0 +1,76 @@ +import { type ReactElement } from "react"; +import { getHintKeys, type HintKey } from "~/utils/keyboardHints"; + +type NodeSearchFooterProps = { + canAct: boolean; + onClose: () => void; + onOpenInNewTab: () => void; + onOpenInSplit: () => void; +}; + +type FooterActionProps = { + disabled?: boolean; + keys: HintKey[]; + label: string; + onClick: () => void; +}; + +// Roam's footer renders each key as a bordered cap rather than bold text, which +// keeps `esc` reading as one of the set instead of standing out. Deliberately +// not `prompt-instruction-command`, whose weight is what makes it stand out. +const KeyHints = ({ keys }: { keys: HintKey[] }): ReactElement => ( + <> + {getHintKeys(keys).map((symbol) => ( + + {symbol} + + ))} + +); + +const FooterAction = ({ + disabled = false, + keys, + label, + onClick, +}: FooterActionProps): ReactElement => ( + +); + +// Sits in Obsidian's `prompt-instructions` container for its type and spacing, +// but styles the keys as Roam's search footer does. +export const NodeSearchFooter = ({ + canAct, + onClose, + onOpenInNewTab, + onOpenInSplit, +}: NodeSearchFooterProps): ReactElement => ( +
+ + + {/* The Escape key itself is handled by Obsidian's modal scope; this button + is the pointer equivalent, so every footer item responds to a click. */} + +
+); diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 4735ea142..ea1d61168 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -19,6 +19,11 @@ import { } from "react"; import { createRoot, Root } from "react-dom/client"; import type DiscourseGraphPlugin from "~/index"; +import { NodeSearchFooter } from "~/components/NodeSearchFooter"; +import { + openFileInNewLeaf, + openFileInNewTab, +} from "~/components/canvas/utils/openFileUtils"; import { QueryEngine, rankDiscourseNodesByTitle, @@ -256,10 +261,11 @@ const ResultList = ({ }, [activeIndex]); return ( + // No `aria-label` here: Obsidian renders one as a hover tooltip, which + // covers the results the moment the pointer enters the list.
(pointerMovedRef.current = true)} className="flex-1 overflow-y-auto" > @@ -279,7 +285,6 @@ const ResultList = ({ > {result.nodeType.badge && ( void; }): ReactElement => { const { app } = plugin; const [candidateState, setCandidateState] = useState({ @@ -395,11 +402,36 @@ const NodeSearch = ({ }); }; + // Closes before opening: `close()` unmounts this React root, so the file and + // app are read first and nothing touches state afterwards. + const openActiveResult = ( + open: (app: App, file: TFile) => Promise, + ): void => { + if (!activeResult) return; + const { file } = activeResult; + onClose(); + void open(app, file).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + new Notice(`Could not open ${file.basename}: ${message}`); + }); + }; + const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; - // Otherwise the caret jumps to the start or end of the query. + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + // Otherwise the caret jumps to the start or end of the query. + event.preventDefault(); + moveActiveIndex(event.key === "ArrowDown" ? 1 : -1); + return; + } + + if (event.key !== "Enter") return; + // Enter also commits an IME candidate, which must not open a file. + if (event.nativeEvent.isComposing) return; + // Mod+Enter and Alt+Enter are left alone for the insert and dock actions. + if (event.metaKey || event.ctrlKey || event.altKey) return; + event.preventDefault(); - moveActiveIndex(event.key === "ArrowDown" ? 1 : -1); + openActiveResult(event.shiftKey ? openFileInNewLeaf : openFileInNewTab); }; return ( @@ -437,6 +469,12 @@ const NodeSearch = ({
+ openActiveResult(openFileInNewTab)} + onOpenInSplit={() => openActiveResult(openFileInNewLeaf)} + /> ); }; @@ -457,7 +495,7 @@ export class NodeSearchModal extends Modal { this.root = createRoot(contentEl); this.root.render( - + this.close()} /> , ); } diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index 63949fec5..af23fd927 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -3921,3 +3921,64 @@ kbd.tlui-kbd { border-radius: var(--radius-s); padding: 0 1px; } + +/* Obsidian's own `.prompt-instructions` rules supply the type and colour. They + centre the row for the quick switcher, which is too narrow to need alignment; + this footer sits under a full-width result list, so the actions line up with + its left edge instead. `flex-shrink` keeps the footer from collapsing inside + the fixed-height flex column above it. */ +.dg-node-search-modal .dg-search-footer { + flex-shrink: 0; + justify-content: flex-start; + text-align: left; + padding-inline: 0; + padding-bottom: 0; +} + +/* Obsidian styles every `button` with its own chrome, so an action that should + read as a shortcut hint has to opt out of it. */ +.dg-node-search-modal .dg-search-footer-action { + display: inline-flex; + align-items: center; + gap: var(--size-2-1); + background-color: transparent; + border: none; + border-radius: 0; + box-shadow: none; + padding: 0; + height: auto; + font-size: inherit; + color: inherit; + cursor: pointer; +} + +.dg-node-search-modal .dg-search-footer-label { + margin-inline-start: var(--size-2-1); +} + +/* Matches Roam's search footer: each key is a bordered cap, so `esc` reads as + one of the set rather than as emphasised text. */ +.dg-node-search-modal .dg-search-footer-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5em; + padding: 0 var(--size-2-1); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background-color: var(--background-primary); + color: var(--text-muted); + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: 1.6; +} + +.dg-node-search-modal .dg-search-footer-action:hover:not(:disabled) { + color: var(--text-normal); +} + +.dg-node-search-modal .dg-search-footer-action:disabled { + cursor: not-allowed; + opacity: 0.5; +} diff --git a/apps/obsidian/src/utils/keyboardHints.ts b/apps/obsidian/src/utils/keyboardHints.ts new file mode 100644 index 000000000..b145a1c67 --- /dev/null +++ b/apps/obsidian/src/utils/keyboardHints.ts @@ -0,0 +1,36 @@ +import { Platform } from "obsidian"; + +export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape"; + +// Obsidian shows glyphs on macOS and spelled-out words everywhere else, so the +// same shortcut has to render two ways. Roam's search footer hardcoded the macOS +// glyphs per action (ENG-2000) and showed the wrong hint on Windows; routing +// every hint through these maps is what keeps that from repeating here. +const MAC_SYMBOLS: Record = { + Mod: "⌘", + Alt: "⌥", + Shift: "⇧", + Enter: "↵", + Escape: "esc", +}; + +const NON_MAC_SYMBOLS: Record = { + Mod: "Ctrl", + Alt: "Alt", + Shift: "Shift", + Enter: "Enter", + Escape: "Esc", +}; + +/** Takes `isMacOS` so the non-mac branch can be checked without that platform. */ +export const formatHintKeys = ({ + keys, + isMacOS, +}: { + keys: HintKey[]; + isMacOS: boolean; +}): string[] => + keys.map((key) => (isMacOS ? MAC_SYMBOLS : NON_MAC_SYMBOLS)[key]); + +export const getHintKeys = (keys: HintKey[]): string[] => + formatHintKeys({ keys, isMacOS: Platform.isMacOS });