LogseqPortalShape.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. import {
  3. delay,
  4. getComputedColor,
  5. TLBoxShape,
  6. TLBoxShapeProps,
  7. TLResetBoundsInfo,
  8. TLResizeInfo,
  9. validUUID,
  10. isBuiltInColor,
  11. } from '@tldraw/core'
  12. import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
  13. import Vec from '@tldraw/vec'
  14. import { action, computed, makeObservable } from 'mobx'
  15. import { observer } from 'mobx-react-lite'
  16. import * as React from 'react'
  17. import type { Shape, SizeLevel } from '.'
  18. import { LogseqQuickSearch } from '../../components/QuickSearch'
  19. import { useCameraMovingRef } from '../../hooks/useCameraMoving'
  20. import { LogseqContext } from '../logseq-context'
  21. import { BindingIndicator } from './BindingIndicator'
  22. import { CustomStyleProps, withClampedStyles } from './style-props'
  23. const HEADER_HEIGHT = 40
  24. const AUTO_RESIZE_THRESHOLD = 1
  25. export interface LogseqPortalShapeProps extends TLBoxShapeProps, CustomStyleProps {
  26. type: 'logseq-portal'
  27. pageId: string // page name or UUID
  28. blockType?: 'P' | 'B'
  29. collapsed?: boolean
  30. compact?: boolean
  31. borderRadius?: number
  32. collapsedHeight?: number
  33. scaleLevel?: SizeLevel
  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 LogseqPortalShapeHeader = observer(
  44. ({
  45. type,
  46. fill,
  47. opacity,
  48. children,
  49. }: {
  50. type: 'P' | 'B'
  51. fill?: string
  52. opacity: number
  53. children: React.ReactNode
  54. }) => {
  55. const bgColor =
  56. fill !== 'var(--ls-secondary-background-color)'
  57. ? getComputedColor(fill, 'background')
  58. : 'var(--ls-tertiary-background-color)'
  59. const fillGradient =
  60. fill && fill !== 'var(--ls-secondary-background-color)'
  61. ? isBuiltInColor(fill)
  62. ? `var(--ls-highlight-color-${fill})`
  63. : fill
  64. : 'var(--ls-secondary-background-color)'
  65. return (
  66. <div
  67. className={`tl-logseq-portal-header tl-logseq-portal-header-${
  68. type === 'P' ? 'page' : 'block'
  69. }`}
  70. >
  71. <div
  72. className="absolute inset-0 tl-logseq-portal-header-bg"
  73. style={{
  74. opacity,
  75. background:
  76. type === 'P' ? bgColor : `linear-gradient(0deg, ${fillGradient}, ${bgColor})`,
  77. }}
  78. ></div>
  79. <div className="relative">{children}</div>
  80. </div>
  81. )
  82. }
  83. )
  84. export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
  85. static id = 'logseq-portal'
  86. static defaultSearchQuery = ''
  87. static defaultSearchFilter: 'B' | 'P' | null = null
  88. static defaultProps: LogseqPortalShapeProps = {
  89. id: 'logseq-portal',
  90. type: 'logseq-portal',
  91. parentId: 'page',
  92. point: [0, 0],
  93. size: [400, 50],
  94. // collapsedHeight is the height before collapsing
  95. collapsedHeight: 0,
  96. stroke: '',
  97. fill: '',
  98. noFill: false,
  99. borderRadius: 8,
  100. strokeWidth: 2,
  101. strokeType: 'line',
  102. opacity: 1,
  103. pageId: '',
  104. collapsed: false,
  105. compact: false,
  106. scaleLevel: 'md',
  107. isAutoResizing: true,
  108. }
  109. hideRotateHandle = true
  110. canChangeAspectRatio = true
  111. canFlip = true
  112. canEdit = true
  113. persist: ((replace?: boolean) => void) | null = null
  114. // For quick add shapes, we want to calculate the page height dynamically
  115. initialHeightCalculated = true
  116. getInnerHeight: (() => number) | null = null // will be overridden in the hook
  117. constructor(props = {} as Partial<LogseqPortalShapeProps>) {
  118. super(props)
  119. makeObservable(this)
  120. if (props.collapsed) {
  121. Object.assign(this.canResize, [true, false])
  122. }
  123. if (props.size?.[1] === 0) {
  124. this.initialHeightCalculated = false
  125. }
  126. }
  127. static isPageOrBlock(id: string): 'P' | 'B' | false {
  128. const blockRefEg = '((62af02d0-0443-42e8-a284-946c162b0f89))'
  129. if (id) {
  130. return /^\(\(.*\)\)$/.test(id) && id.length === blockRefEg.length ? 'B' : 'P'
  131. }
  132. return false
  133. }
  134. @computed get collapsed() {
  135. return this.props.blockType === 'B' ? this.props.compact : this.props.collapsed
  136. }
  137. @action setCollapsed = async (collapsed: boolean) => {
  138. if (this.props.blockType === 'B') {
  139. this.update({ compact: collapsed })
  140. this.canResize[1] = !collapsed
  141. if (!collapsed) {
  142. this.onResetBounds()
  143. }
  144. } else {
  145. const originalHeight = this.props.size[1]
  146. this.canResize[1] = !collapsed
  147. this.update({
  148. isAutoResizing: !collapsed,
  149. collapsed: collapsed,
  150. size: [this.props.size[0], collapsed ? this.getHeaderHeight() : this.props.collapsedHeight],
  151. collapsedHeight: collapsed ? originalHeight : this.props.collapsedHeight,
  152. })
  153. }
  154. this.persist?.()
  155. }
  156. @computed get scaleLevel() {
  157. return this.props.scaleLevel ?? 'md'
  158. }
  159. @action setScaleLevel = async (v?: SizeLevel) => {
  160. const newSize = Vec.mul(
  161. this.props.size,
  162. levelToScale[(v as SizeLevel) ?? 'md'] / levelToScale[this.props.scaleLevel ?? 'md']
  163. )
  164. this.update({
  165. scaleLevel: v,
  166. })
  167. await delay()
  168. this.update({
  169. size: newSize,
  170. })
  171. }
  172. useComponentSize<T extends HTMLElement>(ref: React.RefObject<T> | null, selector = '') {
  173. const [size, setSize] = React.useState<[number, number]>([0, 0])
  174. const app = useApp<Shape>()
  175. React.useEffect(() => {
  176. setTimeout(() => {
  177. if (ref?.current) {
  178. const el = selector ? ref.current.querySelector<HTMLElement>(selector) : ref.current
  179. if (el) {
  180. const updateSize = () => {
  181. const { width, height } = el.getBoundingClientRect()
  182. const bound = Vec.div([width, height], app.viewport.camera.zoom) as [number, number]
  183. setSize(bound)
  184. return bound
  185. }
  186. updateSize()
  187. // Hacky, I know 🤨
  188. this.getInnerHeight = () => updateSize()[1]
  189. const resizeObserver = new ResizeObserver(() => {
  190. updateSize()
  191. })
  192. resizeObserver.observe(el)
  193. return () => {
  194. resizeObserver.disconnect()
  195. }
  196. }
  197. }
  198. return () => {}
  199. }, 10);
  200. }, [ref, selector])
  201. return size
  202. }
  203. getHeaderHeight() {
  204. const scale = levelToScale[this.props.scaleLevel ?? 'md']
  205. return this.props.compact ? 0 : HEADER_HEIGHT * scale
  206. }
  207. getAutoResizeHeight() {
  208. if (this.getInnerHeight) {
  209. return this.getHeaderHeight() + this.getInnerHeight()
  210. }
  211. return null
  212. }
  213. onResetBounds = (info?: TLResetBoundsInfo) => {
  214. const height = this.getAutoResizeHeight()
  215. if (height !== null && Math.abs(height - this.props.size[1]) > AUTO_RESIZE_THRESHOLD) {
  216. this.update({
  217. size: [this.props.size[0], height],
  218. })
  219. this.initialHeightCalculated = true
  220. }
  221. return this
  222. }
  223. onResize = (initialProps: any, info: TLResizeInfo): this => {
  224. const {
  225. bounds,
  226. rotation,
  227. scale: [scaleX, scaleY],
  228. } = info
  229. const nextScale = [...this.scale]
  230. if (scaleX < 0) nextScale[0] *= -1
  231. if (scaleY < 0) nextScale[1] *= -1
  232. let height = bounds.height
  233. if (this.props.isAutoResizing) {
  234. height = this.getAutoResizeHeight() ?? height
  235. }
  236. return this.update({
  237. point: [bounds.minX, bounds.minY],
  238. size: [Math.max(1, bounds.width), Math.max(1, height)],
  239. scale: nextScale,
  240. rotation,
  241. })
  242. }
  243. PortalComponent = observer(({}: TLComponentProps) => {
  244. const {
  245. props: { pageId, fill, opacity },
  246. } = this
  247. const { renderers } = React.useContext(LogseqContext)
  248. const app = useApp<Shape>()
  249. const cpRefContainer = React.useRef<HTMLDivElement>(null)
  250. const [, innerHeight] = this.useComponentSize(
  251. cpRefContainer,
  252. this.props.compact
  253. ? '.tl-logseq-cp-container > .single-block'
  254. : '.tl-logseq-cp-container > .page'
  255. )
  256. if (!renderers?.Page) {
  257. return null // not being correctly configured
  258. }
  259. const { Page, Block } = renderers
  260. const [loaded, setLoaded] = React.useState(false)
  261. React.useEffect(() => {
  262. if (this.props.isAutoResizing) {
  263. const latestInnerHeight = this.getInnerHeight?.() ?? innerHeight
  264. const newHeight = latestInnerHeight + this.getHeaderHeight()
  265. if (innerHeight && Math.abs(newHeight - this.props.size[1]) > AUTO_RESIZE_THRESHOLD) {
  266. this.update({
  267. size: [this.props.size[0], newHeight],
  268. })
  269. if (loaded) app.persist(true)
  270. }
  271. }
  272. }, [innerHeight, this.props.isAutoResizing])
  273. React.useEffect(() => {
  274. if (!this.initialHeightCalculated) {
  275. setTimeout(() => {
  276. this.onResetBounds()
  277. app.persist(true)
  278. })
  279. }
  280. }, [this.initialHeightCalculated])
  281. React.useEffect(() => {
  282. setTimeout(function () {
  283. setLoaded(true)
  284. })
  285. }, [])
  286. return (
  287. <>
  288. <div
  289. className="absolute inset-0 tl-logseq-cp-container-bg"
  290. style={{
  291. textRendering: app.viewport.camera.zoom < 0.5 ? 'optimizeSpeed' : 'auto',
  292. background:
  293. fill && fill !== 'var(--ls-secondary-background-color)'
  294. ? isBuiltInColor(fill)
  295. ? `var(--ls-highlight-color-${fill})`
  296. : fill
  297. : 'var(--ls-secondary-background-color)',
  298. opacity,
  299. }}
  300. ></div>
  301. <div
  302. ref={cpRefContainer}
  303. className="relative tl-logseq-cp-container"
  304. style={{ overflow: this.props.isAutoResizing ? 'visible' : 'auto' }}
  305. >
  306. {(loaded || !this.initialHeightCalculated) &&
  307. (this.props.blockType === 'B' && this.props.compact ? (
  308. <Block blockId={pageId} />
  309. ) : (
  310. <Page pageName={pageId} />
  311. ))}
  312. </div>
  313. </>
  314. )
  315. })
  316. ReactComponent = observer((componentProps: TLComponentProps) => {
  317. const { events, isErasing, isEditing, isBinding } = componentProps
  318. const {
  319. props: { opacity, pageId, fill, scaleLevel, strokeWidth, size, isLocked },
  320. } = this
  321. const app = useApp<Shape>()
  322. const { renderers, handlers } = React.useContext(LogseqContext)
  323. this.persist = () => app.persist()
  324. const isMoving = useCameraMovingRef()
  325. const isSelected = app.selectedIds.has(this.id) && app.selectedIds.size === 1
  326. const isCreating = app.isIn('logseq-portal.creating') && !pageId
  327. const tlEventsEnabled =
  328. (isMoving || (isSelected && !isEditing) || app.selectedTool.id !== 'select') && !isCreating
  329. const stop = React.useCallback(
  330. e => {
  331. if (!tlEventsEnabled) {
  332. // TODO: pinching inside Logseq Shape issue
  333. e.stopPropagation()
  334. }
  335. },
  336. [tlEventsEnabled]
  337. )
  338. // There are some other portal sharing the same page id are selected
  339. const portalSelected =
  340. app.selectedShapesArray.length === 1 &&
  341. app.selectedShapesArray.some(
  342. shape =>
  343. shape.type === 'logseq-portal' &&
  344. shape.props.id !== this.props.id &&
  345. pageId &&
  346. (shape as LogseqPortalShape).props['pageId'] === pageId
  347. )
  348. const scaleRatio = levelToScale[scaleLevel ?? 'md']
  349. // It is a bit weird to update shapes here. Is there a better place?
  350. React.useEffect(() => {
  351. if (this.props.collapsed && isEditing) {
  352. // Should temporarily disable collapsing
  353. this.update({
  354. size: [this.props.size[0], this.props.collapsedHeight],
  355. })
  356. return () => {
  357. this.update({
  358. size: [this.props.size[0], this.getHeaderHeight()],
  359. })
  360. }
  361. }
  362. return () => {
  363. // no-ops
  364. }
  365. }, [isEditing, this.props.collapsed])
  366. React.useEffect(() => {
  367. if (isCreating) {
  368. const screenSize = [app.viewport.bounds.width, app.viewport.bounds.height]
  369. const boundScreenCenter = app.viewport.getScreenPoint([this.bounds.minX, this.bounds.minY])
  370. if (
  371. boundScreenCenter[0] > screenSize[0] - 400 ||
  372. boundScreenCenter[1] > screenSize[1] - 240 ||
  373. app.viewport.camera.zoom > 1.5 ||
  374. app.viewport.camera.zoom < 0.5
  375. ) {
  376. app.viewport.zoomToBounds({ ...this.bounds, minY: this.bounds.maxY + 25 })
  377. }
  378. }
  379. }, [app.viewport.bounds.height.toFixed(2)])
  380. const onPageNameChanged = React.useCallback((id: string) => {
  381. this.initialHeightCalculated = false
  382. const blockType = validUUID(id) ? 'B' : 'P'
  383. this.update({
  384. pageId: id,
  385. size: [400, 320],
  386. blockType: blockType,
  387. compact: blockType === 'B',
  388. })
  389. app.selectTool('select')
  390. app.history.resume()
  391. app.history.persist()
  392. }, [])
  393. const PortalComponent = this.PortalComponent
  394. const blockContent = React.useMemo(() => {
  395. if (pageId && this.props.blockType === 'B') {
  396. return handlers?.queryBlockByUUID(pageId)?.content
  397. }
  398. }, [handlers?.queryBlockByUUID, pageId])
  399. const targetNotFound = this.props.blockType === 'B' && typeof blockContent !== 'string'
  400. const showingPortal = (!this.props.collapsed || isEditing) && !targetNotFound
  401. if (!renderers?.Page) {
  402. return null // not being correctly configured
  403. }
  404. const { Breadcrumb, PageName } = renderers
  405. const portalStyle: React.CSSProperties = {
  406. width: `calc(100% / ${scaleRatio})`,
  407. height: `calc(100% / ${scaleRatio})`,
  408. opacity: isErasing ? 0.2 : 1,
  409. }
  410. // Reduce the chance of blurry text
  411. if (scaleRatio !== 1) {
  412. portalStyle.transform = `scale(${scaleRatio})`
  413. }
  414. return (
  415. <HTMLContainer
  416. style={{
  417. pointerEvents: 'all',
  418. }}
  419. {...events}
  420. >
  421. {isBinding && <BindingIndicator mode="html" strokeWidth={strokeWidth} size={size} />}
  422. <div
  423. data-inner-events={!tlEventsEnabled}
  424. onWheelCapture={stop}
  425. onPointerDown={stop}
  426. onPointerUp={stop}
  427. style={{
  428. width: '100%',
  429. height: '100%',
  430. pointerEvents: !isMoving && (isEditing || isSelected) ? 'all' : 'none',
  431. }}
  432. >
  433. {isCreating ? (
  434. <LogseqQuickSearch
  435. onChange={onPageNameChanged}
  436. onAddBlock={uuid => {
  437. // wait until the editor is mounted
  438. setTimeout(() => {
  439. app.api.editShape(this)
  440. window.logseq?.api?.edit_block?.(uuid)
  441. })
  442. }}
  443. placeholder="Create or search your graph..."
  444. />
  445. ) : (
  446. <div
  447. className="tl-logseq-portal-container"
  448. data-collapsed={this.collapsed}
  449. data-page-id={pageId}
  450. data-portal-selected={portalSelected}
  451. data-editing={isEditing}
  452. style={portalStyle}
  453. >
  454. {!this.props.compact && !targetNotFound && (
  455. <LogseqPortalShapeHeader
  456. type={this.props.blockType ?? 'P'}
  457. fill={fill}
  458. opacity={opacity}
  459. >
  460. {this.props.blockType === 'P' ? (
  461. <PageName pageName={pageId} />
  462. ) : (
  463. <Breadcrumb blockId={pageId} />
  464. )}
  465. </LogseqPortalShapeHeader>
  466. )}
  467. {targetNotFound && <div className="tl-target-not-found">Target not found</div>}
  468. {showingPortal && <PortalComponent {...componentProps} />}
  469. </div>
  470. )}
  471. </div>
  472. </HTMLContainer>
  473. )
  474. })
  475. ReactIndicator = observer(() => {
  476. const bounds = this.getBounds()
  477. return (
  478. <rect
  479. width={bounds.width}
  480. height={bounds.height}
  481. fill="transparent"
  482. rx={8}
  483. ry={8}
  484. strokeDasharray={this.props.isLocked ? '8 2' : 'undefined'}
  485. />
  486. )
  487. })
  488. validateProps = (props: Partial<LogseqPortalShapeProps>) => {
  489. if (props.size !== undefined) {
  490. const scale = levelToScale[this.props.scaleLevel ?? 'md']
  491. props.size[0] = Math.max(props.size[0], 60 * scale)
  492. props.size[1] = Math.max(props.size[1], HEADER_HEIGHT * scale)
  493. }
  494. return withClampedStyles(this, props)
  495. }
  496. getShapeSVGJsx({ preview }: any) {
  497. // Do not need to consider the original point here
  498. const bounds = this.getBounds()
  499. return (
  500. <>
  501. <rect
  502. fill={
  503. this.props.fill && this.props.fill !== 'var(--ls-secondary-background-color)'
  504. ? isBuiltInColor(this.props.fill)
  505. ? `var(--ls-highlight-color-${this.props.fill})`
  506. : this.props.fill
  507. : 'var(--ls-secondary-background-color)'
  508. }
  509. stroke={getComputedColor(this.props.fill, 'background')}
  510. strokeWidth={this.props.strokeWidth ?? 2}
  511. fillOpacity={this.props.opacity ?? 0.2}
  512. width={bounds.width}
  513. rx={8}
  514. ry={8}
  515. height={bounds.height}
  516. />
  517. {!this.props.compact && (
  518. <rect
  519. fill={
  520. this.props.fill && this.props.fill !== 'var(--ls-secondary-background-color)'
  521. ? getComputedColor(this.props.fill, 'background')
  522. : 'var(--ls-tertiary-background-color)'
  523. }
  524. fillOpacity={this.props.opacity ?? 0.2}
  525. x={1}
  526. y={1}
  527. width={bounds.width - 2}
  528. height={HEADER_HEIGHT - 2}
  529. rx={8}
  530. ry={8}
  531. />
  532. )}
  533. <text
  534. style={{
  535. transformOrigin: 'top left',
  536. }}
  537. transform={`translate(${bounds.width / 2}, ${10 + bounds.height / 2})`}
  538. textAnchor="middle"
  539. fontFamily="var(--ls-font-family)"
  540. fontSize="32"
  541. fill="var(--ls-secondary-text-color)"
  542. stroke="var(--ls-secondary-text-color)"
  543. >
  544. {this.props.blockType === 'P' ? this.props.pageId : ''}
  545. </text>
  546. </>
  547. )
  548. }
  549. }