-
Notifications
You must be signed in to change notification settings - Fork 7
ENG-2113 Add footer action bar with open in new tab and split #1292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: eng-2109-create-node-search-modal-with-ranked-results-and-preview
Are you sure you want to change the base?
Changes from all commits
c719fae
d9f017e
f79c3c1
e54e2f1
74a642b
005b5a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| // 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| <div | ||
| ref={listRef} | ||
| role="listbox" | ||
| aria-label="Discourse node search results" | ||
| onMouseMove={() => (pointerMovedRef.current = true)} | ||
| className="flex-1 overflow-y-auto" | ||
| > | ||
|
|
@@ -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, | ||
|
|
@@ -299,8 +304,10 @@ const ResultList = ({ | |
|
|
||
| const NodeSearch = ({ | ||
| plugin, | ||
| onClose, | ||
| }: { | ||
| plugin: DiscourseGraphPlugin; | ||
| onClose: () => void; | ||
| }): ReactElement => { | ||
| const { app } = plugin; | ||
| const [candidateState, setCandidateState] = useState<CandidateState>({ | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a keyboard user Tabs into a footer button, its bubbling Enter keydown is still intercepted here, Useful? React with 👍 / 👎.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }; | ||
|
|
||
| return ( | ||
|
|
@@ -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> | ||
| ); | ||
| }; | ||
|
|
@@ -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>, | ||
| ); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }); | ||
There was a problem hiding this comment.
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?