Merge pull request #44 from tldraw/feature-back-to-content
Back to Content + various fixes
This commit is contained in:
commit
8c84d14df3
22 changed files with 518 additions and 408 deletions
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -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.
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
32
components/tools-panel/back-to-content.tsx
Normal file
32
components/tools-panel/back-to-content.tsx
Normal 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',
|
||||
})
|
|
@ -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',
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
22
hooks/useSafariFocusOutFix.ts
Normal file
22
hooks/useSafariFocusOutFix.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
132
state/state.ts
132
state/state.ts
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
|
|
10
types.ts
10
types.ts
|
@ -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 */
|
||||
/* -------------------------------------------------- */
|
||||
|
|
38
utils/tld.ts
38
utils/tld.ts
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue