LogseqPortalShape.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. import {
  3. delay,
  4. TLBoxShape,
  5. TLBoxShapeProps,
  6. TLResetBoundsInfo,
  7. TLResizeInfo,
  8. validUUID,
  9. getComputedColor,
  10. } from '@tldraw/core'
  11. import { Virtuoso } from 'react-virtuoso'
  12. import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
  13. import { useDebouncedValue } from '@tldraw/react'
  14. import Vec from '@tldraw/vec'
  15. import { action, computed, makeObservable } from 'mobx'
  16. import { observer } from 'mobx-react-lite'
  17. import * as React from 'react'
  18. import type { SizeLevel, Shape } from '.'
  19. import { TablerIcon } from '../../components/icons'
  20. import { TextInput } from '../../components/inputs/TextInput'
  21. import { useCameraMovingRef } from '../../hooks/useCameraMoving'
  22. import { LogseqContext, type SearchResult } from '../logseq-context'
  23. import { BindingIndicator } from './BindingIndicator'
  24. import { CustomStyleProps, withClampedStyles } from './style-props'
  25. const HEADER_HEIGHT = 40
  26. const AUTO_RESIZE_THRESHOLD = 1
  27. export interface LogseqPortalShapeProps extends TLBoxShapeProps, CustomStyleProps {
  28. type: 'logseq-portal'
  29. pageId: string // page name or UUID
  30. blockType?: 'P' | 'B'
  31. collapsed?: boolean
  32. compact?: boolean
  33. borderRadius?: number
  34. collapsedHeight?: number
  35. scaleLevel?: SizeLevel
  36. }
  37. interface LogseqQuickSearchProps {
  38. onChange: (id: string) => void
  39. }
  40. const levelToScale = {
  41. xs: 0.5,
  42. sm: 0.8,
  43. md: 1,
  44. lg: 1.5,
  45. xl: 2,
  46. xxl: 3,
  47. }
  48. const LogseqTypeTag = ({
  49. type,
  50. active,
  51. }: {
  52. type: 'B' | 'P' | 'WP' | 'BS' | 'PS'
  53. active?: boolean
  54. }) => {
  55. const nameMapping = {
  56. B: 'block',
  57. P: 'page',
  58. WP: 'whiteboard',
  59. BS: 'block-search',
  60. PS: 'page-search',
  61. }
  62. return (
  63. <span className="tl-type-tag" data-active={active}>
  64. <i className={`tie tie-${nameMapping[type]}`} />
  65. </span>
  66. )
  67. }
  68. const LogseqPortalShapeHeader = observer(
  69. ({
  70. type,
  71. fill,
  72. opacity,
  73. children,
  74. }: {
  75. type: 'P' | 'B'
  76. fill: string
  77. opacity: number
  78. children: React.ReactNode
  79. }) => {
  80. const bgColor = getComputedColor(fill, 'background')
  81. return (
  82. <div
  83. className={`tl-logseq-portal-header tl-logseq-portal-header-${
  84. type === 'P' ? 'page' : 'block'
  85. }`}
  86. >
  87. <div
  88. className="absolute inset-0 tl-logseq-portal-header-bg"
  89. style={{
  90. opacity,
  91. background:
  92. type === 'P'
  93. ? bgColor
  94. : `linear-gradient(0deg, var(--ls-highlight-color-${
  95. fill ? fill : 'default'
  96. }), ${bgColor}`,
  97. }}
  98. ></div>
  99. <div className="relative">{children}</div>
  100. </div>
  101. )
  102. }
  103. )
  104. function escapeRegExp(text: string) {
  105. return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
  106. }
  107. const highlightedJSX = (input: string, keyword: string) => {
  108. return (
  109. <span>
  110. {input
  111. .split(new RegExp(`(${escapeRegExp(keyword)})`, 'gi'))
  112. .map((part, index) => {
  113. if (index % 2 === 1) {
  114. return <mark className="tl-highlighted">{part}</mark>
  115. }
  116. return part
  117. })
  118. .map((frag, idx) => (
  119. <React.Fragment key={idx}>{frag}</React.Fragment>
  120. ))}
  121. </span>
  122. )
  123. }
  124. const useSearch = (q: string, searchFilter: 'B' | 'P' | null) => {
  125. const { handlers } = React.useContext(LogseqContext)
  126. const [results, setResults] = React.useState<SearchResult | null>(null)
  127. const dq = useDebouncedValue(q, 200)
  128. React.useEffect(() => {
  129. let canceled = false
  130. if (dq.length > 0) {
  131. const filter = { 'pages?': true, 'blocks?': true, 'files?': false }
  132. if (searchFilter === 'B') {
  133. filter['pages?'] = false
  134. } else if (searchFilter === 'P') {
  135. filter['blocks?'] = false
  136. }
  137. handlers.search(dq, filter).then(_results => {
  138. if (!canceled) {
  139. setResults(_results)
  140. }
  141. })
  142. } else {
  143. setResults(null)
  144. }
  145. return () => {
  146. canceled = true
  147. }
  148. }, [dq, handlers?.search])
  149. return results
  150. }
  151. const CircleButton = ({
  152. active,
  153. style,
  154. icon,
  155. otherIcon,
  156. onClick,
  157. }: {
  158. active?: boolean
  159. style?: React.CSSProperties
  160. icon: string
  161. otherIcon?: string
  162. onClick: () => void
  163. }) => {
  164. const [recentlyChanged, setRecentlyChanged] = React.useState(false)
  165. React.useEffect(() => {
  166. setRecentlyChanged(true)
  167. const timer = setTimeout(() => {
  168. setRecentlyChanged(false)
  169. }, 500)
  170. return () => clearTimeout(timer)
  171. }, [active])
  172. return (
  173. <button
  174. data-active={active}
  175. data-recently-changed={recentlyChanged}
  176. style={style}
  177. className="tl-circle-button"
  178. onMouseDown={onClick}
  179. >
  180. <div className="tl-circle-button-icons-wrapper" data-icons-count={otherIcon ? 2 : 1}>
  181. {otherIcon && <TablerIcon name={otherIcon} />}
  182. <TablerIcon name={icon} />
  183. </div>
  184. </button>
  185. )
  186. }
  187. export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
  188. static id = 'logseq-portal'
  189. static defaultSearchQuery = ''
  190. static defaultSearchFilter: 'B' | 'P' | null = null
  191. static defaultProps: LogseqPortalShapeProps = {
  192. id: 'logseq-portal',
  193. type: 'logseq-portal',
  194. parentId: 'page',
  195. point: [0, 0],
  196. size: [400, 50],
  197. // collapsedHeight is the height before collapsing
  198. collapsedHeight: 0,
  199. stroke: '',
  200. fill: '',
  201. noFill: false,
  202. borderRadius: 8,
  203. strokeWidth: 2,
  204. strokeType: 'line',
  205. opacity: 1,
  206. pageId: '',
  207. collapsed: false,
  208. compact: false,
  209. scaleLevel: 'md',
  210. isAutoResizing: true,
  211. }
  212. hideRotateHandle = true
  213. canChangeAspectRatio = true
  214. canFlip = true
  215. canEdit = true
  216. persist: ((replace?: boolean) => void) | null = null
  217. // For quick add shapes, we want to calculate the page height dynamically
  218. initialHeightCalculated = true
  219. getInnerHeight: (() => number) | null = null // will be overridden in the hook
  220. constructor(props = {} as Partial<LogseqPortalShapeProps>) {
  221. super(props)
  222. makeObservable(this)
  223. if (props.collapsed) {
  224. Object.assign(this.canResize, [true, false])
  225. }
  226. if (props.size?.[1] === 0) {
  227. this.initialHeightCalculated = false
  228. }
  229. }
  230. static isPageOrBlock(id: string): 'P' | 'B' | false {
  231. const blockRefEg = '((62af02d0-0443-42e8-a284-946c162b0f89))'
  232. if (id) {
  233. return /^\(\(.*\)\)$/.test(id) && id.length === blockRefEg.length ? 'B' : 'P'
  234. }
  235. return false
  236. }
  237. @computed get collapsed() {
  238. return this.props.blockType === 'B' ? this.props.compact : this.props.collapsed
  239. }
  240. @action setCollapsed = async (collapsed: boolean) => {
  241. if (this.props.blockType === 'B') {
  242. this.update({ compact: collapsed })
  243. this.canResize[1] = !collapsed
  244. if (!collapsed) {
  245. // this will also persist the state, so we can skip persist call
  246. await delay()
  247. this.onResetBounds()
  248. }
  249. this.persist?.()
  250. } else {
  251. const originalHeight = this.props.size[1]
  252. this.canResize[1] = !collapsed
  253. console.log(
  254. collapsed,
  255. collapsed ? this.getHeaderHeight() : this.props.collapsedHeight,
  256. this.getHeaderHeight(),
  257. this.props.collapsedHeight
  258. )
  259. this.update({
  260. isAutoResizing: !collapsed,
  261. collapsed: collapsed,
  262. size: [this.props.size[0], collapsed ? this.getHeaderHeight() : this.props.collapsedHeight],
  263. collapsedHeight: collapsed ? originalHeight : this.props.collapsedHeight,
  264. })
  265. }
  266. }
  267. @computed get scaleLevel() {
  268. return this.props.scaleLevel ?? 'md'
  269. }
  270. @action setScaleLevel = async (v?: SizeLevel) => {
  271. const newSize = Vec.mul(
  272. this.props.size,
  273. levelToScale[(v as SizeLevel) ?? 'md'] / levelToScale[this.props.scaleLevel ?? 'md']
  274. )
  275. this.update({
  276. scaleLevel: v,
  277. })
  278. await delay()
  279. this.update({
  280. size: newSize,
  281. })
  282. }
  283. useComponentSize<T extends HTMLElement>(ref: React.RefObject<T> | null, selector = '') {
  284. const [size, setSize] = React.useState<[number, number]>([0, 0])
  285. const app = useApp<Shape>()
  286. React.useEffect(() => {
  287. if (ref?.current) {
  288. const el = selector ? ref.current.querySelector<HTMLElement>(selector) : ref.current
  289. if (el) {
  290. const updateSize = () => {
  291. const { width, height } = el.getBoundingClientRect()
  292. const bound = Vec.div([width, height], app.viewport.camera.zoom) as [number, number]
  293. setSize(bound)
  294. return bound
  295. }
  296. updateSize()
  297. // Hacky, I know 🤨
  298. this.getInnerHeight = () => updateSize()[1]
  299. const resizeObserver = new ResizeObserver(() => {
  300. updateSize()
  301. })
  302. resizeObserver.observe(el)
  303. return () => {
  304. resizeObserver.disconnect()
  305. }
  306. }
  307. }
  308. return () => {}
  309. }, [ref, selector])
  310. return size
  311. }
  312. getHeaderHeight() {
  313. const scale = levelToScale[this.props.scaleLevel ?? 'md']
  314. return this.props.compact ? 0 : HEADER_HEIGHT * scale
  315. }
  316. getAutoResizeHeight() {
  317. if (this.getInnerHeight) {
  318. return this.getHeaderHeight() + this.getInnerHeight()
  319. }
  320. return null
  321. }
  322. onResetBounds = (info?: TLResetBoundsInfo) => {
  323. const height = this.getAutoResizeHeight()
  324. if (height !== null && Math.abs(height - this.props.size[1]) > AUTO_RESIZE_THRESHOLD) {
  325. this.update({
  326. size: [this.props.size[0], height],
  327. })
  328. this.initialHeightCalculated = true
  329. }
  330. return this
  331. }
  332. onResize = (initialProps: any, info: TLResizeInfo): this => {
  333. const {
  334. bounds,
  335. rotation,
  336. scale: [scaleX, scaleY],
  337. } = info
  338. const nextScale = [...this.scale]
  339. if (scaleX < 0) nextScale[0] *= -1
  340. if (scaleY < 0) nextScale[1] *= -1
  341. let height = bounds.height
  342. if (this.props.isAutoResizing) {
  343. height = this.getAutoResizeHeight() ?? height
  344. }
  345. return this.update({
  346. point: [bounds.minX, bounds.minY],
  347. size: [Math.max(1, bounds.width), Math.max(1, height)],
  348. scale: nextScale,
  349. rotation,
  350. })
  351. }
  352. LogseqQuickSearch = observer(({ onChange }: LogseqQuickSearchProps) => {
  353. const [q, setQ] = React.useState(LogseqPortalShape.defaultSearchQuery)
  354. const [searchFilter, setSearchFilter] = React.useState<'B' | 'P' | null>(
  355. LogseqPortalShape.defaultSearchFilter
  356. )
  357. const rInput = React.useRef<HTMLInputElement>(null)
  358. const { handlers, renderers } = React.useContext(LogseqContext)
  359. const app = useApp<Shape>()
  360. const finishCreating = React.useCallback((id: string) => {
  361. onChange(id)
  362. rInput.current?.blur()
  363. if (id) {
  364. LogseqPortalShape.defaultSearchQuery = ''
  365. LogseqPortalShape.defaultSearchFilter = null
  366. }
  367. }, [])
  368. const onAddBlock = React.useCallback((content: string) => {
  369. const uuid = handlers?.addNewBlock(content)
  370. if (uuid) {
  371. finishCreating(uuid)
  372. // wait until the editor is mounted
  373. setTimeout(() => {
  374. app.api.editShape(this)
  375. window.logseq?.api?.edit_block?.(uuid)
  376. })
  377. }
  378. return uuid
  379. }, [])
  380. const optionsWrapperRef = React.useRef<HTMLDivElement>(null)
  381. const [focusedOptionIdx, setFocusedOptionIdx] = React.useState<number>(0)
  382. const searchResult = useSearch(q, searchFilter)
  383. const [prefixIcon, setPrefixIcon] = React.useState<string>('circle-plus')
  384. React.useEffect(() => {
  385. // autofocus seems not to be working
  386. setTimeout(() => {
  387. rInput.current?.focus()
  388. })
  389. }, [searchFilter])
  390. React.useEffect(() => {
  391. LogseqPortalShape.defaultSearchQuery = q
  392. LogseqPortalShape.defaultSearchFilter = searchFilter
  393. }, [q, searchFilter])
  394. type Option = {
  395. actionIcon: 'search' | 'circle-plus'
  396. onChosen: () => boolean // return true if the action was handled
  397. element: React.ReactNode
  398. }
  399. const options: Option[] = React.useMemo(() => {
  400. const options: Option[] = []
  401. const Breadcrumb = renderers?.Breadcrumb
  402. if (!Breadcrumb || !handlers) {
  403. return []
  404. }
  405. // New block option
  406. options.push({
  407. actionIcon: 'circle-plus',
  408. onChosen: () => {
  409. return !!onAddBlock(q)
  410. },
  411. element: (
  412. <div className="tl-quick-search-option-row">
  413. <LogseqTypeTag active type="B" />
  414. {q.length > 0 ? (
  415. <>
  416. <strong>New whiteboard block:</strong>
  417. {q}
  418. </>
  419. ) : (
  420. <strong>New whiteboard block</strong>
  421. )}
  422. </div>
  423. ),
  424. })
  425. // New page option when no exact match
  426. if (!searchResult?.pages?.some(p => p.toLowerCase() === q.toLowerCase()) && q) {
  427. options.push({
  428. actionIcon: 'circle-plus',
  429. onChosen: () => {
  430. finishCreating(q)
  431. return true
  432. },
  433. element: (
  434. <div className="tl-quick-search-option-row">
  435. <LogseqTypeTag active type="P" />
  436. <strong>New page:</strong>
  437. {q}
  438. </div>
  439. ),
  440. })
  441. }
  442. // search filters
  443. if (q.length === 0 && searchFilter === null) {
  444. options.push(
  445. {
  446. actionIcon: 'search',
  447. onChosen: () => {
  448. setSearchFilter('B')
  449. return true
  450. },
  451. element: (
  452. <div className="tl-quick-search-option-row">
  453. <LogseqTypeTag type="BS" />
  454. Search only blocks
  455. </div>
  456. ),
  457. },
  458. {
  459. actionIcon: 'search',
  460. onChosen: () => {
  461. setSearchFilter('P')
  462. return true
  463. },
  464. element: (
  465. <div className="tl-quick-search-option-row">
  466. <LogseqTypeTag type="PS" />
  467. Search only pages
  468. </div>
  469. ),
  470. }
  471. )
  472. }
  473. // Page results
  474. if ((!searchFilter || searchFilter === 'P') && searchResult && searchResult.pages) {
  475. options.push(
  476. ...searchResult.pages.map(page => {
  477. return {
  478. actionIcon: 'search' as 'search',
  479. onChosen: () => {
  480. finishCreating(page)
  481. return true
  482. },
  483. element: (
  484. <div className="tl-quick-search-option-row">
  485. <LogseqTypeTag type={handlers.isWhiteboardPage(page) ? 'WP' : 'P'} />
  486. {highlightedJSX(page, q)}
  487. </div>
  488. ),
  489. }
  490. })
  491. )
  492. }
  493. // Block results
  494. if ((!searchFilter || searchFilter === 'B') && searchResult && searchResult.blocks) {
  495. options.push(
  496. ...searchResult.blocks
  497. .filter(block => block.content && block.uuid)
  498. .map(({ content, uuid }) => {
  499. const block = handlers.queryBlockByUUID(uuid)
  500. return {
  501. actionIcon: 'search' as 'search',
  502. onChosen: () => {
  503. if (block) {
  504. finishCreating(uuid)
  505. window.logseq?.api?.set_blocks_id?.([uuid])
  506. return true
  507. }
  508. return false
  509. },
  510. element: block ? (
  511. <>
  512. <div className="tl-quick-search-option-row">
  513. <LogseqTypeTag type="B" />
  514. <div className="tl-quick-search-option-breadcrumb">
  515. <Breadcrumb blockId={uuid} />
  516. </div>
  517. </div>
  518. <div className="tl-quick-search-option-row">
  519. <div className="tl-quick-search-option-placeholder" />
  520. {highlightedJSX(content, q)}
  521. </div>
  522. </>
  523. ) : (
  524. <div className="tl-quick-search-option-row">
  525. Cache is outdated. Please click the 'Re-index' button in the graph's dropdown
  526. menu.
  527. </div>
  528. ),
  529. }
  530. })
  531. )
  532. }
  533. return options
  534. }, [q, searchFilter, searchResult, renderers?.Breadcrumb, handlers])
  535. React.useEffect(() => {
  536. const keydownListener = (e: KeyboardEvent) => {
  537. let newIndex = focusedOptionIdx
  538. if (e.key === 'ArrowDown') {
  539. newIndex = Math.min(options.length - 1, focusedOptionIdx + 1)
  540. } else if (e.key === 'ArrowUp') {
  541. newIndex = Math.max(0, focusedOptionIdx - 1)
  542. } else if (e.key === 'Enter') {
  543. options[focusedOptionIdx]?.onChosen()
  544. e.stopPropagation()
  545. e.preventDefault()
  546. } else if (e.key === 'Backspace' && q.length === 0) {
  547. setSearchFilter(null)
  548. } else if (e.key === 'Escape') {
  549. finishCreating('')
  550. }
  551. if (newIndex !== focusedOptionIdx) {
  552. const option = options[newIndex]
  553. setFocusedOptionIdx(newIndex)
  554. setPrefixIcon(option.actionIcon)
  555. e.stopPropagation()
  556. e.preventDefault()
  557. const optionElement = optionsWrapperRef.current?.querySelector(
  558. '.tl-quick-search-option:nth-child(' + (newIndex + 1) + ')'
  559. )
  560. if (optionElement) {
  561. // @ts-expect-error we are using scrollIntoViewIfNeeded, which is not in standards
  562. optionElement?.scrollIntoViewIfNeeded(false)
  563. }
  564. }
  565. }
  566. document.addEventListener('keydown', keydownListener, true)
  567. return () => {
  568. document.removeEventListener('keydown', keydownListener, true)
  569. }
  570. }, [options, focusedOptionIdx, q])
  571. return (
  572. <div className="tl-quick-search">
  573. <CircleButton
  574. icon={prefixIcon}
  575. onClick={() => {
  576. options[focusedOptionIdx]?.onChosen()
  577. }}
  578. />
  579. <div className="tl-quick-search-input-container">
  580. {searchFilter && (
  581. <div className="tl-quick-search-input-filter">
  582. <LogseqTypeTag type={searchFilter} />
  583. {searchFilter === 'B' ? 'Search blocks' : 'Search pages'}
  584. <div
  585. className="tl-quick-search-input-filter-remove"
  586. onClick={() => setSearchFilter(null)}
  587. >
  588. <TablerIcon name="x" />
  589. </div>
  590. </div>
  591. )}
  592. <TextInput
  593. ref={rInput}
  594. type="text"
  595. value={q}
  596. className="tl-quick-search-input"
  597. placeholder="Create or search your graph..."
  598. onChange={q => setQ(q.target.value)}
  599. onKeyDown={e => {
  600. if (e.key === 'Enter') {
  601. finishCreating(q)
  602. }
  603. }}
  604. />
  605. </div>
  606. <div className="tl-quick-search-options" ref={optionsWrapperRef}>
  607. <Virtuoso
  608. style={{ height: Math.min(Math.max(1, options.length), 12) * 36 }}
  609. totalCount={options.length}
  610. itemContent={index => {
  611. const { actionIcon, onChosen, element } = options[index]
  612. return (
  613. <div
  614. key={index}
  615. data-focused={index === focusedOptionIdx}
  616. className="tl-quick-search-option"
  617. tabIndex={0}
  618. onMouseEnter={() => {
  619. setPrefixIcon(actionIcon)
  620. setFocusedOptionIdx(index)
  621. }}
  622. // we have to use mousedown && stop propagation EARLY, otherwise some
  623. // default behavior of clicking the rendered elements will happen
  624. onMouseDownCapture={e => {
  625. if (onChosen()) {
  626. e.stopPropagation()
  627. e.preventDefault()
  628. }
  629. }}
  630. >
  631. {element}
  632. </div>
  633. )
  634. }}
  635. />
  636. </div>
  637. </div>
  638. )
  639. })
  640. PortalComponent = observer(({}: TLComponentProps) => {
  641. const {
  642. props: { pageId, fill, opacity },
  643. } = this
  644. const { renderers } = React.useContext(LogseqContext)
  645. const app = useApp<Shape>()
  646. const cpRefContainer = React.useRef<HTMLDivElement>(null)
  647. const [, innerHeight] = this.useComponentSize(
  648. cpRefContainer,
  649. this.props.compact
  650. ? '.tl-logseq-cp-container > .single-block'
  651. : '.tl-logseq-cp-container > .page'
  652. )
  653. if (!renderers?.Page) {
  654. return null // not being correctly configured
  655. }
  656. const { Page, Block } = renderers
  657. React.useEffect(() => {
  658. if (this.props.isAutoResizing) {
  659. const latestInnerHeight = this.getInnerHeight?.() ?? innerHeight
  660. const newHeight = latestInnerHeight + this.getHeaderHeight()
  661. if (innerHeight && Math.abs(newHeight - this.props.size[1]) > AUTO_RESIZE_THRESHOLD) {
  662. this.update({
  663. size: [this.props.size[0], newHeight],
  664. })
  665. app.persist(true)
  666. }
  667. }
  668. }, [innerHeight, this.props.isAutoResizing])
  669. React.useEffect(() => {
  670. if (!this.initialHeightCalculated) {
  671. setTimeout(() => {
  672. this.onResetBounds()
  673. app.persist(true)
  674. })
  675. }
  676. }, [this.initialHeightCalculated])
  677. return (
  678. <div
  679. ref={cpRefContainer}
  680. className="relative tl-logseq-cp-container"
  681. style={{ overflow: this.props.isAutoResizing ? 'visible' : 'auto' }}
  682. >
  683. <div
  684. className="absolute inset-0 tl-logseq-cp-container-bg"
  685. style={{
  686. background: fill
  687. ? `var(--ls-highlight-color-${fill})`
  688. : 'var(--ls-secondary-background-color)',
  689. opacity,
  690. }}
  691. ></div>
  692. {this.props.blockType === 'B' && this.props.compact ? (
  693. <Block blockId={pageId} />
  694. ) : (
  695. <Page pageName={pageId} />
  696. )}
  697. </div>
  698. )
  699. })
  700. ReactComponent = observer((componentProps: TLComponentProps) => {
  701. const { events, isErasing, isEditing, isBinding } = componentProps
  702. const {
  703. props: { opacity, pageId, stroke, fill, scaleLevel, strokeWidth, size },
  704. } = this
  705. const app = useApp<Shape>()
  706. const { renderers, handlers } = React.useContext(LogseqContext)
  707. this.persist = () => app.persist()
  708. const isMoving = useCameraMovingRef()
  709. const isSelected = app.selectedIds.has(this.id) && app.selectedIds.size === 1
  710. const isCreating = app.isIn('logseq-portal.creating') && !pageId
  711. const tlEventsEnabled =
  712. (isMoving || (isSelected && !isEditing) || app.selectedTool.id !== 'select') && !isCreating
  713. const stop = React.useCallback(
  714. e => {
  715. if (!tlEventsEnabled) {
  716. // TODO: pinching inside Logseq Shape issue
  717. e.stopPropagation()
  718. }
  719. },
  720. [tlEventsEnabled]
  721. )
  722. // There are some other portal sharing the same page id are selected
  723. const portalSelected =
  724. app.selectedShapesArray.length === 1 &&
  725. app.selectedShapesArray.some(
  726. shape =>
  727. shape.type === 'logseq-portal' &&
  728. shape.props.id !== this.props.id &&
  729. pageId &&
  730. (shape as LogseqPortalShape).props['pageId'] === pageId
  731. )
  732. const scaleRatio = levelToScale[scaleLevel ?? 'md']
  733. // It is a bit weird to update shapes here. Is there a better place?
  734. React.useEffect(() => {
  735. if (this.props.collapsed && isEditing) {
  736. // Should temporarily disable collapsing
  737. this.update({
  738. size: [this.props.size[0], this.props.collapsedHeight],
  739. })
  740. return () => {
  741. this.update({
  742. size: [this.props.size[0], this.getHeaderHeight()],
  743. })
  744. }
  745. }
  746. return () => {
  747. // no-ops
  748. }
  749. }, [isEditing, this.props.collapsed])
  750. const onPageNameChanged = React.useCallback((id: string) => {
  751. this.initialHeightCalculated = false
  752. const blockType = validUUID(id) ? 'B' : 'P'
  753. this.update({
  754. pageId: id,
  755. size: [400, 320],
  756. blockType: blockType,
  757. compact: blockType === 'B',
  758. })
  759. app.selectTool('select')
  760. app.history.resume()
  761. app.history.persist()
  762. }, [])
  763. const PortalComponent = this.PortalComponent
  764. const LogseqQuickSearch = this.LogseqQuickSearch
  765. const blockContent = React.useMemo(() => {
  766. if (pageId && this.props.blockType === 'B') {
  767. return handlers?.queryBlockByUUID(pageId)?.content
  768. }
  769. }, [handlers?.queryBlockByUUID, pageId])
  770. const targetNotFound = this.props.blockType === 'B' && typeof blockContent !== 'string'
  771. const showingPortal = (!this.props.collapsed || isEditing) && !targetNotFound
  772. if (!renderers?.Page) {
  773. return null // not being correctly configured
  774. }
  775. const { Breadcrumb, PageNameLink } = renderers
  776. return (
  777. <HTMLContainer
  778. style={{
  779. pointerEvents: 'all',
  780. }}
  781. {...events}
  782. >
  783. {isBinding && <BindingIndicator mode="html" strokeWidth={strokeWidth} size={size} />}
  784. <div
  785. onWheelCapture={stop}
  786. onPointerDown={stop}
  787. onPointerUp={stop}
  788. style={{
  789. width: '100%',
  790. height: '100%',
  791. pointerEvents: !isMoving && (isEditing || isSelected) ? 'all' : 'none',
  792. }}
  793. >
  794. {isCreating ? (
  795. <LogseqQuickSearch onChange={onPageNameChanged} />
  796. ) : (
  797. <>
  798. <div
  799. className="tl-logseq-portal-container"
  800. data-collapsed={this.collapsed}
  801. data-page-id={pageId}
  802. data-portal-selected={portalSelected}
  803. data-editing={isEditing}
  804. style={{
  805. width: `calc(100% / ${scaleRatio})`,
  806. height: `calc(100% / ${scaleRatio})`,
  807. transform: `scale(${scaleRatio})`,
  808. opacity: isErasing ? 0.2 : 1,
  809. }}
  810. >
  811. {!this.props.compact && !targetNotFound && (
  812. <LogseqPortalShapeHeader
  813. type={this.props.blockType ?? 'P'}
  814. fill={fill}
  815. opacity={opacity}
  816. >
  817. {this.props.blockType === 'P' ? (
  818. <PageNameLink pageName={pageId} />
  819. ) : (
  820. <Breadcrumb blockId={pageId} />
  821. )}
  822. </LogseqPortalShapeHeader>
  823. )}
  824. {targetNotFound && <div className="tl-target-not-found">Target not found</div>}
  825. {showingPortal && <PortalComponent {...componentProps} />}
  826. </div>
  827. <CircleButton
  828. active={!!this.collapsed}
  829. style={{ opacity: isSelected ? 1 : 0 }}
  830. icon={this.props.blockType === 'B' ? 'block' : 'page'}
  831. onClick={() => this.setCollapsed(!this.collapsed)}
  832. otherIcon={'whiteboard-element'}
  833. />
  834. </>
  835. )}
  836. </div>
  837. </HTMLContainer>
  838. )
  839. })
  840. ReactIndicator = observer(() => {
  841. const bounds = this.getBounds()
  842. const app = useApp<Shape>()
  843. if (app.selectedShapesArray.length === 1) {
  844. return null
  845. }
  846. return <rect width={bounds.width} height={bounds.height} fill="transparent" rx={8} ry={8} />
  847. })
  848. validateProps = (props: Partial<LogseqPortalShapeProps>) => {
  849. if (props.size !== undefined) {
  850. const scale = levelToScale[this.props.scaleLevel ?? 'md']
  851. props.size[0] = Math.max(props.size[0], 60 * scale)
  852. props.size[1] = Math.max(props.size[1], HEADER_HEIGHT * scale)
  853. }
  854. return withClampedStyles(this, props)
  855. }
  856. getShapeSVGJsx({ preview }: any) {
  857. // Do not need to consider the original point here
  858. const bounds = this.getBounds()
  859. return (
  860. <>
  861. <rect
  862. fill={
  863. this.props.fill
  864. ? `var(--ls-highlight-color-${this.props.fill})`
  865. : 'var(--ls-secondary-background-color)'
  866. }
  867. stroke={getComputedColor(this.props.fill, 'background')}
  868. strokeWidth={this.props.strokeWidth ?? 2}
  869. fillOpacity={this.props.opacity ?? 0.2}
  870. width={bounds.width}
  871. rx={8}
  872. ry={8}
  873. height={bounds.height}
  874. />
  875. {!this.props.compact && (
  876. <rect
  877. fill={
  878. this.props.fill
  879. ? getComputedColor(this.props.fill, 'background')
  880. : 'var(--ls-tertiary-background-color)'
  881. }
  882. fillOpacity={this.props.opacity ?? 0.2}
  883. x={1}
  884. y={1}
  885. width={bounds.width - 2}
  886. height={HEADER_HEIGHT - 2}
  887. rx={8}
  888. ry={8}
  889. />
  890. )}
  891. <text
  892. style={{
  893. transformOrigin: 'top left',
  894. }}
  895. transform={`translate(${bounds.width / 2}, ${10 + bounds.height / 2})`}
  896. textAnchor="middle"
  897. fontFamily="var(--ls-font-family)"
  898. fontSize="32"
  899. fill="var(--ls-secondary-text-color)"
  900. stroke="var(--ls-secondary-text-color)"
  901. >
  902. {this.props.blockType === 'P' ? this.props.pageId : ''}
  903. </text>
  904. </>
  905. )
  906. }
  907. }