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
51 changes: 51 additions & 0 deletions apps/obsidian/src/components/NodeDisplayOptionsMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { App } from "obsidian";
import type { ReactElement } from "react";
import { SearchDropdown } from "~/components/SearchDropdown";
import { activateOnKey } from "~/utils/keyboardHints";

export const NodeDisplayOptionsMenu = ({
app,
isOpen,
onOpenChange,
onShowCandidatesChange,
showCandidates,
}: {
app: App;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onShowCandidatesChange: (showCandidates: boolean) => void;
showCandidates: boolean;
}): ReactElement => {
const toggle = (): void => onShowCandidatesChange(!showCandidates);

return (
<SearchDropdown
app={app}
ariaLabel="Display options"
iconName="sliders-horizontal"
isActive={showCandidates}
isOpen={isOpen}
onOpenChange={onOpenChange}
title="Display options"
>
<div
role="menuitemcheckbox"
aria-checked={showCandidates}
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => activateOnKey(event, toggle)}
onMouseDown={(event) => event.preventDefault()}
className="text-normal hover:bg-modifier-hover flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm"
>
<span>Show candidate nodes</span>
{/* Obsidian's own toggle chrome, so it matches the settings tab. */}
<div
aria-hidden
className={`checkbox-container mod-small pointer-events-none ${
showCandidates ? "is-enabled" : ""
}`}
/>
</div>
</SearchDropdown>
);
};
4 changes: 3 additions & 1 deletion apps/obsidian/src/components/NodeSearchFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getHintKeys, type HintKey } from "~/utils/keyboardHints";
type NodeSearchFooterProps = {
canAct: boolean;
canInsertLink: boolean;
isActiveResultLinkable: boolean;
onClose: () => void;
onInsertLink: () => void;
onOpenInNewTab: () => void;
Expand Down Expand Up @@ -53,6 +54,7 @@ const FooterAction = ({
export const NodeSearchFooter = ({
canAct,
canInsertLink,
isActiveResultLinkable,
onClose,
onInsertLink,
onOpenInNewTab,
Expand All @@ -62,7 +64,7 @@ export const NodeSearchFooter = ({
{/* Absent, not disabled: with no cursor there is nothing to insert into. */}
{canInsertLink && (
<FooterAction
disabled={!canAct}
disabled={!canAct || !isActiveResultLinkable}
keys={["Mod", "Enter"]}
label="insert link at cursor"
onClick={onInsertLink}
Expand Down
115 changes: 94 additions & 21 deletions apps/obsidian/src/components/NodeSearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "react";
import { createRoot, Root } from "react-dom/client";
import type DiscourseGraphPlugin from "~/index";
import { NodeDisplayOptionsMenu } from "~/components/NodeDisplayOptionsMenu";
import { NodeSearchFooter } from "~/components/NodeSearchFooter";
import { NodeSortMenu } from "~/components/NodeSortMenu";
import { NodeTypeChipsSearchInput } from "~/components/NodeTypeChipsSearchInput";
Expand Down Expand Up @@ -185,6 +186,31 @@ const HighlightedTitle = ({
);
};

// Filled for a node, outlined for a candidate; both keep one box size.
const NodeTypePill = ({
badge,
isCandidate,
label,
}: {
badge: NodeTypeBadge;
isCandidate: boolean;
label: string;
}): ReactElement => (
<span
aria-label={isCandidate ? `${label} candidate` : label}
style={{
borderColor: badge.backgroundColor,
backgroundColor: isCandidate ? "transparent" : badge.backgroundColor,
...(isCandidate ? {} : { color: badge.textColor }),
}}
className={`w-full rounded-full border border-solid py-px text-center text-xs font-semibold ${
isCandidate ? "text-normal" : ""
}`}
>
{badge.text}
</span>
);

const ResultList = ({
results,
activeIndex,
Expand Down Expand Up @@ -228,31 +254,36 @@ const ResultList = ({
>
{results.map((result, index) => (
<div
key={result.file.path}
key={`${result.file.path}:${result.tagLine?.line ?? ""}:${result.nodeTypeId}`}
role="option"
aria-selected={index === activeIndex}
onMouseEnter={(event) => hasPointerMoved(event) && onActivate(index)}
onClick={() => onActivate(index)}
// Keeps focus in the search input, so the keyboard path stays live
// after a click.
onMouseDown={(event) => event.preventDefault()}
className={`border-modifier-border flex cursor-pointer items-center gap-2 border-b px-3 py-2 ${
className={`border-modifier-border flex cursor-pointer items-start gap-2 border-b px-3 py-2 ${
index === activeIndex ? "bg-modifier-hover" : ""
}`}
>
{result.nodeType.badge && (
<span
aria-label={result.nodeType.name}
style={{
backgroundColor: result.nodeType.badge.backgroundColor,
color: result.nodeType.badge.textColor,
}}
className="shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold"
>
{result.nodeType.badge.text}
</span>
)}
<HighlightedTitle title={result.title} match={result.match} />
{/* Fixed-width column, so every title starts at the same x. */}
<span className="flex w-11 shrink-0">
{result.nodeType.badge && (
<NodeTypePill
badge={result.nodeType.badge}
isCandidate={!!result.tagLine}
label={result.nodeType.name}
/>
)}
</span>
<div className="min-w-0 flex-1">
<HighlightedTitle title={result.title} match={result.match} />
{result.tagLine && (
<div className="text-muted truncate text-xs">
{`#${result.tagLine.tag} · ${result.file.basename} · L${result.tagLine.line + 1}`}
</div>
)}
</div>
</div>
))}
</div>
Expand Down Expand Up @@ -280,6 +311,10 @@ const NodeSearch = ({
// One value per toolbar, so two panels can never be open at once.
const [openDropdown, setOpenDropdown] = useState<SearchDropdownId>(null);
const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT_KEY);
const [showCandidates, setShowCandidates] = useState(false);
const [tagCandidates, setTagCandidates] = useState<DiscourseNodeCandidate[]>(
[],
);
const [sortDirection, setSortDirection] = useState<SortDirection>(
DEFAULT_SORT_DIRECTION,
);
Expand Down Expand Up @@ -322,6 +357,30 @@ const NodeSearch = ({
}
}, [app]);

// Rescans on every toggle-on, so node type edits made meanwhile are picked up.
useEffect(() => {
if (!showCandidates) {
setTagCandidates([]);
return;
}
let cancelled = false;
const load = async (): Promise<void> => {
try {
const candidates = await new QueryEngine(app).getCandidateNodes(
plugin.settings.nodeTypes,
);
if (!cancelled) setTagCandidates(candidates);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
new Notice(`Could not load candidate nodes: ${message}`);
}
};
void load();
return () => {
cancelled = true;
};
}, [app, plugin.settings.nodeTypes, showCandidates]);

useEffect(() => {
const timeout = window.setTimeout(
() => setDebouncedQuery(query),
Expand All @@ -334,7 +393,7 @@ const NodeSearch = ({
const results = useMemo<SearchResultRow[]>(() => {
if (candidateState.status !== "ready") return [];
const ranked = rankDiscourseNodesByTitle({
candidates: candidateState.candidates,
candidates: [...candidateState.candidates, ...tagCandidates],
query: debouncedQuery,
nodeTypeIds: selectedNodeTypeIds,
});
Expand Down Expand Up @@ -368,6 +427,7 @@ const NodeSearch = ({
selectedNodeTypeIds,
sortDirection,
sortKey,
tagCandidates,
userNames,
]);

Expand Down Expand Up @@ -403,12 +463,12 @@ 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>,
open: (app: App, file: TFile, options: { line?: number }) => Promise<void>,
): void => {
if (!activeResult) return;
const { file } = activeResult;
const { file, tagLine } = activeResult;
onClose();
void open(app, file).catch((error: unknown) => {
void open(app, file, { line: tagLine?.line }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
new Notice(`Could not open ${file.basename}: ${message}`);
});
Expand All @@ -426,9 +486,12 @@ const NodeSearch = ({
if (!isOpen) inputRef.current?.focus();
};

// A candidate is a line, not a node yet, so there is nothing to link to.
const isActiveResultLinkable = !!activeResult && !activeResult.tagLine;

// Closes before inserting, like `openActiveResult`.
const insertLinkToActiveResult = (): void => {
if (!activeResult || !insertTarget) return;
if (!activeResult || !insertTarget || !isActiveResultLinkable) return;
const { file } = activeResult;
onClose();
try {
Expand Down Expand Up @@ -456,7 +519,7 @@ const NodeSearch = ({
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
insertTarget &&
activeResult
isActiveResultLinkable
) {
event.preventDefault();
insertLinkToActiveResult();
Expand Down Expand Up @@ -515,6 +578,15 @@ const NodeSearch = ({
sortDirection={sortDirection}
sortKey={sortKey}
/>
<NodeDisplayOptionsMenu
app={app}
isOpen={openDropdown === "display-options"}
onOpenChange={(isOpen) =>
handleDropdownOpenChange({ id: "display-options", isOpen })
}
onShowCandidatesChange={setShowCandidates}
showCandidates={showCandidates}
/>
</div>
<div className="border-modifier-border mt-3 flex flex-1 overflow-hidden rounded border">
<div className="border-modifier-border flex w-2/5 flex-col border-r">
Expand Down Expand Up @@ -542,6 +614,7 @@ const NodeSearch = ({
<NodeSearchFooter
canAct={candidateState.status === "ready" && !!activeResult}
canInsertLink={!!insertTarget}
isActiveResultLinkable={isActiveResultLinkable}
onClose={onClose}
onInsertLink={insertLinkToActiveResult}
onOpenInNewTab={() => openActiveResult(openFileInNewTab)}
Expand Down
13 changes: 2 additions & 11 deletions apps/obsidian/src/components/NodeSortMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { App, setIcon } from "obsidian";
import type { KeyboardEvent, ReactElement } from "react";
import type { ReactElement } from "react";
import { SearchDropdown } from "~/components/SearchDropdown";
import { activateOnKey } from "~/utils/keyboardHints";
import {
SORT_OPTIONS,
getDefaultDirectionForKey,
Expand All @@ -16,16 +17,6 @@ const DIRECTIONS: { direction: SortDirection; label: string }[] = [
{ direction: "desc", label: "Desc" },
];

// Rows are divs, so Enter and Space have to be wired up the way a button gets them free.
const activateOnKey = (
event: KeyboardEvent<HTMLDivElement>,
activate: () => void,
): void => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
activate();
};

const getDirectionIconName = (direction: SortDirection): string =>
direction === "asc" ? "arrow-up-narrow-wide" : "arrow-down-wide-narrow";

Expand Down
6 changes: 5 additions & 1 deletion apps/obsidian/src/components/SearchDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { App, Scope, setIcon } from "obsidian";
import { useEffect, useRef, type ReactElement, type ReactNode } from "react";

/** Which toolbar panel is open, so two can never be open at once. */
export type SearchDropdownId = "type-filter" | "sort" | null;
export type SearchDropdownId =
| "type-filter"
| "sort"
| "display-options"
| null;

export const SearchDropdown = ({
app,
Expand Down
12 changes: 10 additions & 2 deletions apps/obsidian/src/components/canvas/utils/openFileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,25 @@ export const openFileInSidebar = async (
export const openFileInNewTab = async (
app: App,
file: TFile,
{ line }: { line?: number } = {},
): Promise<void> => {
const leaf = app.workspace.getLeaf("tab");
await leaf.openFile(file);
await leaf.openFile(
file,
line === undefined ? undefined : { eState: { line } },
);
app.workspace.setActiveLeaf(leaf);
};

export const openFileInNewLeaf = async (
app: App,
file: TFile,
{ line }: { line?: number } = {},
): Promise<void> => {
const leaf = app.workspace.getLeaf("split");
await leaf.openFile(file);
await leaf.openFile(
file,
line === undefined ? undefined : { eState: { line } },
);
app.workspace.setActiveLeaf(leaf);
};
Loading
Loading