| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543 |
- import { Decoration, isNonNullable, validUUID } from '@tldraw/core'
- import { useApp } from '@tldraw/react'
- import { observer } from 'mobx-react-lite'
- import React from 'react'
- import type {
- BoxShape,
- EllipseShape,
- HTMLShape,
- IFrameShape,
- LineShape,
- LogseqPortalShape,
- PencilShape,
- PolygonShape,
- Shape,
- TextShape,
- YouTubeShape,
- } from '../../lib'
- import { Button } from '../Button'
- import { TablerIcon } from '../icons'
- import { ColorInput } from '../inputs/ColorInput'
- import { SelectInput, type SelectOption } from '../inputs/SelectInput'
- import { ShapeLinksInput } from '../inputs/ShapeLinksInput'
- import { TextInput } from '../inputs/TextInput'
- import {
- ToggleGroupInput,
- ToggleGroupMultipleInput,
- type ToggleGroupInputOption,
- } from '../inputs/ToggleGroupInput'
- import { ToggleInput } from '../inputs/ToggleInput'
- import { LogseqContext } from '../../lib/logseq-context'
- export const contextBarActionTypes = [
- // Order matters
- 'Edit',
- 'AutoResizing',
- 'Swatch',
- 'NoFill',
- 'StrokeType',
- 'ScaleLevel',
- 'TextStyle',
- 'YoutubeLink',
- 'IFrameSource',
- 'LogseqPortalViewMode',
- 'ArrowMode',
- 'Links',
- ] as const
- type ContextBarActionType = typeof contextBarActionTypes[number]
- const singleShapeActions: ContextBarActionType[] = ['Edit', 'YoutubeLink', 'IFrameSource', 'Links']
- const contextBarActionMapping = new Map<ContextBarActionType, React.FC>()
- type ShapeType = Shape['props']['type']
- export const shapeMapping: Record<ShapeType, ContextBarActionType[]> = {
- 'logseq-portal': [
- 'Swatch',
- 'Edit',
- 'LogseqPortalViewMode',
- 'ScaleLevel',
- 'AutoResizing',
- 'Links',
- ],
- youtube: ['YoutubeLink', 'Links'],
- iframe: ['IFrameSource', 'Links'],
- box: ['Edit', 'TextStyle', 'Swatch', 'NoFill', 'StrokeType', 'Links'],
- ellipse: ['Edit', 'TextStyle', 'Swatch', 'NoFill', 'StrokeType', 'Links'],
- polygon: ['Edit', 'TextStyle', 'Swatch', 'NoFill', 'StrokeType', 'Links'],
- line: ['Edit', 'TextStyle', 'Swatch', 'ArrowMode', 'Links'],
- pencil: ['Swatch', 'Links'],
- highlighter: ['Swatch', 'Links'],
- text: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'AutoResizing', 'Links'],
- html: ['ScaleLevel', 'AutoResizing', 'Links'],
- image: ['Links'],
- video: ['Links'],
- }
- export const withFillShapes = Object.entries(shapeMapping)
- .filter(([key, types]) => {
- return types.includes('NoFill') && types.includes('Swatch')
- })
- .map(([key]) => key) as ShapeType[]
- function filterShapeByAction<S extends Shape>(shapes: Shape[], type: ContextBarActionType): S[] {
- return shapes.filter(shape => shapeMapping[shape.props.type]?.includes(type)) as S[]
- }
- const EditAction = observer(() => {
- const {
- handlers: { isWhiteboardPage, redirectToPage },
- } = React.useContext(LogseqContext)
- const app = useApp<Shape>()
- const shape = filterShapeByAction(app.selectedShapesArray, 'Edit')[0]
- const iconName =
- ('label' in shape.props && shape.props.label) || ('text' in shape.props && shape.props.text)
- ? 'forms'
- : 'text'
- return (
- <Button
- type="button"
- tooltip="Edit"
- onClick={() => {
- app.api.editShape(shape)
- if (shape.props.type === 'logseq-portal') {
- let uuid = shape.props.pageId
- if (shape.props.blockType === 'P') {
- if (isWhiteboardPage(uuid)) {
- redirectToPage(uuid)
- }
- const firstNonePropertyBlock = window.logseq?.api
- ?.get_page_blocks_tree?.(shape.props.pageId)
- .find(b => !('propertiesOrder' in b))
- uuid = firstNonePropertyBlock.uuid
- }
- window.logseq?.api?.edit_block?.(uuid)
- }
- }}
- >
- <TablerIcon name={iconName} />
- </Button>
- )
- })
- const AutoResizingAction = observer(() => {
- const app = useApp<Shape>()
- const shapes = filterShapeByAction<LogseqPortalShape | TextShape | HTMLShape>(
- app.selectedShapesArray,
- 'AutoResizing'
- )
- const pressed = shapes.every(s => s.props.isAutoResizing)
- return (
- <ToggleInput
- tooltip="Auto Resize"
- toggle={shapes.every(s => s.props.type === 'logseq-portal')}
- className="tl-button"
- pressed={pressed}
- onPressedChange={v => {
- shapes.forEach(s => {
- if (s.props.type === 'logseq-portal') {
- s.update({
- isAutoResizing: v,
- })
- } else {
- s.onResetBounds({ zoom: app.viewport.camera.zoom })
- }
- })
- app.persist()
- }}
- >
- <TablerIcon name="dimensions" />
- </ToggleInput>
- )
- })
- const LogseqPortalViewModeAction = observer(() => {
- const app = useApp<Shape>()
- const shapes = filterShapeByAction<LogseqPortalShape>(
- app.selectedShapesArray,
- 'LogseqPortalViewMode'
- )
- const collapsed = shapes.every(s => s.collapsed)
- const ViewModeOptions: ToggleGroupInputOption[] = [
- {
- value: '1',
- icon: 'object-compact',
- tooltip: 'Collapse',
- },
- {
- value: '0',
- icon: 'object-expanded',
- tooltip: 'Expand',
- },
- ]
- return (
- <ToggleGroupInput
- title="View Mode"
- options={ViewModeOptions}
- value={collapsed ? '1' : '0'}
- onValueChange={v => {
- shapes.forEach(shape => {
- shape.setCollapsed(v === '1' ? true : false)
- })
- app.persist()
- }}
- />
- )
- })
- const ScaleLevelAction = observer(() => {
- const app = useApp<Shape>()
- const shapes = filterShapeByAction<LogseqPortalShape>(app.selectedShapesArray, 'ScaleLevel')
- const scaleLevel = new Set(shapes.map(s => s.scaleLevel)).size > 1 ? '' : shapes[0].scaleLevel
- const sizeOptions: SelectOption[] = [
- {
- label: 'Extra Small',
- value: 'xs',
- },
- {
- label: 'Small',
- value: 'sm',
- },
- {
- label: 'Medium',
- value: 'md',
- },
- {
- label: 'Large',
- value: 'lg',
- },
- {
- label: 'Extra Large',
- value: 'xl',
- },
- {
- label: 'Huge',
- value: 'xxl',
- },
- ]
- return (
- <SelectInput
- tooltip="Scale level"
- options={sizeOptions}
- value={scaleLevel}
- onValueChange={v => {
- shapes.forEach(shape => {
- shape.setScaleLevel(v as LogseqPortalShape['props']['scaleLevel'])
- })
- app.persist()
- }}
- />
- )
- })
- const IFrameSourceAction = observer(() => {
- const app = useApp<Shape>()
- const shape = filterShapeByAction<IFrameShape>(app.selectedShapesArray, 'IFrameSource')[0]
- const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
- shape.onIFrameSourceChange(e.target.value.trim().toLowerCase())
- app.persist()
- }, [])
- const handleReload = React.useCallback(() => {
- shape.reload()
- }, [])
- return (
- <span className="flex gap-3">
- <Button tooltip="Reload" type="button" onClick={handleReload}>
- <TablerIcon name="refresh" />
- </Button>
- <TextInput
- title="Website Url"
- className="tl-iframe-src"
- value={`${shape.props.url}`}
- onChange={handleChange}
- />
- <Button tooltip="Open website url" type="button" onClick={() => window.open(shape.props.url)}>
- <TablerIcon name="external-link" />
- </Button>
- </span>
- )
- })
- const YoutubeLinkAction = observer(() => {
- const app = useApp<Shape>()
- const shape = filterShapeByAction<YouTubeShape>(app.selectedShapesArray, 'YoutubeLink')[0]
- const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
- shape.onYoutubeLinkChange(e.target.value)
- app.persist()
- }, [])
- return (
- <span className="flex gap-3">
- <TextInput
- title="YouTube Link"
- className="tl-youtube-link"
- value={`${shape.props.url}`}
- onChange={handleChange}
- />
- <Button
- tooltip="Open YouTube Link"
- type="button"
- onClick={() => window.logseq?.api?.open_external_link?.(shape.props.url)}
- >
- <TablerIcon name="external-link" />
- </Button>
- </span>
- )
- })
- const NoFillAction = observer(() => {
- const app = useApp<Shape>()
- const shapes = filterShapeByAction<BoxShape | PolygonShape | EllipseShape>(
- app.selectedShapesArray,
- 'NoFill'
- )
- const handleChange = React.useCallback((v: boolean) => {
- shapes.forEach(s => s.update({ noFill: v }))
- app.persist()
- }, [])
- const noFill = shapes.every(s => s.props.noFill)
- return (
- <ToggleInput tooltip="Fill" className="tl-button" pressed={noFill} onPressedChange={handleChange}>
- <TablerIcon name={noFill ? 'droplet-off' : 'droplet'} />
- </ToggleInput>
- )
- })
- const SwatchAction = observer(() => {
- const app = useApp<Shape>()
- // Placeholder
- const shapes = filterShapeByAction<
- BoxShape | PolygonShape | EllipseShape | LineShape | PencilShape | TextShape
- >(app.selectedShapesArray, 'Swatch')
- const handleSetColor = React.useCallback((color: string) => {
- shapes.forEach(s => {
- s.update({ fill: color, stroke: color })
- })
- app.persist()
- }, [])
- const handleSetOpacity = React.useCallback((opacity: number) => {
- shapes.forEach(s => {
- s.update({ opacity: opacity })
- })
- app.persist()
- }, [])
- const color = shapes[0].props.noFill ? shapes[0].props.stroke : shapes[0].props.fill
- return (
- <ColorInput
- popoverSide="top"
- color={color}
- opacity={shapes[0].props.opacity}
- setOpacity={handleSetOpacity}
- setColor={handleSetColor}
- />
- )
- })
- const StrokeTypeAction = observer(() => {
- const app = useApp<Shape>()
- const shapes = filterShapeByAction<
- BoxShape | PolygonShape | EllipseShape | LineShape | PencilShape
- >(app.selectedShapesArray, 'StrokeType')
- const StrokeTypeOptions: ToggleGroupInputOption[] = [
- {
- value: 'line',
- icon: 'circle',
- tooltip: 'Solid',
- },
- {
- value: 'dashed',
- icon: 'circle-dashed',
- tooltip: 'Dashed',
- },
- ]
- const value = shapes.every(s => s.props.strokeType === 'dashed')
- ? 'dashed'
- : shapes.every(s => s.props.strokeType === 'line')
- ? 'line'
- : 'mixed'
- return (
- <ToggleGroupInput
- title="Stroke Type"
- options={StrokeTypeOptions}
- value={value}
- onValueChange={v => {
- shapes.forEach(shape => {
- shape.update({
- strokeType: v,
- })
- })
- app.persist()
- }}
- />
- )
- })
- const ArrowModeAction = observer(() => {
- const app = useApp<Shape>()
- const shapes = filterShapeByAction<LineShape>(app.selectedShapesArray, 'ArrowMode')
- const StrokeTypeOptions: ToggleGroupInputOption[] = [
- {
- value: 'start',
- icon: 'arrow-narrow-left',
- },
- {
- value: 'end',
- icon: 'arrow-narrow-right',
- },
- ]
- const startValue = shapes.every(s => s.props.decorations?.start === Decoration.Arrow)
- const endValue = shapes.every(s => s.props.decorations?.end === Decoration.Arrow)
- const value = [startValue ? 'start' : null, endValue ? 'end' : null].filter(isNonNullable)
- const valueToDecorations = (value: string[]) => {
- return {
- start: value.includes('start') ? Decoration.Arrow : null,
- end: value.includes('end') ? Decoration.Arrow : null,
- }
- }
- return (
- <ToggleGroupMultipleInput
- title="Arrow Head"
- options={StrokeTypeOptions}
- value={value}
- onValueChange={v => {
- shapes.forEach(shape => {
- shape.update({
- decorations: valueToDecorations(v),
- })
- })
- app.persist()
- }}
- />
- )
- })
- const TextStyleAction = observer(() => {
- const app = useApp<Shape>()
- const shapes = filterShapeByAction<TextShape>(app.selectedShapesArray, 'TextStyle')
- const bold = shapes.every(s => s.props.fontWeight > 500)
- const italic = shapes.every(s => s.props.italic)
- return (
- <span className="flex gap-1">
- <ToggleInput
- tooltip="Bold"
- className="tl-button"
- pressed={bold}
- onPressedChange={v => {
- shapes.forEach(shape => {
- shape.update({
- fontWeight: v ? 700 : 400,
- })
- shape.onResetBounds()
- })
- app.persist()
- }}
- >
- <TablerIcon name="bold" />
- </ToggleInput>
- <ToggleInput
- tooltip="Italic"
- className="tl-button"
- pressed={italic}
- onPressedChange={v => {
- shapes.forEach(shape => {
- shape.update({
- italic: v,
- })
- shape.onResetBounds()
- })
- app.persist()
- }}
- >
- <TablerIcon name="italic" />
- </ToggleInput>
- </span>
- )
- })
- const LinksAction = observer(() => {
- const app = useApp<Shape>()
- const shape = app.selectedShapesArray[0]
- const handleChange = (refs: string[]) => {
- shape.update({ refs: refs })
- app.persist()
- }
- return (
- <ShapeLinksInput
- onRefsChange={handleChange}
- refs={shape.props.refs ?? []}
- shapeType={shape.props.type}
- side="right"
- pageId={shape.props.type === 'logseq-portal' ? shape.props.pageId : undefined}
- portalType={shape.props.type === 'logseq-portal' ? shape.props.blockType : undefined}
- />
- )
- })
- contextBarActionMapping.set('Edit', EditAction)
- contextBarActionMapping.set('AutoResizing', AutoResizingAction)
- contextBarActionMapping.set('LogseqPortalViewMode', LogseqPortalViewModeAction)
- contextBarActionMapping.set('ScaleLevel', ScaleLevelAction)
- contextBarActionMapping.set('YoutubeLink', YoutubeLinkAction)
- contextBarActionMapping.set('IFrameSource', IFrameSourceAction)
- contextBarActionMapping.set('NoFill', NoFillAction)
- contextBarActionMapping.set('Swatch', SwatchAction)
- contextBarActionMapping.set('StrokeType', StrokeTypeAction)
- contextBarActionMapping.set('ArrowMode', ArrowModeAction)
- contextBarActionMapping.set('TextStyle', TextStyleAction)
- contextBarActionMapping.set('Links', LinksAction)
- const getContextBarActionTypes = (type: ShapeType) => {
- return (shapeMapping[type] ?? []).filter(isNonNullable)
- }
- export const getContextBarActionsForShapes = (shapes: Shape[]) => {
- const types = shapes.map(s => s.props.type)
- const actionTypes = new Set(shapes.length > 0 ? getContextBarActionTypes(types[0]) : [])
- for (let i = 1; i < types.length && actionTypes.size > 0; i++) {
- const otherActionTypes = getContextBarActionTypes(types[i])
- actionTypes.forEach(action => {
- if (!otherActionTypes.includes(action)) {
- actionTypes.delete(action)
- }
- })
- }
- if (shapes.length > 1) {
- singleShapeActions.forEach(action => {
- if (actionTypes.has(action)) {
- actionTypes.delete(action)
- }
- })
- }
- return Array.from(actionTypes)
- .sort((a, b) => contextBarActionTypes.indexOf(a) - contextBarActionTypes.indexOf(b))
- .map(action => contextBarActionMapping.get(action)!)
- }
|