diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b5c68e55b..1f693dbf3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 8dd2fca62..9fbd52701 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -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', diff --git a/components/canvas/context-menu/context-menu.tsx b/components/canvas/context-menu/context-menu.tsx index f1719f9c9..7fec2fcd5 100644 --- a/components/canvas/context-menu/context-menu.tsx +++ b/components/canvas/context-menu/context-menu.tsx @@ -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 ( <_ContextMenu.Trigger>{children} - + {selectedShapeIds.length ? ( <> {/* state.send('COPIED')}> Copy - + {commandKey()} C - + state.send('CUT')}> Cut - + {commandKey()} X - + */} state.send('DUPLICATED')}> Duplicate - + {commandKey()} D - + {hasGroupSelected || @@ -137,20 +134,20 @@ export default function ContextMenu({ {hasGroupSelected && ( state.send('UNGROUPED')}> Ungroup - + {commandKey()} G - + )} {hasTwoOrMore && ( state.send('GROUPED')}> Group - + {commandKey()} G - + )} @@ -164,11 +161,11 @@ export default function ContextMenu({ } > To Front - + {commandKey()} ] - + Forward - + {commandKey()} ] - + @@ -192,10 +189,10 @@ export default function ContextMenu({ } > Backward - + {commandKey()} [ - + @@ -205,11 +202,11 @@ export default function ContextMenu({ } > To Back - + {commandKey()} [ - + {hasTwoOrMore && ( @@ -221,36 +218,36 @@ export default function ContextMenu({ state.send('COPIED_TO_SVG')}> Copy to SVG - + {commandKey()} C - + state.send('DELETED')}> Delete - + - + ) : ( <> state.send('UNDO')}> Undo - + {commandKey()} Z - + state.send('REDO')}> Redo - + {commandKey()} Z - + )} @@ -277,7 +274,6 @@ function AlignDistributeSubMenu({ as={_ContextMenu.Content} sideOffset={2} alignOffset={-2} - isMobile={isMobile()} selectedStyle={hasThreeOrMore ? 'threeOrMore' : 'twoOrMore'} > @@ -355,12 +351,7 @@ function MoveToPageMenu() { - + {sorted.map(({ id, name }) => ( { - // 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) => ( - + {shapesToRender.map((node) => ( + ))} ) } interface ShapeNodeProps { - node: Node - parentPoint?: number[] + node: ShapeTreeNode + allowHovers: boolean } const ShapeNode = ({ @@ -75,58 +33,25 @@ const ShapeNode = ({ isSelected, isCurrentParent, }, + allowHovers, }: ShapeNodeProps) => { return ( <> {children.map((childNode) => ( - + ))} ) } - -/** - * 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) - }) - } -} diff --git a/components/debug-panel/debug-panel.tsx b/components/debug-panel/debug-panel.tsx index f12bf60bd..35e5f0617 100644 --- a/components/debug-panel/debug-panel.tsx +++ b/components/debug-panel/debug-panel.tsx @@ -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', diff --git a/components/editor.tsx b/components/editor.tsx index 15fd3386f..77bec4583 100644 --- a/components/editor.tsx +++ b/components/editor.tsx @@ -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 { - ) } @@ -56,6 +54,8 @@ const Layout = styled('main', { display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-start', + boxSizing: 'border-box', + pointerEvents: 'none', '& > *': { PointerEvent: 'all', diff --git a/components/menu/menu.tsx b/components/menu/menu.tsx index 09cd81e44..27f0e9752 100644 --- a/components/menu/menu.tsx +++ b/components/menu/menu.tsx @@ -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() { New Project - + {commandKey()} N - + Open... - + {commandKey()} L - + Save - + {commandKey()} S - + Save As... - + {commandKey()} S - + + + + Sign Out + + + + @@ -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() { > Dark Mode + + Debug Mode + ) } diff --git a/components/shared.tsx b/components/shared.tsx index 932711556..3557c9f8d 100644 --- a/components/shared.tsx +++ b/components/shared.tsx @@ -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 {children} +} + /* -------------------------------------------------- */ /* 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({ - + {children} @@ -699,12 +706,7 @@ export function ContextMenuSubMenu({ - + {children} diff --git a/components/status-bar.tsx b/components/status-bar.tsx index 86faf018c..9571baf88 100644 --- a/components/status-bar.tsx +++ b/components/status-bar.tsx @@ -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 (
- {active.join(' | ')} | {log} | {status} ( - {Object.values(others).length || 0}) + {active.join(' | ')} - {log}
+
{shapesInView || '0'} Shapes
) } 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, diff --git a/components/style-panel/style-panel.tsx b/components/style-panel/style-panel.tsx index 86f27e829..03012ef93 100644 --- a/components/style-panel/style-panel.tsx +++ b/components/style-panel/style-panel.tsx @@ -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(null) - const isOpen = useSelector((s) => s.data.settings.isStyleOpen) return ( - + @@ -54,84 +45,57 @@ export default function StylePanel(): JSX.Element { {isOpen && } - + ) } function SelectedShapeContent(): JSX.Element { const selectedShapesCount = useSelector((s) => s.values.selectedIds.length) + const showKbds = !isMobile() + return ( <> -
+ -
+ 1} hasThreeOrMore={selectedShapesCount > 2} /> -
+ Copy - - - + {showKbds && ( + + {commandKey()} + C + + )} Paste - - - + {showKbds && ( + + {commandKey()} + V + + )} - + Copy to SVG - - - + {showKbds && ( + + + {commandKey()} + C + + )} ) } - -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', - }, - }, - }, -}) diff --git a/components/tools-panel/back-to-content.tsx b/components/tools-panel/back-to-content.tsx new file mode 100644 index 000000000..4528f3c2d --- /dev/null +++ b/components/tools-panel/back-to-content.tsx @@ -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 ( + + state.send('ZOOMED_TO_CONTENT')}> + Back to content + + + ) +} + +export default memo(BackToContent) + +const BackToContentButton = styled(motion(FloatingContainer), { + pointerEvents: 'all', + width: 'fit-content', + gridRow: 1, + flexGrow: 2, + display: 'block', +}) diff --git a/components/tools-panel/tools-panel.tsx b/components/tools-panel/tools-panel.tsx index 8f01e3f6f..bb3f74d47 100644 --- a/components/tools-panel/tools-panel.tsx +++ b/components/tools-panel/tools-panel.tsx @@ -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 { + + + + ) } 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', +}) diff --git a/hooks/useCanvasEvents.ts b/hooks/useCanvasEvents.ts index 4ceed8d36..384ba2ec7 100644 --- a/hooks/useCanvasEvents.ts +++ b/hooks/useCanvasEvents.ts @@ -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 ) { - const handlePointerDown = useCallback((e: React.PointerEvent) => { - if (!inputs.canAccept(e.pointerId)) return + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + 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) => { + 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) => { + 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) => { + if ('safari' in window) { + e.preventDefault() } }, []) diff --git a/hooks/useKeyboardEvents.ts b/hooks/useKeyboardEvents.ts index 7be87e302..0d28a2659 100644 --- a/hooks/useKeyboardEvents.ts +++ b/hooks/useKeyboardEvents.ts @@ -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) } diff --git a/hooks/useSafariFocusOutFix.ts b/hooks/useSafariFocusOutFix.ts new file mode 100644 index 000000000..97837a4aa --- /dev/null +++ b/hooks/useSafariFocusOutFix.ts @@ -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) + } + } + }, []) +} diff --git a/hooks/useZoomEvents.ts b/hooks/useZoomEvents.ts index 3631d06c5..836a23c1a 100644 --- a/hooks/useZoomEvents.ts +++ b/hooks/useZoomEvents.ts @@ -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), diff --git a/pages/sponsorware.tsx b/pages/sponsorware.tsx index c09676ee8..9f488f018 100644 --- a/pages/sponsorware.tsx +++ b/pages/sponsorware.tsx @@ -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', diff --git a/state/clipboard.ts b/state/clipboard.ts index ba7bb5401..214d899e6 100644 --- a/state/clipboard.ts +++ b/state/clipboard.ts @@ -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 diff --git a/state/state.ts b/state/state.ts index 851645cbc..bdf3f02ff 100644 --- a/state/state.ts +++ b/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) { diff --git a/styles/stitches.config.ts b/styles/stitches.config.ts index 5c6dd3cd0..ef08a46d4 100644 --- a/styles/stitches.config.ts +++ b/styles/stitches.config.ts @@ -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', diff --git a/types.ts b/types.ts index 43fee715d..4cc0aa02b 100644 --- a/types.ts +++ b/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 */ /* -------------------------------------------------- */ diff --git a/utils/tld.ts b/utils/tld.ts index 7ada7baf7..9946ba564 100644 --- a/utils/tld.ts +++ b/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) + }) + } + } }