contextBarActionFactory.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import { Decoration, isNonNullable, validUUID } from '@tldraw/core'
  2. import { useApp } from '@tldraw/react'
  3. import { observer } from 'mobx-react-lite'
  4. import React from 'react'
  5. import type {
  6. BoxShape,
  7. EllipseShape,
  8. HTMLShape,
  9. IFrameShape,
  10. LineShape,
  11. LogseqPortalShape,
  12. PencilShape,
  13. PolygonShape,
  14. Shape,
  15. TextShape,
  16. YouTubeShape,
  17. } from '../../lib'
  18. import { Button } from '../Button'
  19. import { TablerIcon } from '../icons'
  20. import { ColorInput } from '../inputs/ColorInput'
  21. import { SelectInput, type SelectOption } from '../inputs/SelectInput'
  22. import { ShapeLinksInput } from '../inputs/ShapeLinksInput'
  23. import { TextInput } from '../inputs/TextInput'
  24. import {
  25. ToggleGroupInput,
  26. ToggleGroupMultipleInput,
  27. type ToggleGroupInputOption,
  28. } from '../inputs/ToggleGroupInput'
  29. import { ToggleInput } from '../inputs/ToggleInput'
  30. import { LogseqContext } from '../../lib/logseq-context'
  31. export const contextBarActionTypes = [
  32. // Order matters
  33. 'Edit',
  34. 'AutoResizing',
  35. 'Swatch',
  36. 'NoFill',
  37. 'StrokeType',
  38. 'ScaleLevel',
  39. 'TextStyle',
  40. 'YoutubeLink',
  41. 'IFrameSource',
  42. 'LogseqPortalViewMode',
  43. 'ArrowMode',
  44. 'Links',
  45. ] as const
  46. type ContextBarActionType = typeof contextBarActionTypes[number]
  47. const singleShapeActions: ContextBarActionType[] = ['Edit', 'YoutubeLink', 'IFrameSource', 'Links']
  48. const contextBarActionMapping = new Map<ContextBarActionType, React.FC>()
  49. type ShapeType = Shape['props']['type']
  50. export const shapeMapping: Record<ShapeType, ContextBarActionType[]> = {
  51. 'logseq-portal': [
  52. 'Swatch',
  53. 'Edit',
  54. 'LogseqPortalViewMode',
  55. 'ScaleLevel',
  56. 'AutoResizing',
  57. 'Links',
  58. ],
  59. youtube: ['YoutubeLink', 'Links'],
  60. iframe: ['IFrameSource', 'Links'],
  61. box: ['Edit', 'TextStyle', 'Swatch', 'NoFill', 'StrokeType', 'Links'],
  62. ellipse: ['Edit', 'TextStyle', 'Swatch', 'NoFill', 'StrokeType', 'Links'],
  63. polygon: ['Edit', 'TextStyle', 'Swatch', 'NoFill', 'StrokeType', 'Links'],
  64. line: ['Edit', 'TextStyle', 'Swatch', 'ArrowMode', 'Links'],
  65. pencil: ['Swatch', 'Links'],
  66. highlighter: ['Swatch', 'Links'],
  67. text: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'AutoResizing', 'Links'],
  68. html: ['ScaleLevel', 'AutoResizing', 'Links'],
  69. image: ['Links'],
  70. video: ['Links'],
  71. }
  72. export const withFillShapes = Object.entries(shapeMapping)
  73. .filter(([key, types]) => {
  74. return types.includes('NoFill') && types.includes('Swatch')
  75. })
  76. .map(([key]) => key) as ShapeType[]
  77. function filterShapeByAction<S extends Shape>(shapes: Shape[], type: ContextBarActionType): S[] {
  78. return shapes.filter(shape => shapeMapping[shape.props.type]?.includes(type)) as S[]
  79. }
  80. const EditAction = observer(() => {
  81. const {
  82. handlers: { isWhiteboardPage, redirectToPage },
  83. } = React.useContext(LogseqContext)
  84. const app = useApp<Shape>()
  85. const shape = filterShapeByAction(app.selectedShapesArray, 'Edit')[0]
  86. const iconName =
  87. ('label' in shape.props && shape.props.label) || ('text' in shape.props && shape.props.text)
  88. ? 'forms'
  89. : 'text'
  90. return (
  91. <Button
  92. type="button"
  93. tooltip="Edit"
  94. onClick={() => {
  95. app.api.editShape(shape)
  96. if (shape.props.type === 'logseq-portal') {
  97. let uuid = shape.props.pageId
  98. if (shape.props.blockType === 'P') {
  99. if (isWhiteboardPage(uuid)) {
  100. redirectToPage(uuid)
  101. }
  102. const firstNonePropertyBlock = window.logseq?.api
  103. ?.get_page_blocks_tree?.(shape.props.pageId)
  104. .find(b => !('propertiesOrder' in b))
  105. uuid = firstNonePropertyBlock.uuid
  106. }
  107. window.logseq?.api?.edit_block?.(uuid)
  108. }
  109. }}
  110. >
  111. <TablerIcon name={iconName} />
  112. </Button>
  113. )
  114. })
  115. const AutoResizingAction = observer(() => {
  116. const app = useApp<Shape>()
  117. const shapes = filterShapeByAction<LogseqPortalShape | TextShape | HTMLShape>(
  118. app.selectedShapesArray,
  119. 'AutoResizing'
  120. )
  121. const pressed = shapes.every(s => s.props.isAutoResizing)
  122. return (
  123. <ToggleInput
  124. tooltip="Auto Resize"
  125. toggle={shapes.every(s => s.props.type === 'logseq-portal')}
  126. className="tl-button"
  127. pressed={pressed}
  128. onPressedChange={v => {
  129. shapes.forEach(s => {
  130. if (s.props.type === 'logseq-portal') {
  131. s.update({
  132. isAutoResizing: v,
  133. })
  134. } else {
  135. s.onResetBounds({ zoom: app.viewport.camera.zoom })
  136. }
  137. })
  138. app.persist()
  139. }}
  140. >
  141. <TablerIcon name="dimensions" />
  142. </ToggleInput>
  143. )
  144. })
  145. const LogseqPortalViewModeAction = observer(() => {
  146. const app = useApp<Shape>()
  147. const shapes = filterShapeByAction<LogseqPortalShape>(
  148. app.selectedShapesArray,
  149. 'LogseqPortalViewMode'
  150. )
  151. const collapsed = shapes.every(s => s.collapsed)
  152. const ViewModeOptions: ToggleGroupInputOption[] = [
  153. {
  154. value: '1',
  155. icon: 'object-compact',
  156. tooltip: 'Collapse',
  157. },
  158. {
  159. value: '0',
  160. icon: 'object-expanded',
  161. tooltip: 'Expand',
  162. },
  163. ]
  164. return (
  165. <ToggleGroupInput
  166. title="View Mode"
  167. options={ViewModeOptions}
  168. value={collapsed ? '1' : '0'}
  169. onValueChange={v => {
  170. shapes.forEach(shape => {
  171. shape.setCollapsed(v === '1' ? true : false)
  172. })
  173. app.persist()
  174. }}
  175. />
  176. )
  177. })
  178. const ScaleLevelAction = observer(() => {
  179. const app = useApp<Shape>()
  180. const shapes = filterShapeByAction<LogseqPortalShape>(app.selectedShapesArray, 'ScaleLevel')
  181. const scaleLevel = new Set(shapes.map(s => s.scaleLevel)).size > 1 ? '' : shapes[0].scaleLevel
  182. const sizeOptions: SelectOption[] = [
  183. {
  184. label: 'Extra Small',
  185. value: 'xs',
  186. },
  187. {
  188. label: 'Small',
  189. value: 'sm',
  190. },
  191. {
  192. label: 'Medium',
  193. value: 'md',
  194. },
  195. {
  196. label: 'Large',
  197. value: 'lg',
  198. },
  199. {
  200. label: 'Extra Large',
  201. value: 'xl',
  202. },
  203. {
  204. label: 'Huge',
  205. value: 'xxl',
  206. },
  207. ]
  208. return (
  209. <SelectInput
  210. tooltip="Scale level"
  211. options={sizeOptions}
  212. value={scaleLevel}
  213. onValueChange={v => {
  214. shapes.forEach(shape => {
  215. shape.setScaleLevel(v as LogseqPortalShape['props']['scaleLevel'])
  216. })
  217. app.persist()
  218. }}
  219. />
  220. )
  221. })
  222. const IFrameSourceAction = observer(() => {
  223. const app = useApp<Shape>()
  224. const shape = filterShapeByAction<IFrameShape>(app.selectedShapesArray, 'IFrameSource')[0]
  225. const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  226. shape.onIFrameSourceChange(e.target.value.trim().toLowerCase())
  227. app.persist()
  228. }, [])
  229. const handleReload = React.useCallback(() => {
  230. shape.reload()
  231. }, [])
  232. return (
  233. <span className="flex gap-3">
  234. <Button tooltip="Reload" type="button" onClick={handleReload}>
  235. <TablerIcon name="refresh" />
  236. </Button>
  237. <TextInput
  238. title="Website Url"
  239. className="tl-iframe-src"
  240. value={`${shape.props.url}`}
  241. onChange={handleChange}
  242. />
  243. <Button tooltip="Open website url" type="button" onClick={() => window.open(shape.props.url)}>
  244. <TablerIcon name="external-link" />
  245. </Button>
  246. </span>
  247. )
  248. })
  249. const YoutubeLinkAction = observer(() => {
  250. const app = useApp<Shape>()
  251. const shape = filterShapeByAction<YouTubeShape>(app.selectedShapesArray, 'YoutubeLink')[0]
  252. const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  253. shape.onYoutubeLinkChange(e.target.value)
  254. app.persist()
  255. }, [])
  256. return (
  257. <span className="flex gap-3">
  258. <TextInput
  259. title="YouTube Link"
  260. className="tl-youtube-link"
  261. value={`${shape.props.url}`}
  262. onChange={handleChange}
  263. />
  264. <Button
  265. tooltip="Open YouTube Link"
  266. type="button"
  267. onClick={() => window.logseq?.api?.open_external_link?.(shape.props.url)}
  268. >
  269. <TablerIcon name="external-link" />
  270. </Button>
  271. </span>
  272. )
  273. })
  274. const NoFillAction = observer(() => {
  275. const app = useApp<Shape>()
  276. const shapes = filterShapeByAction<BoxShape | PolygonShape | EllipseShape>(
  277. app.selectedShapesArray,
  278. 'NoFill'
  279. )
  280. const handleChange = React.useCallback((v: boolean) => {
  281. shapes.forEach(s => s.update({ noFill: v }))
  282. app.persist()
  283. }, [])
  284. const noFill = shapes.every(s => s.props.noFill)
  285. return (
  286. <ToggleInput tooltip="Fill" className="tl-button" pressed={noFill} onPressedChange={handleChange}>
  287. <TablerIcon name={noFill ? 'droplet-off' : 'droplet'} />
  288. </ToggleInput>
  289. )
  290. })
  291. const SwatchAction = observer(() => {
  292. const app = useApp<Shape>()
  293. // Placeholder
  294. const shapes = filterShapeByAction<
  295. BoxShape | PolygonShape | EllipseShape | LineShape | PencilShape | TextShape
  296. >(app.selectedShapesArray, 'Swatch')
  297. const handleSetColor = React.useCallback((color: string) => {
  298. shapes.forEach(s => {
  299. s.update({ fill: color, stroke: color })
  300. })
  301. app.persist()
  302. }, [])
  303. const handleSetOpacity = React.useCallback((opacity: number) => {
  304. shapes.forEach(s => {
  305. s.update({ opacity: opacity })
  306. })
  307. app.persist()
  308. }, [])
  309. const color = shapes[0].props.noFill ? shapes[0].props.stroke : shapes[0].props.fill
  310. return (
  311. <ColorInput
  312. popoverSide="top"
  313. color={color}
  314. opacity={shapes[0].props.opacity}
  315. setOpacity={handleSetOpacity}
  316. setColor={handleSetColor}
  317. />
  318. )
  319. })
  320. const StrokeTypeAction = observer(() => {
  321. const app = useApp<Shape>()
  322. const shapes = filterShapeByAction<
  323. BoxShape | PolygonShape | EllipseShape | LineShape | PencilShape
  324. >(app.selectedShapesArray, 'StrokeType')
  325. const StrokeTypeOptions: ToggleGroupInputOption[] = [
  326. {
  327. value: 'line',
  328. icon: 'circle',
  329. tooltip: 'Solid',
  330. },
  331. {
  332. value: 'dashed',
  333. icon: 'circle-dashed',
  334. tooltip: 'Dashed',
  335. },
  336. ]
  337. const value = shapes.every(s => s.props.strokeType === 'dashed')
  338. ? 'dashed'
  339. : shapes.every(s => s.props.strokeType === 'line')
  340. ? 'line'
  341. : 'mixed'
  342. return (
  343. <ToggleGroupInput
  344. title="Stroke Type"
  345. options={StrokeTypeOptions}
  346. value={value}
  347. onValueChange={v => {
  348. shapes.forEach(shape => {
  349. shape.update({
  350. strokeType: v,
  351. })
  352. })
  353. app.persist()
  354. }}
  355. />
  356. )
  357. })
  358. const ArrowModeAction = observer(() => {
  359. const app = useApp<Shape>()
  360. const shapes = filterShapeByAction<LineShape>(app.selectedShapesArray, 'ArrowMode')
  361. const StrokeTypeOptions: ToggleGroupInputOption[] = [
  362. {
  363. value: 'start',
  364. icon: 'arrow-narrow-left',
  365. },
  366. {
  367. value: 'end',
  368. icon: 'arrow-narrow-right',
  369. },
  370. ]
  371. const startValue = shapes.every(s => s.props.decorations?.start === Decoration.Arrow)
  372. const endValue = shapes.every(s => s.props.decorations?.end === Decoration.Arrow)
  373. const value = [startValue ? 'start' : null, endValue ? 'end' : null].filter(isNonNullable)
  374. const valueToDecorations = (value: string[]) => {
  375. return {
  376. start: value.includes('start') ? Decoration.Arrow : null,
  377. end: value.includes('end') ? Decoration.Arrow : null,
  378. }
  379. }
  380. return (
  381. <ToggleGroupMultipleInput
  382. title="Arrow Head"
  383. options={StrokeTypeOptions}
  384. value={value}
  385. onValueChange={v => {
  386. shapes.forEach(shape => {
  387. shape.update({
  388. decorations: valueToDecorations(v),
  389. })
  390. })
  391. app.persist()
  392. }}
  393. />
  394. )
  395. })
  396. const TextStyleAction = observer(() => {
  397. const app = useApp<Shape>()
  398. const shapes = filterShapeByAction<TextShape>(app.selectedShapesArray, 'TextStyle')
  399. const bold = shapes.every(s => s.props.fontWeight > 500)
  400. const italic = shapes.every(s => s.props.italic)
  401. return (
  402. <span className="flex gap-1">
  403. <ToggleInput
  404. tooltip="Bold"
  405. className="tl-button"
  406. pressed={bold}
  407. onPressedChange={v => {
  408. shapes.forEach(shape => {
  409. shape.update({
  410. fontWeight: v ? 700 : 400,
  411. })
  412. shape.onResetBounds()
  413. })
  414. app.persist()
  415. }}
  416. >
  417. <TablerIcon name="bold" />
  418. </ToggleInput>
  419. <ToggleInput
  420. tooltip="Italic"
  421. className="tl-button"
  422. pressed={italic}
  423. onPressedChange={v => {
  424. shapes.forEach(shape => {
  425. shape.update({
  426. italic: v,
  427. })
  428. shape.onResetBounds()
  429. })
  430. app.persist()
  431. }}
  432. >
  433. <TablerIcon name="italic" />
  434. </ToggleInput>
  435. </span>
  436. )
  437. })
  438. const LinksAction = observer(() => {
  439. const app = useApp<Shape>()
  440. const shape = app.selectedShapesArray[0]
  441. const handleChange = (refs: string[]) => {
  442. shape.update({ refs: refs })
  443. app.persist()
  444. }
  445. return (
  446. <ShapeLinksInput
  447. onRefsChange={handleChange}
  448. refs={shape.props.refs ?? []}
  449. shapeType={shape.props.type}
  450. side="right"
  451. pageId={shape.props.type === 'logseq-portal' ? shape.props.pageId : undefined}
  452. portalType={shape.props.type === 'logseq-portal' ? shape.props.blockType : undefined}
  453. />
  454. )
  455. })
  456. contextBarActionMapping.set('Edit', EditAction)
  457. contextBarActionMapping.set('AutoResizing', AutoResizingAction)
  458. contextBarActionMapping.set('LogseqPortalViewMode', LogseqPortalViewModeAction)
  459. contextBarActionMapping.set('ScaleLevel', ScaleLevelAction)
  460. contextBarActionMapping.set('YoutubeLink', YoutubeLinkAction)
  461. contextBarActionMapping.set('IFrameSource', IFrameSourceAction)
  462. contextBarActionMapping.set('NoFill', NoFillAction)
  463. contextBarActionMapping.set('Swatch', SwatchAction)
  464. contextBarActionMapping.set('StrokeType', StrokeTypeAction)
  465. contextBarActionMapping.set('ArrowMode', ArrowModeAction)
  466. contextBarActionMapping.set('TextStyle', TextStyleAction)
  467. contextBarActionMapping.set('Links', LinksAction)
  468. const getContextBarActionTypes = (type: ShapeType) => {
  469. return (shapeMapping[type] ?? []).filter(isNonNullable)
  470. }
  471. export const getContextBarActionsForShapes = (shapes: Shape[]) => {
  472. const types = shapes.map(s => s.props.type)
  473. const actionTypes = new Set(shapes.length > 0 ? getContextBarActionTypes(types[0]) : [])
  474. for (let i = 1; i < types.length && actionTypes.size > 0; i++) {
  475. const otherActionTypes = getContextBarActionTypes(types[i])
  476. actionTypes.forEach(action => {
  477. if (!otherActionTypes.includes(action)) {
  478. actionTypes.delete(action)
  479. }
  480. })
  481. }
  482. if (shapes.length > 1) {
  483. singleShapeActions.forEach(action => {
  484. if (actionTypes.has(action)) {
  485. actionTypes.delete(action)
  486. }
  487. })
  488. }
  489. return Array.from(actionTypes)
  490. .sort((a, b) => contextBarActionTypes.indexOf(a) - contextBarActionTypes.indexOf(b))
  491. .map(action => contextBarActionMapping.get(action)!)
  492. }