/* eslint-disable @typescript-eslint/no-explicit-any */ import { delay, TLBoxShape, TLBoxShapeProps, TLResetBoundsInfo, TLResizeInfo, validUUID, } from '@tldraw/core' import { HTMLContainer, TLComponentProps, useApp } 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 { useCameraMovingRef } from '../../hooks/useCameraMoving' import { LogseqContext, type SearchResult } from '../logseq-context' import { CustomStyleProps, withClampedStyles } from './style-props' const HEADER_HEIGHT = 40 const AUTO_RESIZE_THRESHOLD = 1 export interface LogseqPortalShapeProps extends TLBoxShapeProps, CustomStyleProps { type: 'logseq-portal' pageId: string // page name or UUID blockType?: 'P' | 'B' collapsed?: boolean compact?: boolean collapsedHeight?: number scaleLevel?: SizeLevel } interface LogseqQuickSearchProps { onChange: (id: string) => void } const levelToScale = { xs: 0.5, sm: 0.8, md: 1, lg: 1.5, xl: 2, xxl: 3, } const LogseqTypeTag = ({ type, active, }: { type: 'B' | 'P' | 'WP' | 'BS' | 'PS' active?: boolean }) => { const nameMapping = { B: 'block', P: 'page', WP: 'whiteboard', BS: 'block-search', PS: 'page-search', } return ( ) } const LogseqPortalShapeHeader = observer( ({ type, children }: { type: 'P' | 'B'; children: React.ReactNode }) => { return (
{children}
) } ) function escapeRegExp(text: string) { return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') } const highlightedJSX = (input: string, keyword: string) => { return ( {input .split(new RegExp(`(${escapeRegExp(keyword)})`, 'gi')) .map((part, index) => { if (index % 2 === 1) { return {part} } return part }) .map((frag, idx) => ( {frag} ))} ) } const useSearch = (q: string, searchFilter: 'B' | 'P' | null) => { const { handlers } = React.useContext(LogseqContext) const [results, setResults] = React.useState(null) React.useEffect(() => { let canceled = false const searchHandler = handlers?.search if (q.length > 0 && searchHandler) { const filter = { 'pages?': true, 'blocks?': true, 'files?': false } if (searchFilter === 'B') { filter['pages?'] = false } else if (searchFilter === 'P') { filter['blocks?'] = false } handlers.search(q, filter).then(_results => { if (!canceled) { setResults(_results) } }) } else { setResults(null) } return () => { canceled = true } }, [q, handlers?.search]) return results } export class LogseqPortalShape extends TLBoxShape { static id = 'logseq-portal' static defaultProps: LogseqPortalShapeProps = { id: 'logseq-portal', type: 'logseq-portal', parentId: 'page', point: [0, 0], size: [400, 50], // collapsedHeight is the height before collapsing collapsedHeight: 0, stroke: 'var(--ls-primary-text-color)', fill: 'var(--ls-secondary-background-color)', noFill: false, strokeWidth: 2, strokeType: 'line', opacity: 1, pageId: '', collapsed: false, compact: false, scaleLevel: 'md', isAutoResizing: true, } hideRotateHandle = true canChangeAspectRatio = true canFlip = true canEdit = true persist: ((replace?: boolean) => void) | null = null // For quick add shapes, we want to calculate the page height dynamically initialHeightCalculated = true getInnerHeight: (() => number) | null = null // will be overridden in the hook constructor(props = {} as Partial) { super(props) makeObservable(this) if (props.collapsed) { Object.assign(this.canResize, [true, false]) } if (props.size?.[1] === 0) { this.initialHeightCalculated = false } } static isPageOrBlock(id: string): 'P' | 'B' | false { const blockRefEg = '((62af02d0-0443-42e8-a284-946c162b0f89))' if (id) { return /^\(\(.*\)\)$/.test(id) && id.length === blockRefEg.length ? 'B' : 'P' } return false } @computed get collapsed() { return this.props.blockType === 'B' ? this.props.compact : this.props.collapsed } @action setCollapsed = async (collapsed: boolean) => { if (this.props.blockType === 'B') { this.update({ compact: collapsed }) this.canResize[1] = !collapsed if (!collapsed) { // this will also persist the state, so we can skip persist call await delay() this.onResetBounds() } this.persist?.() } else { const originalHeight = this.props.size[1] this.canResize[1] = !collapsed this.update({ collapsed: collapsed, size: [this.props.size[0], collapsed ? this.getHeaderHeight() : this.props.collapsedHeight], collapsedHeight: collapsed ? originalHeight : this.props.collapsedHeight, }) } } @computed get scaleLevel() { return this.props.scaleLevel ?? 'md' } @action setScaleLevel = async (v?: SizeLevel) => { const newSize = Vec.mul( this.props.size, levelToScale[(v as SizeLevel) ?? 'md'] / levelToScale[this.props.scaleLevel ?? 'md'] ) this.update({ scaleLevel: v, }) await delay() this.update({ size: newSize, }) } useComponentSize(ref: React.RefObject | null, selector = '') { const [size, setSize] = React.useState<[number, number]>([0, 0]) const app = useApp() React.useEffect(() => { if (ref?.current) { const el = selector ? ref.current.querySelector(selector) : ref.current if (el) { const updateSize = () => { const { width, height } = el.getBoundingClientRect() const bound = Vec.div([width, height], app.viewport.camera.zoom) as [number, number] setSize(bound) return bound } updateSize() // Hacky, I know 🤨 this.getInnerHeight = () => updateSize()[1] const resizeObserver = new ResizeObserver(() => { updateSize() }) resizeObserver.observe(el) return () => { resizeObserver.disconnect() } } } return () => {} }, [ref, selector]) return size } getHeaderHeight() { const scale = levelToScale[this.props.scaleLevel ?? 'md'] return this.props.compact ? 0 : HEADER_HEIGHT * scale } getAutoResizeHeight() { if (this.getInnerHeight) { return this.getHeaderHeight() + this.getInnerHeight() } return null } onResetBounds = (info?: TLResetBoundsInfo) => { const height = this.getAutoResizeHeight() if (height !== null && Math.abs(height - this.props.size[1]) > AUTO_RESIZE_THRESHOLD) { this.update({ size: [this.props.size[0], height], }) this.initialHeightCalculated = true } return this } onResize = (initialProps: any, info: TLResizeInfo): this => { const { bounds, rotation, scale: [scaleX, scaleY], } = info const nextScale = [...this.scale] if (scaleX < 0) nextScale[0] *= -1 if (scaleY < 0) nextScale[1] *= -1 let height = bounds.height if (this.props.isAutoResizing) { height = this.getAutoResizeHeight() ?? height } return this.update({ point: [bounds.minX, bounds.minY], size: [Math.max(1, bounds.width), Math.max(1, height)], scale: nextScale, rotation, }) } LogseqQuickSearch = observer(({ onChange }: LogseqQuickSearchProps) => { const [q, setQ] = React.useState('') const rInput = React.useRef(null) const { handlers, renderers } = React.useContext(LogseqContext) const app = useApp() const finishCreating = React.useCallback((id: string) => { onChange(id) rInput.current?.blur() }, []) 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(null) const [focusedOptionIdx, setFocusedOptionIdx] = React.useState(0) const [searchFilter, setSearchFilter] = React.useState<'B' | 'P' | null>(null) const searchResult = useSearch(q, searchFilter) const [prefixIcon, setPrefixIcon] = React.useState('circle-plus') React.useEffect(() => { // autofocus seems not to be working setTimeout(() => { rInput.current?.focus() }) }, [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: (
{q.length > 0 ? ( <> New whiteboard block: {q} ) : ( New whiteboard block )}
), }) // New page 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: (
New page: {q}
), }) } // search filters if (q.length === 0 && searchFilter === null) { options.push( { actionIcon: 'search', onChosen: () => { setSearchFilter('B') return true }, element: (
Search only blocks
), }, { actionIcon: 'search', onChosen: () => { setSearchFilter('P') return true }, 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) return true }, 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 }) => { 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 ? ( <>
{highlightedJSX(content, q)}
) : (
Cache is outdated. Please click the 'Re-index' button in the graph's dropdown menu.
), } }) ) } 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) } 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 (
{searchFilter && (
{searchFilter === 'B' ? 'Search blocks' : 'Search pages'}
setSearchFilter(null)} >
)} setQ(q.target.value)} onKeyDown={e => { if (e.key === 'Enter') { finishCreating(q) } }} />
{options.map(({ actionIcon, onChosen, element }, index) => { return (
{ setPrefixIcon(actionIcon) setFocusedOptionIdx(index) }} // we have to use mousedown && stop propagation, otherwise some // default behavior of clicking the rendered elements will happen onMouseDown={e => { if (onChosen()) { e.stopPropagation() e.preventDefault() } }} > {element}
) })}
) }) PortalComponent = observer(({}: TLComponentProps) => { const { props: { pageId }, } = this const { renderers } = React.useContext(LogseqContext) const app = useApp() const cpRefContainer = React.useRef(null) const [, innerHeight] = this.useComponentSize( cpRefContainer, this.props.compact ? '.tl-logseq-cp-container > .single-block' : '.tl-logseq-cp-container > .page' ) if (!renderers?.Page) { return null // not being correctly configured } const { Page, Block } = renderers React.useEffect(() => { if (this.props.isAutoResizing) { const latestInnerHeight = this.getInnerHeight?.() ?? innerHeight const newHeight = latestInnerHeight + this.getHeaderHeight() if (innerHeight && Math.abs(newHeight - this.props.size[1]) > AUTO_RESIZE_THRESHOLD) { this.update({ size: [this.props.size[0], newHeight], }) app.persist(true) } } }, [innerHeight, this.props.isAutoResizing]) React.useEffect(() => { if (!this.initialHeightCalculated) { setTimeout(() => { this.onResetBounds() app.persist(true) }) } }, [this.initialHeightCalculated]) return (
{this.props.blockType === 'B' && this.props.compact ? ( ) : ( )}
) }) ReactComponent = observer((componentProps: TLComponentProps) => { const { events, isErasing, isEditing, isBinding } = componentProps const { props: { opacity, pageId, stroke, fill, scaleLevel }, } = this const app = useApp() const { renderers, handlers } = React.useContext(LogseqContext) this.persist = () => app.persist() const isMoving = useCameraMovingRef() const isSelected = app.selectedIds.has(this.id) && app.selectedIds.size === 1 const isCreating = app.isIn('logseq-portal.creating') && !pageId const tlEventsEnabled = (isMoving || (isSelected && !isEditing) || app.selectedTool.id !== 'select') && !isCreating const stop = React.useCallback( e => { if (!tlEventsEnabled) { // TODO: pinching inside Logseq Shape issue e.stopPropagation() } }, [tlEventsEnabled] ) // There are some other portal sharing the same page id are selected const portalSelected = app.selectedShapesArray.length === 1 && app.selectedShapesArray.some( shape => shape.type === 'logseq-portal' && shape.props.id !== this.props.id && pageId && (shape as LogseqPortalShape).props['pageId'] === pageId ) const scaleRatio = levelToScale[scaleLevel ?? 'md'] // It is a bit weird to update shapes here. Is there a better place? React.useEffect(() => { if (this.props.collapsed && isEditing) { // Should temporarily disable collapsing this.update({ size: [this.props.size[0], this.props.collapsedHeight], }) return () => { this.update({ size: [this.props.size[0], this.getHeaderHeight()], }) } } return () => { // no-ops } }, [isEditing, this.props.collapsed]) const onPageNameChanged = React.useCallback((id: string) => { this.initialHeightCalculated = false const blockType = validUUID(id) ? 'B' : 'P' this.update({ pageId: id, size: [400, 320], blockType: blockType, compact: blockType === 'B', }) app.selectTool('select') app.history.resume() app.history.persist() }, []) const PortalComponent = this.PortalComponent const LogseqQuickSearch = this.LogseqQuickSearch const blockContent = React.useMemo(() => { if (pageId && this.props.blockType === 'B') { return handlers?.queryBlockByUUID(pageId)?.content } }, [handlers?.queryBlockByUUID, pageId]) const targetNotFound = this.props.blockType === 'B' && typeof blockContent !== 'string' const showingPortal = (!this.props.collapsed || isEditing) && !targetNotFound if (!renderers?.Page) { return null // not being correctly configured } const { Breadcrumb, PageNameLink } = renderers return (
{isCreating ? ( ) : (
{!this.props.compact && !targetNotFound && ( {this.props.blockType === 'P' ? ( ) : ( )} )} {targetNotFound &&
Target not found
} {showingPortal && }
)}
) }) ReactIndicator = observer(() => { const bounds = this.getBounds() const app = useApp() if (app.selectedShapesArray.length === 1) { return null } return }) validateProps = (props: Partial) => { if (props.size !== undefined) { const scale = levelToScale[this.props.scaleLevel ?? 'md'] props.size[0] = Math.max(props.size[0], 240 * scale) props.size[1] = Math.max(props.size[1], HEADER_HEIGHT * scale) } return withClampedStyles(this, props) } getShapeSVGJsx({ preview }: any) { // Do not need to consider the original point here const bounds = this.getBounds() return ( <> {!this.props.compact && ( )} {this.props.blockType === 'P' ? this.props.pageId : ''} ) } }