[feature] fonts (#308)

* adds fonts

* Add alignment options

* Update useKeyboardShortcuts.tsx

* Improve style panel

* Alignment for sticky notes

* swap fonts
This commit is contained in:
Steve Ruiz 2021-11-20 09:37:42 +00:00 committed by GitHub
parent a0891ca3ff
commit 0685ca3871
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 420 additions and 83 deletions

View file

@ -11,7 +11,7 @@ export interface RowButtonProps {
children: React.ReactNode children: React.ReactNode
disabled?: boolean disabled?: boolean
kbd?: string kbd?: string
variant?: 'wide' variant?: 'wide' | 'styleMenu'
isSponsor?: boolean isSponsor?: boolean
isActive?: boolean isActive?: boolean
isWarning?: boolean isWarning?: boolean
@ -130,6 +130,9 @@ export const StyledRowButton = styled('button', {
small: {}, small: {},
}, },
variant: { variant: {
styleMenu: {
margin: '$1 0 $1 0',
},
wide: { wide: {
gridColumn: '1 / span 4', gridColumn: '1 / span 4',
}, },

View file

@ -1,6 +1,6 @@
import * as React from 'react' import * as React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { strokes, fills, defaultStyle } from '~state/shapes/shared/shape-styles' import { strokes, fills, defaultTextStyle } from '~state/shapes/shared/shape-styles'
import { useTldrawApp } from '~hooks' import { useTldrawApp } from '~hooks'
import { import {
DMCheckboxItem, DMCheckboxItem,
@ -19,37 +19,65 @@ import {
SizeSmallIcon, SizeSmallIcon,
} from '~components/Primitives/icons' } from '~components/Primitives/icons'
import { ToolButton } from '~components/Primitives/ToolButton' import { ToolButton } from '~components/Primitives/ToolButton'
import { TDSnapshot, ColorStyle, DashStyle, SizeStyle, ShapeStyles } from '~types' import {
TDSnapshot,
ColorStyle,
DashStyle,
SizeStyle,
ShapeStyles,
FontStyle,
AlignStyle,
} from '~types'
import { styled } from '~styles' import { styled } from '~styles'
import { breakpoints } from '~components/breakpoints' import { breakpoints } from '~components/breakpoints'
import { Divider } from '~components/Primitives/Divider' import { Divider } from '~components/Primitives/Divider'
import { preventEvent } from '~components/preventEvent' import { preventEvent } from '~components/preventEvent'
import {
TextAlignCenterIcon,
TextAlignJustifyIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
} from '@radix-ui/react-icons'
const currentStyleSelector = (s: TDSnapshot) => s.appState.currentStyle const currentStyleSelector = (s: TDSnapshot) => s.appState.currentStyle
const selectedIdsSelector = (s: TDSnapshot) => const selectedIdsSelector = (s: TDSnapshot) =>
s.document.pageStates[s.appState.currentPageId].selectedIds s.document.pageStates[s.appState.currentPageId].selectedIds
const STYLE_KEYS = Object.keys(defaultStyle) as (keyof ShapeStyles)[] const STYLE_KEYS = Object.keys(defaultTextStyle) as (keyof ShapeStyles)[]
const DASHES = { const DASH_ICONS = {
[DashStyle.Draw]: <DashDrawIcon />, [DashStyle.Draw]: <DashDrawIcon />,
[DashStyle.Solid]: <DashSolidIcon />, [DashStyle.Solid]: <DashSolidIcon />,
[DashStyle.Dashed]: <DashDashedIcon />, [DashStyle.Dashed]: <DashDashedIcon />,
[DashStyle.Dotted]: <DashDottedIcon />, [DashStyle.Dotted]: <DashDottedIcon />,
} }
const SIZES = { const SIZE_ICONS = {
[SizeStyle.Small]: <SizeSmallIcon />, [SizeStyle.Small]: <SizeSmallIcon />,
[SizeStyle.Medium]: <SizeMediumIcon />, [SizeStyle.Medium]: <SizeMediumIcon />,
[SizeStyle.Large]: <SizeLargeIcon />, [SizeStyle.Large]: <SizeLargeIcon />,
} }
const themeSelector = (data: TDSnapshot) => (data.settings.isDarkMode ? 'dark' : 'light') 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 showTextStylesSelector = (s: TDSnapshot) => {
const pageId = s.appState.currentPageId
const page = s.document.pages[pageId]
return s.document.pageStates[pageId].selectedIds.some((id) => 'text' in page.shapes[id])
}
export const StyleMenu = React.memo(function ColorMenu(): JSX.Element { export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
const app = useTldrawApp() const app = useTldrawApp()
const theme = app.useStore(themeSelector) const theme = app.useStore(themeSelector)
const showTextStyles = app.useStore(showTextStylesSelector)
const currentStyle = app.useStore(currentStyleSelector) const currentStyle = app.useStore(currentStyleSelector)
const selectedIds = app.useStore(selectedIdsSelector) const selectedIds = app.useStore(selectedIdsSelector)
@ -111,6 +139,14 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
app.style({ size: value as SizeStyle }) 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 })
}, [])
return ( return (
<DropdownMenu.Root dir="ltr"> <DropdownMenu.Root dir="ltr">
<DMTriggerIcon> <DMTriggerIcon>
@ -126,53 +162,56 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
fill={fills[theme][displayedStyle.color as ColorStyle]} fill={fills[theme][displayedStyle.color as ColorStyle]}
/> />
)} )}
{DASHES[displayedStyle.dash]} {DASH_ICONS[displayedStyle.dash]}
</OverlapIcons> </OverlapIcons>
</DMTriggerIcon> </DMTriggerIcon>
<DMContent> <DMContent>
<StyledRow variant="tall"> <StyledRow variant="tall">
<span>Color</span> <span>Color</span>
<ColorGrid> <ColorGrid>
{Object.keys(strokes.light).map((colorStyle: string) => ( {Object.keys(strokes.light).map((style: string) => (
<DropdownMenu.Item key={colorStyle} onSelect={preventEvent} asChild> <DropdownMenu.Item key={style} onSelect={preventEvent} asChild>
<ToolButton <ToolButton
variant="icon" variant="icon"
isActive={displayedStyle.color === colorStyle} isActive={displayedStyle.color === style}
onClick={() => app.style({ color: colorStyle as ColorStyle })} onClick={() => app.style({ color: style as ColorStyle })}
> >
<CircleIcon <CircleIcon
size={18} size={18}
strokeWidth={2.5} strokeWidth={2.5}
fill={ fill={
displayedStyle.isFilled displayedStyle.isFilled ? fills.light[style as ColorStyle] : 'transparent'
? fills.light[colorStyle as ColorStyle]
: 'transparent'
} }
stroke={strokes.light[colorStyle as ColorStyle]} stroke={strokes.light[style as ColorStyle]}
/> />
</ToolButton> </ToolButton>
</DropdownMenu.Item> </DropdownMenu.Item>
))} ))}
</ColorGrid> </ColorGrid>
</StyledRow> </StyledRow>
<Divider /> <DMCheckboxItem
variant="styleMenu"
checked={!!displayedStyle.isFilled}
onCheckedChange={handleToggleFilled}
>
Fill
</DMCheckboxItem>
<StyledRow> <StyledRow>
Dash Dash
<StyledGroup dir="ltr" value={displayedStyle.dash} onValueChange={handleDashChange}> <StyledGroup dir="ltr" value={displayedStyle.dash} onValueChange={handleDashChange}>
{Object.values(DashStyle).map((dashStyle) => ( {Object.values(DashStyle).map((style) => (
<DMRadioItem <DMRadioItem
key={dashStyle} key={style}
isActive={dashStyle === displayedStyle.dash} isActive={style === displayedStyle.dash}
value={dashStyle} value={style}
onSelect={preventEvent} onSelect={preventEvent}
bp={breakpoints} bp={breakpoints}
> >
{DASHES[dashStyle as DashStyle]} {DASH_ICONS[style as DashStyle]}
</DMRadioItem> </DMRadioItem>
))} ))}
</StyledGroup> </StyledGroup>
</StyledRow> </StyledRow>
<Divider />
<StyledRow> <StyledRow>
Size Size
<StyledGroup dir="ltr" value={displayedStyle.size} onValueChange={handleSizeChange}> <StyledGroup dir="ltr" value={displayedStyle.size} onValueChange={handleSizeChange}>
@ -184,15 +223,52 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
onSelect={preventEvent} onSelect={preventEvent}
bp={breakpoints} bp={breakpoints}
> >
{SIZES[sizeStyle as SizeStyle]} {SIZE_ICONS[sizeStyle as SizeStyle]}
</DMRadioItem> </DMRadioItem>
))} ))}
</StyledGroup> </StyledGroup>
</StyledRow> </StyledRow>
{showTextStyles && (
<>
<Divider /> <Divider />
<DMCheckboxItem checked={!!displayedStyle.isFilled} onCheckedChange={handleToggleFilled}> <StyledRow>
Fill Font
</DMCheckboxItem> <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}
>
<FontIcon fontStyle={fontStyle}>Aa</FontIcon>
</DMRadioItem>
))}
</StyledGroup>
</StyledRow>
<StyledRow>
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}
>
{ALIGN_ICONS[style]}
</DMRadioItem>
))}
</StyledGroup>
</StyledRow>
</>
)}
</DMContent> </DMContent>
</DropdownMenu.Root> </DropdownMenu.Root>
) )
@ -237,7 +313,7 @@ export const StyledRow = styled('div', {
fontFamily: '$ui', fontFamily: '$ui',
fontWeight: 400, fontWeight: 400,
fontSize: '$1', fontSize: '$1',
padding: '0 0 0 $3', padding: '$2 0 $2 $3',
borderRadius: 4, borderRadius: 4,
userSelect: 'none', userSelect: 'none',
margin: 0, margin: 0,
@ -250,6 +326,7 @@ export const StyledRow = styled('div', {
variant: { variant: {
tall: { tall: {
alignItems: 'flex-start', alignItems: 'flex-start',
padding: '0 0 0 $3',
'& > span': { '& > span': {
paddingTop: '$4', paddingTop: '$4',
}, },
@ -261,6 +338,7 @@ export const StyledRow = styled('div', {
const StyledGroup = styled(DropdownMenu.DropdownMenuRadioGroup, { const StyledGroup = styled(DropdownMenu.DropdownMenuRadioGroup, {
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
gap: '$1',
}) })
const OverlapIcons = styled('div', { const OverlapIcons = styled('div', {
@ -270,3 +348,28 @@ const OverlapIcons = styled('div', {
gridRow: 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',
},
},
},
})

View file

@ -1,6 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { TDShapeType } from '~types' import { AlignStyle, TDShapeType } from '~types'
import { useFileSystemHandlers, useTldrawApp } from '~hooks' import { useFileSystemHandlers, useTldrawApp } from '~hooks'
export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) { export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
@ -97,7 +97,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
// Dark Mode // Dark Mode
useHotkeys( useHotkeys(
'ctrl+shift+d,command+shift+d', 'ctrl+shift+d,+shift+d',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
app.toggleDarkMode() app.toggleDarkMode()
@ -110,7 +110,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
// Focus Mode // Focus Mode
useHotkeys( useHotkeys(
'ctrl+.,command+.', 'ctrl+.,+.',
() => { () => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
app.toggleFocusMode() app.toggleFocusMode()
@ -124,7 +124,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers() const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers()
useHotkeys( useHotkeys(
'ctrl+n,command+n', 'ctrl+n,+n',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
@ -134,7 +134,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
[app] [app]
) )
useHotkeys( useHotkeys(
'ctrl+s,command+s', 'ctrl+s,+s',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
@ -145,7 +145,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
) )
useHotkeys( useHotkeys(
'ctrl+shift+s,command+shift+s', 'ctrl+shift+s,+shift+s',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
@ -155,7 +155,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
[app] [app]
) )
useHotkeys( useHotkeys(
'ctrl+o,command+o', 'ctrl+o,+o',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
@ -168,10 +168,12 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
// Undo Redo // Undo Redo
useHotkeys( useHotkeys(
'command+z,ctrl+z', '+z,ctrl+z',
() => { () => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
console.log('Hello')
if (app.session) { if (app.session) {
app.cancelSession() app.cancelSession()
} else { } else {
@ -183,7 +185,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
) )
useHotkeys( useHotkeys(
'ctrl+shift-z,command+shift+z', 'ctrl+shift-z,+shift+z',
() => { () => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
@ -200,7 +202,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
// Undo Redo // Undo Redo
useHotkeys( useHotkeys(
'command+u,ctrl+u', '+u,ctrl+u',
() => { () => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
app.undoSelect() app.undoSelect()
@ -210,7 +212,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
) )
useHotkeys( useHotkeys(
'ctrl+shift-u,command+shift+u', 'ctrl+shift-u,+shift+u',
() => { () => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
app.redoSelect() app.redoSelect()
@ -224,7 +226,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
// Camera // Camera
useHotkeys( useHotkeys(
'ctrl+=,command+=', 'ctrl+=,+=',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
@ -236,7 +238,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
) )
useHotkeys( useHotkeys(
'ctrl+-,command+-', 'ctrl+-,+-',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
@ -280,7 +282,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
// Duplicate // Duplicate
useHotkeys( useHotkeys(
'ctrl+d,command+d', 'ctrl+d,+d',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
@ -341,7 +343,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
// Select All // Select All
useHotkeys( useHotkeys(
'command+a,ctrl+a', '+a,ctrl+a',
() => { () => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
app.selectAll() app.selectAll()
@ -433,7 +435,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
) )
useHotkeys( useHotkeys(
'command+shift+l,ctrl+shift+l', '+shift+l,ctrl+shift+l',
() => { () => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
app.toggleLocked() app.toggleLocked()
@ -445,7 +447,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
// Copy, Cut & Paste // Copy, Cut & Paste
useHotkeys( useHotkeys(
'command+c,ctrl+c', '+c,ctrl+c',
() => { () => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
app.copy() app.copy()
@ -455,7 +457,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
) )
useHotkeys( useHotkeys(
'command+x,ctrl+x', '+x,ctrl+x',
() => { () => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
app.cut() app.cut()
@ -465,7 +467,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
) )
useHotkeys( useHotkeys(
'command+v,ctrl+v', '+v,ctrl+v',
() => { () => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
app.paste() app.paste()
@ -477,7 +479,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
// Group & Ungroup // Group & Ungroup
useHotkeys( useHotkeys(
'command+g,ctrl+g', '+g,ctrl+g',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
@ -489,7 +491,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
) )
useHotkeys( useHotkeys(
'command+shift+g,ctrl+shift+g', '+shift+g,ctrl+shift+g',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
@ -543,7 +545,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
) )
useHotkeys( useHotkeys(
'command+shift+backspace', 'ctrl+shift+backspace,⌘+shift+backspace',
(e) => { (e) => {
if (!canHandleEvent()) return if (!canHandleEvent()) return
if (app.settings.isDebugMode) { if (app.settings.isDebugMode) {
@ -554,4 +556,39 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
undefined, undefined,
[app] [app]
) )
// Text Align
useHotkeys(
'alt+command+l,alt+ctrl+l',
(e) => {
if (!canHandleEvent()) return
app.style({ textAlign: AlignStyle.Start })
e.preventDefault()
},
undefined,
[app]
)
useHotkeys(
'alt+command+t,alt+ctrl+t',
(e) => {
if (!canHandleEvent()) return
app.style({ textAlign: AlignStyle.Middle })
e.preventDefault()
},
undefined,
[app]
)
useHotkeys(
'alt+command+r,alt+ctrl+r',
(e) => {
if (!canHandleEvent()) return
app.style({ textAlign: AlignStyle.End })
e.preventDefault()
},
undefined,
[app]
)
} }

View file

@ -4,7 +4,7 @@ const styles = new Map<string, HTMLStyleElement>()
const UID = `Tldraw-fonts` const UID = `Tldraw-fonts`
const CSS = ` const CSS = `
@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&display=swap') @import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');
` `
export function useStylesheet() { export function useStylesheet() {

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Patch, StateManager } from 'rko' import { StateManager } from 'rko'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import { import {
TLBoundsEventHandler, TLBoundsEventHandler,
@ -247,11 +247,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
* @protected * @protected
* @returns The final state * @returns The final state
*/ */
protected cleanup = ( protected cleanup = (state: TDSnapshot, prev: TDSnapshot): TDSnapshot => {
state: TDSnapshot,
prev: TDSnapshot,
patch: Patch<TDSnapshot>
): TDSnapshot => {
const next = { ...state } const next = { ...state }
// Remove deleted shapes and bindings (in Commands, these will be set to undefined) // Remove deleted shapes and bindings (in Commands, these will be set to undefined)
@ -2064,7 +2060,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const shapesToUpdate = shapes.filter((shape) => pageShapes[shape.id]) const shapesToUpdate = shapes.filter((shape) => pageShapes[shape.id])
if (shapesToUpdate.length === 0) return this if (shapesToUpdate.length === 0) return this
return this.setState( return this.setState(
Commands.update(this, shapesToUpdate, this.currentPageId), Commands.updateShapes(this, shapesToUpdate, this.currentPageId),
'updated_shapes' 'updated_shapes'
) )
} }
@ -2079,7 +2075,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const shapesToUpdate = shapes.filter((shape) => pageShapes[shape.id]) const shapesToUpdate = shapes.filter((shape) => pageShapes[shape.id])
if (shapesToUpdate.length === 0) return this if (shapesToUpdate.length === 0) return this
return this.patchState( return this.patchState(
Commands.update(this, shapesToUpdate, this.currentPageId).after, Commands.updateShapes(this, shapesToUpdate, this.currentPageId).after,
'updated_shapes' 'updated_shapes'
) )
} }
@ -2333,6 +2329,15 @@ export class TldrawApp extends StateManager<TDSnapshot> {
return this.setState(Commands.toggleShapesDecoration(this, ids, handleId)) return this.setState(Commands.toggleShapesDecoration(this, ids, handleId))
} }
/**
* Set the props of one or more shapes
* @param props The props to set on the shapes.
* @param ids The ids of the shapes to set props on.
*/
setShapeProps = <T extends TDShape>(props: Partial<T>, ids = this.selectedIds) => {
return this.setState(Commands.setShapesProps(this, ids, props))
}
/** /**
* Rotate one or more shapes by a delta. * Rotate one or more shapes by a delta.
* @param delta The delta in radians. * @param delta The delta in radians.
@ -2800,12 +2805,12 @@ export class TldrawApp extends StateManager<TDSnapshot> {
getShapeUtil = TLDR.getShapeUtil getShapeUtil = TLDR.getShapeUtil
static version = 13 static version = 14
static defaultDocument: TDDocument = { static defaultDocument: TDDocument = {
id: 'doc', id: 'doc',
name: 'New Document', name: 'New Document',
version: 13, version: 14,
pages: { pages: {
page: { page: {
id: 'page', id: 'page',
@ -2843,15 +2848,14 @@ export class TldrawApp extends StateManager<TDSnapshot> {
showCloneHandles: false, showCloneHandles: false,
}, },
appState: { appState: {
status: TDStatus.Idle,
activeTool: 'select', activeTool: 'select',
hoveredId: undefined, hoveredId: undefined,
currentPageId: 'page', currentPageId: 'page',
pages: [{ id: 'page', name: 'page', childIndex: 1 }],
currentStyle: defaultStyle, currentStyle: defaultStyle,
isToolLocked: false, isToolLocked: false,
isStyleOpen: false, isStyleOpen: false,
isEmptyCanvas: false, isEmptyCanvas: false,
status: TDStatus.Idle,
snapLines: [], snapLines: [],
}, },
document: TldrawApp.defaultDocument, document: TldrawApp.defaultDocument,

View file

@ -21,3 +21,4 @@ export * from './toggleShapesProp'
export * from './translateShapes' export * from './translateShapes'
export * from './ungroupShapes' export * from './ungroupShapes'
export * from './updateShapes' export * from './updateShapes'
export * from './setShapesProps'

View file

@ -0,0 +1 @@
export * from './setShapesProps'

View file

@ -0,0 +1,3 @@
describe('Set shapes props command', () => {
it.todo('sets the props of the provided shapes')
})

View file

@ -0,0 +1,56 @@
import type { TDShape, TldrawCommand } from '~types'
import type { TldrawApp } from '~state'
export function setShapesProps<T extends TDShape>(
app: TldrawApp,
ids: string[],
partial: Partial<T>
): TldrawCommand {
const { currentPageId, selectedIds } = app
const initialShapes = ids
.map((id) => app.getShape<T>(id))
.filter((shape) => (partial['isLocked'] ? true : !shape.isLocked))
const before: Record<string, Partial<TDShape>> = {}
const after: Record<string, Partial<TDShape>> = {}
const keys = Object.keys(partial) as (keyof T)[]
initialShapes.forEach((shape) => {
before[shape.id] = Object.fromEntries(keys.map((key) => [key, shape[key]]))
after[shape.id] = partial
})
return {
id: 'set_props',
before: {
document: {
pages: {
[currentPageId]: {
shapes: before,
},
},
pageStates: {
[currentPageId]: {
selectedIds,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: after,
},
},
pageStates: {
[currentPageId]: {
selectedIds,
},
},
},
},
}
}

View file

@ -2,7 +2,7 @@ import type { TldrawCommand, TDShape } from '~types'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
import type { TldrawApp } from '../../internal' import type { TldrawApp } from '../../internal'
export function update( export function updateShapes(
app: TldrawApp, app: TldrawApp,
updates: ({ id: string } & Partial<TDShape>)[], updates: ({ id: string } & Partial<TDShape>)[],
pageId: string pageId: string

View file

@ -1,11 +1,19 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
import { Decoration, TDDocument, TDShapeType } from '~types' import { Decoration, FontStyle, TDDocument, TDShapeType, TextShape } from '~types'
export function migrate(document: TDDocument, newVersion: number): TDDocument { export function migrate(document: TDDocument, newVersion: number): TDDocument {
const { version = 0 } = document const { version = 0 } = document
if (version === newVersion) return document if (version === newVersion) return document
if (version < 14) {
Object.values(document.pages).forEach((page) => {
Object.values(page.shapes)
.filter((shape) => shape.type === TDShapeType.Text)
.forEach((shape) => (shape as TextShape).style.font === FontStyle.Script)
})
}
// Lowercase styles, move binding meta to binding // Lowercase styles, move binding meta to binding
if (version <= 13) { if (version <= 13) {
Object.values(document.pages).forEach((page) => { Object.values(document.pages).forEach((page) => {

View file

@ -1,13 +1,13 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import { Utils, HTMLContainer, TLBounds } from '@tldraw/core' import { Utils, HTMLContainer, TLBounds } from '@tldraw/core'
import { defaultStyle } from '../shared/shape-styles' import { defaultTextStyle } from '../shared/shape-styles'
import { StickyShape, TDMeta, TDShapeType, TransformInfo } from '~types' import { AlignStyle, StickyShape, TDMeta, TDShapeType, TransformInfo } from '~types'
import { getBoundsRectangle, TextAreaUtils } from '../shared' import { getBoundsRectangle, TextAreaUtils } from '../shared'
import { TDShapeUtil } from '../TDShapeUtil' import { TDShapeUtil } from '../TDShapeUtil'
import { getStickyFontStyle, getStickyShapeStyle } from '../shared/shape-styles' import { getStickyFontStyle, getStickyShapeStyle } from '../shared/shape-styles'
import { styled } from '~styles' import { styled } from '~styles'
import Vec from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import { GHOSTED_OPACITY } from '~constants' import { GHOSTED_OPACITY } from '~constants'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
@ -35,7 +35,7 @@ export class StickyUtil extends TDShapeUtil<T, E> {
size: [200, 200], size: [200, 200],
text: '', text: '',
rotation: 0, rotation: 0,
style: defaultStyle, style: defaultTextStyle,
}, },
props props
) )
@ -165,7 +165,7 @@ export class StickyUtil extends TDShapeUtil<T, E> {
isGhost={isGhost} isGhost={isGhost}
style={{ backgroundColor: fill, ...style }} style={{ backgroundColor: fill, ...style }}
> >
<StyledText ref={rText} isEditing={isEditing}> <StyledText ref={rText} isEditing={isEditing} alignment={shape.style.textAlign}>
{shape.text}&#8203; {shape.text}&#8203;
</StyledText> </StyledText>
{isEditing && ( {isEditing && (
@ -184,6 +184,7 @@ export class StickyUtil extends TDShapeUtil<T, E> {
autoSave="false" autoSave="false"
autoFocus autoFocus
spellCheck={false} spellCheck={false}
alignment={shape.style.textAlign}
/> />
)} )}
</StyledStickyContainer> </StyledStickyContainer>
@ -291,6 +292,20 @@ const StyledText = styled('div', {
opacity: 1, opacity: 1,
}, },
}, },
alignment: {
[AlignStyle.Start]: {
textAlign: 'left',
},
[AlignStyle.Middle]: {
textAlign: 'center',
},
[AlignStyle.End]: {
textAlign: 'right',
},
[AlignStyle.Justify]: {
textAlign: 'justify',
},
},
}, },
...commonTextWrapping, ...commonTextWrapping,
}) })
@ -310,4 +325,20 @@ const StyledTextArea = styled('textarea', {
resize: 'none', resize: 'none',
caretColor: 'black', caretColor: 'black',
...commonTextWrapping, ...commonTextWrapping,
variants: {
alignment: {
[AlignStyle.Start]: {
textAlign: 'left',
},
[AlignStyle.Middle]: {
textAlign: 'center',
},
[AlignStyle.End]: {
textAlign: 'right',
},
[AlignStyle.Justify]: {
textAlign: 'justify',
},
},
},
}) })

View file

@ -1,14 +1,15 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import { Utils, HTMLContainer, TLBounds } from '@tldraw/core' import { Utils, HTMLContainer, TLBounds } from '@tldraw/core'
import { defaultStyle, getShapeStyle, getFontStyle } from '../shared/shape-styles' import { defaultTextStyle, getShapeStyle, getFontStyle } from '../shared/shape-styles'
import { TextShape, TDMeta, TDShapeType, TransformInfo } from '~types' import { TextShape, TDMeta, TDShapeType, TransformInfo, AlignStyle } from '~types'
import { TextAreaUtils } from '../shared' import { TextAreaUtils } from '../shared'
import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants' import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants'
import { TDShapeUtil } from '../TDShapeUtil' import { TDShapeUtil } from '../TDShapeUtil'
import { styled } from '~styles' import { styled } from '~styles'
import Vec from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
import { getTextAlign } from '../shared/getTextAlign'
type T = TextShape type T = TextShape
type E = HTMLDivElement type E = HTMLDivElement
@ -33,7 +34,7 @@ export class TextUtil extends TDShapeUtil<T, E> {
point: [0, 0], point: [0, 0],
rotation: 0, rotation: 0,
text: ' ', text: ' ',
style: defaultStyle, style: defaultTextStyle,
}, },
props props
) )
@ -50,7 +51,39 @@ export class TextUtil extends TDShapeUtil<T, E> {
const handleChange = React.useCallback( const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => { (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onShapeChange?.({ ...shape, text: TLDR.normalizeText(e.currentTarget.value) }) let delta = [0, 0]
const currentBounds = this.getBounds(shape)
switch (shape.style.textAlign) {
case AlignStyle.Start: {
break
}
case AlignStyle.Middle: {
const nextBounds = this.getBounds({
...shape,
text: TLDR.normalizeText(e.currentTarget.value),
})
delta = Vec.div([nextBounds.width - currentBounds.width, 0], 2)
break
}
case AlignStyle.End: {
const nextBounds = this.getBounds({
...shape,
text: TLDR.normalizeText(e.currentTarget.value),
})
delta = [nextBounds.width - currentBounds.width, 0]
break
}
}
onShapeChange?.({
...shape,
point: Vec.sub(shape.point, delta),
text: TLDR.normalizeText(e.currentTarget.value),
})
}, },
[shape] [shape]
) )
@ -126,6 +159,7 @@ export class TextUtil extends TDShapeUtil<T, E> {
style={{ style={{
font, font,
color: styles.stroke, color: styles.stroke,
textAlign: getTextAlign(style.textAlign),
}} }}
> >
{isBinding && ( {isBinding && (
@ -147,6 +181,7 @@ export class TextUtil extends TDShapeUtil<T, E> {
style={{ style={{
font, font,
color: styles.stroke, color: styles.stroke,
textAlign: 'inherit',
}} }}
name="text" name="text"
defaultValue={text} defaultValue={text}

View file

@ -14,9 +14,11 @@ Object {
"style": Object { "style": Object {
"color": "black", "color": "black",
"dash": "draw", "dash": "draw",
"font": "script",
"isFilled": false, "isFilled": false,
"scale": 1, "scale": 1,
"size": "small", "size": "small",
"textAlign": "start",
}, },
"text": " ", "text": " ",
"type": "text", "type": "text",

View file

@ -0,0 +1,12 @@
import { AlignStyle } from '~types'
const ALIGN_VALUES = {
[AlignStyle.Start]: 'left',
[AlignStyle.Middle]: 'center',
[AlignStyle.End]: 'right',
[AlignStyle.Justify]: 'justify',
} as const
export function getTextAlign(alignStyle: AlignStyle = AlignStyle.Start) {
return ALIGN_VALUES[alignStyle]
}

View file

@ -1,5 +1,5 @@
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
import { Theme, ColorStyle, DashStyle, ShapeStyles, SizeStyle } from '~types' import { Theme, ColorStyle, DashStyle, ShapeStyles, SizeStyle, FontStyle, AlignStyle } from '~types'
const canvasLight = '#fafafa' const canvasLight = '#fafafa'
@ -84,6 +84,20 @@ const fontSizes = {
auto: 'auto', auto: 'auto',
} }
const fontFaces = {
[FontStyle.Script]: '"Caveat Brush"',
[FontStyle.Sans]: '"Source Sans Pro", sans-serif',
[FontStyle.Serif]: '"Source Serif Pro", serif',
[FontStyle.Mono]: '"Source Code Pro", monospace',
}
const fontSizeModifiers = {
[FontStyle.Script]: 1,
[FontStyle.Sans]: 1,
[FontStyle.Serif]: 1,
[FontStyle.Mono]: 1,
}
const stickyFontSizes = { const stickyFontSizes = {
[SizeStyle.Small]: 24, [SizeStyle.Small]: 24,
[SizeStyle.Medium]: 36, [SizeStyle.Medium]: 36,
@ -95,8 +109,12 @@ export function getStrokeWidth(size: SizeStyle): number {
return strokeWidths[size] return strokeWidths[size]
} }
export function getFontSize(size: SizeStyle): number { export function getFontSize(size: SizeStyle, fontStyle: FontStyle = FontStyle.Script): number {
return fontSizes[size] return fontSizes[size] * fontSizeModifiers[fontStyle]
}
export function getFontFace(font: FontStyle = FontStyle.Script): string {
return fontFaces[font]
} }
export function getStickyFontSize(size: SizeStyle): number { export function getStickyFontSize(size: SizeStyle): number {
@ -104,17 +122,19 @@ export function getStickyFontSize(size: SizeStyle): number {
} }
export function getFontStyle(style: ShapeStyles): string { export function getFontStyle(style: ShapeStyles): string {
const fontSize = getFontSize(style.size) const fontSize = getFontSize(style.size, style.font)
const fontFace = getFontFace(style.font)
const { scale = 1 } = style const { scale = 1 } = style
return `${fontSize * scale}px/1.3 "Caveat Brush"` return `${fontSize * scale}px/1.3 ${fontFace}`
} }
export function getStickyFontStyle(style: ShapeStyles): string { export function getStickyFontStyle(style: ShapeStyles): string {
const fontSize = getStickyFontSize(style.size) const fontSize = getStickyFontSize(style.size)
const fontFace = getFontFace(style.font)
const { scale = 1 } = style const { scale = 1 } = style
return `${fontSize * scale}px/1.3 "Caveat Brush"` return `${fontSize * scale}px/1.3 ${fontFace}`
} }
export function getStickyShapeStyle(style: ShapeStyles, isDarkMode = false) { export function getStickyShapeStyle(style: ShapeStyles, isDarkMode = false) {
@ -158,3 +178,9 @@ export const defaultStyle: ShapeStyles = {
dash: DashStyle.Draw, dash: DashStyle.Draw,
scale: 1, scale: 1,
} }
export const defaultTextStyle: ShapeStyles = {
...defaultStyle,
font: FontStyle.Script,
textAlign: AlignStyle.Start,
}

View file

@ -94,7 +94,6 @@ export interface TDSnapshot {
appState: { appState: {
currentStyle: ShapeStyles currentStyle: ShapeStyles
currentPageId: string currentPageId: string
pages: Pick<TLPage<TDShape, TDBinding>, 'id' | 'name' | 'childIndex'>[]
hoveredId?: string hoveredId?: string
activeTool: TDToolType activeTool: TDToolType
isToolLocked: boolean isToolLocked: boolean
@ -401,10 +400,26 @@ export enum FontSize {
ExtraLarge = 'extraLarge', ExtraLarge = 'extraLarge',
} }
export enum AlignStyle {
Start = 'start',
Middle = 'middle',
End = 'end',
Justify = 'justify',
}
export enum FontStyle {
Script = 'script',
Sans = 'sans',
Serif = 'erif',
Mono = 'mono',
}
export type ShapeStyles = { export type ShapeStyles = {
color: ColorStyle color: ColorStyle
size: SizeStyle size: SizeStyle
dash: DashStyle dash: DashStyle
font?: FontStyle
textAlign?: AlignStyle
isFilled?: boolean isFilled?: boolean
scale?: number scale?: number
} }