mirror of https://github.com/logseq/logseq
fix: more styles chanes
parent
77f77422e6
commit
4f96e42b01
|
@ -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} />
|
||||
}
|
||||
|
|
|
@ -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 +1,2 @@
|
|||
export * from './Button'
|
||||
export * from './CircleButton'
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
|
@ -0,0 +1 @@
|
|||
export * from './QuickSearch'
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in New Issue