kopia lustrzana https://github.com/Tldraw/Tldraw
432 wiersze
12 KiB
TypeScript
432 wiersze
12 KiB
TypeScript
import * as React from 'react'
|
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
import { strokes, fills, defaultTextStyle } from '~state/shapes/shared/shape-styles'
|
|
import { FormattedMessage } from 'react-intl'
|
|
import { useTldrawApp } from '~hooks'
|
|
import {
|
|
DMCheckboxItem,
|
|
DMContent,
|
|
DMDivider,
|
|
DMRadioItem,
|
|
} from '~components/Primitives/DropdownMenu'
|
|
import {
|
|
CircleIcon,
|
|
DashDashedIcon,
|
|
DashDottedIcon,
|
|
DashDrawIcon,
|
|
DashSolidIcon,
|
|
SizeLargeIcon,
|
|
SizeMediumIcon,
|
|
SizeSmallIcon,
|
|
} from '~components/Primitives/icons'
|
|
import { ToolButton } from '~components/Primitives/ToolButton'
|
|
import {
|
|
TDSnapshot,
|
|
ColorStyle,
|
|
DashStyle,
|
|
SizeStyle,
|
|
ShapeStyles,
|
|
FontStyle,
|
|
AlignStyle,
|
|
TDShapeType,
|
|
} from '~types'
|
|
import { styled } from '~styles'
|
|
import { breakpoints } from '~components/breakpoints'
|
|
import { Divider } from '~components/Primitives/Divider'
|
|
import { preventEvent } from '~components/preventEvent'
|
|
import {
|
|
TextAlignCenterIcon,
|
|
TextAlignJustifyIcon,
|
|
TextAlignLeftIcon,
|
|
TextAlignRightIcon,
|
|
} from '@radix-ui/react-icons'
|
|
|
|
const currentStyleSelector = (s: TDSnapshot) => s.appState.currentStyle
|
|
const selectedIdsSelector = (s: TDSnapshot) =>
|
|
s.document.pageStates[s.appState.currentPageId].selectedIds
|
|
|
|
const STYLE_KEYS = Object.keys(defaultTextStyle) as (keyof ShapeStyles)[]
|
|
|
|
const DASH_ICONS = {
|
|
[DashStyle.Draw]: <DashDrawIcon />,
|
|
[DashStyle.Solid]: <DashSolidIcon />,
|
|
[DashStyle.Dashed]: <DashDashedIcon />,
|
|
[DashStyle.Dotted]: <DashDottedIcon />,
|
|
}
|
|
|
|
const SIZE_ICONS = {
|
|
[SizeStyle.Small]: <SizeSmallIcon />,
|
|
[SizeStyle.Medium]: <SizeMediumIcon />,
|
|
[SizeStyle.Large]: <SizeLargeIcon />,
|
|
}
|
|
|
|
const ALIGN_ICONS = {
|
|
[AlignStyle.Start]: <TextAlignLeftIcon />,
|
|
[AlignStyle.Middle]: <TextAlignCenterIcon />,
|
|
[AlignStyle.End]: <TextAlignRightIcon />,
|
|
[AlignStyle.Justify]: <TextAlignJustifyIcon />,
|
|
}
|
|
|
|
const themeSelector = (s: TDSnapshot) => (s.settings.isDarkMode ? 'dark' : 'light')
|
|
|
|
const keepOpenSelector = (s: TDSnapshot) => s.settings.keepStyleMenuOpen
|
|
|
|
const optionsSelector = (s: TDSnapshot) => {
|
|
const { activeTool, currentPageId: pageId } = s.appState
|
|
switch (activeTool) {
|
|
case 'select': {
|
|
const page = s.document.pages[pageId]
|
|
let hasText = false
|
|
let hasLabel = false
|
|
for (const id of s.document.pageStates[pageId].selectedIds) {
|
|
if ('text' in page.shapes[id]) hasText = true
|
|
if ('label' in page.shapes[id]) hasLabel = true
|
|
}
|
|
return hasText ? 'text' : hasLabel ? 'label' : ''
|
|
}
|
|
case TDShapeType.Text: {
|
|
return 'text'
|
|
}
|
|
case TDShapeType.Rectangle: {
|
|
return 'label'
|
|
}
|
|
case TDShapeType.Ellipse: {
|
|
return 'label'
|
|
}
|
|
case TDShapeType.Triangle: {
|
|
return 'label'
|
|
}
|
|
case TDShapeType.Arrow: {
|
|
return 'label'
|
|
}
|
|
case TDShapeType.Line: {
|
|
return 'label'
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export const StyleMenu = React.memo(function ColorMenu() {
|
|
const app = useTldrawApp()
|
|
|
|
const theme = app.useStore(themeSelector)
|
|
|
|
const keepOpen = app.useStore(keepOpenSelector)
|
|
|
|
const options = app.useStore(optionsSelector)
|
|
|
|
const currentStyle = app.useStore(currentStyleSelector)
|
|
|
|
const selectedIds = app.useStore(selectedIdsSelector)
|
|
|
|
const [displayedStyle, setDisplayedStyle] = React.useState(currentStyle)
|
|
|
|
const rDisplayedStyle = React.useRef(currentStyle)
|
|
|
|
React.useEffect(() => {
|
|
const {
|
|
appState: { currentStyle },
|
|
page,
|
|
selectedIds,
|
|
} = app
|
|
let commonStyle = {} as ShapeStyles
|
|
if (selectedIds.length <= 0) {
|
|
commonStyle = currentStyle
|
|
} else {
|
|
const overrides = new Set<string>([])
|
|
app.selectedIds
|
|
.map((id) => page.shapes[id])
|
|
.forEach((shape) => {
|
|
STYLE_KEYS.forEach((key) => {
|
|
if (overrides.has(key)) return
|
|
if (commonStyle[key] === undefined) {
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
commonStyle[key] = shape.style[key]
|
|
} else {
|
|
if (commonStyle[key] === shape.style[key]) return
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
commonStyle[key] = shape.style[key]
|
|
overrides.add(key)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
// Until we can work out the correct logic for deciding whether or not to
|
|
// update the selected style, do a string comparison. Yuck!
|
|
if (JSON.stringify(commonStyle) !== JSON.stringify(rDisplayedStyle.current)) {
|
|
rDisplayedStyle.current = commonStyle
|
|
setDisplayedStyle(commonStyle)
|
|
}
|
|
}, [currentStyle, selectedIds])
|
|
|
|
const handleToggleKeepOpen = React.useCallback((checked: boolean) => {
|
|
app.setSetting('keepStyleMenuOpen', checked)
|
|
}, [])
|
|
|
|
const handleToggleFilled = React.useCallback((checked: boolean) => {
|
|
app.style({ isFilled: checked })
|
|
}, [])
|
|
|
|
const handleDashChange = React.useCallback((value: string) => {
|
|
app.style({ dash: value as DashStyle })
|
|
}, [])
|
|
|
|
const handleSizeChange = React.useCallback((value: string) => {
|
|
app.style({ size: value as SizeStyle })
|
|
}, [])
|
|
|
|
const handleFontChange = React.useCallback((value: string) => {
|
|
app.style({ font: value as FontStyle })
|
|
}, [])
|
|
|
|
const handleTextAlignChange = React.useCallback((value: string) => {
|
|
app.style({ textAlign: value as AlignStyle })
|
|
}, [])
|
|
|
|
const handleMenuOpenChange = React.useCallback(
|
|
(open: boolean) => {
|
|
app.setMenuOpen(open)
|
|
},
|
|
[app]
|
|
)
|
|
|
|
return (
|
|
<DropdownMenu.Root
|
|
dir="ltr"
|
|
onOpenChange={handleMenuOpenChange}
|
|
open={keepOpen ? true : undefined}
|
|
modal={false}
|
|
>
|
|
<DropdownMenu.Trigger asChild id="TD-Styles">
|
|
<ToolButton variant="text">
|
|
<FormattedMessage id="styles" />
|
|
<OverlapIcons
|
|
style={{
|
|
color: strokes[theme][displayedStyle.color as ColorStyle],
|
|
}}
|
|
>
|
|
{displayedStyle.isFilled && (
|
|
<CircleIcon
|
|
size={16}
|
|
stroke="none"
|
|
fill={fills[theme][displayedStyle.color as ColorStyle]}
|
|
/>
|
|
)}
|
|
{DASH_ICONS[displayedStyle.dash]}
|
|
</OverlapIcons>
|
|
</ToolButton>
|
|
</DropdownMenu.Trigger>
|
|
<DMContent>
|
|
<StyledRow variant="tall" id="TD-Styles-Color-Container">
|
|
<span>
|
|
<FormattedMessage id="style.menu.color" />
|
|
</span>
|
|
<ColorGrid>
|
|
{Object.keys(strokes.light).map((style: string) => (
|
|
<DropdownMenu.Item
|
|
key={style}
|
|
onSelect={preventEvent}
|
|
asChild
|
|
id={`TD-Styles-Color-Swatch-${style}`}
|
|
>
|
|
<ToolButton
|
|
variant="icon"
|
|
isActive={displayedStyle.color === style}
|
|
onClick={() => app.style({ color: style as ColorStyle })}
|
|
>
|
|
<CircleIcon
|
|
size={18}
|
|
strokeWidth={2.5}
|
|
fill={
|
|
displayedStyle.isFilled ? fills.light[style as ColorStyle] : 'transparent'
|
|
}
|
|
stroke={strokes.light[style as ColorStyle]}
|
|
/>
|
|
</ToolButton>
|
|
</DropdownMenu.Item>
|
|
))}
|
|
</ColorGrid>
|
|
</StyledRow>
|
|
<DMCheckboxItem
|
|
variant="styleMenu"
|
|
checked={!!displayedStyle.isFilled}
|
|
onCheckedChange={handleToggleFilled}
|
|
id="TD-Styles-Fill"
|
|
>
|
|
<FormattedMessage id="style.menu.fill" />
|
|
</DMCheckboxItem>
|
|
<StyledRow id="TD-Styles-Dash-Container">
|
|
<FormattedMessage id="style.menu.dash" />
|
|
<StyledGroup dir="ltr" value={displayedStyle.dash} onValueChange={handleDashChange}>
|
|
{Object.values(DashStyle).map((style) => (
|
|
<DMRadioItem
|
|
key={style}
|
|
isActive={style === displayedStyle.dash}
|
|
value={style}
|
|
onSelect={preventEvent}
|
|
bp={breakpoints}
|
|
id={`TD-Styles-Dash-${style}`}
|
|
>
|
|
{DASH_ICONS[style as DashStyle]}
|
|
</DMRadioItem>
|
|
))}
|
|
</StyledGroup>
|
|
</StyledRow>
|
|
<StyledRow id="TD-Styles-Size-Container">
|
|
<FormattedMessage id="style.menu.size" />
|
|
<StyledGroup dir="ltr" value={displayedStyle.size} onValueChange={handleSizeChange}>
|
|
{Object.values(SizeStyle).map((sizeStyle) => (
|
|
<DMRadioItem
|
|
key={sizeStyle}
|
|
isActive={sizeStyle === displayedStyle.size}
|
|
value={sizeStyle}
|
|
onSelect={preventEvent}
|
|
bp={breakpoints}
|
|
id={`TD-Styles-Dash-${sizeStyle}`}
|
|
>
|
|
{SIZE_ICONS[sizeStyle as SizeStyle]}
|
|
</DMRadioItem>
|
|
))}
|
|
</StyledGroup>
|
|
</StyledRow>
|
|
{(options === 'text' || options === 'label') && (
|
|
<>
|
|
<Divider />
|
|
<StyledRow id="TD-Styles-Font-Container">
|
|
<FormattedMessage id="style.menu.font" />
|
|
<StyledGroup dir="ltr" value={displayedStyle.font} onValueChange={handleFontChange}>
|
|
{Object.values(FontStyle).map((fontStyle) => (
|
|
<DMRadioItem
|
|
key={fontStyle}
|
|
isActive={fontStyle === displayedStyle.font}
|
|
value={fontStyle}
|
|
onSelect={preventEvent}
|
|
bp={breakpoints}
|
|
id={`TD-Styles-Font-${fontStyle}`}
|
|
>
|
|
<FontIcon fontStyle={fontStyle}>Aa</FontIcon>
|
|
</DMRadioItem>
|
|
))}
|
|
</StyledGroup>
|
|
</StyledRow>
|
|
{options === 'text' && (
|
|
<StyledRow id="TD-Styles-Align-Container">
|
|
<FormattedMessage id="style.menu.align" />
|
|
<StyledGroup
|
|
dir="ltr"
|
|
value={displayedStyle.textAlign}
|
|
onValueChange={handleTextAlignChange}
|
|
>
|
|
{Object.values(AlignStyle).map((style) => (
|
|
<DMRadioItem
|
|
key={style}
|
|
isActive={style === displayedStyle.textAlign}
|
|
value={style}
|
|
onSelect={preventEvent}
|
|
bp={breakpoints}
|
|
id={`TD-Styles-Align-${style}`}
|
|
>
|
|
{ALIGN_ICONS[style]}
|
|
</DMRadioItem>
|
|
))}
|
|
</StyledGroup>
|
|
</StyledRow>
|
|
)}
|
|
</>
|
|
)}
|
|
<DMDivider />
|
|
<DMCheckboxItem
|
|
variant="styleMenu"
|
|
checked={keepOpen}
|
|
onCheckedChange={handleToggleKeepOpen}
|
|
id="TD-Styles-Keep-Open"
|
|
>
|
|
<FormattedMessage id="style.menu.keep.open" />
|
|
</DMCheckboxItem>
|
|
</DMContent>
|
|
</DropdownMenu.Root>
|
|
)
|
|
})
|
|
|
|
const ColorGrid = styled('div', {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(4, auto)',
|
|
gap: 0,
|
|
})
|
|
|
|
export const StyledRow = styled('div', {
|
|
position: 'relative',
|
|
width: '100%',
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
minHeight: '32px',
|
|
outline: 'none',
|
|
color: '$text',
|
|
fontFamily: '$ui',
|
|
fontWeight: 400,
|
|
fontSize: '$1',
|
|
padding: '$2 0 $2 $3',
|
|
borderRadius: 4,
|
|
userSelect: 'none',
|
|
margin: 0,
|
|
display: 'flex',
|
|
gap: '$3',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
variants: {
|
|
variant: {
|
|
tall: {
|
|
alignItems: 'flex-start',
|
|
padding: '0 0 0 $3',
|
|
'& > span': {
|
|
paddingTop: '$4',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
const StyledGroup = styled(DropdownMenu.DropdownMenuRadioGroup, {
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
gap: '$1',
|
|
})
|
|
|
|
const OverlapIcons = styled('div', {
|
|
display: 'grid',
|
|
'& > *': {
|
|
gridColumn: 1,
|
|
gridRow: 1,
|
|
},
|
|
})
|
|
|
|
const FontIcon = styled('div', {
|
|
width: 32,
|
|
height: 32,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '$3',
|
|
variants: {
|
|
fontStyle: {
|
|
[FontStyle.Script]: {
|
|
fontFamily: 'Caveat Brush',
|
|
},
|
|
[FontStyle.Sans]: {
|
|
fontFamily: 'Recursive',
|
|
},
|
|
[FontStyle.Serif]: {
|
|
fontFamily: 'Georgia',
|
|
},
|
|
[FontStyle.Mono]: {
|
|
fontFamily: 'Recursive Mono',
|
|
},
|
|
},
|
|
},
|
|
})
|