Adds page options panel to rename / duplicate / delete pages
This commit is contained in:
parent
0f85119ed0
commit
fd67e2791d
19 changed files with 662 additions and 74 deletions
38
__tests__/commands/create-page.test.ts
Normal file
38
__tests__/commands/create-page.test.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import TestState from '../test-utils'
|
||||
|
||||
describe('create page command', () => {
|
||||
const tt = new TestState()
|
||||
tt.resetDocumentState().save()
|
||||
|
||||
describe('creates a page', () => {
|
||||
it('does command', () => {
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(1)
|
||||
|
||||
tt.send('CREATED_PAGE')
|
||||
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(2)
|
||||
})
|
||||
|
||||
it('changes to the new page', () => {
|
||||
tt.restore().send('CREATED_PAGE')
|
||||
|
||||
const pageId = Object.keys(tt.data.document.pages)[1]
|
||||
|
||||
expect(tt.data.currentPageId).toBe(pageId)
|
||||
})
|
||||
|
||||
it('un-does command', () => {
|
||||
tt.restore().send('CREATED_PAGE').undo()
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(1)
|
||||
const pageId = Object.keys(tt.data.document.pages)[0]
|
||||
expect(tt.data.currentPageId).toBe(pageId)
|
||||
})
|
||||
|
||||
it('re-does command', () => {
|
||||
tt.restore().send('CREATED_PAGE').undo().redo()
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(2)
|
||||
const pageId = Object.keys(tt.data.document.pages)[1]
|
||||
expect(tt.data.currentPageId).toBe(pageId)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -2,23 +2,44 @@ import TestState from '../test-utils'
|
|||
|
||||
describe('delete page command', () => {
|
||||
const tt = new TestState()
|
||||
tt.resetDocumentState()
|
||||
tt.resetDocumentState().save()
|
||||
|
||||
describe('when last page is selected', () => {
|
||||
it('does command', () => {
|
||||
// TODO
|
||||
null
|
||||
tt.restore().send('CREATED_PAGE')
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(2)
|
||||
|
||||
const pageId = Object.keys(tt.data.document.pages)[1]
|
||||
tt.send('DELETED_PAGE', { id: pageId })
|
||||
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(1)
|
||||
|
||||
const firstPageId = Object.keys(tt.data.document.pages)[0]
|
||||
expect(tt.data.currentPageId).toBe(firstPageId)
|
||||
})
|
||||
|
||||
it('un-does command', () => {
|
||||
// TODO
|
||||
null
|
||||
tt.restore().send('CREATED_PAGE')
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(2)
|
||||
|
||||
const pageId = Object.keys(tt.data.document.pages)[1]
|
||||
tt.send('DELETED_PAGE', { id: pageId }).undo()
|
||||
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(2)
|
||||
|
||||
expect(tt.data.currentPageId).toBe(pageId)
|
||||
})
|
||||
|
||||
it('re-does command', () => {
|
||||
// TODO
|
||||
null
|
||||
})
|
||||
tt.restore().send('CREATED_PAGE')
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(2)
|
||||
|
||||
const pageId = Object.keys(tt.data.document.pages)[1]
|
||||
tt.send('DELETED_PAGE', { id: pageId }).undo().redo()
|
||||
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(1)
|
||||
|
||||
const firstPageId = Object.keys(tt.data.document.pages)[0]
|
||||
expect(tt.data.currentPageId).toBe(firstPageId)
|
||||
})
|
||||
|
||||
describe('when first page is selected', () => {
|
||||
|
|
109
__tests__/commands/duplicate-page.test.ts
Normal file
109
__tests__/commands/duplicate-page.test.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { ShapeType } from 'types'
|
||||
import TestState from '../test-utils'
|
||||
|
||||
describe('duplicate page command', () => {
|
||||
const tt = new TestState()
|
||||
tt.resetDocumentState()
|
||||
.createShape(
|
||||
{
|
||||
type: ShapeType.Rectangle,
|
||||
point: [0, 0],
|
||||
size: [100, 100],
|
||||
childIndex: 1,
|
||||
},
|
||||
'rect1'
|
||||
)
|
||||
.save()
|
||||
|
||||
describe('duplicates a page', () => {
|
||||
it('does, undoes, and redoes command', () => {
|
||||
tt.restore()
|
||||
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(1)
|
||||
const pageId = Object.keys(tt.data.document.pages)[0]
|
||||
expect(tt.getShape('rect1').parentId).toBe(pageId)
|
||||
|
||||
tt.send('DUPLICATED_PAGE', { id: pageId })
|
||||
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(2)
|
||||
|
||||
const newPageId = Object.keys(tt.data.document.pages)[1]
|
||||
|
||||
expect(tt.data.currentPageId).toBe(newPageId)
|
||||
|
||||
expect(tt.getShape('rect1').parentId).toBe(newPageId)
|
||||
|
||||
tt.undo()
|
||||
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(1)
|
||||
expect(tt.data.currentPageId).toBe(Object.keys(tt.data.document.pages)[0])
|
||||
|
||||
expect(tt.getShape('rect1').parentId).toBe(pageId)
|
||||
|
||||
tt.redo()
|
||||
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(2)
|
||||
expect(tt.data.currentPageId).toBe(Object.keys(tt.data.document.pages)[1])
|
||||
|
||||
expect(tt.getShape('rect1').parentId).toBe(newPageId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('duplicates a page other than the current page', () => {
|
||||
tt.restore()
|
||||
.reset()
|
||||
.send('CREATED_PAGE')
|
||||
.createShape(
|
||||
{
|
||||
type: ShapeType.Rectangle,
|
||||
point: [0, 0],
|
||||
size: [100, 100],
|
||||
childIndex: 1,
|
||||
},
|
||||
'rect2'
|
||||
)
|
||||
.send('CHANGED_PAGE', { id: 'page1' })
|
||||
|
||||
const firstPageId = Object.keys(tt.data.document.pages)[0]
|
||||
|
||||
// We should be back on the first page
|
||||
expect(tt.data.currentPageId).toBe(firstPageId)
|
||||
|
||||
// But we should have two pages
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(2)
|
||||
|
||||
const secondPageId = Object.keys(tt.data.document.pages)[1]
|
||||
|
||||
// Now we duplicate the second page
|
||||
tt.send('DUPLICATED_PAGE', { id: secondPageId })
|
||||
|
||||
// We should now have three pages
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(3)
|
||||
|
||||
// The third page should also have a shape named rect2
|
||||
const thirdPageId = Object.keys(tt.data.document.pages)[2]
|
||||
|
||||
// We should have changed pages to the third page
|
||||
expect(tt.data.currentPageId).toBe(thirdPageId)
|
||||
|
||||
// And it should be the parent of the third page
|
||||
expect(tt.getShape('rect2').parentId).toBe(thirdPageId)
|
||||
|
||||
tt.undo()
|
||||
|
||||
// We should still be on the first page, but we should
|
||||
// have only two pages; the third page should be deleted
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(2)
|
||||
expect(tt.data.document.pages[thirdPageId]).toBe(undefined)
|
||||
expect(tt.data.currentPageId).toBe(firstPageId)
|
||||
|
||||
tt.redo()
|
||||
|
||||
// We should be back on the third page
|
||||
expect(Object.keys(tt.data.document.pages).length).toBe(3)
|
||||
expect(tt.data.document.pages[thirdPageId]).toBeTruthy()
|
||||
expect(tt.data.currentPageId).toBe(Object.keys(tt.data.document.pages)[2])
|
||||
|
||||
expect(tt.getShape('rect2').parentId).toBe(thirdPageId)
|
||||
})
|
||||
})
|
|
@ -91,7 +91,11 @@ class TestState {
|
|||
*/
|
||||
createShape(props: Partial<Shape>, id = uniqueId()): TestState {
|
||||
const shape = createShape(props.type, props)
|
||||
getShapeUtils(shape).setProperty(shape, 'id', id)
|
||||
|
||||
getShapeUtils(shape)
|
||||
.setProperty(shape, 'id', id)
|
||||
.setProperty(shape, 'parentId', this.data.currentPageId)
|
||||
|
||||
this.data.document.pages[this.data.currentPageId].shapes[shape.id] = shape
|
||||
return this
|
||||
}
|
||||
|
|
|
@ -10,14 +10,21 @@ import PagePanel from './page-panel/page-panel'
|
|||
import CodePanel from './code-panel/code-panel'
|
||||
import DebugPanel from './debug-panel/debug-panel'
|
||||
import ControlsPanel from './controls-panel/controls-panel'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export default function Editor({ roomId }: { roomId?: string }): JSX.Element {
|
||||
useKeyboardEvents()
|
||||
const rLayout = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
rLayout.current?.focus()
|
||||
}, [])
|
||||
|
||||
useKeyboardEvents(rLayout)
|
||||
useLoadOnMount(roomId)
|
||||
useStateTheme()
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout ref={rLayout} tabIndex={-1}>
|
||||
<MenuButtons>
|
||||
<Menu />
|
||||
<DebugPanel />
|
||||
|
@ -57,6 +64,7 @@ const Layout = styled('main', {
|
|||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
|
||||
pointerEvents: 'none',
|
||||
'& > *': {
|
||||
|
|
|
@ -32,7 +32,7 @@ function Menu() {
|
|||
<IconButton as={Trigger} bp={breakpoints}>
|
||||
<HamburgerMenuIcon />
|
||||
</IconButton>
|
||||
<Content as={MenuContent} sideOffset={8}>
|
||||
<Content as={MenuContent} sideOffset={8} align="start">
|
||||
<DropdownMenuButton onSelect={handleNew} disabled>
|
||||
<span>New Project</span>
|
||||
<Kbd>
|
||||
|
|
93
components/page-panel/page-options.tsx
Normal file
93
components/page-panel/page-options.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import * as Dialog from '@radix-ui/react-alert-dialog'
|
||||
import { MixerVerticalIcon } from '@radix-ui/react-icons'
|
||||
import {
|
||||
breakpoints,
|
||||
IconButton,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
RowButton,
|
||||
MenuTextInput,
|
||||
DialogInputWrapper,
|
||||
Divider,
|
||||
} from 'components/shared'
|
||||
import state, { useSelector } from 'state'
|
||||
import { Page } from 'types'
|
||||
|
||||
export default function PageOptions({ page }: { page: Page }): JSX.Element {
|
||||
const hasOnlyOnePage = useSelector(
|
||||
(s) => Object.keys(s.data.document.pages).length <= 1
|
||||
)
|
||||
|
||||
function handleNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
state.send('CHANGED_PAGE_NAME', {
|
||||
id: page.id,
|
||||
name: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
|
||||
function handleDuplicate() {
|
||||
state.send('DUPLICATED_PAGE', { id: page.id })
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
state.send('DELETED_PAGE', { id: page.id })
|
||||
}
|
||||
|
||||
function handleOpenChange() {
|
||||
if (page.name.length === 0) {
|
||||
state.send('CHANGED_PAGE_NAME', {
|
||||
id: page.id,
|
||||
name: 'Page',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function stopPropagation(e: React.KeyboardEvent<HTMLDivElement>) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root onOpenChange={handleOpenChange}>
|
||||
<Dialog.Trigger
|
||||
as={IconButton}
|
||||
bp={breakpoints}
|
||||
size="small"
|
||||
data-shy="true"
|
||||
>
|
||||
<MixerVerticalIcon />
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Overlay as={DialogOverlay} />
|
||||
<Dialog.Content
|
||||
as={DialogContent}
|
||||
onKeyPress={stopPropagation}
|
||||
onKeyDown={stopPropagation}
|
||||
onKeyUp={stopPropagation}
|
||||
>
|
||||
<DialogInputWrapper>
|
||||
<MenuTextInput value={page.name} onChange={handleNameChange} />
|
||||
</DialogInputWrapper>
|
||||
<Divider />
|
||||
<Dialog.Action
|
||||
as={RowButton}
|
||||
bp={breakpoints}
|
||||
onClick={handleDuplicate}
|
||||
>
|
||||
Duplicate
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
as={RowButton}
|
||||
bp={breakpoints}
|
||||
disabled={hasOnlyOnePage}
|
||||
onClick={handleDelete}
|
||||
warn={true}
|
||||
>
|
||||
Delete
|
||||
</Dialog.Action>
|
||||
<Divider />
|
||||
<Dialog.Cancel as={RowButton} bp={breakpoints}>
|
||||
Cancel
|
||||
</Dialog.Cancel>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
|
@ -7,10 +7,10 @@ import {
|
|||
RowButton,
|
||||
MenuContent,
|
||||
FloatingContainer,
|
||||
IconButton,
|
||||
IconWrapper,
|
||||
} from 'components/shared'
|
||||
import { MixerVerticalIcon, PlusIcon, CheckIcon } from '@radix-ui/react-icons'
|
||||
import PageOptions from './page-options'
|
||||
import { PlusIcon, CheckIcon } from '@radix-ui/react-icons'
|
||||
import state, { useSelector } from 'state'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
|
@ -48,7 +48,7 @@ export default function PagePanel(): JSX.Element {
|
|||
<span>{documentPages[currentPageId].name}</span>
|
||||
</RowButton>
|
||||
</FloatingContainer>
|
||||
<MenuContent as={DropdownMenu.Content} sideOffset={8}>
|
||||
<MenuContent as={DropdownMenu.Content} sideOffset={8} align="start">
|
||||
<DropdownMenu.RadioGroup
|
||||
value={currentPageId}
|
||||
onValueChange={(id) => {
|
||||
|
@ -56,24 +56,22 @@ export default function PagePanel(): JSX.Element {
|
|||
state.send('CHANGED_PAGE', { id })
|
||||
}}
|
||||
>
|
||||
{sorted.map(({ id, name }) => (
|
||||
<ButtonWithOptions key={id}>
|
||||
{sorted.map((page) => (
|
||||
<ButtonWithOptions key={page.id}>
|
||||
<DropdownMenu.RadioItem
|
||||
as={RowButton}
|
||||
bp={breakpoints}
|
||||
value={id}
|
||||
value={page.id}
|
||||
variant="pageButton"
|
||||
>
|
||||
<span>{name}</span>
|
||||
<span>{page.name}</span>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<IconWrapper>
|
||||
<IconWrapper size="small">
|
||||
<CheckIcon />
|
||||
</IconWrapper>
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
<IconButton bp={breakpoints} size="small" data-shy="true">
|
||||
<MixerVerticalIcon />
|
||||
</IconButton>
|
||||
<PageOptions page={page} />
|
||||
</ButtonWithOptions>
|
||||
))}
|
||||
</DropdownMenu.RadioGroup>
|
||||
|
|
|
@ -120,7 +120,7 @@ export const RowButton = styled('button', {
|
|||
},
|
||||
|
||||
'&:disabled': {
|
||||
opacity: 0.1,
|
||||
opacity: 0.3,
|
||||
},
|
||||
|
||||
variants: {
|
||||
|
@ -163,9 +163,9 @@ export const RowButton = styled('button', {
|
|||
},
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
warn: {
|
||||
true: {
|
||||
opacity: 0.3,
|
||||
color: '$warn',
|
||||
},
|
||||
},
|
||||
isActive: {
|
||||
|
@ -517,22 +517,72 @@ export function Kbd({ children }: { children: React.ReactNode }): JSX.Element {
|
|||
return <StyledKbd>{children}</StyledKbd>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Dialog */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
export const DialogContent = styled('div', {
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
minWidth: 240,
|
||||
maxWidth: 'fit-content',
|
||||
maxHeight: '85vh',
|
||||
marginTop: '-5vh',
|
||||
pointerEvents: 'all',
|
||||
backgroundColor: '$panel',
|
||||
border: '1px solid $panel',
|
||||
padding: '$0',
|
||||
boxShadow: '$4',
|
||||
borderRadius: '4px',
|
||||
font: '$ui',
|
||||
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
export const DialogOverlay = styled('div', {
|
||||
backgroundColor: 'rgba(0, 0, 0, .15)',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
})
|
||||
|
||||
export const DialogInputWrapper = styled('div', {
|
||||
padding: '$4 $2',
|
||||
})
|
||||
|
||||
export const DialogTitleRow = styled('div', {
|
||||
display: 'flex',
|
||||
padding: '0 0 0 $4',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
h3: {
|
||||
fontSize: '$1',
|
||||
},
|
||||
})
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Menus */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
export const MenuContent = styled('div', {
|
||||
position: 'relative',
|
||||
backgroundColor: '$panel',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'all',
|
||||
userSelect: 'none',
|
||||
zIndex: 180,
|
||||
minWidth: 180,
|
||||
pointerEvents: 'all',
|
||||
backgroundColor: '$panel',
|
||||
border: '1px solid $panel',
|
||||
padding: '$0',
|
||||
boxShadow: '$4',
|
||||
minWidth: 180,
|
||||
borderRadius: '4px',
|
||||
font: '$ui',
|
||||
})
|
||||
|
||||
|
@ -545,6 +595,41 @@ export const Divider = styled('div', {
|
|||
marginLeft: '-$2',
|
||||
})
|
||||
|
||||
export function MenuButton({
|
||||
warn,
|
||||
onSelect,
|
||||
children,
|
||||
disabled = false,
|
||||
}: {
|
||||
warn?: boolean
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
children: React.ReactNode
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<RowButton
|
||||
bp={breakpoints}
|
||||
disabled={disabled}
|
||||
warn={warn}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
{children}
|
||||
</RowButton>
|
||||
)
|
||||
}
|
||||
|
||||
export const MenuTextInput = styled('input', {
|
||||
backgroundColor: '$panel',
|
||||
border: 'none',
|
||||
padding: '$4 $3',
|
||||
width: '100%',
|
||||
outline: 'none',
|
||||
background: '$input',
|
||||
borderRadius: '4px',
|
||||
font: '$ui',
|
||||
fontSize: '$1',
|
||||
})
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Dropdown Menu */
|
||||
/* -------------------------------------------------- */
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { useEffect } from 'react'
|
||||
import { MutableRefObject, useEffect } from 'react'
|
||||
import state from 'state'
|
||||
import inputs from 'state/inputs'
|
||||
import { ColorStyle, MoveType, SizeStyle } from 'types'
|
||||
import { metaKey } from 'utils'
|
||||
|
||||
export default function useKeyboardEvents() {
|
||||
export default function useKeyboardEvents(
|
||||
ref: MutableRefObject<HTMLDivElement>
|
||||
) {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const info = inputs.keydown(e)
|
||||
|
@ -365,11 +367,11 @@ export default function useKeyboardEvents() {
|
|||
}
|
||||
}
|
||||
|
||||
document.body.addEventListener('keydown', handleKeyDown)
|
||||
document.body.addEventListener('keyup', handleKeyUp)
|
||||
ref.current?.addEventListener('keydown', handleKeyDown)
|
||||
ref.current?.addEventListener('keyup', handleKeyUp)
|
||||
return () => {
|
||||
document.body.removeEventListener('keydown', handleKeyDown)
|
||||
document.body.removeEventListener('keyup', handleKeyUp)
|
||||
ref.current?.removeEventListener('keydown', handleKeyDown)
|
||||
ref.current?.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
|
|
@ -33,9 +33,10 @@
|
|||
"@liveblocks/node": "^0.3.0",
|
||||
"@liveblocks/react": "^0.8.0",
|
||||
"@monaco-editor/react": "^4.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^0.0.19",
|
||||
"@radix-ui/react-checkbox": "^0.0.16",
|
||||
"@radix-ui/react-context-menu": "^0.0.23",
|
||||
"@radix-ui/react-dialog": "^0.0.18",
|
||||
"@radix-ui/react-dialog": "^0.0.19",
|
||||
"@radix-ui/react-dropdown-menu": "^0.0.21",
|
||||
"@radix-ui/react-hover-card": "^0.0.3",
|
||||
"@radix-ui/react-icons": "^1.0.3",
|
||||
|
|
|
@ -15,26 +15,33 @@ export default function createPage(data: Data, goToPage = true): void {
|
|||
category: 'canvas',
|
||||
do(data) {
|
||||
const { page, pageState, currentPageId } = snapshot
|
||||
|
||||
storage.savePage(data, data.document.id, currentPageId)
|
||||
|
||||
data.document.pages[page.id] = page
|
||||
data.pageStates[page.id] = pageState
|
||||
|
||||
if (goToPage) {
|
||||
storage.savePage(data, data.document.id, currentPageId)
|
||||
storage.loadPage(data, data.document.id, page.id)
|
||||
data.currentPageId = page.id
|
||||
} else {
|
||||
data.currentPageId = currentPageId
|
||||
}
|
||||
data.currentParentId = page.id
|
||||
|
||||
storage.savePage(data, data.document.id, page.id)
|
||||
storage.saveDocumentToLocalStorage(data)
|
||||
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
|
||||
}
|
||||
},
|
||||
undo(data) {
|
||||
const { page, currentPageId } = snapshot
|
||||
delete data.document.pages[page.id]
|
||||
delete data.pageStates[page.id]
|
||||
|
||||
if (goToPage) {
|
||||
storage.loadPage(data, data.document.id, currentPageId)
|
||||
data.currentPageId = currentPageId
|
||||
storage.saveDocumentToLocalStorage(data)
|
||||
data.currentParentId = currentPageId
|
||||
|
||||
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
|
|
|
@ -14,23 +14,38 @@ export default function deletePage(data: Data, pageId: string): void {
|
|||
name: 'delete_page',
|
||||
category: 'canvas',
|
||||
do(data) {
|
||||
storage.saveAppStateToLocalStorage(data)
|
||||
storage.saveDocumentToLocalStorage(data)
|
||||
|
||||
data.currentPageId = snapshot.nextPageId
|
||||
data.currentParentId = snapshot.nextPageId
|
||||
|
||||
delete data.document.pages[pageId]
|
||||
delete data.pageStates[pageId]
|
||||
|
||||
if (snapshot.isCurrent) {
|
||||
storage.loadPage(data, snapshot.nextPageId)
|
||||
}
|
||||
},
|
||||
undo(data) {
|
||||
storage.saveAppStateToLocalStorage(data)
|
||||
storage.saveDocumentToLocalStorage(data)
|
||||
|
||||
data.currentPageId = snapshot.currentPageId
|
||||
data.currentParentId = snapshot.currentParentId
|
||||
data.document.pages[pageId] = snapshot.page
|
||||
data.pageStates[pageId] = snapshot.pageState
|
||||
|
||||
if (snapshot.isCurrent) {
|
||||
storage.loadPage(data, snapshot.currentPageId)
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function getSnapshot(data: Data, pageId: string) {
|
||||
const { currentPageId, document } = data
|
||||
const { currentPageId, currentParentId, document } = data
|
||||
|
||||
const page = deepClone(tld.getPage(data))
|
||||
|
||||
|
@ -38,14 +53,23 @@ function getSnapshot(data: Data, pageId: string) {
|
|||
|
||||
const isCurrent = data.currentPageId === pageId
|
||||
|
||||
const pageIds = Object.keys(document.pages)
|
||||
|
||||
const pageIndex = pageIds.indexOf(pageId)
|
||||
|
||||
const nextPageId = isCurrent
|
||||
? Object.values(document.pages).filter((page) => page.id !== pageId)[0]?.id // TODO: should be at nextIndex
|
||||
? pageIndex === 0
|
||||
? pageIds[1]
|
||||
: pageIndex === pageIds.length - 1
|
||||
? pageIds[pageIndex - 1]
|
||||
: pageIds[pageIndex + 1]
|
||||
: currentPageId
|
||||
|
||||
return {
|
||||
nextPageId,
|
||||
isCurrent,
|
||||
currentPageId,
|
||||
currentParentId,
|
||||
page,
|
||||
pageState,
|
||||
}
|
||||
|
|
103
state/commands/duplicate-page.ts
Normal file
103
state/commands/duplicate-page.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
import Command from './command'
|
||||
import history from '../history'
|
||||
import { Data, Page } from 'types'
|
||||
import { deepClone, uniqueId } from 'utils/utils'
|
||||
import tld from 'utils/tld'
|
||||
import storage from 'state/storage'
|
||||
import { getShapeUtils } from 'state/shape-utils'
|
||||
|
||||
export default function duplicatePage(
|
||||
data: Data,
|
||||
id: string,
|
||||
goToPage = true
|
||||
): void {
|
||||
const snapshot = getSnapshot(data, id)
|
||||
|
||||
history.execute(
|
||||
data,
|
||||
new Command({
|
||||
name: 'create_page',
|
||||
category: 'canvas',
|
||||
do(data) {
|
||||
const { from, to } = snapshot
|
||||
|
||||
data.document.pages[to.pageId] = to.page
|
||||
data.pageStates[to.pageId] = to.pageState
|
||||
|
||||
storage.savePage(data, data.document.id, to.pageId)
|
||||
|
||||
if (goToPage) {
|
||||
storage.savePage(data, data.document.id, from.pageId)
|
||||
storage.loadPage(data, data.document.id, to.pageId)
|
||||
data.currentPageId = to.pageId
|
||||
data.currentParentId = to.pageId
|
||||
|
||||
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
|
||||
}
|
||||
},
|
||||
undo(data) {
|
||||
const { from, to } = snapshot
|
||||
delete data.document.pages[to.pageId]
|
||||
delete data.pageStates[to.pageId]
|
||||
|
||||
if (goToPage) {
|
||||
storage.loadPage(data, data.document.id, from.pageId)
|
||||
data.currentPageId = from.pageId
|
||||
data.currentParentId = from.pageId
|
||||
|
||||
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function getSnapshot(data: Data, id: string) {
|
||||
const { currentPageId } = data
|
||||
|
||||
const oldPage: Page =
|
||||
id === currentPageId
|
||||
? data.document.pages[id]
|
||||
: storage.getPageFromLocalStorage(data, data.document.id, id)
|
||||
|
||||
const newPage: Page = deepClone(oldPage)
|
||||
|
||||
newPage.id = uniqueId()
|
||||
|
||||
// Iterate the page's name
|
||||
const lastNameChar = oldPage.name[oldPage.name.length - 1]
|
||||
|
||||
if (Number.isNaN(Number(lastNameChar))) {
|
||||
newPage.name = `${oldPage.name} 1`
|
||||
} else {
|
||||
newPage.name = `${oldPage.name.slice(0, -1)}${Number(lastNameChar) + 1}`
|
||||
}
|
||||
|
||||
Object.values(newPage.shapes).forEach((shape) => {
|
||||
if (shape.parentId === oldPage.id) {
|
||||
getShapeUtils(shape).setProperty(shape, 'parentId', newPage.id)
|
||||
}
|
||||
})
|
||||
|
||||
const oldPageState =
|
||||
id === currentPageId
|
||||
? data.pageStates[id]
|
||||
: storage.getPageStateFromLocalStorage(data, data.document.id, id)
|
||||
|
||||
const newPageState = deepClone(oldPageState)
|
||||
|
||||
newPageState.id = newPage.id
|
||||
|
||||
return {
|
||||
currentPageId,
|
||||
from: {
|
||||
pageId: currentPageId,
|
||||
pageState: deepClone(data.pageStates[currentPageId]),
|
||||
},
|
||||
to: {
|
||||
pageId: newPage.id,
|
||||
page: newPage,
|
||||
pageState: newPageState,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ import align from './align'
|
|||
import changePage from './change-page'
|
||||
import createPage from './create-page'
|
||||
import deletePage from './delete-page'
|
||||
import duplicatePage from './duplicate-page'
|
||||
import deleteShapes from './delete-shapes'
|
||||
import createShapes from './create-shapes'
|
||||
import distribute from './distribute'
|
||||
|
@ -34,6 +35,7 @@ const commands = {
|
|||
createShapes,
|
||||
deletePage,
|
||||
deleteShapes,
|
||||
duplicatePage,
|
||||
distribute,
|
||||
doublePointHandle,
|
||||
draw,
|
||||
|
|
|
@ -246,10 +246,6 @@ const state = createState({
|
|||
unless: ['isReadOnly', 'isInSession'],
|
||||
do: ['clearSelectedIds', 'createPage'],
|
||||
},
|
||||
DELETED_PAGE: {
|
||||
unlessAny: ['isReadOnly', 'isInSession', 'hasOnlyOnePage'],
|
||||
do: 'deletePage',
|
||||
},
|
||||
SELECTED_SELECT_TOOL: {
|
||||
unless: 'isInSession',
|
||||
to: 'selecting',
|
||||
|
@ -324,6 +320,18 @@ const state = createState({
|
|||
unless: 'isInSession',
|
||||
do: 'changePage',
|
||||
},
|
||||
CHANGED_PAGE_NAME: {
|
||||
unlessAny: ['isReadOnly', 'isInSession'],
|
||||
do: 'changePageName',
|
||||
},
|
||||
DUPLICATED_PAGE: {
|
||||
unlessAny: ['isReadOnly', 'isInSession'],
|
||||
do: 'duplicatePage',
|
||||
},
|
||||
DELETED_PAGE: {
|
||||
unlessAny: ['isReadOnly', 'isInSession', 'hasOnlyOnePage'],
|
||||
do: 'deletePage',
|
||||
},
|
||||
ZOOMED_TO_ACTUAL: {
|
||||
if: 'hasSelection',
|
||||
do: 'zoomCameraToSelectionActual',
|
||||
|
@ -1280,7 +1288,7 @@ const state = createState({
|
|||
return data.settings.isPenLocked
|
||||
},
|
||||
hasOnlyOnePage(data) {
|
||||
return Object.keys(data.document.pages).length === 1
|
||||
return Object.keys(data.document.pages).length <= 1
|
||||
},
|
||||
selectionIncludesGroups(data) {
|
||||
return tld
|
||||
|
@ -1361,9 +1369,9 @@ const state = createState({
|
|||
const newPageId = 'page1'
|
||||
|
||||
data.document.id = newDocumentId
|
||||
data.pointedId = null
|
||||
data.hoveredId = null
|
||||
data.editingId = null
|
||||
data.pointedId = undefined
|
||||
data.hoveredId = undefined
|
||||
data.editingId = undefined
|
||||
data.currentPageId = newPageId
|
||||
data.currentParentId = newPageId
|
||||
data.currentCodeFileId = 'file0'
|
||||
|
@ -1411,9 +1419,16 @@ const state = createState({
|
|||
createPage(data) {
|
||||
commands.createPage(data, true)
|
||||
},
|
||||
changePageName(data, payload: { id: string; name: string }) {
|
||||
data.document.pages[payload.id].name = payload.name
|
||||
},
|
||||
deletePage(data, payload: { id: string }) {
|
||||
commands.deletePage(data, payload.id)
|
||||
},
|
||||
duplicatePage(data, payload: { id: string }) {
|
||||
commands.duplicatePage(data, payload.id, true)
|
||||
},
|
||||
|
||||
/* --------------------- Shapes --------------------- */
|
||||
resetShapes(data) {
|
||||
const page = tld.getPage(data)
|
||||
|
@ -1815,7 +1830,7 @@ const state = createState({
|
|||
tld.getPageState(data).selectedIds = [selectedShape.id]
|
||||
},
|
||||
clearEditingId(data) {
|
||||
data.editingId = null
|
||||
data.editingId = undefined
|
||||
},
|
||||
|
||||
/* ---------------------- Tool ---------------------- */
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Data, PageState, TLDocument } from 'types'
|
||||
import { Data, Page, PageState, TLDocument } from 'types'
|
||||
import { decompress, compress } from 'utils'
|
||||
import state from './state'
|
||||
import { uniqueId } from 'utils/utils'
|
||||
|
@ -254,6 +254,56 @@ class Storage {
|
|||
)
|
||||
}
|
||||
|
||||
getPageFromLocalStorage(
|
||||
data: Data,
|
||||
fileId = data.document.id,
|
||||
pageId = data.currentPageId
|
||||
): Page {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
let page: Page
|
||||
|
||||
try {
|
||||
const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId))
|
||||
if (savedPage === null) {
|
||||
throw Error('That page is not in local storage.')
|
||||
}
|
||||
|
||||
page = JSON.parse(decompress(savedPage))
|
||||
} catch (e) {
|
||||
console.warn('Could not load a page with the id', pageId)
|
||||
}
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
getPageStateFromLocalStorage(
|
||||
data: Data,
|
||||
fileId = data.document.id,
|
||||
pageId = data.currentPageId
|
||||
): PageState {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
let pageState: PageState
|
||||
|
||||
try {
|
||||
const savedPageState = localStorage.getItem(
|
||||
storageId(fileId, 'pageState', pageId)
|
||||
)
|
||||
if (savedPageState === null) {
|
||||
throw Error('That page state is not in local storage.')
|
||||
}
|
||||
|
||||
pageState = JSON.parse(decompress(savedPageState))
|
||||
} catch (e) {
|
||||
console.warn('Could not load a page state with the id', pageId)
|
||||
}
|
||||
|
||||
return pageState
|
||||
}
|
||||
|
||||
loadPage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
@ -263,7 +313,21 @@ class Storage {
|
|||
try {
|
||||
// If we have a page in local storage, move it into state
|
||||
const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId))
|
||||
|
||||
if (savedPage === null) {
|
||||
// Why would the page be null?
|
||||
// TODO: Find out why the page would be null.
|
||||
|
||||
data.document.pages[pageId] = {
|
||||
id: pageId,
|
||||
type: 'page',
|
||||
childIndex: Object.keys(data.document.pages).length,
|
||||
name: 'New Page',
|
||||
shapes: {},
|
||||
}
|
||||
} else {
|
||||
data.document.pages[pageId] = JSON.parse(decompress(savedPage))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not load a page with the id', pageId)
|
||||
|
||||
|
@ -303,8 +367,6 @@ class Storage {
|
|||
JSON.stringify(data.pageStates[pageId])
|
||||
)
|
||||
|
||||
// Prepare new state
|
||||
|
||||
// Now clear out the other pages from state.
|
||||
Object.values(data.document.pages).forEach((page) => {
|
||||
if (page.id !== data.currentPageId) {
|
||||
|
|
|
@ -24,6 +24,7 @@ const { styled, global, css, theme, getCssString } = createCss({
|
|||
muted: '#777777',
|
||||
input: '#f3f3f3',
|
||||
inputBorder: '#dddddd',
|
||||
warn: 'rgba(255, 100, 100, 1)',
|
||||
lineError: 'rgba(255, 0, 0, .1)',
|
||||
},
|
||||
shadows: {
|
||||
|
@ -40,6 +41,7 @@ const { styled, global, css, theme, getCssString } = createCss({
|
|||
2: '4px',
|
||||
3: '8px',
|
||||
4: '12px',
|
||||
5: '16px',
|
||||
},
|
||||
fontSizes: {
|
||||
0: '10px',
|
||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -1349,6 +1349,20 @@
|
|||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-alert-dialog@^0.0.19":
|
||||
version "0.0.19"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-0.0.19.tgz#5b69bfe063cdb13f49630ad2705e71228505d147"
|
||||
integrity sha512-SJRUT2s0/WLCvCEbfuKL5EM6QNXjZQkX9ZgkwKvgRNYu5zYEmCmlCUWDJbPIX1Y7w/a6tuEm24f3Uywd8VcBxw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "0.0.5"
|
||||
"@radix-ui/react-compose-refs" "0.0.5"
|
||||
"@radix-ui/react-context" "0.0.5"
|
||||
"@radix-ui/react-dialog" "0.0.19"
|
||||
"@radix-ui/react-polymorphic" "0.0.12"
|
||||
"@radix-ui/react-primitive" "0.0.14"
|
||||
"@radix-ui/react-slot" "0.0.12"
|
||||
|
||||
"@radix-ui/react-arrow@0.0.14":
|
||||
version "0.0.14"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-0.0.14.tgz#70b2c66efbf3cde0c9dd0895417e39f6cdf31805"
|
||||
|
@ -1420,10 +1434,10 @@
|
|||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-dialog@^0.0.18":
|
||||
version "0.0.18"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-0.0.18.tgz#8d2d9f8816bb8447056031583a3a3cb2b6305281"
|
||||
integrity sha512-EH8yxFh3hQQ/hIPQsBzdJgx3oWTEmLu2a2x2PfRjxbDhcDIjcYJWdeEMjkTUjkBwpz3h6L/JWqnYJ2dqA65Deg==
|
||||
"@radix-ui/react-dialog@0.0.19", "@radix-ui/react-dialog@^0.0.19":
|
||||
version "0.0.19"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-0.0.19.tgz#5a76fa380142a7a97c15c585ab071f63fba5297d"
|
||||
integrity sha512-7FbWaj/C/TDpfJ+VJ4wNAQIjENDNfwAqNvAfeb+TEtBjgjmsfRDgA1AMenlA5N1QuRtAokRMTHUs3ukW49oQ+g==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "0.0.5"
|
||||
|
@ -1437,7 +1451,7 @@
|
|||
"@radix-ui/react-portal" "0.0.14"
|
||||
"@radix-ui/react-presence" "0.0.14"
|
||||
"@radix-ui/react-primitive" "0.0.14"
|
||||
"@radix-ui/react-slot" "0.0.11"
|
||||
"@radix-ui/react-slot" "0.0.12"
|
||||
"@radix-ui/react-use-controllable-state" "0.0.6"
|
||||
aria-hidden "^1.1.1"
|
||||
react-remove-scroll "^2.4.0"
|
||||
|
|
Loading…
Reference in a new issue