Merge pull request #44 from tldraw/feature-back-to-content

Back to Content + various fixes
This commit is contained in:
Steve Ruiz 2021-07-11 14:10:18 +01:00 committed by GitHub
commit 8c84d14df3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 518 additions and 408 deletions

View file

@ -1,40 +1,5 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
name: Bug Report
about: Writing and other documentation.
title: '[Bug] Bug description'
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View file

@ -15,6 +15,7 @@ import Coop from './coop/coop'
import Brush from './brush'
import Defs from './defs'
import Page from './page'
import useSafariFocusOutFix from 'hooks/useSafariFocusOutFix'
function resetError() {
null
@ -28,6 +29,8 @@ export default function Canvas(): JSX.Element {
useZoomEvents()
useSafariFocusOutFix()
const events = useCanvasEvents(rCanvas)
const isReady = useSelector((s) => s.isIn('ready'))
@ -62,9 +65,10 @@ const MainSVG = styled('svg', {
height: '100%',
touchAction: 'none',
zIndex: 100,
backgroundColor: '$canvas',
pointerEvents: 'all',
// cursor: 'none',
backgroundColor: '$canvas',
borderTop: '1px solid $border',
borderBottom: '1px solid $border',
'& *': {
userSelect: 'none',

View file

@ -12,7 +12,7 @@ import {
ContextMenuRoot,
MenuContent,
} from 'components/shared'
import { commandKey, deepCompareArrays, isMobile } from 'utils'
import { commandKey, deepCompareArrays } from 'utils'
import state, { useSelector } from 'state'
import {
AlignType,
@ -36,6 +36,7 @@ import {
StretchHorizontallyIcon,
StretchVerticallyIcon,
} from '@radix-ui/react-icons'
import { Kbd } from 'components/shared'
function alignTop() {
state.send('ALIGNED', { type: AlignType.Top })
@ -101,34 +102,30 @@ export default function ContextMenu({
return (
<ContextMenuRoot>
<_ContextMenu.Trigger>{children}</_ContextMenu.Trigger>
<MenuContent
as={_ContextMenu.Content}
ref={rContent}
isMobile={isMobile()}
>
<MenuContent as={_ContextMenu.Content} ref={rContent}>
{selectedShapeIds.length ? (
<>
{/* <ContextMenuButton onSelect={() => state.send('COPIED')}>
<span>Copy</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span>C</span>
</kbd>
</Kbd>
</ContextMenuButton>
<ContextMenuButton onSelect={() => state.send('CUT')}>
<span>Cut</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span>X</span>
</kbd>
</Kbd>
</ContextMenuButton>
*/}
<ContextMenuButton onSelect={() => state.send('DUPLICATED')}>
<span>Duplicate</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span>D</span>
</kbd>
</Kbd>
</ContextMenuButton>
<ContextMenuDivider />
{hasGroupSelected ||
@ -137,20 +134,20 @@ export default function ContextMenu({
{hasGroupSelected && (
<ContextMenuButton onSelect={() => state.send('UNGROUPED')}>
<span>Ungroup</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span></span>
<span>G</span>
</kbd>
</Kbd>
</ContextMenuButton>
)}
{hasTwoOrMore && (
<ContextMenuButton onSelect={() => state.send('GROUPED')}>
<span>Group</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span>G</span>
</kbd>
</Kbd>
</ContextMenuButton>
)}
</>
@ -164,11 +161,11 @@ export default function ContextMenu({
}
>
<span>To Front</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span></span>
<span>]</span>
</kbd>
</Kbd>
</ContextMenuButton>
<ContextMenuButton
@ -179,10 +176,10 @@ export default function ContextMenu({
}
>
<span>Forward</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span>]</span>
</kbd>
</Kbd>
</ContextMenuButton>
<ContextMenuButton
onSelect={() =>
@ -192,10 +189,10 @@ export default function ContextMenu({
}
>
<span>Backward</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span>[</span>
</kbd>
</Kbd>
</ContextMenuButton>
<ContextMenuButton
onSelect={() =>
@ -205,11 +202,11 @@ export default function ContextMenu({
}
>
<span>To Back</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span></span>
<span>[</span>
</kbd>
</Kbd>
</ContextMenuButton>
</ContextMenuSubMenu>
{hasTwoOrMore && (
@ -221,36 +218,36 @@ export default function ContextMenu({
<MoveToPageMenu />
<ContextMenuButton onSelect={() => state.send('COPIED_TO_SVG')}>
<span>Copy to SVG</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span></span>
<span>C</span>
</kbd>
</Kbd>
</ContextMenuButton>
<ContextMenuDivider />
<ContextMenuButton onSelect={() => state.send('DELETED')}>
<span>Delete</span>
<kbd>
<Kbd>
<span></span>
</kbd>
</Kbd>
</ContextMenuButton>
</>
) : (
<>
<ContextMenuButton onSelect={() => state.send('UNDO')}>
<span>Undo</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span>Z</span>
</kbd>
</Kbd>
</ContextMenuButton>
<ContextMenuButton onSelect={() => state.send('REDO')}>
<span>Redo</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span></span>
<span>Z</span>
</kbd>
</Kbd>
</ContextMenuButton>
</>
)}
@ -277,7 +274,6 @@ function AlignDistributeSubMenu({
as={_ContextMenu.Content}
sideOffset={2}
alignOffset={-2}
isMobile={isMobile()}
selectedStyle={hasThreeOrMore ? 'threeOrMore' : 'twoOrMore'}
>
<ContextMenuIconButton onSelect={alignLeft}>
@ -355,12 +351,7 @@ function MoveToPageMenu() {
<ChevronRightIcon />
</IconWrapper>
</ContextMenuButton>
<MenuContent
as={_ContextMenu.Content}
sideOffset={2}
alignOffset={-2}
isMobile={isMobile()}
>
<MenuContent as={_ContextMenu.Content} sideOffset={2} alignOffset={-2}>
{sorted.map(({ id, name }) => (
<ContextMenuButton
key={id}

View file

@ -1,68 +1,26 @@
import { useSelector } from 'state'
import tld from 'utils/tld'
import { Data, Shape, ShapeType } from 'types'
import { getShapeUtils } from 'state/shape-utils'
import { boundsCollide, boundsContain } from 'utils'
import { ShapeTreeNode } from 'types'
import ShapeComponent from './shape'
/*
On each state change, populate a tree structure with all of
the shapes that we need to render..
*/
interface Node {
shape: Shape
children: Node[]
isEditing: boolean
isHovered: boolean
isSelected: boolean
isDarkMode: boolean
isCurrentParent: boolean
}
export default function Page(): JSX.Element {
// Get a tree of shapes to render
const shapeTree = useSelector((s) => {
// Get the shapes that fit into the current viewport
const shapesToRender = useSelector((s) => s.values.shapesToRender)
const viewport = tld.getViewport(s.data)
const shapesToShow = s.values.currentShapes.filter((shape) => {
const shapeBounds = getShapeUtils(shape).getBounds(shape)
return (
shape.type === ShapeType.Ray ||
shape.type === ShapeType.Line ||
boundsContain(viewport, shapeBounds) ||
boundsCollide(viewport, shapeBounds)
)
})
// Should we allow shapes to be hovered?
const allowHovers = s.isInAny('selecting', 'text', 'editingShape')
// Populate the shape tree
const tree: Node[] = []
shapesToShow.forEach((shape) =>
addToTree(s.data, s.values.selectedIds, allowHovers, tree, shape)
)
return tree
})
const allowHovers = useSelector((s) =>
s.isInAny('selecting', 'text', 'editingShape')
)
return (
<>
{shapeTree.map((node) => (
<ShapeNode key={node.shape.id} node={node} />
{shapesToRender.map((node) => (
<ShapeNode key={node.shape.id} node={node} allowHovers={allowHovers} />
))}
</>
)
}
interface ShapeNodeProps {
node: Node
parentPoint?: number[]
node: ShapeTreeNode
allowHovers: boolean
}
const ShapeNode = ({
@ -75,58 +33,25 @@ const ShapeNode = ({
isSelected,
isCurrentParent,
},
allowHovers,
}: ShapeNodeProps) => {
return (
<>
<ShapeComponent
shape={shape}
isEditing={isEditing}
isHovered={isHovered}
isHovered={allowHovers && isHovered}
isSelected={isSelected}
isDarkMode={isDarkMode}
isCurrentParent={isCurrentParent}
/>
{children.map((childNode) => (
<ShapeNode key={childNode.shape.id} node={childNode} />
<ShapeNode
key={childNode.shape.id}
node={childNode}
allowHovers={allowHovers}
/>
))}
</>
)
}
/**
* Populate the shape tree. This helper is recursive and only one call is needed.
*
* ### Example
*
*```ts
* addDataToTree(data, selectedIds, allowHovers, branch, shape)
*```
*/
function addToTree(
data: Data,
selectedIds: string[],
allowHovers: boolean,
branch: Node[],
shape: Shape
): void {
const node = {
shape,
children: [],
isHovered: data.hoveredId === shape.id,
isCurrentParent: data.currentParentId === shape.id,
isEditing: data.editingId === shape.id,
isDarkMode: data.settings.isDarkMode,
isSelected: selectedIds.includes(shape.id),
}
branch.push(node)
if (shape.children) {
shape.children
.map((id) => tld.getShape(data, id))
.sort((a, b) => a.childIndex - b.childIndex)
.forEach((childShape) => {
addToTree(data, selectedIds, allowHovers, node.children, childShape)
})
}
}

View file

@ -2,8 +2,13 @@
import styled from 'styles'
import React, { useRef } from 'react'
import state, { useSelector } from 'state'
import * as Panel from '../panel'
import { breakpoints, IconButton, RowButton, IconWrapper } from '../shared'
import * as Panel from 'components/panel'
import {
breakpoints,
IconButton,
RowButton,
IconWrapper,
} from 'components/shared'
import {
Cross2Icon,
PlayIcon,
@ -179,7 +184,6 @@ export default function CodePanel(): JSX.Element {
}
const StylePanelRoot = styled(Panel.Root, {
marginRight: '8px',
width: 'fit-content',
maxWidth: 'fit-content',
overflow: 'hidden',

View file

@ -2,7 +2,6 @@ import useKeyboardEvents from 'hooks/useKeyboardEvents'
import useLoadOnMount from 'hooks/useLoadOnMount'
import Menu from './menu/menu'
import Canvas from './canvas/canvas'
import StatusBar from './status-bar'
import ToolsPanel from './tools-panel/tools-panel'
import StylePanel from './style-panel/style-panel'
import styled from 'styles'
@ -28,7 +27,6 @@ export default function Editor({ roomId }: { roomId?: string }): JSX.Element {
<StylePanel />
<Canvas />
<ToolsPanel />
<StatusBar />
</Layout>
)
}
@ -56,6 +54,8 @@ const Layout = styled('main', {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-start',
boxSizing: 'border-box',
pointerEvents: 'none',
'& > *': {
PointerEvent: 'all',

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import { HamburgerMenuIcon } from '@radix-ui/react-icons'
import { ExitIcon, HamburgerMenuIcon } from '@radix-ui/react-icons'
import { Trigger, Content } from '@radix-ui/react-dropdown-menu'
import { memo } from 'react'
import {
@ -12,14 +12,18 @@ import {
DropdownMenuSubMenu,
DropdownMenuDivider,
DropdownMenuCheckboxItem,
IconWrapper,
Kbd,
} from '../shared'
import state, { useSelector } from 'state'
import { commandKey } from 'utils'
import { signOut } from 'next-auth/client'
const handleNew = () => state.send('CREATED_NEW_PROJECT')
const handleSave = () => state.send('SAVED')
const handleLoad = () => state.send('LOADED_FROM_FILE_STSTEM')
const toggleDarkMode = () => state.send('TOGGLED_DARK_MODE')
const toggleDebugMode = () => state.send('TOGGLED_DEBUG_MODE')
function Menu() {
return (
@ -31,38 +35,45 @@ function Menu() {
<Content as={MenuContent} sideOffset={8}>
<DropdownMenuButton onSelect={handleNew} disabled>
<span>New Project</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span>N</span>
</kbd>
</Kbd>
</DropdownMenuButton>
<DropdownMenuDivider />
<DropdownMenuButton onSelect={handleLoad}>
<span>Open...</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span>L</span>
</kbd>
</Kbd>
</DropdownMenuButton>
<RecentFiles />
<DropdownMenuDivider />
<DropdownMenuButton onSelect={handleSave}>
<span>Save</span>
<kbd>
<Kbd>
<span>{commandKey()}</span>
<span>S</span>
</kbd>
</Kbd>
</DropdownMenuButton>
<DropdownMenuButton onSelect={handleSave}>
<span>Save As...</span>
<kbd>
<Kbd>
<span></span>
<span>{commandKey()}</span>
<span>S</span>
</kbd>
</Kbd>
</DropdownMenuButton>
<DropdownMenuDivider />
<Preferences />
<DropdownMenuDivider />
<DropdownMenuButton onSelect={signOut}>
<span>Sign Out</span>
<IconWrapper size="small">
<ExitIcon />
</IconWrapper>
</DropdownMenuButton>
</Content>
</DropdownMenuRoot>
</FloatingContainer>
@ -88,6 +99,7 @@ function RecentFiles() {
}
function Preferences() {
const isDebugMode = useSelector((s) => s.data.settings.isDebugMode)
const isDarkMode = useSelector((s) => s.data.settings.isDarkMode)
return (
@ -98,6 +110,12 @@ function Preferences() {
>
<span>Dark Mode</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={isDebugMode}
onCheckedChange={toggleDebugMode}
>
<span>Debug Mode</span>
</DropdownMenuCheckboxItem>
</DropdownMenuSubMenu>
)
}

View file

@ -3,7 +3,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import * as RadioGroup from '@radix-ui/react-radio-group'
import * as Panel from './panel'
import styled from 'styles'
import { forwardRef } from 'react'
import React, { forwardRef } from 'react'
import { CheckIcon, ChevronRightIcon } from '@radix-ui/react-icons'
import { isMobile } from 'utils'
@ -466,6 +466,14 @@ export const FloatingContainer = styled('div', {
zIndex: 200,
variants: {
direction: {
row: {
flexDirection: 'row',
},
column: {
flexDirection: 'column',
},
},
elevation: {
0: {
boxShadow: 'none',
@ -483,6 +491,23 @@ export const FloatingContainer = styled('div', {
},
})
export const StyledKbd = styled('kbd', {
marginLeft: '32px',
fontSize: '$1',
fontFamily: '$ui',
fontWeight: 400,
'& > span': {
display: 'inline-block',
width: '12px',
},
})
export function Kbd({ children }: { children: React.ReactNode }): JSX.Element {
if (isMobile()) return null
return <StyledKbd>{children}</StyledKbd>
}
/* -------------------------------------------------- */
/* Menus */
/* -------------------------------------------------- */
@ -498,30 +523,17 @@ export const MenuContent = styled('div', {
border: '1px solid $panel',
padding: '$0',
boxShadow: '$4',
minWidth: 200,
minWidth: 180,
font: '$ui',
})
'& kbd': {
marginLeft: '32px',
fontSize: '$1',
fontFamily: '$ui',
fontWeight: 400,
},
'& kbd > span': {
display: 'inline-block',
width: '12px',
},
variants: {
isMobile: {
true: {
'& kbd': {
display: 'none',
},
},
},
},
export const Divider = styled('div', {
backgroundColor: '$hover',
height: 1,
marginTop: '$2',
marginRight: '-$2',
marginBottom: '$2',
marginLeft: '-$2',
})
/* -------------------------------------------------- */
@ -565,12 +577,7 @@ export function DropdownMenuSubMenu({
<ChevronRightIcon />
</IconWrapper>
</RowButton>
<MenuContent
as={DropdownMenu.Content}
sideOffset={2}
alignOffset={-2}
isMobile={isMobile()}
>
<MenuContent as={DropdownMenu.Content} sideOffset={2} alignOffset={-2}>
{children}
<DropdownMenuArrow offset={13} />
</MenuContent>
@ -699,12 +706,7 @@ export function ContextMenuSubMenu({
<ChevronRightIcon />
</IconWrapper>
</ContextMenu.TriggerItem>
<ContextMenu.Content
as={MenuContent}
sideOffset={2}
alignOffset={-2}
isMobile={isMobile()}
>
<ContextMenu.Content as={MenuContent} sideOffset={2} alignOffset={-2}>
{children}
<ContextMenuArrow offset={13} />
</ContextMenu.Content>

View file

@ -1,14 +1,13 @@
import { useStateDesigner } from '@state-designer/react'
import state from 'state'
import { useCoopSelector } from 'state/coop/coop-state'
import styled from 'styles'
const size: any = { '@sm': 'small' }
export default function StatusBar(): JSX.Element {
const local = useStateDesigner(state)
const status = useCoopSelector((s) => s.data.status)
const others = useCoopSelector((s) => s.data.others)
const shapesInView = state.values.shapesToRender.length
const active = local.active.slice(1).map((s) => {
const states = s.split('.')
@ -17,29 +16,26 @@ export default function StatusBar(): JSX.Element {
const log = local.log[0]
if (process.env.NODE_ENV !== 'development') return null
return (
<StatusBarContainer size={size}>
<Section>
{active.join(' | ')} | {log} | {status} (
{Object.values(others).length || 0})
{active.join(' | ')} - {log}
</Section>
<Section>{shapesInView || '0'} Shapes</Section>
</StatusBarContainer>
)
}
const StatusBarContainer = styled('div', {
position: 'absolute',
bottom: 0,
left: 0,
width: '100%',
zIndex: 300,
height: 40,
userSelect: 'none',
borderTop: '1px solid $border',
gridArea: 'status',
display: 'grid',
display: 'flex',
color: '$text',
gridTemplateColumns: 'auto 1fr auto',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '$panel',
gap: 8,

View file

@ -1,13 +1,12 @@
import styled from 'styles'
import state, { useSelector } from 'state'
import * as Panel from 'components/panel'
import { useRef } from 'react'
import {
IconButton,
IconWrapper,
ButtonsRow,
RowButton,
breakpoints,
RowButton,
FloatingContainer,
Divider,
Kbd,
} from 'components/shared'
import ShapesFunctions from './shapes-functions'
import AlignDistribute from './align-distribute'
@ -16,14 +15,8 @@ import QuickSizeSelect from './quick-size-select'
import QuickDashSelect from './quick-dash-select'
import QuickFillSelect from './quick-fill-select'
import Tooltip from 'components/tooltip'
import { motion } from 'framer-motion'
import {
ClipboardCopyIcon,
ClipboardIcon,
DotsHorizontalIcon,
Share2Icon,
Cross2Icon,
} from '@radix-ui/react-icons'
import { DotsHorizontalIcon, Cross2Icon } from '@radix-ui/react-icons'
import { commandKey, isMobile } from 'utils'
const handleStylePanelOpen = () => state.send('TOGGLED_STYLE_PANEL_OPEN')
const handleCopy = () => state.send('COPIED')
@ -31,12 +24,10 @@ const handlePaste = () => state.send('PASTED')
const handleCopyToSvg = () => state.send('COPIED_TO_SVG')
export default function StylePanel(): JSX.Element {
const rContainer = useRef<HTMLDivElement>(null)
const isOpen = useSelector((s) => s.data.settings.isStyleOpen)
return (
<StylePanelRoot dir="ltr" ref={rContainer} isOpen={isOpen}>
<FloatingContainer direction="column">
<ButtonsRow>
<QuickColorSelect />
<QuickSizeSelect />
@ -54,84 +45,57 @@ export default function StylePanel(): JSX.Element {
</IconButton>
</ButtonsRow>
{isOpen && <SelectedShapeContent />}
</StylePanelRoot>
</FloatingContainer>
)
}
function SelectedShapeContent(): JSX.Element {
const selectedShapesCount = useSelector((s) => s.values.selectedIds.length)
const showKbds = !isMobile()
return (
<>
<hr />
<Divider />
<ShapesFunctions />
<hr />
<Divider />
<AlignDistribute
hasTwoOrMore={selectedShapesCount > 1}
hasThreeOrMore={selectedShapesCount > 2}
/>
<hr />
<Divider />
<RowButton
bp={breakpoints}
disabled={selectedShapesCount === 0}
onClick={handleCopy}
>
<span>Copy</span>
<IconWrapper size="small">
<ClipboardCopyIcon />
</IconWrapper>
{showKbds && (
<Kbd>
<span>{commandKey()}</span>
<span>C</span>
</Kbd>
)}
</RowButton>
<RowButton bp={breakpoints} onClick={handlePaste}>
<span>Paste</span>
<IconWrapper size="small">
<ClipboardIcon />
</IconWrapper>
{showKbds && (
<Kbd>
<span>{commandKey()}</span>
<span>V</span>
</Kbd>
)}
</RowButton>
<RowButton
bp={breakpoints}
disabled={selectedShapesCount === 0}
onClick={handleCopyToSvg}
>
<RowButton bp={breakpoints} onClick={handleCopyToSvg}>
<span>Copy to SVG</span>
<IconWrapper size="small">
<Share2Icon />
</IconWrapper>
{showKbds && (
<Kbd>
<span></span>
<span>{commandKey()}</span>
<span>C</span>
</Kbd>
)}
</RowButton>
</>
)
}
const StylePanelRoot = styled(motion(Panel.Root), {
minWidth: 1,
width: 'fit-content',
maxWidth: 'fit-content',
overflow: 'hidden',
position: 'relative',
border: '1px solid $panel',
boxShadow: '$4',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pointerEvents: 'all',
padding: '$0',
zIndex: 300,
'& hr': {
marginTop: 2,
marginBottom: 2,
marginLeft: '-$0',
border: 'none',
height: 1,
backgroundColor: '$brushFill',
width: 'calc(100% + 4px)',
},
variants: {
isOpen: {
true: {},
false: {
width: 'fit-content',
},
},
},
})

View file

@ -0,0 +1,32 @@
import { FloatingContainer, RowButton } from 'components/shared'
import { motion } from 'framer-motion'
import { memo } from 'react'
import state, { useSelector } from 'state'
import styled from 'styles'
function BackToContent() {
const shouldDisplay = useSelector((s) => {
const { currentShapes, shapesToRender } = s.values
return currentShapes.length > 0 && shapesToRender.length === 0
})
if (!shouldDisplay) return null
return (
<BackToContentButton initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<RowButton onClick={() => state.send('ZOOMED_TO_CONTENT')}>
Back to content
</RowButton>
</BackToContentButton>
)
}
export default memo(BackToContent)
const BackToContentButton = styled(motion(FloatingContainer), {
pointerEvents: 'all',
width: 'fit-content',
gridRow: 1,
flexGrow: 2,
display: 'block',
})

View file

@ -8,14 +8,16 @@ import {
SquareIcon,
TextIcon,
} from '@radix-ui/react-icons'
import { PrimaryButton, SecondaryButton } from './shared'
import { FloatingContainer } from '../shared'
import React from 'react'
import * as React from 'react'
import state, { useSelector } from 'state'
import StatusBar from 'components/status-bar'
import { FloatingContainer } from 'components/shared'
import { PrimaryButton, SecondaryButton } from './shared'
import styled from 'styles'
import { ShapeType } from 'types'
import UndoRedo from './undo-redo'
import Zoom from './zoom'
import BackToContent from './back-to-content'
const selectArrowTool = () => state.send('SELECTED_ARROW_TOOL')
const selectDrawTool = () => state.send('SELECTED_DRAW_TOOL')
@ -45,6 +47,7 @@ export default function ToolsPanel(): JSX.Element {
</FloatingContainer>
</LeftWrap>
<CenterWrap>
<BackToContent />
<FloatingContainer>
<PrimaryButton
label={ShapeType.Draw}
@ -95,13 +98,16 @@ export default function ToolsPanel(): JSX.Element {
</FloatingContainer>
<UndoRedo />
</RightWrap>
<StatusWrap>
<StatusBar />
</StatusWrap>
</ToolsPanelContainer>
)
}
const ToolsPanelContainer = styled('div', {
position: 'fixed',
bottom: 44,
bottom: 0,
left: 0,
right: 0,
width: '100%',
@ -109,10 +115,11 @@ const ToolsPanelContainer = styled('div', {
maxWidth: '100%',
display: 'grid',
gridTemplateColumns: '1fr auto 1fr',
padding: '0 8px 12px 8px',
padding: '0',
alignItems: 'flex-end',
zIndex: 200,
gap: 12,
gridGap: '$4',
gridRowGap: '$4',
})
const CenterWrap = styled('div', {
@ -120,13 +127,17 @@ const CenterWrap = styled('div', {
gridColumn: 2,
display: 'flex',
width: 'fit-content',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 12,
})
const LeftWrap = styled('div', {
gridRow: 1,
gridColumn: 1,
display: 'flex',
paddingLeft: '$3',
variants: {
size: {
mobile: {
@ -153,6 +164,7 @@ const RightWrap = styled('div', {
gridRow: 1,
gridColumn: 3,
display: 'flex',
paddingRight: '$3',
variants: {
size: {
mobile: {
@ -174,3 +186,8 @@ const RightWrap = styled('div', {
},
},
})
const StatusWrap = styled('div', {
gridRow: 2,
gridColumn: '1 / span 3',
})

View file

@ -1,92 +1,88 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { MutableRefObject, useCallback, useEffect } from 'react'
import { MutableRefObject, useCallback } from 'react'
import state from 'state'
import {
fastBrushSelect,
fastDrawUpdate,
fastPanUpdate,
fastTransform,
fastTranslate,
} from 'state/hacks'
import inputs from 'state/inputs'
import { isMobile } from 'utils'
import Vec from 'utils/vec'
function handleFocusOut() {
state.send('BLURRED_EDITING_SHAPE')
}
export default function useCanvasEvents(
rCanvas: MutableRefObject<SVGGElement>
) {
const handlePointerDown = useCallback((e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
const handlePointerDown = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!inputs.canAccept(e.pointerId)) return
rCanvas.current.setPointerCapture(e.pointerId)
rCanvas.current.setPointerCapture(e.pointerId)
const info = inputs.pointerDown(e, 'canvas')
const info = inputs.pointerDown(e, 'canvas')
if (e.button === 0) {
if (inputs.isDoubleClick() && !(info.altKey || info.metaKey)) {
state.send('DOUBLE_POINTED_CANVAS', info)
if (e.button === 0) {
if (inputs.isDoubleClick() && !(info.altKey || info.metaKey)) {
state.send('DOUBLE_POINTED_CANVAS', info)
}
state.send('POINTED_CANVAS', info)
} else if (e.button === 2) {
state.send('RIGHT_POINTED', info)
}
},
[]
)
const handlePointerMove = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!inputs.canAccept(e.pointerId)) return
const prev = inputs.pointer?.point
const info = inputs.pointerMove(e)
if (prev && state.isIn('selecting') && inputs.keys[' ']) {
const delta = Vec.sub(prev, info.point)
fastPanUpdate(delta)
state.send('KEYBOARD_PANNED_CAMERA', {
delta: Vec.sub(prev, info.point),
})
return
}
state.send('POINTED_CANVAS', info)
} else if (e.button === 2) {
state.send('RIGHT_POINTED', info)
}
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
const prev = inputs.pointer?.point
const info = inputs.pointerMove(e)
if (prev && state.isIn('selecting') && inputs.keys[' ']) {
state.send('KEYBOARD_PANNED_CAMERA', { delta: Vec.sub(prev, info.point) })
return
}
if (state.isIn('draw.editing')) {
fastDrawUpdate(info)
} else if (state.isIn('brushSelecting')) {
fastBrushSelect(info.point)
} else if (state.isIn('translatingSelection')) {
fastTranslate(info)
} else if (state.isIn('transformingSelection')) {
fastTransform(info)
}
state.send('MOVED_POINTER', info)
}, [])
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
rCanvas.current.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', {
id: 'canvas',
...inputs.pointerUp(e, 'canvas'),
})
}, [])
const handleTouchStart = useCallback(() => {
// if (isMobile()) {
// if (e.touches.length === 2) {
// state.send('TOUCH_UNDO')
// } else state.send('TOUCHED_CANVAS')
// }
}, [])
// Send event on iOS when a user presses the "Done" key while editing a text element
useEffect(() => {
if (isMobile()) {
document.addEventListener('focusout', handleFocusOut)
return () => {
document.removeEventListener('focusout', handleFocusOut)
if (state.isIn('draw.editing')) {
fastDrawUpdate(info)
} else if (state.isIn('brushSelecting')) {
fastBrushSelect(info.point)
} else if (state.isIn('translatingSelection')) {
fastTranslate(info)
} else if (state.isIn('transformingSelection')) {
fastTransform(info)
}
state.send('MOVED_POINTER', info)
},
[]
)
const handlePointerUp = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!inputs.canAccept(e.pointerId)) return
rCanvas.current.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', {
id: 'canvas',
...inputs.pointerUp(e, 'canvas'),
})
},
[]
)
const handleTouchStart = useCallback((e: React.TouchEvent<SVGSVGElement>) => {
if ('safari' in window) {
e.preventDefault()
}
}, [])

View file

@ -195,11 +195,7 @@ export default function useKeyboardEvents() {
}
case 'd': {
if (metaKey(e)) {
if (e.shiftKey) {
state.send('TOGGLED_DEBUG_MODE')
} else {
state.send('DUPLICATED', info)
}
state.send('DUPLICATED', info)
} else {
state.send('SELECTED_DRAW_TOOL', info)
}

View file

@ -0,0 +1,22 @@
import isMobile from 'ismobilejs'
import { useEffect } from 'react'
import state from 'state'
// Send event on iOS when a user presses the "Done" key while editing
// a text element.
function handleFocusOut() {
state.send('BLURRED_EDITING_SHAPE')
}
export default function useSafariFocusOutFix(): void {
useEffect(() => {
if (isMobile().apple) {
document.addEventListener('focusout', handleFocusOut)
return () => {
document.removeEventListener('focusout', handleFocusOut)
}
}
}, [])
}

View file

@ -4,7 +4,15 @@ import state from 'state'
import inputs from 'state/inputs'
import vec from 'utils/vec'
import { useGesture } from 'react-use-gesture'
import { fastPinchCamera, fastZoomUpdate } from 'state/hacks'
import {
fastBrushSelect,
fastDrawUpdate,
fastPanUpdate,
fastPinchCamera,
fastTransform,
fastTranslate,
fastZoomUpdate,
} from 'state/hacks'
/**
* Capture zoom gestures (pinches, wheels and pans) and send to the state.
@ -24,6 +32,20 @@ export default function useZoomEvents() {
return
}
fastPanUpdate(delta)
const info = inputs.pointer
if (state.isIn('draw.editing')) {
fastDrawUpdate(info)
} else if (state.isIn('brushSelecting')) {
fastBrushSelect(info.point)
} else if (state.isIn('translatingSelection')) {
fastTranslate(info)
} else if (state.isIn('transformingSelection')) {
fastTransform(info)
}
state.send('PANNED_CAMERA', {
delta,
...inputs.wheel(event as WheelEvent),

View file

@ -83,7 +83,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
const OuterContent = styled('div', {
backgroundColor: '$canvas',
padding: '32px',
padding: '8px 8px 64px 8px',
margin: '0 auto',
overflow: 'scroll',
position: 'fixed',
@ -100,6 +100,7 @@ const OuterContent = styled('div', {
const Content = styled('div', {
width: '720px',
padding: '8px 16px',
maxWidth: '100%',
backgroundColor: '$panel',
borderRadius: '4px',

View file

@ -9,6 +9,8 @@ class Clipboard {
fallback = false
copy = (shapes: Shape[], onComplete?: () => void) => {
if (shapes === undefined) return
this.current = JSON.stringify({ id: 'tldr', shapes })
if ('permissions' in navigator && 'clipboard' in navigator) {
@ -37,7 +39,7 @@ class Clipboard {
return this
}
sendPastedTextToState(text = this.current) {
sendPastedTextToState = (text = this.current) => {
if (text === undefined) return
try {
@ -62,10 +64,13 @@ class Clipboard {
copySelectionToSvg(data: Data) {
const shapes = tld.getSelectedShapes(data)
const shapesToCopy = shapes.length > 0 ? shapes : tld.getShapes(data)
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
shapes
if (shapesToCopy.length === 0) return
shapesToCopy
.sort((a, b) => a.childIndex - b.childIndex)
.forEach((shape) => {
const group = document.getElementById(shape.id)
@ -78,7 +83,7 @@ class Clipboard {
})
const bounds = getCommonBounds(
...shapes.map((shape) => getShapeUtils(shape).getBounds(shape))
...shapesToCopy.map((shape) => getShapeUtils(shape).getBounds(shape))
)
// No content

View file

@ -17,6 +17,8 @@ import {
deepClone,
pointInBounds,
uniqueId,
boundsContain,
boundsCollide,
} from 'utils'
import tld from '../utils/tld'
import {
@ -35,6 +37,7 @@ import {
DashStyle,
SizeStyle,
ColorStyle,
ShapeTreeNode,
} from 'types'
import { getFontSize } from './shape-styles'
import logger from './logger'
@ -196,7 +199,10 @@ const state = createState({
DISABLED_PEN_LOCK: 'disablePenLock',
TOGGLED_CODE_PANEL_OPEN: ['toggleCodePanel', 'saveAppState'],
TOGGLED_STYLE_PANEL_OPEN: 'toggleStylePanel',
PANNED_CAMERA: 'panCamera',
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'panCamera',
},
POINTED_CANVAS: ['closeStylePanel', 'clearCurrentParentId'],
COPIED_STATE_TO_CLIPBOARD: 'copyStateToClipboard',
COPIED: { if: 'hasSelection', do: 'copyToClipboard' },
@ -332,8 +338,10 @@ const state = createState({
ZOOMED_TO_SELECTION: {
if: 'hasSelection',
do: 'zoomCameraToSelection',
else: 'zoomCameraToFit',
},
ZOOMED_TO_FIT: ['zoomCameraToFit', 'zoomCameraToActual'],
ZOOMED_TO_CONTENT: 'zoomCameraToContent',
ZOOMED_TO_FIT: 'zoomCameraToFit',
ZOOMED_IN: 'zoomIn',
ZOOMED_OUT: 'zoomOut',
RESET_CAMERA: 'resetCamera',
@ -357,7 +365,10 @@ const state = createState({
selecting: {
onEnter: ['setActiveToolSelect', 'clearInputs'],
on: {
KEYBOARD_PANNED_CAMERA: 'panCamera',
KEYBOARD_PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'panCamera',
},
STARTED_PINCHING: {
unless: 'isInSession',
to: 'pinching.selectPinching',
@ -601,7 +612,10 @@ const state = createState({
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTransformSession',
},
PANNED_CAMERA: 'updateTransformSession',
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTransformSession',
},
PRESSED_SHIFT_KEY: 'keyUpdateTransformSession',
RELEASED_SHIFT_KEY: 'keyUpdateTransformSession',
STOPPED_POINTING: { to: 'selecting' },
@ -613,8 +627,14 @@ const state = createState({
onExit: 'completeSession',
on: {
STARTED_PINCHING: { to: 'pinching' },
MOVED_POINTER: 'updateTranslateSession',
PANNED_CAMERA: 'updateTranslateSession',
MOVED_POINTER: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTranslateSession',
},
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTranslateSession',
},
PRESSED_SHIFT_KEY: 'keyUpdateTranslateSession',
RELEASED_SHIFT_KEY: 'keyUpdateTranslateSession',
PRESSED_ALT_KEY: 'keyUpdateTranslateSession',
@ -650,8 +670,14 @@ const state = createState({
'startBrushSession',
],
on: {
MOVED_POINTER: { if: 'isTestMode', do: 'updateBrushSession' },
PANNED_CAMERA: 'updateBrushSession',
MOVED_POINTER: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateBrushSession',
},
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateBrushSession',
},
STOPPED_POINTING: { to: 'selecting' },
STARTED_PINCHING: { to: 'pinching' },
CANCELLED: { do: 'cancelSession', to: 'selecting' },
@ -780,7 +806,10 @@ const state = createState({
},
PRESSED_SHIFT: 'keyUpdateDrawSession',
RELEASED_SHIFT: 'keyUpdateDrawSession',
PANNED_CAMERA: 'updateDrawSession',
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateDrawSession',
},
MOVED_POINTER: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateDrawSession',
@ -839,8 +868,14 @@ const state = createState({
onExit: 'completeSession',
onEnter: 'startTranslateSession',
on: {
MOVED_POINTER: 'updateTranslateSession',
PANNED_CAMERA: 'updateTranslateSession',
MOVED_POINTER: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTranslateSession',
},
PANNED_CAMERA: {
ifAny: ['isSimulating', 'isTestMode'],
do: 'updateTranslateSession',
},
},
},
},
@ -1084,16 +1119,24 @@ const state = createState({
bounds: {
onEnter: 'startDrawTransformSession',
on: {
MOVED_POINTER: 'updateTransformSession',
PANNED_CAMERA: 'updateTransformSession',
MOVED_POINTER: {
do: 'updateTransformSession',
},
PANNED_CAMERA: {
do: 'updateTransformSession',
},
},
},
direction: {
onEnter: 'startDirectionSession',
onExit: 'completeSession',
on: {
MOVED_POINTER: 'updateDirectionSession',
PANNED_CAMERA: 'updateDirectionSession',
MOVED_POINTER: {
do: 'updateDirectionSession',
},
PANNED_CAMERA: {
do: 'updateDirectionSession',
},
},
},
},
@ -1900,6 +1943,28 @@ const state = createState({
tld.setZoomCSS(camera.zoom)
},
zoomCameraToContent(data) {
const camera = tld.getCurrentCamera(data)
const page = tld.getPage(data)
const shapes = Object.values(page.shapes)
if (shapes.length === 0) {
return
}
const bounds = getCommonBounds(
...Object.values(shapes).map((shape) =>
getShapeUtils(shape).getBounds(shape)
)
)
const { zoom } = camera
const mx = (window.innerWidth - bounds.width * zoom) / 2 / zoom
const my = (window.innerHeight - bounds.height * zoom) / 2 / zoom
camera.point = vec.add([-bounds.minX, -bounds.minY], [mx, my])
},
zoomCamera(data, payload: { delta: number; point: number[] }) {
const camera = tld.getCurrentCamera(data)
const next = camera.zoom - (payload.delta / 100) * camera.zoom
@ -2072,8 +2137,13 @@ const state = createState({
pasteFromClipboard(data) {
clipboard.paste()
if (clipboard.fallback) {
commands.paste(data, JSON.parse(clipboard.current).shapes)
try {
commands.paste(data, JSON.parse(clipboard.current).shapes)
} catch (e) {
console.warn('Could not paste that text.')
}
}
},
@ -2180,6 +2250,36 @@ const state = createState({
return commonStyle
},
shapesToRender(data) {
const viewport = tld.getViewport(data)
const page = tld.getPage(data)
const shapesToShow = Object.values(page.shapes).filter((shape) => {
if (shape.parentId !== page.id) return false
const shapeBounds = getShapeUtils(shape).getBounds(shape)
return (
shape.type === ShapeType.Ray ||
shape.type === ShapeType.Line ||
boundsContain(viewport, shapeBounds) ||
boundsCollide(viewport, shapeBounds)
)
})
// Populate the shape tree
const tree: ShapeTreeNode[] = []
const selectedIds = tld.getSelectedIds(data)
shapesToShow
.sort((a, b) => a.childIndex - b.childIndex)
.forEach((shape) => tld.addToShapeTree(data, selectedIds, tree, shape))
return tree
},
},
options: {
onSend(eventName, payload, didCauseUpdate) {

View file

@ -15,15 +15,15 @@ const { styled, global, css, theme, getCssString } = createCss({
boundsBg: 'rgba(65, 132, 244, 0.05)',
highlight: 'rgba(65, 132, 244, 0.15)',
overlay: 'rgba(0, 0, 0, 0.15)',
border: '#aaa',
border: '#aaaaaa',
canvas: '#f8f9fa',
panel: '#fefefe',
inactive: '#cccccf',
hover: '#efefef',
text: '#333',
muted: '#777',
text: '#333333',
muted: '#777777',
input: '#f3f3f3',
inputBorder: '#ddd',
inputBorder: '#dddddd',
lineError: 'rgba(255, 0, 0, .1)',
},
shadows: {
@ -39,6 +39,7 @@ const { styled, global, css, theme, getCssString } = createCss({
1: '3px',
2: '4px',
3: '8px',
4: '12px',
},
fontSizes: {
0: '10px',
@ -96,8 +97,8 @@ const light = theme({})
const dark = theme({
colors: {
brushFill: 'rgba(0,0,0,.05)',
brushStroke: 'rgba(0,0,0,.25)',
brushFill: 'rgba(180, 180, 180, .05)',
brushStroke: 'rgba(180, 180, 180, .25)',
hint: 'rgba(216, 226, 249, 1.000)',
selected: 'rgba(38, 150, 255, 1.000)',
bounds: 'rgba(38, 150, 255, 1.000)',
@ -136,6 +137,7 @@ const globalStyles = global({
padding: '0px',
margin: '0px',
overscrollBehavior: 'none',
overscrollBehaviorX: 'none',
fontFamily: '$ui',
fontSize: '$2',
color: '$text',

View file

@ -278,6 +278,16 @@ export interface CodeResult {
error: CodeError
}
export interface ShapeTreeNode {
shape: Shape
children: ShapeTreeNode[]
isEditing: boolean
isHovered: boolean
isSelected: boolean
isDarkMode: boolean
isCurrentParent: boolean
}
/* -------------------------------------------------- */
/* Editor UI */
/* -------------------------------------------------- */

View file

@ -12,6 +12,7 @@ import {
PageState,
ShapeUtility,
ParentShape,
ShapeTreeNode,
} from 'types'
import { AssertionError } from 'assert'
@ -537,4 +538,41 @@ export default class StateUtils {
this.updateParents(data, parentToUpdateIds)
}
/**
* Populate the shape tree. This helper is recursive and only one call is needed.
*
* ### Example
*
*```ts
* addDataToTree(data, selectedIds, allowHovers, branch, shape)
*```
*/
static addToShapeTree(
data: Data,
selectedIds: string[],
branch: ShapeTreeNode[],
shape: Shape
): void {
const node = {
shape,
children: [],
isHovered: data.hoveredId === shape.id,
isCurrentParent: data.currentParentId === shape.id,
isEditing: data.editingId === shape.id,
isDarkMode: data.settings.isDarkMode,
isSelected: selectedIds.includes(shape.id),
}
branch.push(node)
if (shape.children) {
shape.children
.map((id) => this.getShape(data, id))
.sort((a, b) => a.childIndex - b.childIndex)
.forEach((childShape) => {
this.addToShapeTree(data, selectedIds, node.children, childShape)
})
}
}
}