diff --git a/tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx b/tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx index fbf26394c..abe314b59 100644 --- a/tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx +++ b/tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx @@ -52,11 +52,15 @@ const LogseqPortalShapeHeader = observer( } ) +function escapeRegExp(text: string) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') +} + const highlightedJSX = (input: string, keyword: string) => { return ( {input - .split(new RegExp(`(${keyword})`, 'gi')) + .split(new RegExp(`(${escapeRegExp(keyword)})`, 'gi')) .map((part, index) => { if (index % 2 === 1) { return {part} @@ -299,8 +303,11 @@ export class LogseqPortalShape extends TLBoxShape { } }, []) + const optionsWrapperRef = React.useRef(null) + + const [focusedOptionIdx, setFocusedOptionIdx] = React.useState(0) + const searchResult = useSearch(q) - const Breadcrumb = renderers?.Breadcrumb const [prefixIcon, setPrefixIcon] = React.useState('circle-plus') const [searchFilter, setSearchFilter] = React.useState<'B' | 'P' | null>(null) @@ -312,10 +319,183 @@ export class LogseqPortalShape extends TLBoxShape { }) }, [searchFilter]) - if (!Breadcrumb) { - return null + type Option = { + actionIcon: 'search' | 'circle-plus' + onChosen: () => void + element: React.ReactNode } + const options: Option[] = React.useMemo(() => { + const options: Option[] = [] + + const Breadcrumb = renderers?.Breadcrumb + + if (!Breadcrumb) { + return [] + } + + // New block option + options.push({ + actionIcon: 'circle-plus', + onChosen: () => { + onAddBlock(q) + }, + element: ( +
+ + {q.length > 0 ? ( + <> + New whiteboard block: + {q} + + ) : ( + New whiteboard block + )} +
+ ), + }) + + // New page option + if (searchResult?.pages?.length === 0 && q) { + options.push({ + actionIcon: 'circle-plus', + onChosen: () => { + finishCreating(q) + }, + element: ( +
+ + New page: + {q} +
+ ), + }) + } + + // search filters + if (q.length === 0 && searchFilter === null) { + options.push( + { + actionIcon: 'search', + onChosen: () => { + setSearchFilter('B') + }, + element: ( +
+ + Search only blocks +
+ ), + }, + { + actionIcon: 'search', + onChosen: () => { + setSearchFilter('P') + }, + element: ( +
+ + Search only pages +
+ ), + } + ) + } + + // Page results + if ((!searchFilter || searchFilter === 'P') && searchResult && searchResult.pages) { + options.push( + ...searchResult.pages.map(page => { + return { + actionIcon: 'search' as 'search', + onChosen: () => { + finishCreating(page) + }, + element: ( +
+ + {highlightedJSX(page, q)} +
+ ), + } + }) + ) + } + + // Block results + if ((!searchFilter || searchFilter === 'B') && searchResult && searchResult.blocks) { + options.push( + ...searchResult.blocks + .filter(block => block.content && block.uuid) + .map(({ content, uuid }) => { + return { + actionIcon: 'search' as 'search', + onChosen: () => { + finishCreating(uuid) + }, + element: ( + <> +
+ +
+ +
+
+
+
+ {highlightedJSX(content, q)} +
+ + ), + } + }) + ) + } + return options + }, [q, searchFilter, searchResult, renderers?.Breadcrumb]) + + React.useEffect(() => { + const keydownListener = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + const index = Math.min(options.length - 1, focusedOptionIdx + 1) + const option = options[index] + setFocusedOptionIdx(index) + setPrefixIcon(option.actionIcon) + e.stopPropagation() + e.preventDefault() + } else if (e.key === 'ArrowUp') { + const index = Math.max(0, focusedOptionIdx - 1) + setFocusedOptionIdx(index) + const option = options[index] + setFocusedOptionIdx(index) + setPrefixIcon(option.actionIcon) + e.stopPropagation() + e.preventDefault() + } else if (e.key === 'Enter') { + options[focusedOptionIdx]?.onChosen() + e.stopPropagation() + e.preventDefault() + } else if (e.key === 'Backspace' && q.length === 0) { + setSearchFilter(null) + } + } + document.addEventListener('keydown', keydownListener, true) + return () => { + document.removeEventListener('keydown', keydownListener, true) + } + }, [options, focusedOptionIdx, q]) + + React.useEffect(() => { + const optionElement = optionsWrapperRef.current?.querySelector( + '.tl-quick-search-option:nth-child(' + (focusedOptionIdx + 1) + ')' + ) + + if (optionElement) { + // @ts-expect-error we are using scrollIntoViewIfNeeded, which is not in standards + optionElement?.scrollIntoViewIfNeeded(false) + } + }, [options, focusedOptionIdx, optionsWrapperRef]) + return (
@@ -351,97 +531,24 @@ export class LogseqPortalShape extends TLBoxShape { />
-
-
setPrefixIcon('circle-plus')} - onClick={() => onAddBlock(q)} - > -
- - {q.length > 0 ? ( - <> - New whiteboard block: - {q} - - ) : ( - New whiteboard block - )} -
-
- {searchResult?.pages?.length === 0 && q && ( -
setPrefixIcon('circle-plus')} - onClick={() => finishCreating(q)} - > -
- - New page: - {q} -
-
- )} - {q.length === 0 && searchFilter === null && ( - <> +
+ {options.map(({ actionIcon, onChosen, element }, index) => { + return (
setPrefixIcon('search')} - onClick={() => setSearchFilter('B')} + tabIndex={0} + onMouseEnter={() => { + setPrefixIcon(actionIcon) + setFocusedOptionIdx(index) + }} + onClick={onChosen} > -
- - Search only blocks -
+ {element}
-
setPrefixIcon('search')} - onClick={() => setSearchFilter('P')} - > -
- - Search only pages -
-
- - )} - {(!searchFilter || searchFilter === 'P') && - searchResult?.pages?.map(name => ( -
setPrefixIcon('search')} - onClick={() => finishCreating(name)} - > -
- - {highlightedJSX(name, q)} -
-
- ))} - {(!searchFilter || searchFilter === 'B') && - searchResult?.blocks - ?.filter(block => block.content && block.uuid) - .map(({ content, uuid }) => ( -
setPrefixIcon('search')} - onClick={() => finishCreating(uuid)} - > -
- -
- -
-
-
-
- {highlightedJSX(content, q)} -
-
- ))} + ) + })}
) diff --git a/tldraw/apps/tldraw-logseq/src/styles.css b/tldraw/apps/tldraw-logseq/src/styles.css index e2f44bf70..ae944444d 100644 --- a/tldraw/apps/tldraw-logseq/src/styles.css +++ b/tldraw/apps/tldraw-logseq/src/styles.css @@ -560,7 +560,7 @@ html[data-theme='light'] .tl-quick-search-input-filter-remove { gap: 0.5em; } -.tl-quick-search-option:hover { +.tl-quick-search-option[data-focused=true] { background-color: var(--ls-menu-hover-color, #f4f5f7); } diff --git a/tldraw/packages/core/src/utils/index.ts b/tldraw/packages/core/src/utils/index.ts index abbe16b72..fb1aafe65 100644 --- a/tldraw/packages/core/src/utils/index.ts +++ b/tldraw/packages/core/src/utils/index.ts @@ -71,3 +71,7 @@ export function isDarwin(): boolean { export function modKey(e: any): boolean { return isDarwin() ? e.metaKey : e.ctrlKey } + +export function isNonNullable(value: TValue): value is NonNullable { + return Boolean(value) +}