LogseqPortalShape.tsx 25 KB

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