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
76 changes: 76 additions & 0 deletions apps/obsidian/src/components/NodeSearchFooter.tsx
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what this comment is supposed to be informing me. Was there a different choice made in Obsidian as opposed to Roam that this comment is trying to infer about?

// 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) => (
<kbd className="dg-search-footer-key" key={symbol}>
{symbol}
</kbd>
))}
</>
);

const FooterAction = ({
disabled = false,
keys,
label,
onClick,
}: FooterActionProps): ReactElement => (
<button
type="button"
className="prompt-instruction dg-search-footer-action"
disabled={disabled}
onClick={onClick}
// Same reason as the result rows: keep focus in the query input so arrow-key

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the same reason?

// navigation still works after a click.
onMouseDown={(event) => event.preventDefault()}
>
<KeyHints keys={keys} />
<span className="dg-search-footer-label">{label}</span>
</button>
);

// 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 => (
<div className="prompt-instructions dg-search-footer">
<FooterAction
disabled={!canAct}
keys={["Enter"]}
label="open in new tab"
onClick={onOpenInNewTab}
/>
<FooterAction
disabled={!canAct}
keys={["Shift", "Enter"]}
label="open in split"
onClick={onOpenInSplit}
/>
{/* 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. */}
<FooterAction keys={["Escape"]} label="close" onClick={onClose} />
</div>
);
50 changes: 44 additions & 6 deletions apps/obsidian/src/components/NodeSearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
<div
ref={listRef}
role="listbox"
aria-label="Discourse node search results"
onMouseMove={() => (pointerMovedRef.current = true)}
className="flex-1 overflow-y-auto"
>
Expand All @@ -279,7 +285,6 @@ const ResultList = ({
>
{result.nodeType.badge && (
<span
title={result.nodeType.name}
aria-label={result.nodeType.name}
style={{
backgroundColor: result.nodeType.badge.backgroundColor,
Expand All @@ -299,8 +304,10 @@ const ResultList = ({

const NodeSearch = ({
plugin,
onClose,
}: {
plugin: DiscourseGraphPlugin;
onClose: () => void;
}): ReactElement => {
const { app } = plugin;
const [candidateState, setCandidateState] = useState<CandidateState>({
Expand Down Expand Up @@ -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>,
): 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<HTMLDivElement>) => {
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);
Comment on lines 433 to +434

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let focused footer buttons handle Enter

When a keyboard user Tabs into a footer button, its bubbling Enter keydown is still intercepted here, preventDefault() suppresses the button's native click, and the selection shortcut runs instead. Consequently, Enter on the close button opens the active result in a new tab, while Enter on the split button also opens a new tab; ignore Enter events originating from footer buttons or scope this shortcut to the search input.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

};

return (
Expand Down Expand Up @@ -437,6 +469,12 @@ const NodeSearch = ({
</div>
<PreviewPane app={app} result={activeResult} authorName={authorName} />
</div>
<NodeSearchFooter
canAct={candidateState.status === "ready" && !!activeResult}
onClose={onClose}
onOpenInNewTab={() => openActiveResult(openFileInNewTab)}
onOpenInSplit={() => openActiveResult(openFileInNewLeaf)}
/>
</div>
);
};
Expand All @@ -457,7 +495,7 @@ export class NodeSearchModal extends Modal {
this.root = createRoot(contentEl);
this.root.render(
<StrictMode>
<NodeSearch plugin={this.plugin} />
<NodeSearch plugin={this.plugin} onClose={() => this.close()} />
</StrictMode>,
);
}
Expand Down
61 changes: 61 additions & 0 deletions apps/obsidian/src/styles/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer tailwind where we can

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;
}
36 changes: 36 additions & 0 deletions apps/obsidian/src/utils/keyboardHints.ts
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some unnecessary comments here

// 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<HintKey, string> = {
Mod: "⌘",
Alt: "⌥",
Shift: "⇧",
Enter: "↵",
Escape: "esc",
};

const NON_MAC_SYMBOLS: Record<HintKey, string> = {
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 });