Adds context menu, "move to page"

This commit is contained in:
Steve Ruiz 2021-06-10 10:49:16 +01:00
parent c48537121f
commit f2d3231315
17 changed files with 533 additions and 47 deletions

View file

@ -51,7 +51,7 @@ export default function Bounds() {
if (isSingleHandles) return null
const size = (isMobile().any ? 10 : 8) / zoom // Touch target size
const size = (isMobile() ? 10 : 8) / zoom // Touch target size
const center = getBoundsCenter(bounds)
return (

View file

@ -5,15 +5,18 @@ import styled from 'styles'
import { deepCompareArrays, getPage } from 'utils/utils'
function handlePointerDown(e: React.PointerEvent<SVGRectElement>) {
if (e.buttons !== 1) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId)
if (e.button === 0) {
state.send('POINTED_BOUNDS', inputs.pointerDown(e, 'bounds'))
} else if (e.button === 2) {
state.send('RIGHT_POINTED', inputs.pointerDown(e, 'bounds'))
}
}
function handlePointerUp(e: React.PointerEvent<SVGRectElement>) {
if (e.buttons !== 1) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
e.currentTarget.releasePointerCapture(e.pointerId)
@ -36,7 +39,7 @@ export default function BoundsBg() {
if (selectedIds.length === 1) {
const { shapes } = getPage(s.data)
const selected = Array.from(s.values.selectedIds.values())[0]
return shapes[selected].rotation
return shapes[selected]?.rotation
} else {
return 0
}

View file

@ -21,10 +21,12 @@ export default function Handles() {
s.isInAny('notPointing', 'pinching', 'translatingHandles')
)
if (!shape.handles || !isSelecting) return null
if (!shape || !shape.handles || !isSelecting) return null
const center = getShapeUtils(shape).getCenter(shape)
console.log(shape)
return (
<g transform={`rotate(${shape.rotation * (180 / Math.PI)},${center})`}>
{Object.values(shape.handles).map((handle) => (

View file

@ -1,7 +1,6 @@
import styled from 'styles'
import state, { useSelector } from 'state'
import inputs from 'state/inputs'
import React, { useCallback, useRef } from 'react'
import React, { useRef } from 'react'
import useZoomEvents from 'hooks/useZoomEvents'
import useCamera from 'hooks/useCamera'
import Defs from './defs'
@ -12,6 +11,7 @@ import BoundsBg from './bounds/bounds-bg'
import Selected from './selected'
import Handles from './bounds/handles'
import useCanvasEvents from 'hooks/useCanvasEvents'
import ContextMenu from 'components/context-menu'
export default function Canvas() {
const rCanvas = useRef<SVGSVGElement>(null)
@ -26,6 +26,7 @@ export default function Canvas() {
const isReady = useSelector((s) => s.isIn('ready'))
return (
<ContextMenu>
<MainSVG ref={rCanvas} {...events}>
<Defs />
{isReady && (
@ -39,6 +40,7 @@ export default function Canvas() {
</g>
)}
</MainSVG>
</ContextMenu>
)
}

View file

@ -7,6 +7,7 @@ import { ShapeStyles, ShapeType } from 'types'
import useShapeEvents from 'hooks/useShapeEvents'
import * as vec from 'utils/vec'
import { getShapeStyle } from 'lib/shape-styles'
import ContextMenu from 'components/context-menu'
interface ShapeProps {
id: string
@ -51,6 +52,7 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
{...events}
/>
)}
{!shape.isHidden && <RealShape isGroup={isGroup} id={id} style={style} />}
{isGroup &&
shape.children.map((shapeId) => (

297
components/context-menu.tsx Normal file
View file

@ -0,0 +1,297 @@
import * as _ContextMenu from '@radix-ui/react-context-menu'
import * as _Dropdown from '@radix-ui/react-dropdown-menu'
import styled from 'styles'
import { RowButton } from './shared'
import {
commandKey,
deepCompareArrays,
getSelectedShapes,
isMobile,
} from 'utils/utils'
import state, { useSelector } from 'state'
import { MoveType, ShapeType } from 'types'
import React, { useRef } from 'react'
export default function ContextMenu({
children,
}: {
children: React.ReactNode
}) {
const selectedShapes = useSelector(
(s) => getSelectedShapes(s.data),
deepCompareArrays
)
const rContent = useRef<HTMLDivElement>(null)
const hasGroupSelectd = selectedShapes.some((s) => s.type === ShapeType.Group)
const hasMultipleSelected = selectedShapes.length > 1
return (
<_ContextMenu.Root>
<_ContextMenu.Trigger>{children}</_ContextMenu.Trigger>
<StyledContent ref={rContent} isMobile={isMobile()}>
{selectedShapes.length ? (
<>
{/* <Button onSelect={() => state.send('COPIED')}>
<span>Copy</span>
<kbd>
<span>{commandKey()}</span>
<span>C</span>
</kbd>
</Button>
<Button onSelect={() => state.send('CUT')}>
<span>Cut</span>
<kbd>
<span>{commandKey()}</span>
<span>X</span>
</kbd>
</Button>
*/}
<Button onSelect={() => state.send('DUPLICATED')}>
<span>Duplicate</span>
<kbd>
<span>{commandKey()}</span>
<span>D</span>
</kbd>
</Button>
<StyledDivider />
<Button
onSelect={() =>
state.send('MOVED', {
type: MoveType.ToFront,
})
}
>
<span>Move To Front</span>
<kbd>
<span>{commandKey()}</span>
<span></span>
<span>]</span>
</kbd>
</Button>
<Button
onSelect={() =>
state.send('MOVED', {
type: MoveType.Forward,
})
}
>
<span>Move Forward</span>
<kbd>
<span>{commandKey()}</span>
<span>]</span>
</kbd>
</Button>
<Button
onSelect={() =>
state.send('MOVED', {
type: MoveType.Backward,
})
}
>
<span>Move Backward</span>
<kbd>
<span>{commandKey()}</span>
<span>[</span>
</kbd>
</Button>
<Button
onSelect={() =>
state.send('MOVED', {
type: MoveType.ToBack,
})
}
>
<span>Move to Back</span>
<kbd>
<span>{commandKey()}</span>
<span></span>
<span>[</span>
</kbd>
</Button>
{hasGroupSelectd ||
(hasMultipleSelected && (
<>
<StyledDivider />
{hasGroupSelectd && (
<Button onSelect={() => state.send('UNGROUPED')}>
<span>Ungroup</span>
<kbd>
<span>{commandKey()}</span>
<span></span>
<span>G</span>
</kbd>
</Button>
)}
{hasMultipleSelected && (
<Button onSelect={() => state.send('GROUPED')}>
<span>Group</span>
<kbd>
<span>{commandKey()}</span>
<span>G</span>
</kbd>
</Button>
)}
</>
))}
<StyledDivider />
{/* <Button onSelect={() => state.send('MOVED_TO_PAGE')}> */}
<_ContextMenu.Item>
<MoveToPageDropDown>Move to Page</MoveToPageDropDown>
</_ContextMenu.Item>
{/* </Button> */}
<Button onSelect={() => state.send('DELETED')}>
<span>Delete</span>
<kbd>
<span></span>
</kbd>
</Button>
</>
) : (
<>
<Button onSelect={() => state.send('UNDO')}>
<span>Undo</span>
<kbd>
<span>{commandKey()}</span>
<span>Z</span>
</kbd>
</Button>
<Button onSelect={() => state.send('REDO')}>
<span>Redo</span>
<kbd>
<span>{commandKey()}</span>
<span></span>
<span>Z</span>
</kbd>
</Button>
</>
)}
</StyledContent>
</_ContextMenu.Root>
)
}
const StyledContent = styled(_ContextMenu.Content, {
position: 'relative',
backgroundColor: '$panel',
borderRadius: '4px',
overflow: 'hidden',
pointerEvents: 'all',
userSelect: 'none',
zIndex: 200,
padding: 2,
border: '1px solid $panel',
boxShadow: '0px 2px 4px rgba(0,0,0,.2)',
minWidth: 128,
'& kbd': {
marginLeft: '32px',
fontSize: '$1',
fontFamily: '$ui',
},
'& kbd > span': {
display: 'inline-block',
width: '12px',
},
variants: {
isMobile: {
true: {
'& kbd': {
display: 'none',
},
},
},
},
})
const StyledDivider = styled(_ContextMenu.Separator, {
backgroundColor: '$hover',
height: 1,
margin: '2px -2px',
})
function Button({
onSelect,
children,
disabled = false,
}: {
onSelect: () => void
disabled?: boolean
children: React.ReactNode
}) {
return (
<_ContextMenu.Item
as={RowButton}
disabled={disabled}
bp={{ '@initial': 'mobile', '@sm': 'small' }}
onSelect={onSelect}
>
{children}
</_ContextMenu.Item>
)
}
function MoveToPageDropDown({ children }: { children: React.ReactNode }) {
const documentPages = useSelector((s) => s.data.document.pages)
const currentPageId = useSelector((s) => s.data.currentPageId)
if (!documentPages[currentPageId]) return null
const sorted = Object.values(documentPages)
.sort((a, b) => a.childIndex - b.childIndex)
.filter((a) => a.id !== currentPageId)
return (
<_Dropdown.Root>
<_Dropdown.Trigger
as={RowButton}
bp={{ '@initial': 'mobile', '@sm': 'small' }}
>
{children}
</_Dropdown.Trigger>
<StyledDialogContent side="right" sideOffset={8}>
{sorted.map(({ id, name }) => (
<_Dropdown.Item
as={RowButton}
key={id}
bp={{ '@initial': 'mobile', '@sm': 'small' }}
disabled={id === currentPageId}
onSelect={() => state.send('MOVED_TO_PAGE', { id })}
>
<span>{name}</span>
</_Dropdown.Item>
))}
</StyledDialogContent>
</_Dropdown.Root>
)
}
const StyledDialogContent = styled(_Dropdown.Content, {
// position: 'fixed',
// top: '50%',
// left: '50%',
// transform: 'translate(-50%, -50%)',
// minWidth: 200,
// maxWidth: 'fit-content',
// maxHeight: '85vh',
// marginTop: '-5vh',
minWidth: 128,
backgroundColor: '$panel',
borderRadius: '4px',
overflow: 'hidden',
pointerEvents: 'all',
userSelect: 'none',
zIndex: 200,
padding: 2,
border: '1px solid $panel',
boxShadow: '0px 2px 4px rgba(0,0,0,.2)',
'&:focus': {
outline: 'none',
},
})

View file

@ -9,6 +9,7 @@ import StylePanel from './style-panel/style-panel'
import { useSelector } from 'state'
import styled from 'styles'
import PagePanel from './page-panel/page-panel'
import ContextMenu from './context-menu'
export default function Editor() {
useKeyboardEvents()
@ -25,11 +26,6 @@ export default function Editor() {
<Spacer />
<StylePanel />
<Canvas />
{/* <LeftPanels>
<CodePanel />
{hasControls && <ControlsPanel />}
</LeftPanels> */}
<ToolsPanel />
<StatusBar />
</Layout>

View file

@ -105,6 +105,10 @@ export const RowButton = styled('button', {
zIndex: 1,
},
'& :disabled': {
opacity: 0.5,
},
variants: {
bp: {
mobile: {},

View file

@ -9,8 +9,14 @@ export default function useCanvasEvents(
) {
const handlePointerDown = useCallback((e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
rCanvas.current.setPointerCapture(e.pointerId)
if (e.button === 0) {
state.send('POINTED_CANVAS', inputs.pointerDown(e, 'canvas'))
} else if (e.button === 2) {
state.send('RIGHT_POINTED', inputs.pointerDown(e, 'canvas'))
}
}, [])
const handleTouchStart = useCallback((e: React.TouchEvent) => {

View file

@ -14,10 +14,15 @@ export default function useShapeEvents(
e.stopPropagation()
rGroup.current.setPointerCapture(e.pointerId)
if (e.button === 0) {
if (inputs.isDoubleClick()) {
state.send('DOUBLE_POINTED_SHAPE', inputs.pointerDown(e, id))
}
state.send('POINTED_SHAPE', inputs.pointerDown(e, id))
} else {
state.send('RIGHT_POINTED', inputs.pointerDown(e, id))
}
},
[id]
)

View file

@ -2,14 +2,17 @@ import align from './align'
import arrow from './arrow'
import changePage from './change-page'
import createPage from './create-page'
import deleteSelected from './delete-selected'
import deletePage from './delete-page'
import deleteSelected from './delete-selected'
import direct from './direct'
import distribute from './distribute'
import draw from './draw'
import duplicate from './duplicate'
import generate from './generate'
import group from './group'
import handle from './handle'
import move from './move'
import moveToPage from './move-to-page'
import nudge from './nudge'
import rotate from './rotate'
import rotateCcw from './rotate-ccw'
@ -19,8 +22,6 @@ import toggle from './toggle'
import transform from './transform'
import transformSingle from './transform-single'
import translate from './translate'
import handle from './handle'
import group from './group'
import ungroup from './ungroup'
const commands = {
@ -35,7 +36,10 @@ const commands = {
draw,
duplicate,
generate,
group,
handle,
move,
moveToPage,
nudge,
rotate,
rotateCcw,
@ -45,8 +49,6 @@ const commands = {
transform,
transformSingle,
translate,
handle,
group,
ungroup,
}

View file

@ -0,0 +1,107 @@
import Command from './command'
import history from '../history'
import { Data } from 'types'
import {
getDocumentBranch,
getPage,
getPageState,
getSelectedIds,
getSelectedShapes,
getTopParentId,
setToArray,
uniqueArray,
} from 'utils/utils'
import { getShapeUtils } from 'lib/shape-utils'
import * as vec from 'utils/vec'
import storage from 'state/storage'
export default function nudgeCommand(data: Data, toPageId: string) {
const { currentPageId: fromPageId } = data
const selectedIds = setToArray(getSelectedIds(data))
const selectedParents = uniqueArray(
...selectedIds.map((id) => getTopParentId(data, id))
)
history.execute(
data,
new Command({
name: 'set_direction',
category: 'canvas',
manualSelection: true,
do(data) {
// The page we're moving the shapes from
const fromPage = getPage(data, fromPageId)
// Get all of the selected shapes and their descendents
const shapesToMove = selectedParents.flatMap((id) =>
getDocumentBranch(data, id).map((id) => fromPage.shapes[id])
)
// Delete the shapes from the "from" page
shapesToMove.forEach((shape) => delete fromPage.shapes[shape.id])
// Clear the current page state's selected ids
getPageState(data, fromPageId).selectedIds.clear()
// Save the "from" page
storage.savePage(data, fromPageId)
// Load the "to" page
storage.loadPage(data, toPageId)
// The page we're moving the shapes to
const toPage = getPage(data, toPageId)
// Add all of the selected shapes to the "from" page. Any shapes that
// were children of the "from" page should become children of the "to"
// page. Grouped shapes should keep their same parent.
// What about shapes that were children of a group that we haven't moved?
shapesToMove.forEach((shape) => {
toPage.shapes[shape.id] = shape
if (shape.parentId === fromPageId) {
getShapeUtils(shape).setProperty(shape, 'parentId', toPageId)
}
})
console.log('from', getPage(data, fromPageId))
console.log('to', getPage(data, toPageId))
// Select the selected ids on the new page
getPageState(data, toPageId).selectedIds = new Set(selectedIds)
// Move to the new page
data.currentPageId = toPageId
},
undo(data) {
const toPage = getPage(data, fromPageId)
const shapesToMove = selectedParents.flatMap((id) =>
getDocumentBranch(data, id).map((id) => toPage.shapes[id])
)
shapesToMove.forEach((shape) => delete toPage.shapes[shape.id])
getPageState(data, toPageId).selectedIds.clear()
storage.savePage(data, toPageId)
storage.loadPage(data, fromPageId)
const fromPage = getPage(data, toPageId)
shapesToMove.forEach((shape) => {
fromPage.shapes[shape.id] = shape
if (shape.parentId === toPageId) {
getShapeUtils(shape).setProperty(shape, 'parentId', fromPageId)
}
})
getPageState(data, fromPageId).selectedIds = new Set(selectedIds)
data.currentPageId = fromPageId
},
})
)
}

View file

@ -7,6 +7,7 @@ import {
getPage,
getPageState,
getShapes,
getTopParentId,
setSelectedIds,
setToArray,
} from 'utils/utils'
@ -77,7 +78,7 @@ export function getBrushSnapshot(data: Data) {
const { selectedIds } = getPageState(cData)
const shapesToTest = getShapes(cData)
.filter((shape) => shape.type !== ShapeType.Group)
.filter((shape) => shape.type !== ShapeType.Group && !shape.isHidden)
.filter(
(shape) => !(selectedIds.has(shape.id) || selectedIds.has(shape.parentId))
)
@ -100,11 +101,3 @@ export function getBrushSnapshot(data: Data) {
}
export type BrushSnapshot = ReturnType<typeof getBrushSnapshot>
function getTopParentId(data: Data, id: string): string {
const shape = getPage(data).shapes[id]
return shape.parentId === data.currentPageId ||
shape.parentId === data.currentParentId
? id
: getTopParentId(data, shape.parentId)
}

View file

@ -188,6 +188,10 @@ const state = createState({
CHANGED_CODE_CONTROL: 'updateControls',
GENERATED_FROM_CODE: ['setCodeControls', 'setGeneratedShapes'],
TOGGLED_TOOL_LOCK: 'toggleToolLock',
MOVED_TO_PAGE: {
if: 'hasSelection',
do: ['moveSelectionToPage', 'zoomCameraToSelectionActual'],
},
MOVED: { if: 'hasSelection', do: 'moveSelection' },
DUPLICATED: { if: 'hasSelection', do: 'duplicateSelection' },
ROTATED_CCW: { if: 'hasSelection', do: 'rotateSelectionCcw' },
@ -268,6 +272,25 @@ const state = createState({
to: 'pointingBounds',
},
],
RIGHT_POINTED: [
{
if: 'isPointingCanvas',
do: 'clearSelectedIds',
else: {
if: 'isPointingShape',
then: [
'setPointedId',
{
unless: 'isPointedShapeSelected',
do: [
'clearSelectedIds',
'pushPointedIdToSelectedIds',
],
},
],
},
},
],
},
},
pointingBounds: {
@ -756,15 +779,28 @@ const state = createState({
},
},
conditions: {
isPointingCanvas(data, payload: PointerInfo) {
return payload.target === 'canvas'
},
isPointingBounds(data, payload: PointerInfo) {
return getSelectedIds(data).size > 0 && payload.target === 'bounds'
},
isPointingShape(data, payload: PointerInfo) {
return (
payload.target &&
payload.target !== 'canvas' &&
payload.target !== 'bounds'
)
},
isReadOnly(data) {
return data.isReadOnly
},
distanceImpliesDrag(data, payload: PointerInfo) {
return vec.dist2(payload.origin, payload.point) > 8
},
hasPointedTarget(data, payload: PointerInfo) {
return payload.target !== undefined
},
isPointedShapeSelected(data) {
return getSelectedIds(data).has(data.pointedId)
},
@ -1121,6 +1157,9 @@ const state = createState({
moveSelection(data, payload: { type: MoveType }) {
commands.move(data, payload.type)
},
moveSelectionToPage(data, payload: { id: string }) {
commands.moveToPage(data, payload.id)
},
alignSelection(data, payload: { type: AlignType }) {
commands.align(data, payload.type)
},

View file

@ -11,7 +11,8 @@ const { styled, global, css, theme, getCssString } = createCss({
hint: 'rgba(216, 226, 249, 1.000)',
selected: 'rgba(66, 133, 244, 1.000)',
bounds: 'rgba(65, 132, 244, 1.000)',
boundsBg: 'rgba(65, 132, 244, 0.050)',
boundsBg: 'rgba(65, 132, 244, 0.05)',
overlay: 'rgba(0, 0, 0, 0.15)',
border: '#aaa',
canvas: '#fafafa',
panel: '#fefefe',

12
todo.md
View file

@ -24,4 +24,14 @@
## Pages
- [x] Make selection part of page state
- [ ] Allow only one page to be in the document at a time
- [x] Allow only one page to be in the document at a time
## Context Menu
- [x] Create context Menu
- [ ] Wire up events
## Move to Page
- [ ] Move to Page Command
- [ ] Dialog

View file

@ -1398,7 +1398,7 @@ export function getSelectedBounds(data: Data) {
}
export function isMobile() {
return _isMobile()
return _isMobile().any
}
export function getRotatedBounds(shape: Shape) {
@ -1661,6 +1661,7 @@ export function getParentRotation(
export function getDocumentBranch(data: Data, id: string): string[] {
const shape = getPage(data).shapes[id]
if (shape.type !== ShapeType.Group) return [id]
return [
@ -1741,3 +1742,19 @@ export function pointsBetween(a: number[], b: number[], steps = 6) {
export function shuffleArr<T>(arr: T[], offset: number): T[] {
return arr.map((_, i) => arr[(i + offset) % arr.length])
}
export function commandKey() {
return isDarwin() ? '⌘' : 'Ctrl'
}
export function getTopParentId(data: Data, id: string): string {
const shape = getPage(data).shapes[id]
return shape.parentId === data.currentPageId ||
shape.parentId === data.currentParentId
? id
: getTopParentId(data, shape.parentId)
}
export function uniqueArray<T extends string | number | Symbol>(...items: T[]) {
return Array.from(new Set(items).values())
}