瀏覽代碼

fix: more styles chanes

Peng Xiao 2 年之前
父節點
當前提交
4f96e42b01

+ 2 - 2
tldraw/apps/tldraw-logseq/src/components/Button/Button.tsx

@@ -2,6 +2,6 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
   children: React.ReactNode
 }
 
-export function Button(props: ButtonProps) {
-  return <button className="tl-button" {...props} />
+export function Button({ className, ...rest }: ButtonProps) {
+  return <button className={'tl-button ' + (className ?? '')} {...rest} />
 }

+ 41 - 0
tldraw/apps/tldraw-logseq/src/components/Button/CircleButton.tsx

@@ -0,0 +1,41 @@
+import React from 'react'
+import { TablerIcon } from '../icons'
+
+export const CircleButton = ({
+  active,
+  style,
+  icon,
+  otherIcon,
+  onClick,
+}: {
+  active?: boolean
+  style?: React.CSSProperties
+  icon: string
+  otherIcon?: string
+  onClick: () => void
+}) => {
+  const [recentlyChanged, setRecentlyChanged] = React.useState(false)
+
+  React.useEffect(() => {
+    setRecentlyChanged(true)
+    const timer = setTimeout(() => {
+      setRecentlyChanged(false)
+    }, 500)
+    return () => clearTimeout(timer)
+  }, [active])
+
+  return (
+    <button
+      data-active={active}
+      data-recently-changed={recentlyChanged}
+      style={style}
+      className="tl-circle-button"
+      onMouseDown={onClick}
+    >
+      <div className="tl-circle-button-icons-wrapper" data-icons-count={otherIcon ? 2 : 1}>
+        {otherIcon && <TablerIcon name={otherIcon} />}
+        <TablerIcon name={icon} />
+      </div>
+    </button>
+  )
+}

+ 1 - 0
tldraw/apps/tldraw-logseq/src/components/Button/index.ts

@@ -1 +1,2 @@
 export * from './Button'
+export * from './CircleButton'

+ 5 - 1
tldraw/apps/tldraw-logseq/src/components/PopoverButton/PopoverButton.tsx

@@ -56,7 +56,11 @@ export const PopoverButton = observer(
 
     return (
       <Popover.Root onOpenChange={o => setIsOpen(o)}>
-        <Popover.Trigger {...rest} data-border={border} className="tl-popover-trigger-button">
+        <Popover.Trigger
+          {...rest}
+          data-border={border}
+          className="tl-button tl-popover-trigger-button"
+        >
           {label}
         </Popover.Trigger>
 

+ 437 - 0
tldraw/apps/tldraw-logseq/src/components/QuickSearch/QuickSearch.tsx

@@ -0,0 +1,437 @@
+import { useApp, useDebouncedValue } from '@tldraw/react'
+import { observer } from 'mobx-react-lite'
+import React from 'react'
+import { Virtuoso } from 'react-virtuoso'
+import { LogseqPortalShape, type Shape } from '../../lib'
+import { LogseqContext, SearchResult } from '../../lib/logseq-context'
+import { CircleButton } from '../Button'
+import { TablerIcon } from '../icons'
+import { TextInput } from '../inputs/TextInput'
+
+interface LogseqQuickSearchProps {
+  onChange: (id: string) => void
+  className?: string
+  create?: boolean
+  placeholder?: string
+  style?: React.CSSProperties
+  onBlur?: () => void
+  shape?: LogseqPortalShape
+}
+
+const LogseqTypeTag = ({
+  type,
+  active,
+}: {
+  type: 'B' | 'P' | 'BA' | 'PA' | 'WA' | 'WP' | 'BS' | 'PS'
+  active?: boolean
+}) => {
+  const nameMapping = {
+    B: 'block',
+    P: 'page',
+    WP: 'whiteboard',
+    BA: 'new-block',
+    PA: 'new-page',
+    WA: 'new-whiteboard',
+    BS: 'block-search',
+    PS: 'page-search',
+  }
+  return (
+    <span className="tl-type-tag" data-active={active}>
+      <i className={`tie tie-${nameMapping[type]}`} />
+    </span>
+  )
+}
+
+function escapeRegExp(text: string) {
+  return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
+}
+
+const highlightedJSX = (input: string, keyword: string) => {
+  return (
+    <span>
+      {input
+        .split(new RegExp(`(${escapeRegExp(keyword)})`, 'gi'))
+        .map((part, index) => {
+          if (index % 2 === 1) {
+            return <mark className="tl-highlighted">{part}</mark>
+          }
+          return part
+        })
+        .map((frag, idx) => (
+          <React.Fragment key={idx}>{frag}</React.Fragment>
+        ))}
+    </span>
+  )
+}
+
+const useSearch = (q: string, searchFilter: 'B' | 'P' | null) => {
+  const { handlers } = React.useContext(LogseqContext)
+  const [results, setResults] = React.useState<SearchResult | null>(null)
+  const dq = useDebouncedValue(q, 200)
+
+  React.useEffect(() => {
+    let canceled = false
+    if (dq.length > 0) {
+      const filter = { 'pages?': true, 'blocks?': true, 'files?': false }
+      if (searchFilter === 'B') {
+        filter['pages?'] = false
+      } else if (searchFilter === 'P') {
+        filter['blocks?'] = false
+      }
+      handlers.search(dq, filter).then(_results => {
+        if (!canceled) {
+          setResults(_results)
+        }
+      })
+    } else {
+      setResults(null)
+    }
+    return () => {
+      canceled = true
+    }
+  }, [dq, handlers?.search])
+
+  return results
+}
+
+export const LogseqQuickSearch = observer(
+  ({ className, style, placeholder, create, onChange, onBlur, shape }: LogseqQuickSearchProps) => {
+    const [q, setQ] = React.useState(LogseqPortalShape.defaultSearchQuery)
+    const [searchFilter, setSearchFilter] = React.useState<'B' | 'P' | null>(
+      LogseqPortalShape.defaultSearchFilter
+    )
+    const rInput = React.useRef<HTMLInputElement>(null)
+    const { handlers, renderers } = React.useContext(LogseqContext)
+    const app = useApp<Shape>()
+
+    const finishSearching = React.useCallback((id: string) => {
+      onChange(id)
+      rInput.current?.blur()
+      if (id) {
+        LogseqPortalShape.defaultSearchQuery = ''
+        LogseqPortalShape.defaultSearchFilter = null
+      }
+    }, [])
+
+    // TODO: should move this implementation to LogseqPortalShape
+    const onAddBlock = React.useCallback(
+      (content: string) => {
+        const uuid = handlers?.addNewBlock(content)
+        if (uuid) {
+          finishSearching(uuid)
+          // wait until the editor is mounted
+          setTimeout(() => {
+            app.api.editShape(shape)
+            window.logseq?.api?.edit_block?.(uuid)
+          })
+        }
+        return uuid
+      },
+      [shape]
+    )
+
+    const optionsWrapperRef = React.useRef<HTMLDivElement>(null)
+
+    const [focusedOptionIdx, setFocusedOptionIdx] = React.useState<number>(0)
+
+    const searchResult = useSearch(q, searchFilter)
+
+    const [prefixIcon, setPrefixIcon] = React.useState<string>('circle-plus')
+
+    React.useEffect(() => {
+      // autofocus seems not to be working
+      setTimeout(() => {
+        rInput.current?.focus()
+      })
+    }, [searchFilter])
+
+    React.useEffect(() => {
+      LogseqPortalShape.defaultSearchQuery = q
+      LogseqPortalShape.defaultSearchFilter = searchFilter
+    }, [q, searchFilter])
+
+    type Option = {
+      actionIcon: 'search' | 'circle-plus'
+      onChosen: () => boolean // return true if the action was handled
+      element: React.ReactNode
+    }
+
+    const options: Option[] = React.useMemo(() => {
+      const options: Option[] = []
+
+      const Breadcrumb = renderers?.Breadcrumb
+
+      if (!Breadcrumb || !handlers) {
+        return []
+      }
+
+      if (create) {
+        // New block option
+        options.push({
+          actionIcon: 'circle-plus',
+          onChosen: () => {
+            return !!onAddBlock(q)
+          },
+          element: (
+            <div className="tl-quick-search-option-row">
+              <LogseqTypeTag active type="BA" />
+              {q.length > 0 ? (
+                <>
+                  <strong>New block:</strong>
+                  {q}
+                </>
+              ) : (
+                <strong>New block</strong>
+              )}
+            </div>
+          ),
+        })
+      }
+
+      // New page or whiteboard option when no exact match
+      if (!searchResult?.pages?.some(p => p.toLowerCase() === q.toLowerCase()) && q && create) {
+        options.push(
+          {
+            actionIcon: 'circle-plus',
+            onChosen: () => {
+              finishSearching(q)
+              return true
+            },
+            element: (
+              <div className="tl-quick-search-option-row">
+                <LogseqTypeTag active type="PA" />
+                <strong>New page:</strong>
+                {q}
+              </div>
+            ),
+          },
+          {
+            actionIcon: 'circle-plus',
+            onChosen: () => {
+              handlers?.addNewWhiteboard(q)
+              finishSearching(q)
+              return true
+            },
+            element: (
+              <div className="tl-quick-search-option-row">
+                <LogseqTypeTag active type="WA" />
+                <strong>New whiteboard:</strong>
+                {q}
+              </div>
+            ),
+          }
+        )
+      }
+
+      // search filters
+      if (q.length === 0 && searchFilter === null) {
+        options.push(
+          {
+            actionIcon: 'search',
+            onChosen: () => {
+              setSearchFilter('B')
+              return true
+            },
+            element: (
+              <div className="tl-quick-search-option-row">
+                <LogseqTypeTag type="BS" />
+                Search only blocks
+              </div>
+            ),
+          },
+          {
+            actionIcon: 'search',
+            onChosen: () => {
+              setSearchFilter('P')
+              return true
+            },
+            element: (
+              <div className="tl-quick-search-option-row">
+                <LogseqTypeTag type="PS" />
+                Search only pages
+              </div>
+            ),
+          }
+        )
+      }
+
+      // Page results
+      if ((!searchFilter || searchFilter === 'P') && searchResult && searchResult.pages) {
+        options.push(
+          ...searchResult.pages.map(page => {
+            return {
+              actionIcon: 'search' as 'search',
+              onChosen: () => {
+                finishSearching(page)
+                return true
+              },
+              element: (
+                <div className="tl-quick-search-option-row">
+                  <LogseqTypeTag type={handlers.isWhiteboardPage(page) ? 'WP' : 'P'} />
+                  {highlightedJSX(page, q)}
+                </div>
+              ),
+            }
+          })
+        )
+      }
+
+      // Block results
+      if ((!searchFilter || searchFilter === 'B') && searchResult && searchResult.blocks) {
+        options.push(
+          ...searchResult.blocks
+            .filter(block => block.content && block.uuid)
+            .map(({ content, uuid }) => {
+              const block = handlers.queryBlockByUUID(uuid)
+              return {
+                actionIcon: 'search' as 'search',
+                onChosen: () => {
+                  if (block) {
+                    finishSearching(uuid)
+                    window.logseq?.api?.set_blocks_id?.([uuid])
+                    return true
+                  }
+                  return false
+                },
+                element: block ? (
+                  <>
+                    <div className="tl-quick-search-option-row">
+                      <LogseqTypeTag type="B" />
+                      <div className="tl-quick-search-option-breadcrumb">
+                        <Breadcrumb blockId={uuid} />
+                      </div>
+                    </div>
+                    <div className="tl-quick-search-option-row">
+                      <div className="tl-quick-search-option-placeholder" />
+                      {highlightedJSX(content, q)}
+                    </div>
+                  </>
+                ) : (
+                  <div className="tl-quick-search-option-row">
+                    Cache is outdated. Please click the 'Re-index' button in the graph's dropdown
+                    menu.
+                  </div>
+                ),
+              }
+            })
+        )
+      }
+      return options
+    }, [q, searchFilter, searchResult, renderers?.Breadcrumb, handlers])
+
+    React.useEffect(() => {
+      const keydownListener = (e: KeyboardEvent) => {
+        let newIndex = focusedOptionIdx
+        if (e.key === 'ArrowDown') {
+          newIndex = Math.min(options.length - 1, focusedOptionIdx + 1)
+        } else if (e.key === 'ArrowUp') {
+          newIndex = Math.max(0, focusedOptionIdx - 1)
+        } else if (e.key === 'Enter') {
+          options[focusedOptionIdx]?.onChosen()
+          e.stopPropagation()
+          e.preventDefault()
+        } else if (e.key === 'Backspace' && q.length === 0) {
+          setSearchFilter(null)
+        } else if (e.key === 'Escape') {
+          finishSearching('')
+        }
+
+        if (newIndex !== focusedOptionIdx) {
+          const option = options[newIndex]
+          setFocusedOptionIdx(newIndex)
+          setPrefixIcon(option.actionIcon)
+          e.stopPropagation()
+          e.preventDefault()
+          const optionElement = optionsWrapperRef.current?.querySelector(
+            '.tl-quick-search-option:nth-child(' + (newIndex + 1) + ')'
+          )
+          if (optionElement) {
+            // @ts-expect-error we are using scrollIntoViewIfNeeded, which is not in standards
+            optionElement?.scrollIntoViewIfNeeded(false)
+          }
+        }
+      }
+      document.addEventListener('keydown', keydownListener, true)
+      return () => {
+        document.removeEventListener('keydown', keydownListener, true)
+      }
+    }, [options, focusedOptionIdx, q])
+
+    return (
+      <div className={'tl-quick-search ' + (className ?? '')} style={style}>
+        <CircleButton
+          icon={prefixIcon}
+          onClick={() => {
+            options[focusedOptionIdx]?.onChosen()
+          }}
+        />
+        <div className="tl-quick-search-input-container">
+          {searchFilter && (
+            <div className="tl-quick-search-input-filter">
+              <LogseqTypeTag type={searchFilter} />
+              {searchFilter === 'B' ? 'Search blocks' : 'Search pages'}
+              <div
+                className="tl-quick-search-input-filter-remove"
+                onClick={() => setSearchFilter(null)}
+              >
+                <TablerIcon name="x" />
+              </div>
+            </div>
+          )}
+          <TextInput
+            ref={rInput}
+            type="text"
+            value={q}
+            className="tl-quick-search-input"
+            placeholder={placeholder ?? 'Create or search your graph...'}
+            onChange={q => setQ(q.target.value)}
+            onKeyDown={e => {
+              if (e.key === 'Enter') {
+                finishSearching(q)
+              }
+            }}
+            onBlur={onBlur}
+          />
+        </div>
+        {/* TODO: refactor to radix-ui popover */}
+        {options.length > 0 && (
+          <div
+            onWheelCapture={e => e.stopPropagation()}
+            className="tl-quick-search-options"
+            ref={optionsWrapperRef}
+          >
+            <Virtuoso
+              style={{ height: Math.min(Math.max(1, options.length), 12) * 40 }}
+              totalCount={options.length}
+              itemContent={index => {
+                const { actionIcon, onChosen, element } = options[index]
+                return (
+                  <div
+                    key={index}
+                    data-focused={index === focusedOptionIdx}
+                    className="tl-quick-search-option"
+                    tabIndex={0}
+                    onMouseEnter={() => {
+                      setPrefixIcon(actionIcon)
+                      setFocusedOptionIdx(index)
+                    }}
+                    // we have to use mousedown && stop propagation EARLY, otherwise some
+                    // default behavior of clicking the rendered elements will happen
+                    onMouseDownCapture={e => {
+                      if (onChosen()) {
+                        e.stopPropagation()
+                        e.preventDefault()
+                      }
+                    }}
+                  >
+                    {element}
+                  </div>
+                )
+              }}
+            />
+          </div>
+        )}
+      </div>
+    )
+  }
+)

+ 1 - 0
tldraw/apps/tldraw-logseq/src/components/QuickSearch/index.ts

@@ -0,0 +1 @@
+export * from './QuickSearch'

+ 34 - 16
tldraw/apps/tldraw-logseq/src/components/inputs/ShapeLinksInput.tsx

@@ -5,7 +5,7 @@ import { LogseqContext } from '../../lib/logseq-context'
 import { Button } from '../Button'
 import { TablerIcon } from '../icons'
 import { PopoverButton } from '../PopoverButton'
-import { TextInput } from './TextInput'
+import { LogseqQuickSearch } from '../QuickSearch'
 
 interface ShapeLinksInputProps extends React.HTMLAttributes<HTMLButtonElement> {
   shapeType: string
@@ -33,7 +33,9 @@ function ShapeLinkItem({
   return (
     <div className="tl-shape-links-panel-item color-level">
       <TablerIcon name={type === 'P' ? 'page' : 'block'} />
-      {type === 'P' ? <PageNameLink pageName={id} /> : <Breadcrumb levelLimit={2} blockId={id} />}
+      <div className="whitespace-pre break-all overflow-hidden text-ellipsis">
+        {type === 'P' ? <PageNameLink pageName={id} /> : <Breadcrumb levelLimit={1} blockId={id} />}
+      </div>
       <div className="flex-1" />
       <Button title="Open Page" type="button" onClick={() => handlers?.redirectToPage(id)}>
         <TablerIcon name="external-link" />
@@ -64,7 +66,7 @@ export function ShapeLinksInput({
   ...rest
 }: ShapeLinksInputProps) {
   const noOfLinks = refs.length + (pageId ? 1 : 0)
-  const [value, setValue] = React.useState('')
+  const [showQuickSearch, setShowQuickSearch] = React.useState(false)
 
   return (
     <PopoverButton
@@ -98,20 +100,36 @@ export function ShapeLinksInput({
             This <strong>{shapeType}</strong> can be linked to any other block, page or whiteboard
             element you have stored in Logseq.
           </div>
-          <TextInput
-            value={value}
-            onChange={e => {
-              setValue(e.target.value)
-            }}
-            onKeyDown={e => {
-              if (e.key === 'Enter') {
-                if (value && !refs.includes(value)) {
-                  onRefsChange([...refs, value])
+
+          <div className="h-2" />
+
+          {showQuickSearch ? (
+            <LogseqQuickSearch
+              style={{
+                width: 'calc(100% - 46px)',
+                marginLeft: '46px',
+              }}
+              onBlur={() => setShowQuickSearch(false)}
+              placeholder="Start typing to search..."
+              onChange={newValue => {
+                if (newValue && !refs.includes(newValue)) {
+                  onRefsChange([...refs, newValue])
+                  setShowQuickSearch(false)
                 }
-              }
-              e.stopPropagation()
-            }}
-          />
+              }}
+            />
+          ) : (
+            <div>
+              <Button
+                className="tl-shape-links-panel-add-button"
+                onClick={() => setShowQuickSearch(true)}
+              >
+                <TablerIcon name="plus" />
+                Add a new link
+              </Button>
+            </div>
+          )}
+          <div className="h-2" />
           <div className="flex flex-col items-stretch gap-2">
             {refs.map((ref, i) => {
               return (

+ 11 - 454
tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx

@@ -1,25 +1,23 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 import {
   delay,
+  getComputedColor,
   TLBoxShape,
   TLBoxShapeProps,
   TLResetBoundsInfo,
   TLResizeInfo,
   validUUID,
-  getComputedColor,
 } from '@tldraw/core'
-import { Virtuoso } from 'react-virtuoso'
 import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
-import { useDebouncedValue } from '@tldraw/react'
 import Vec from '@tldraw/vec'
 import { action, computed, makeObservable } from 'mobx'
 import { observer } from 'mobx-react-lite'
 import * as React from 'react'
-import type { SizeLevel, Shape } from '.'
-import { TablerIcon } from '../../components/icons'
-import { TextInput } from '../../components/inputs/TextInput'
+import type { Shape, SizeLevel } from '.'
+import { CircleButton } from '../../components/Button'
+import { LogseqQuickSearch } from '../../components/QuickSearch'
 import { useCameraMovingRef } from '../../hooks/useCameraMoving'
-import { LogseqContext, type SearchResult } from '../logseq-context'
+import { LogseqContext } from '../logseq-context'
 import { BindingIndicator } from './BindingIndicator'
 import { CustomStyleProps, withClampedStyles } from './style-props'
 
@@ -37,10 +35,6 @@ export interface LogseqPortalShapeProps extends TLBoxShapeProps, CustomStyleProp
   scaleLevel?: SizeLevel
 }
 
-interface LogseqQuickSearchProps {
-  onChange: (id: string) => void
-}
-
 const levelToScale = {
   xs: 0.5,
   sm: 0.8,
@@ -50,30 +44,6 @@ const levelToScale = {
   xxl: 3,
 }
 
-const LogseqTypeTag = ({
-  type,
-  active,
-}: {
-  type: 'B' | 'P' | 'BA' | 'PA' | 'WA' | 'WP' | 'BS' | 'PS'
-  active?: boolean
-}) => {
-  const nameMapping = {
-    B: 'block',
-    P: 'page',
-    WP: 'whiteboard',
-    BA: 'new-block',
-    PA: 'new-page',
-    WA: 'new-whiteboard',
-    BS: 'block-search',
-    PS: 'page-search',
-  }
-  return (
-    <span className="tl-type-tag" data-active={active}>
-      <i className={`tie tie-${nameMapping[type]}`} />
-    </span>
-  )
-}
-
 const LogseqPortalShapeHeader = observer(
   ({
     type,
@@ -110,97 +80,6 @@ const LogseqPortalShapeHeader = observer(
   }
 )
 
-function escapeRegExp(text: string) {
-  return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
-}
-
-const highlightedJSX = (input: string, keyword: string) => {
-  return (
-    <span>
-      {input
-        .split(new RegExp(`(${escapeRegExp(keyword)})`, 'gi'))
-        .map((part, index) => {
-          if (index % 2 === 1) {
-            return <mark className="tl-highlighted">{part}</mark>
-          }
-          return part
-        })
-        .map((frag, idx) => (
-          <React.Fragment key={idx}>{frag}</React.Fragment>
-        ))}
-    </span>
-  )
-}
-
-const useSearch = (q: string, searchFilter: 'B' | 'P' | null) => {
-  const { handlers } = React.useContext(LogseqContext)
-  const [results, setResults] = React.useState<SearchResult | null>(null)
-  const dq = useDebouncedValue(q, 200)
-
-  React.useEffect(() => {
-    let canceled = false
-    if (dq.length > 0) {
-      const filter = { 'pages?': true, 'blocks?': true, 'files?': false }
-      if (searchFilter === 'B') {
-        filter['pages?'] = false
-      } else if (searchFilter === 'P') {
-        filter['blocks?'] = false
-      }
-      handlers.search(dq, filter).then(_results => {
-        if (!canceled) {
-          setResults(_results)
-        }
-      })
-    } else {
-      setResults(null)
-    }
-    return () => {
-      canceled = true
-    }
-  }, [dq, handlers?.search])
-
-  return results
-}
-
-const CircleButton = ({
-  active,
-  style,
-  icon,
-  otherIcon,
-  onClick,
-}: {
-  active?: boolean
-  style?: React.CSSProperties
-  icon: string
-  otherIcon?: string
-  onClick: () => void
-}) => {
-  const [recentlyChanged, setRecentlyChanged] = React.useState(false)
-
-  React.useEffect(() => {
-    setRecentlyChanged(true)
-    const timer = setTimeout(() => {
-      setRecentlyChanged(false)
-    }, 500)
-    return () => clearTimeout(timer)
-  }, [active])
-
-  return (
-    <button
-      data-active={active}
-      data-recently-changed={recentlyChanged}
-      style={style}
-      className="tl-circle-button"
-      onMouseDown={onClick}
-    >
-      <div className="tl-circle-button-icons-wrapper" data-icons-count={otherIcon ? 2 : 1}>
-        {otherIcon && <TablerIcon name={otherIcon} />}
-        <TablerIcon name={icon} />
-      </div>
-    </button>
-  )
-}
-
 export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
   static id = 'logseq-portal'
   static defaultSearchQuery = ''
@@ -384,332 +263,6 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     })
   }
 
-  LogseqQuickSearch = observer(({ onChange }: LogseqQuickSearchProps) => {
-    const [q, setQ] = React.useState(LogseqPortalShape.defaultSearchQuery)
-    const [searchFilter, setSearchFilter] = React.useState<'B' | 'P' | null>(
-      LogseqPortalShape.defaultSearchFilter
-    )
-    const rInput = React.useRef<HTMLInputElement>(null)
-    const { handlers, renderers } = React.useContext(LogseqContext)
-    const app = useApp<Shape>()
-
-    const finishCreating = React.useCallback((id: string) => {
-      onChange(id)
-      rInput.current?.blur()
-      if (id) {
-        LogseqPortalShape.defaultSearchQuery = ''
-        LogseqPortalShape.defaultSearchFilter = null
-      }
-    }, [])
-
-    const onAddBlock = React.useCallback((content: string) => {
-      const uuid = handlers?.addNewBlock(content)
-      if (uuid) {
-        finishCreating(uuid)
-        // wait until the editor is mounted
-        setTimeout(() => {
-          app.api.editShape(this)
-          window.logseq?.api?.edit_block?.(uuid)
-        })
-      }
-      return uuid
-    }, [])
-
-    const optionsWrapperRef = React.useRef<HTMLDivElement>(null)
-
-    const [focusedOptionIdx, setFocusedOptionIdx] = React.useState<number>(0)
-
-    const searchResult = useSearch(q, searchFilter)
-
-    const [prefixIcon, setPrefixIcon] = React.useState<string>('circle-plus')
-
-    React.useEffect(() => {
-      // autofocus seems not to be working
-      setTimeout(() => {
-        rInput.current?.focus()
-      })
-    }, [searchFilter])
-
-    React.useEffect(() => {
-      LogseqPortalShape.defaultSearchQuery = q
-      LogseqPortalShape.defaultSearchFilter = searchFilter
-    }, [q, searchFilter])
-
-    type Option = {
-      actionIcon: 'search' | 'circle-plus'
-      onChosen: () => boolean // return true if the action was handled
-      element: React.ReactNode
-    }
-
-    const options: Option[] = React.useMemo(() => {
-      const options: Option[] = []
-
-      const Breadcrumb = renderers?.Breadcrumb
-
-      if (!Breadcrumb || !handlers) {
-        return []
-      }
-
-      // New block option
-      options.push({
-        actionIcon: 'circle-plus',
-        onChosen: () => {
-          return !!onAddBlock(q)
-        },
-        element: (
-          <div className="tl-quick-search-option-row">
-            <LogseqTypeTag active type="BA" />
-            {q.length > 0 ? (
-              <>
-                <strong>New block:</strong>
-                {q}
-              </>
-            ) : (
-              <strong>New block</strong>
-            )}
-          </div>
-        ),
-      })
-
-      // New page or whiteboard option when no exact match
-      if (!searchResult?.pages?.some(p => p.toLowerCase() === q.toLowerCase()) && q) {
-        options.push(
-          {
-            actionIcon: 'circle-plus',
-            onChosen: () => {
-              finishCreating(q)
-              return true
-            },
-            element: (
-              <div className="tl-quick-search-option-row">
-                <LogseqTypeTag active type="PA" />
-                <strong>New page:</strong>
-                {q}
-              </div>
-            ),
-          },
-          {
-            actionIcon: 'circle-plus',
-            onChosen: () => {
-              handlers?.addNewWhiteboard(q)
-              finishCreating(q)
-              return true
-            },
-            element: (
-              <div className="tl-quick-search-option-row">
-                <LogseqTypeTag active type="WA" />
-                <strong>New whiteboard:</strong>
-                {q}
-              </div>
-            ),
-          }
-        )
-      }
-
-      // search filters
-      if (q.length === 0 && searchFilter === null) {
-        options.push(
-          {
-            actionIcon: 'search',
-            onChosen: () => {
-              setSearchFilter('B')
-              return true
-            },
-            element: (
-              <div className="tl-quick-search-option-row">
-                <LogseqTypeTag type="BS" />
-                Search only blocks
-              </div>
-            ),
-          },
-          {
-            actionIcon: 'search',
-            onChosen: () => {
-              setSearchFilter('P')
-              return true
-            },
-            element: (
-              <div className="tl-quick-search-option-row">
-                <LogseqTypeTag type="PS" />
-                Search only pages
-              </div>
-            ),
-          }
-        )
-      }
-
-      // Page results
-      if ((!searchFilter || searchFilter === 'P') && searchResult && searchResult.pages) {
-        options.push(
-          ...searchResult.pages.map(page => {
-            return {
-              actionIcon: 'search' as 'search',
-              onChosen: () => {
-                finishCreating(page)
-                return true
-              },
-              element: (
-                <div className="tl-quick-search-option-row">
-                  <LogseqTypeTag type={handlers.isWhiteboardPage(page) ? 'WP' : 'P'} />
-                  {highlightedJSX(page, q)}
-                </div>
-              ),
-            }
-          })
-        )
-      }
-
-      // Block results
-      if ((!searchFilter || searchFilter === 'B') && searchResult && searchResult.blocks) {
-        options.push(
-          ...searchResult.blocks
-            .filter(block => block.content && block.uuid)
-            .map(({ content, uuid }) => {
-              const block = handlers.queryBlockByUUID(uuid)
-              return {
-                actionIcon: 'search' as 'search',
-                onChosen: () => {
-                  if (block) {
-                    finishCreating(uuid)
-                    window.logseq?.api?.set_blocks_id?.([uuid])
-                    return true
-                  }
-                  return false
-                },
-                element: block ? (
-                  <>
-                    <div className="tl-quick-search-option-row">
-                      <LogseqTypeTag type="B" />
-                      <div className="tl-quick-search-option-breadcrumb">
-                        <Breadcrumb blockId={uuid} />
-                      </div>
-                    </div>
-                    <div className="tl-quick-search-option-row">
-                      <div className="tl-quick-search-option-placeholder" />
-                      {highlightedJSX(content, q)}
-                    </div>
-                  </>
-                ) : (
-                  <div className="tl-quick-search-option-row">
-                    Cache is outdated. Please click the 'Re-index' button in the graph's dropdown
-                    menu.
-                  </div>
-                ),
-              }
-            })
-        )
-      }
-      return options
-    }, [q, searchFilter, searchResult, renderers?.Breadcrumb, handlers])
-
-    React.useEffect(() => {
-      const keydownListener = (e: KeyboardEvent) => {
-        let newIndex = focusedOptionIdx
-        if (e.key === 'ArrowDown') {
-          newIndex = Math.min(options.length - 1, focusedOptionIdx + 1)
-        } else if (e.key === 'ArrowUp') {
-          newIndex = Math.max(0, focusedOptionIdx - 1)
-        } else if (e.key === 'Enter') {
-          options[focusedOptionIdx]?.onChosen()
-          e.stopPropagation()
-          e.preventDefault()
-        } else if (e.key === 'Backspace' && q.length === 0) {
-          setSearchFilter(null)
-        } else if (e.key === 'Escape') {
-          finishCreating('')
-        }
-
-        if (newIndex !== focusedOptionIdx) {
-          const option = options[newIndex]
-          setFocusedOptionIdx(newIndex)
-          setPrefixIcon(option.actionIcon)
-          e.stopPropagation()
-          e.preventDefault()
-          const optionElement = optionsWrapperRef.current?.querySelector(
-            '.tl-quick-search-option:nth-child(' + (newIndex + 1) + ')'
-          )
-          if (optionElement) {
-            // @ts-expect-error we are using scrollIntoViewIfNeeded, which is not in standards
-            optionElement?.scrollIntoViewIfNeeded(false)
-          }
-        }
-      }
-      document.addEventListener('keydown', keydownListener, true)
-      return () => {
-        document.removeEventListener('keydown', keydownListener, true)
-      }
-    }, [options, focusedOptionIdx, q])
-
-    return (
-      <div className="tl-quick-search">
-        <CircleButton
-          icon={prefixIcon}
-          onClick={() => {
-            options[focusedOptionIdx]?.onChosen()
-          }}
-        />
-        <div className="tl-quick-search-input-container">
-          {searchFilter && (
-            <div className="tl-quick-search-input-filter">
-              <LogseqTypeTag type={searchFilter} />
-              {searchFilter === 'B' ? 'Search blocks' : 'Search pages'}
-              <div
-                className="tl-quick-search-input-filter-remove"
-                onClick={() => setSearchFilter(null)}
-              >
-                <TablerIcon name="x" />
-              </div>
-            </div>
-          )}
-          <TextInput
-            ref={rInput}
-            type="text"
-            value={q}
-            className="tl-quick-search-input"
-            placeholder="Create or search your graph..."
-            onChange={q => setQ(q.target.value)}
-            onKeyDown={e => {
-              if (e.key === 'Enter') {
-                finishCreating(q)
-              }
-            }}
-          />
-        </div>
-        <div className="tl-quick-search-options" ref={optionsWrapperRef}>
-          <Virtuoso
-            style={{ height: Math.min(Math.max(1, options.length), 12) * 40 }}
-            totalCount={options.length}
-            itemContent={index => {
-              const { actionIcon, onChosen, element } = options[index]
-              return (
-                <div
-                  key={index}
-                  data-focused={index === focusedOptionIdx}
-                  className="tl-quick-search-option"
-                  tabIndex={0}
-                  onMouseEnter={() => {
-                    setPrefixIcon(actionIcon)
-                    setFocusedOptionIdx(index)
-                  }}
-                  // we have to use mousedown && stop propagation EARLY, otherwise some
-                  // default behavior of clicking the rendered elements will happen
-                  onMouseDownCapture={e => {
-                    if (onChosen()) {
-                      e.stopPropagation()
-                      e.preventDefault()
-                    }
-                  }}
-                >
-                  {element}
-                </div>
-              )
-            }}
-          />
-        </div>
-      </div>
-    )
-  })
-
   PortalComponent = observer(({}: TLComponentProps) => {
     const {
       props: { pageId, fill, opacity },
@@ -852,7 +405,6 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     }, [])
 
     const PortalComponent = this.PortalComponent
-    const LogseqQuickSearch = this.LogseqQuickSearch
 
     const blockContent = React.useMemo(() => {
       if (pageId && this.props.blockType === 'B') {
@@ -889,7 +441,12 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
           }}
         >
           {isCreating ? (
-            <LogseqQuickSearch onChange={onPageNameChanged} />
+            <LogseqQuickSearch
+              onChange={onPageNameChanged}
+              create
+              shape={this}
+              placeholder="Create or search your graph..."
+            />
           ) : (
             <>
               <div

+ 14 - 4
tldraw/apps/tldraw-logseq/src/styles.css

@@ -572,8 +572,9 @@ button.tl-select-input-trigger {
 }
 
 .tl-quick-search-input-container {
-  @apply flex items-center rounded-lg text-base;
+  @apply flex items-center rounded-lg text-base max-w-full;
 
+  min-height: 40px;
   background-color: var(--ls-secondary-background-color);
   padding: 6px 16px;
   box-shadow: var(--shadow-small);
@@ -618,7 +619,11 @@ button.tl-select-input-trigger {
 .tl-text-input {
   @apply absolute inset-0;
 
-  outline: none;
+  outline: none !important;
+}
+
+.tl-quick-search .tl-text-input {
+  border: none;
 }
 
 .tl-input-hidden {
@@ -633,7 +638,7 @@ button.tl-select-input-trigger {
 }
 
 .tl-quick-search-options {
-  @apply absolute left-0 w-full flex;
+  @apply absolute left-0 w-full flex z-10;
 
   top: calc(100% + 12px);
   background-color: var(--ls-primary-background-color);
@@ -1025,7 +1030,7 @@ html[data-theme='dark'] {
 .tl-shape-links-panel,
 .tl-shape-links-reference-panel {
   @apply p-3;
-  width: 320px;
+  width: 340px;
   color: var(--ls-primary-text-color);
 }
 
@@ -1046,6 +1051,11 @@ html[data-theme='dark'] {
   }
 }
 
+.tl-shape-links-panel-add-button {
+  @apply w-full font-medium text-base h-[40px];
+  background-color: var(--ls-secondary-background-color);
+}
+
 .tl-popover-trigger-button {
   @apply rounded text-sm;