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() type ShapeType = Shape['props']['type'] export const shapeMapping: Record = { '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(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() 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 ( ) }) const AutoResizingAction = observer(() => { const app = useApp() const shapes = filterShapeByAction( app.selectedShapesArray, 'AutoResizing' ) const pressed = shapes.every(s => s.props.isAutoResizing) return ( 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() }} > ) }) const LogseqPortalViewModeAction = observer(() => { const app = useApp() const shapes = filterShapeByAction( 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 ( { shapes.forEach(shape => { shape.setCollapsed(v === '1' ? true : false) }) app.persist() }} /> ) }) const ScaleLevelAction = observer(() => { const app = useApp() const shapes = filterShapeByAction(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 ( { shapes.forEach(shape => { shape.setScaleLevel(v as LogseqPortalShape['props']['scaleLevel']) }) app.persist() }} /> ) }) const IFrameSourceAction = observer(() => { const app = useApp() const shape = filterShapeByAction(app.selectedShapesArray, 'IFrameSource')[0] const handleChange = React.useCallback((e: React.ChangeEvent) => { shape.onIFrameSourceChange(e.target.value.trim().toLowerCase()) app.persist() }, []) const handleReload = React.useCallback(() => { shape.reload() }, []) return ( ) }) const YoutubeLinkAction = observer(() => { const app = useApp() const shape = filterShapeByAction(app.selectedShapesArray, 'YoutubeLink')[0] const handleChange = React.useCallback((e: React.ChangeEvent) => { shape.onYoutubeLinkChange(e.target.value) app.persist() }, []) return ( ) }) const NoFillAction = observer(() => { const app = useApp() const shapes = filterShapeByAction( 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 ( ) }) const SwatchAction = observer(() => { const app = useApp() // 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 ( ) }) const StrokeTypeAction = observer(() => { const app = useApp() 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 ( { shapes.forEach(shape => { shape.update({ strokeType: v, }) }) app.persist() }} /> ) }) const ArrowModeAction = observer(() => { const app = useApp() const shapes = filterShapeByAction(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 ( { shapes.forEach(shape => { shape.update({ decorations: valueToDecorations(v), }) }) app.persist() }} /> ) }) const TextStyleAction = observer(() => { const app = useApp() const shapes = filterShapeByAction(app.selectedShapesArray, 'TextStyle') const bold = shapes.every(s => s.props.fontWeight > 500) const italic = shapes.every(s => s.props.italic) return ( { shapes.forEach(shape => { shape.update({ fontWeight: v ? 700 : 400, }) shape.onResetBounds() }) app.persist() }} > { shapes.forEach(shape => { shape.update({ italic: v, }) shape.onResetBounds() }) app.persist() }} > ) }) const LinksAction = observer(() => { const app = useApp() const shape = app.selectedShapesArray[0] const handleChange = (refs: string[]) => { shape.update({ refs: refs }) app.persist() } return ( ) }) 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)!) }