Compare commits
7 Commits
a7568c1bb2
...
5d146aaf3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d146aaf3f | |||
| 87e482e943 | |||
| de6af8c6b5 | |||
| 1c0957b7bb | |||
| c47d16fa30 | |||
| fb2951d335 | |||
| 26d5677546 |
@ -14,6 +14,7 @@
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@tailwindcss/forms": "^0.5.6",
|
||||
"@tanstack/react-query": "^4.35.3",
|
||||
"classnames": "^2.3.2",
|
||||
"ramda": "^0.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
314
src/App.tsx
314
src/App.tsx
@ -1,212 +1,154 @@
|
||||
import {
|
||||
ChangeEventHandler, Dispatch,
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
ReactNode, SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import {FixedSizeList as List} from "react-window";
|
||||
import Select, {createFilter, MenuListProps} from "react-select";
|
||||
import * as R from 'ramda';
|
||||
import {useEffect, useState} from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import {useQuery} from "@tanstack/react-query";
|
||||
import {LinkCollection, type Option} from "./aliases";
|
||||
|
||||
// TODO: Fix this for wrapping items, esp on phones
|
||||
const height = 35;
|
||||
import {CheckIcon, ChevronUpDownIcon} from '@heroicons/react/20/solid'
|
||||
import {Combobox} from '@headlessui/react'
|
||||
|
||||
function MenuList(props: MenuListProps) {
|
||||
const {
|
||||
options,
|
||||
children,
|
||||
maxHeight,
|
||||
getValue
|
||||
} = props as Omit<MenuListProps, 'children'> & {
|
||||
children: ReactNode[]
|
||||
};
|
||||
|
||||
const [value] = getValue();
|
||||
const initialOffset = options.indexOf(value) * height;
|
||||
|
||||
return (<List
|
||||
width={'100%'}
|
||||
height={maxHeight}
|
||||
itemCount={children?.length ?? 0}
|
||||
itemSize={height}
|
||||
initialScrollOffset={initialOffset}
|
||||
>
|
||||
{({
|
||||
index,
|
||||
style
|
||||
}) => <div style={style}>{children[index]}</div>}
|
||||
</List>);
|
||||
type ResultNoteApi = {
|
||||
score: number
|
||||
vault: string
|
||||
path: string
|
||||
basename: string
|
||||
foundWords: string[]
|
||||
matches: SearchMatchApi[]
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
interface Option {
|
||||
label: string,
|
||||
value: string,
|
||||
data: {
|
||||
backlinks: unknown[]
|
||||
aliases?: string[]
|
||||
}
|
||||
type SearchMatchApi = {
|
||||
match: string
|
||||
offset: number
|
||||
}
|
||||
|
||||
export function LargeSelect() {
|
||||
export function OmnisearchSelect({setSelected}: {
|
||||
setSelected: (value: Option) => void
|
||||
}) {
|
||||
const {
|
||||
data: options,
|
||||
data: metadata,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['obsidian-metadata'],
|
||||
initialData: [],
|
||||
refetchInterval: false,
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/metadata")
|
||||
const response = await fetch("http://localhost:9002/metadata")
|
||||
const fullData: any[] = await response.json();
|
||||
|
||||
return R.sortBy(v => -(v.data.backlinks?.length ?? 0), fullData.map(md => ({
|
||||
value: md.relativePath,
|
||||
label: md.fileName,
|
||||
data: md,
|
||||
}) as Option));
|
||||
return fullData;
|
||||
},
|
||||
});
|
||||
|
||||
const [selected, setSelected] = useState<Option | null>(null);
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedPerson, setSelectedPerson] = useState<ResultNoteApi | null>(null);
|
||||
|
||||
const onChange = useCallback((value: Option) => {
|
||||
setSelected(value);
|
||||
navigator.clipboard.writeText(`[[${value.label}]]`)
|
||||
}, []);
|
||||
const {data: filteredPeople} = useQuery({
|
||||
queryKey: ['obsidian-omnisearch', query],
|
||||
initialData: [],
|
||||
queryFn: async () => {
|
||||
if (query === '') {
|
||||
return [];
|
||||
} else {
|
||||
const response = await fetch(`http://localhost:9002/search?q=${query}`)
|
||||
const fullData: ResultNoteApi[] = await response.json();
|
||||
|
||||
return fullData;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPerson != undefined && metadata.length > 0) {
|
||||
console.log(selectedPerson);
|
||||
const data = metadata.find(md => md.relativePath === selectedPerson.path);
|
||||
if (!data) {
|
||||
debugger
|
||||
}
|
||||
|
||||
setSelected({
|
||||
value: selectedPerson.path,
|
||||
label: selectedPerson.basename,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}, [selectedPerson, metadata]);
|
||||
|
||||
return (<Combobox as="div" value={selectedPerson} onChange={setSelectedPerson}>
|
||||
<Combobox.Label className="block text-sm font-medium leading-6 text-gray-900">Search for note</Combobox.Label>
|
||||
<div className="relative mt-2">
|
||||
<Combobox.Input
|
||||
aria-disabled={isLoading}
|
||||
className="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
displayValue={(person: ResultNoteApi) => person?.basename}
|
||||
/>
|
||||
|
||||
<Combobox.Button
|
||||
className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true"/>
|
||||
</Combobox.Button>
|
||||
|
||||
{filteredPeople.length > 0 && (
|
||||
<Combobox.Options
|
||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{filteredPeople.map((person) => (
|
||||
<Combobox.Option
|
||||
key={person.path}
|
||||
value={person}
|
||||
className={({active}) =>
|
||||
classNames(
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
|
||||
)
|
||||
}
|
||||
>
|
||||
{({
|
||||
active,
|
||||
selected
|
||||
}) => (
|
||||
<>
|
||||
<div className="flex">
|
||||
<span
|
||||
className={classNames('truncate', selected && 'font-semibold')}>{person?.basename}</span>
|
||||
<span
|
||||
className={classNames(
|
||||
'ml-2 truncate text-gray-500',
|
||||
active ? 'text-indigo-200' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
{person?.path}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true"/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</div>
|
||||
</Combobox>);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [selected, setSelected] = useState<Option | null>(null);
|
||||
|
||||
return (
|
||||
<div className={"m-5 text-lg"}>
|
||||
<Select
|
||||
classNames={{input: () => 'select-input-wrapper'}}
|
||||
onChange={onChange as any}
|
||||
components={{MenuList}}
|
||||
isDisabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
isClearable={true}
|
||||
options={options}
|
||||
filterOption={createFilter({ignoreAccents: false})}
|
||||
/>
|
||||
{/*<NaiveSelect setSelected={setSelected}/>*/}
|
||||
<OmnisearchSelect setSelected={setSelected}/>
|
||||
|
||||
{selected && <div className={"mt-2"}>
|
||||
<div className="py-1">
|
||||
<Alias original={selected.label}/>
|
||||
</div>
|
||||
|
||||
{selected.data.aliases?.map(alias => (
|
||||
<div className="py-1">
|
||||
<Alias original={selected.label} alias={alias}/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="py-1">
|
||||
<CustomAlias selected={selected}/>
|
||||
</div>
|
||||
</div>}
|
||||
{selected && <LinkCollection selected={selected}/>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomAlias({selected}: {
|
||||
selected: Option
|
||||
}) {
|
||||
const [alias, setAlias] = useState('');
|
||||
|
||||
// Reset when selection changes
|
||||
useEffect(() => {
|
||||
setAlias('');
|
||||
}, [selected.value]);
|
||||
|
||||
const onClick: MouseEventHandler = useCallback((e) => {
|
||||
if ((e.target as HTMLElement).tagName == 'SPAN' && alias.length > 0) {
|
||||
navigator.clipboard.writeText(`[[${selected!.label}|${alias}]]`);
|
||||
}
|
||||
}, [selected.value, selected.label, alias]);
|
||||
|
||||
return (
|
||||
<span onClick={onClick} className={"rounded-md p-1 hover:bg-slate-100"}>
|
||||
<span className={"text-slate-300 p-0.5"}>[[</span>
|
||||
<span>{selected.label}</span>
|
||||
<span className={"text-slate-300 p-0.5"}>|</span>
|
||||
<span>
|
||||
<CustomAliasField content={alias} setContent={setAlias} selected={selected}/>
|
||||
</span>
|
||||
<span className={"text-slate-300 p-0.5"}>]]</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomAliasField({
|
||||
selected,
|
||||
content,
|
||||
setContent
|
||||
}: {
|
||||
selected: Option,
|
||||
content: string,
|
||||
setContent: Dispatch<SetStateAction<string>>,
|
||||
}) {
|
||||
const [width, setWidth] = useState<any>(0);
|
||||
const span = useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Resize on change of content
|
||||
useEffect(() => {
|
||||
setWidth(span.current!.offsetWidth);
|
||||
// setWidth(content.length + 'ch');
|
||||
}, [content]);
|
||||
|
||||
const changeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(evt => {
|
||||
setContent(evt.target.value);
|
||||
}, []);
|
||||
|
||||
const onCustomElementKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback((e) => {
|
||||
if (e.key == 'Enter') {
|
||||
navigator.clipboard.writeText(`[[${selected!.label}|${content}]]`);
|
||||
(e.target as HTMLInputElement).blur()
|
||||
}
|
||||
}, [selected.value, selected.label, content]);
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
zIndex: -100,
|
||||
whiteSpace: 'pre',
|
||||
}} ref={span}>{content}</span>
|
||||
<input
|
||||
className={"border-none p-0 px-1"}
|
||||
type="text" style={{width: `calc(${width}px + 0.25rem)`}} autoFocus onChange={changeHandler}
|
||||
onKeyDown={onCustomElementKeyDown}/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Alias({
|
||||
original,
|
||||
alias
|
||||
}: {
|
||||
original: string,
|
||||
alias?: string
|
||||
}) {
|
||||
const onClick = useCallback(() => {
|
||||
if (alias) {
|
||||
navigator.clipboard.writeText(`[[${original}|${alias}]]`)
|
||||
} else {
|
||||
navigator.clipboard.writeText(`[[${original}]]`)
|
||||
}
|
||||
}, [original, alias]);
|
||||
|
||||
return (
|
||||
<span className={"rounded-md p-1 hover:bg-slate-100"} onClick={onClick}>
|
||||
<span className={"text-slate-300 p-0.5"}>[[</span>
|
||||
<span>{original}</span>
|
||||
{alias && <><span className={"text-slate-300 p-0.5"}>|</span>
|
||||
<span>{alias}</span></>}
|
||||
<span className={"text-slate-300 p-0.5"}>]]</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
74
src/NaiveSelect.tsx
Normal file
74
src/NaiveSelect.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
// TODO: Fix this for wrapping items, esp on phones
|
||||
import Select, {createFilter, MenuListProps} from "react-select";
|
||||
import {ReactNode, useCallback} from "react";
|
||||
import {FixedSizeList as List} from "react-window";
|
||||
import type {Option} from "./aliases.tsx";
|
||||
import {useQuery} from "@tanstack/react-query";
|
||||
import * as R from "ramda";
|
||||
|
||||
const height = 35;
|
||||
|
||||
function MenuList(props: MenuListProps) {
|
||||
const {
|
||||
options,
|
||||
children,
|
||||
maxHeight,
|
||||
getValue
|
||||
} = props as Omit<MenuListProps, 'children'> & {
|
||||
children: ReactNode[]
|
||||
};
|
||||
|
||||
const [value] = getValue();
|
||||
const initialOffset = options.indexOf(value) * height;
|
||||
|
||||
return (<List
|
||||
width={'100%'}
|
||||
height={maxHeight}
|
||||
itemCount={children?.length ?? 0}
|
||||
itemSize={height}
|
||||
initialScrollOffset={initialOffset}
|
||||
>
|
||||
{({
|
||||
index,
|
||||
style
|
||||
}) => <div style={style}>{children[index]}</div>}
|
||||
</List>);
|
||||
}
|
||||
|
||||
export function NaiveSelect({setSelected}: {
|
||||
setSelected: (value: Option) => void
|
||||
}) {
|
||||
const {
|
||||
data: options,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['obsidian-metadata'],
|
||||
initialData: [],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("http://localhost:9002/metadata")
|
||||
const fullData: any[] = await response.json();
|
||||
|
||||
return R.sortBy(v => -(v.data.backlinks?.length ?? 0), fullData.map(md => ({
|
||||
value: md.relativePath,
|
||||
label: md.fileName,
|
||||
data: md,
|
||||
}) as Option));
|
||||
},
|
||||
});
|
||||
|
||||
const onChange = useCallback((value: Option) => {
|
||||
setSelected(value);
|
||||
navigator.clipboard.writeText(`[[${value.label}]]`)
|
||||
}, []);
|
||||
|
||||
return (<Select
|
||||
classNames={{input: () => 'select-input-wrapper'}}
|
||||
onChange={onChange as any}
|
||||
components={{MenuList}}
|
||||
isDisabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
isClearable={true}
|
||||
options={options}
|
||||
filterOption={createFilter({ignoreAccents: false})}
|
||||
/>)
|
||||
}
|
||||
140
src/aliases.tsx
Normal file
140
src/aliases.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
Dispatch,
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
|
||||
export interface Option {
|
||||
label: string,
|
||||
value: string,
|
||||
data: {
|
||||
backlinks: unknown[]
|
||||
aliases?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function LinkCollection({ selected }: { selected: Option }) {
|
||||
return (
|
||||
<div className={"mt-2"}>
|
||||
<div className="py-1">
|
||||
<Alias original={selected.label}/>
|
||||
</div>
|
||||
|
||||
{selected.data.aliases?.map(alias => (
|
||||
<div className="py-1">
|
||||
<Alias original={selected.label} alias={alias}/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="py-1">
|
||||
<CustomAlias selected={selected}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomAlias({selected}: {
|
||||
selected: Option
|
||||
}) {
|
||||
const [alias, setAlias] = useState('');
|
||||
|
||||
// Reset when selection changes
|
||||
useEffect(() => {
|
||||
setAlias('');
|
||||
}, [selected.value]);
|
||||
|
||||
const onClick: MouseEventHandler = useCallback((e) => {
|
||||
if ((e.target as HTMLElement).tagName == 'SPAN' && alias.length > 0) {
|
||||
navigator.clipboard.writeText(`[[${selected!.label}|${alias}]]`);
|
||||
}
|
||||
}, [selected.value, selected.label, alias]);
|
||||
|
||||
return (
|
||||
<span onClick={onClick} className={"rounded-md p-1 hover:bg-slate-100"}>
|
||||
<span className={"text-slate-300 p-0.5"}>[[</span>
|
||||
<span>{selected.label}</span>
|
||||
<span className={"text-slate-300 p-0.5"}>|</span>
|
||||
<span>
|
||||
<CustomAliasField content={alias} setContent={setAlias} selected={selected}/>
|
||||
</span>
|
||||
<span className={"text-slate-300 p-0.5"}>]]</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomAliasField({
|
||||
selected,
|
||||
content,
|
||||
setContent
|
||||
}: {
|
||||
selected: Option,
|
||||
content: string,
|
||||
setContent: Dispatch<SetStateAction<string>>,
|
||||
}) {
|
||||
const [width, setWidth] = useState<any>(0);
|
||||
const span = useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Resize on change of content
|
||||
useEffect(() => {
|
||||
setWidth(span.current!.offsetWidth);
|
||||
// setWidth(content.length + 'ch');
|
||||
}, [content]);
|
||||
|
||||
const changeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(evt => {
|
||||
setContent(evt.target.value);
|
||||
}, []);
|
||||
|
||||
const onCustomElementKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback((e) => {
|
||||
if (e.key == 'Enter') {
|
||||
navigator.clipboard.writeText(`[[${selected!.label}|${content}]]`);
|
||||
(e.target as HTMLInputElement).blur()
|
||||
}
|
||||
}, [selected.value, selected.label, content]);
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
zIndex: -100,
|
||||
whiteSpace: 'pre',
|
||||
}} ref={span}>{content}</span>
|
||||
<input
|
||||
className={"border-none p-0 px-1"}
|
||||
type="text" style={{width: `calc(${width}px + 0.25rem)`}} autoFocus onChange={changeHandler}
|
||||
onKeyDown={onCustomElementKeyDown}/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Alias({
|
||||
original,
|
||||
alias
|
||||
}: {
|
||||
original: string,
|
||||
alias?: string
|
||||
}) {
|
||||
const onClick = useCallback(() => {
|
||||
if (alias) {
|
||||
navigator.clipboard.writeText(`[[${original}|${alias}]]`)
|
||||
} else {
|
||||
navigator.clipboard.writeText(`[[${original}]]`)
|
||||
}
|
||||
}, [original, alias]);
|
||||
|
||||
return (
|
||||
<span className={"rounded-md p-1 hover:bg-slate-100"} onClick={onClick}>
|
||||
<span className={"text-slate-300 p-0.5"}>[[</span>
|
||||
<span>{original}</span>
|
||||
{alias && <><span className={"text-slate-300 p-0.5"}>|</span>
|
||||
<span>{alias}</span></>}
|
||||
<span className={"text-slate-300 p-0.5"}>]]</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -2,14 +2,14 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './index.css'
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {LargeSelect} from "./App.tsx";
|
||||
import {App} from "./App.tsx";
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LargeSelect/>
|
||||
<App/>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@ -14,6 +14,9 @@ export default defineConfig(({ command, mode }) => {
|
||||
proxy: {
|
||||
'/metadata': {
|
||||
target: env['OBSIDIAN_BEACHHEAD_SERVER']
|
||||
},
|
||||
'/search': {
|
||||
target: env['OBSIDIAN_BEACHHEAD_SERVER']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user