Merge pull request #52 from tldraw/page-options-menu

Adds page options panel to rename / duplicate / delete pages
This commit is contained in:
Steve Ruiz 2021-07-14 12:48:58 +01:00 committed by GitHub
commit d026b49aeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 846 additions and 79 deletions

View file

@ -26,7 +26,7 @@
"currentCodeFileId": "file0", "currentCodeFileId": "file0",
"codeControls": {}, "codeControls": {},
"document": { "document": {
"id": "home", "id": "TESTING",
"name": "My Document", "name": "My Document",
"pages": { "pages": {
"page1": { "page1": {

View file

@ -1,6 +1,6 @@
{ {
"document": { "document": {
"id": "home", "id": "TESTING",
"name": "My Document", "name": "My Document",
"pages": { "pages": {
"page1": { "page1": {

View file

@ -50,7 +50,7 @@ for (let i = 0; i < count; i++) {
"name": "index.ts", "name": "index.ts",
}, },
}, },
"id": "home", "id": "TESTING",
"name": "My Document", "name": "My Document",
"pages": Object { "pages": Object {
"page1": Object { "page1": Object {
@ -454,7 +454,7 @@ for (let i = 0; i < count; i++) {
"name": "index.ts", "name": "index.ts",
}, },
}, },
"id": "home", "id": "TESTING",
"name": "My Document", "name": "My Document",
"pages": Object { "pages": Object {
"page1": Object { "page1": Object {

View 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)
})
})
})

View file

@ -2,23 +2,44 @@ import TestState from '../test-utils'
describe('delete page command', () => { describe('delete page command', () => {
const tt = new TestState() const tt = new TestState()
tt.resetDocumentState() tt.resetDocumentState().save()
describe('when last page is selected', () => { it('does command', () => {
it('does command', () => { tt.reset().restore().send('CREATED_PAGE')
// TODO expect(Object.keys(tt.data.document.pages).length).toBe(2)
null
})
it('un-does command', () => { const pageId = Object.keys(tt.data.document.pages)[1]
// TODO tt.send('DELETED_PAGE', { id: pageId })
null
})
it('re-does command', () => { expect(Object.keys(tt.data.document.pages).length).toBe(1)
// TODO
null const firstPageId = Object.keys(tt.data.document.pages)[0]
}) expect(tt.data.currentPageId).toBe(firstPageId)
})
it('un-does command', () => {
tt.reset().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', () => {
tt.reset().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', () => { describe('when first page is selected', () => {

View 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.reset().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)
})
})

View file

@ -0,0 +1,55 @@
import TestState from '../test-utils'
describe('rename page command', () => {
const tt = new TestState()
tt.resetDocumentState().save()
describe('renames a page', () => {
it('does, undoes, and redoes command', () => {
tt.restore().reset().send('CREATED_PAGE')
const pageId = Object.keys(tt.data.document.pages)[1]
expect(tt.data.document.pages[pageId].name).toBe('Page 2')
tt.send('RENAMED_PAGE', { id: pageId, name: 'My First Page' })
expect(tt.data.document.pages[pageId].name).toBe('My First Page')
tt.undo()
expect(tt.data.document.pages[pageId].name).toBe('Page 2')
tt.redo()
expect(tt.data.document.pages[pageId].name).toBe('My First Page')
})
})
describe('renames a page other than the current page', () => {
tt.restore()
.reset()
.send('CREATED_PAGE')
.send('CHANGED_PAGE', { id: 'page1' })
expect(Object.keys(tt.data.document.pages).length).toBe(2)
expect(tt.data.currentPageId).toBe('page1')
const secondPageId = Object.keys(tt.data.document.pages)[1]
expect(tt.data.document.pages[secondPageId].name).toBe('Page 2')
tt.send('RENAMED_PAGE', { id: secondPageId, name: 'My Second Page' })
expect(tt.data.document.pages[secondPageId].name).toBe('My Second Page')
tt.undo()
expect(tt.data.document.pages[secondPageId].name).toBe('Page 2')
tt.redo()
expect(tt.data.document.pages[secondPageId].name).toBe('My Second Page')
})
})

View file

@ -91,7 +91,11 @@ class TestState {
*/ */
createShape(props: Partial<Shape>, id = uniqueId()): TestState { createShape(props: Partial<Shape>, id = uniqueId()): TestState {
const shape = createShape(props.type, props) 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 this.data.document.pages[this.data.currentPageId].shapes[shape.id] = shape
return this return this
} }

View file

@ -10,14 +10,21 @@ import PagePanel from './page-panel/page-panel'
import CodePanel from './code-panel/code-panel' import CodePanel from './code-panel/code-panel'
import DebugPanel from './debug-panel/debug-panel' import DebugPanel from './debug-panel/debug-panel'
import ControlsPanel from './controls-panel/controls-panel' import ControlsPanel from './controls-panel/controls-panel'
import { useEffect, useRef } from 'react'
export default function Editor({ roomId }: { roomId?: string }): JSX.Element { export default function Editor({ roomId }: { roomId?: string }): JSX.Element {
useKeyboardEvents() const rLayout = useRef<HTMLDivElement>(null)
useEffect(() => {
rLayout.current?.focus()
}, [])
useKeyboardEvents(rLayout)
useLoadOnMount(roomId) useLoadOnMount(roomId)
useStateTheme() useStateTheme()
return ( return (
<Layout> <Layout ref={rLayout} tabIndex={-1}>
<MenuButtons> <MenuButtons>
<Menu /> <Menu />
<DebugPanel /> <DebugPanel />
@ -57,6 +64,7 @@ const Layout = styled('main', {
alignItems: 'flex-start', alignItems: 'flex-start',
justifyContent: 'flex-start', justifyContent: 'flex-start',
boxSizing: 'border-box', boxSizing: 'border-box',
outline: 'none',
pointerEvents: 'none', pointerEvents: 'none',
'& > *': { '& > *': {

View file

@ -32,7 +32,7 @@ function Menu() {
<IconButton as={Trigger} bp={breakpoints}> <IconButton as={Trigger} bp={breakpoints}>
<HamburgerMenuIcon /> <HamburgerMenuIcon />
</IconButton> </IconButton>
<Content as={MenuContent} sideOffset={8}> <Content as={MenuContent} sideOffset={8} align="start">
<DropdownMenuButton onSelect={handleNew} disabled> <DropdownMenuButton onSelect={handleNew} disabled>
<span>New Project</span> <span>New Project</span>
<Kbd> <Kbd>

View file

@ -0,0 +1,107 @@
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 { useState } from 'react'
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
)
const [name, setName] = useState(page.name)
function handleNameChange(e: React.ChangeEvent<HTMLInputElement>) {
setName(e.currentTarget.value)
}
function handleDuplicate() {
state.send('DUPLICATED_PAGE', { id: page.id })
}
function handleDelete() {
state.send('DELETED_PAGE', { id: page.id })
}
function handleOpenChange(isOpen: boolean) {
if (isOpen) return
if (page.name.length === 0) {
state.send('RENAMED_PAGE', {
id: page.id,
name: 'Page',
})
}
state.send('SAVED_PAGE_RENAME', { id: page.id })
}
function handleSave() {
state.send('RENAMED_PAGE', {
id: page.id,
name,
})
}
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={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.Action as={RowButton} bp={breakpoints} onClick={handleSave}>
Save
</Dialog.Action>
<Dialog.Cancel as={RowButton} bp={breakpoints}>
Cancel
</Dialog.Cancel>
</Dialog.Content>
</Dialog.Root>
)
}

View file

@ -7,10 +7,10 @@ import {
RowButton, RowButton,
MenuContent, MenuContent,
FloatingContainer, FloatingContainer,
IconButton,
IconWrapper, IconWrapper,
} from 'components/shared' } 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 state, { useSelector } from 'state'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@ -44,11 +44,11 @@ export default function PagePanel(): JSX.Element {
}} }}
> >
<FloatingContainer> <FloatingContainer>
<RowButton as={DropdownMenu.Trigger} bp={breakpoints}> <RowButton as={DropdownMenu.Trigger} bp={breakpoints} variant="noIcon">
<span>{documentPages[currentPageId].name}</span> <span>{documentPages[currentPageId].name}</span>
</RowButton> </RowButton>
</FloatingContainer> </FloatingContainer>
<MenuContent as={DropdownMenu.Content} sideOffset={8}> <MenuContent as={DropdownMenu.Content} sideOffset={8} align="start">
<DropdownMenu.RadioGroup <DropdownMenu.RadioGroup
value={currentPageId} value={currentPageId}
onValueChange={(id) => { onValueChange={(id) => {
@ -56,24 +56,22 @@ export default function PagePanel(): JSX.Element {
state.send('CHANGED_PAGE', { id }) state.send('CHANGED_PAGE', { id })
}} }}
> >
{sorted.map(({ id, name }) => ( {sorted.map((page) => (
<ButtonWithOptions key={id}> <ButtonWithOptions key={page.id}>
<DropdownMenu.RadioItem <DropdownMenu.RadioItem
as={RowButton} as={RowButton}
bp={breakpoints} bp={breakpoints}
value={id} value={page.id}
variant="pageButton" variant="pageButton"
> >
<span>{name}</span> <span>{page.name}</span>
<DropdownMenu.ItemIndicator> <DropdownMenu.ItemIndicator>
<IconWrapper> <IconWrapper size="small">
<CheckIcon /> <CheckIcon />
</IconWrapper> </IconWrapper>
</DropdownMenu.ItemIndicator> </DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem> </DropdownMenu.RadioItem>
<IconButton bp={breakpoints} size="small" data-shy="true"> <PageOptions page={page} />
<MixerVerticalIcon />
</IconButton>
</ButtonWithOptions> </ButtonWithOptions>
))} ))}
</DropdownMenu.RadioGroup> </DropdownMenu.RadioGroup>

View file

@ -105,6 +105,7 @@ export const RowButton = styled('button', {
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '4px 8px 4px 12px', padding: '4px 8px 4px 12px',
borderRadius: 4, borderRadius: 4,
userSelect: 'none',
'& label': { '& label': {
fontWeight: '$1', fontWeight: '$1',
@ -120,7 +121,7 @@ export const RowButton = styled('button', {
}, },
'&:disabled': { '&:disabled': {
opacity: 0.1, opacity: 0.3,
}, },
variants: { variants: {
@ -145,6 +146,9 @@ export const RowButton = styled('button', {
}, },
}, },
variant: { variant: {
noIcon: {
padding: '4px 12px',
},
pageButton: { pageButton: {
display: 'grid', display: 'grid',
gridTemplateColumns: '24px auto', gridTemplateColumns: '24px auto',
@ -163,9 +167,9 @@ export const RowButton = styled('button', {
}, },
}, },
}, },
disabled: { warn: {
true: { true: {
opacity: 0.3, color: '$warn',
}, },
}, },
isActive: { isActive: {
@ -517,22 +521,72 @@ export function Kbd({ children }: { children: React.ReactNode }): JSX.Element {
return <StyledKbd>{children}</StyledKbd> 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 */ /* Menus */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
export const MenuContent = styled('div', { export const MenuContent = styled('div', {
position: 'relative', position: 'relative',
backgroundColor: '$panel',
borderRadius: '4px',
overflow: 'hidden', overflow: 'hidden',
pointerEvents: 'all',
userSelect: 'none', userSelect: 'none',
zIndex: 180, zIndex: 180,
minWidth: 180,
pointerEvents: 'all',
backgroundColor: '$panel',
border: '1px solid $panel', border: '1px solid $panel',
padding: '$0', padding: '$0',
boxShadow: '$4', boxShadow: '$4',
minWidth: 180, borderRadius: '4px',
font: '$ui', font: '$ui',
}) })
@ -545,6 +599,41 @@ export const Divider = styled('div', {
marginLeft: '-$2', 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 */ /* Dropdown Menu */
/* -------------------------------------------------- */ /* -------------------------------------------------- */

View file

@ -1,11 +1,13 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { useEffect } from 'react' import { MutableRefObject, useEffect } from 'react'
import state from 'state' import state from 'state'
import inputs from 'state/inputs' import inputs from 'state/inputs'
import { ColorStyle, MoveType, SizeStyle } from 'types' import { ColorStyle, MoveType, SizeStyle } from 'types'
import { metaKey } from 'utils' import { metaKey } from 'utils'
export default function useKeyboardEvents() { export default function useKeyboardEvents(
ref: MutableRefObject<HTMLDivElement>
) {
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
const info = inputs.keydown(e) const info = inputs.keydown(e)
@ -365,11 +367,11 @@ export default function useKeyboardEvents() {
} }
} }
document.body.addEventListener('keydown', handleKeyDown) ref.current?.addEventListener('keydown', handleKeyDown)
document.body.addEventListener('keyup', handleKeyUp) ref.current?.addEventListener('keyup', handleKeyUp)
return () => { return () => {
document.body.removeEventListener('keydown', handleKeyDown) ref.current?.removeEventListener('keydown', handleKeyDown)
document.body.removeEventListener('keyup', handleKeyUp) ref.current?.removeEventListener('keyup', handleKeyUp)
} }
}, []) }, [])
} }

View file

@ -33,9 +33,10 @@
"@liveblocks/node": "^0.3.0", "@liveblocks/node": "^0.3.0",
"@liveblocks/react": "^0.8.0", "@liveblocks/react": "^0.8.0",
"@monaco-editor/react": "^4.2.1", "@monaco-editor/react": "^4.2.1",
"@radix-ui/react-alert-dialog": "^0.0.19",
"@radix-ui/react-checkbox": "^0.0.16", "@radix-ui/react-checkbox": "^0.0.16",
"@radix-ui/react-context-menu": "^0.0.23", "@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-dropdown-menu": "^0.0.21",
"@radix-ui/react-hover-card": "^0.0.3", "@radix-ui/react-hover-card": "^0.0.3",
"@radix-ui/react-icons": "^1.0.3", "@radix-ui/react-icons": "^1.0.3",

View file

@ -15,26 +15,40 @@ export default function createPage(data: Data, goToPage = true): void {
category: 'canvas', category: 'canvas',
do(data) { do(data) {
const { page, pageState, currentPageId } = snapshot const { page, pageState, currentPageId } = snapshot
storage.savePage(data, data.document.id, currentPageId)
data.document.pages[page.id] = page data.document.pages[page.id] = page
data.pageStates[page.id] = pageState data.pageStates[page.id] = pageState
storage.savePage(data, data.document.id, page.id)
if (goToPage) { if (goToPage) {
storage.loadPage(data, data.document.id, page.id)
data.currentPageId = page.id data.currentPageId = page.id
} else { data.currentParentId = page.id
data.currentPageId = currentPageId
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
} }
storage.savePage(data, data.document.id, page.id) storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data) storage.saveDocumentToLocalStorage(data)
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
}, },
undo(data) { undo(data) {
const { page, currentPageId } = snapshot const { page, currentPageId } = snapshot
delete data.document.pages[page.id] delete data.document.pages[page.id]
delete data.pageStates[page.id] delete data.pageStates[page.id]
data.currentPageId = currentPageId
if (goToPage) {
storage.loadPage(data, data.document.id, currentPageId)
data.currentPageId = currentPageId
data.currentParentId = currentPageId
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
}
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data) storage.saveDocumentToLocalStorage(data)
tld.setZoomCSS(tld.getPageState(data).camera.zoom)
}, },
}) })
) )

View file

@ -14,23 +14,44 @@ export default function deletePage(data: Data, pageId: string): void {
name: 'delete_page', name: 'delete_page',
category: 'canvas', category: 'canvas',
do(data) { do(data) {
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
data.currentPageId = snapshot.nextPageId data.currentPageId = snapshot.nextPageId
data.currentParentId = snapshot.nextPageId
delete data.document.pages[pageId] delete data.document.pages[pageId]
delete data.pageStates[pageId] delete data.pageStates[pageId]
storage.loadPage(data, snapshot.nextPageId)
if (snapshot.isCurrent) {
storage.loadPage(data, data.document.id, snapshot.nextPageId)
}
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
}, },
undo(data) { undo(data) {
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
data.currentPageId = snapshot.currentPageId data.currentPageId = snapshot.currentPageId
data.currentParentId = snapshot.currentParentId
data.document.pages[pageId] = snapshot.page data.document.pages[pageId] = snapshot.page
data.pageStates[pageId] = snapshot.pageState data.pageStates[pageId] = snapshot.pageState
storage.loadPage(data, snapshot.currentPageId)
if (snapshot.isCurrent) {
storage.loadPage(data, data.document.id, snapshot.currentPageId)
}
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
}, },
}) })
) )
} }
function getSnapshot(data: Data, pageId: string) { function getSnapshot(data: Data, pageId: string) {
const { currentPageId, document } = data const { currentPageId, currentParentId, document } = data
const page = deepClone(tld.getPage(data)) const page = deepClone(tld.getPage(data))
@ -38,14 +59,23 @@ function getSnapshot(data: Data, pageId: string) {
const isCurrent = data.currentPageId === pageId const isCurrent = data.currentPageId === pageId
const pageIds = Object.keys(document.pages)
const pageIndex = pageIds.indexOf(pageId)
const nextPageId = isCurrent 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 : currentPageId
return { return {
nextPageId, nextPageId,
isCurrent, isCurrent,
currentPageId, currentPageId,
currentParentId,
page, page,
pageState, pageState,
} }

View file

@ -0,0 +1,109 @@
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: 'duplicate_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)
}
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
},
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)
}
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
},
})
)
}
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,
},
}
}

View file

@ -2,6 +2,7 @@ import align from './align'
import changePage from './change-page' import changePage from './change-page'
import createPage from './create-page' import createPage from './create-page'
import deletePage from './delete-page' import deletePage from './delete-page'
import duplicatePage from './duplicate-page'
import deleteShapes from './delete-shapes' import deleteShapes from './delete-shapes'
import createShapes from './create-shapes' import createShapes from './create-shapes'
import distribute from './distribute' import distribute from './distribute'
@ -25,6 +26,7 @@ import toggle from './toggle'
import transform from './transform' import transform from './transform'
import transformSingle from './transform-single' import transformSingle from './transform-single'
import translate from './translate' import translate from './translate'
import renamePage from './rename-page'
import ungroup from './ungroup' import ungroup from './ungroup'
const commands = { const commands = {
@ -34,6 +36,7 @@ const commands = {
createShapes, createShapes,
deletePage, deletePage,
deleteShapes, deleteShapes,
duplicatePage,
distribute, distribute,
doublePointHandle, doublePointHandle,
draw, draw,
@ -55,6 +58,7 @@ const commands = {
transform, transform,
transformSingle, transformSingle,
translate, translate,
renamePage,
ungroup, ungroup,
} }

View file

@ -65,7 +65,7 @@ export default function moveToPageCommand(data: Data, newPageId: string): void {
storage.savePage(data, data.document.id, fromPageId) storage.savePage(data, data.document.id, fromPageId)
// Load the "to" page // Load the "to" page
storage.loadPage(data, toPageId) storage.loadPage(data, data.document.id, toPageId)
// The page we're moving the shapes to // The page we're moving the shapes to
const toPage = tld.getPage(data) const toPage = tld.getPage(data)
@ -119,7 +119,7 @@ export default function moveToPageCommand(data: Data, newPageId: string): void {
storage.savePage(data, data.document.id, fromPageId) storage.savePage(data, data.document.id, fromPageId)
storage.loadPage(data, toPageId) storage.loadPage(data, data.document.id, toPageId)
const toPage = tld.getPage(data) const toPage = tld.getPage(data)

View file

@ -0,0 +1,65 @@
import Command from './command'
import history from '../history'
import { Data, Page } from 'types'
import storage from 'state/storage'
export default function renamePage(
data: Data,
pageId: string,
name: string
): void {
const snapshot = getSnapshot(data, pageId)
history.execute(
data,
new Command({
name: 'rename_page',
category: 'canvas',
do(data) {
if (pageId === data.currentPageId) {
data.document.pages[pageId].name = name
}
storage.renamePageInLocalStorage(data, data.document.id, pageId, name)
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
},
undo(data) {
if (pageId === data.currentPageId) {
data.document.pages[pageId].name = snapshot.from.name
}
storage.renamePageInLocalStorage(
data,
data.document.id,
pageId,
snapshot.from.name
)
storage.saveAppStateToLocalStorage(data)
storage.saveDocumentToLocalStorage(data)
},
})
)
}
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)
return {
currentPageId,
from: {
pageId: oldPage.id,
name: oldPage.name,
},
to: {
pageId: oldPage.id,
},
}
}

View file

@ -246,10 +246,6 @@ const state = createState({
unless: ['isReadOnly', 'isInSession'], unless: ['isReadOnly', 'isInSession'],
do: ['clearSelectedIds', 'createPage'], do: ['clearSelectedIds', 'createPage'],
}, },
DELETED_PAGE: {
unlessAny: ['isReadOnly', 'isInSession', 'hasOnlyOnePage'],
do: 'deletePage',
},
SELECTED_SELECT_TOOL: { SELECTED_SELECT_TOOL: {
unless: 'isInSession', unless: 'isInSession',
to: 'selecting', to: 'selecting',
@ -324,6 +320,18 @@ const state = createState({
unless: 'isInSession', unless: 'isInSession',
do: 'changePage', do: 'changePage',
}, },
RENAMED_PAGE: {
unlessAny: ['isReadOnly', 'isInSession'],
do: 'renamePage',
},
DUPLICATED_PAGE: {
unlessAny: ['isReadOnly', 'isInSession'],
do: 'duplicatePage',
},
DELETED_PAGE: {
unlessAny: ['isReadOnly', 'isInSession', 'hasOnlyOnePage'],
do: 'deletePage',
},
ZOOMED_TO_ACTUAL: { ZOOMED_TO_ACTUAL: {
if: 'hasSelection', if: 'hasSelection',
do: 'zoomCameraToSelectionActual', do: 'zoomCameraToSelectionActual',
@ -1280,7 +1288,7 @@ const state = createState({
return data.settings.isPenLocked return data.settings.isPenLocked
}, },
hasOnlyOnePage(data) { hasOnlyOnePage(data) {
return Object.keys(data.document.pages).length === 1 return Object.keys(data.document.pages).length <= 1
}, },
selectionIncludesGroups(data) { selectionIncludesGroups(data) {
return tld return tld
@ -1361,9 +1369,9 @@ const state = createState({
const newPageId = 'page1' const newPageId = 'page1'
data.document.id = newDocumentId data.document.id = newDocumentId
data.pointedId = null data.pointedId = undefined
data.hoveredId = null data.hoveredId = undefined
data.editingId = null data.editingId = undefined
data.currentPageId = newPageId data.currentPageId = newPageId
data.currentParentId = newPageId data.currentParentId = newPageId
data.currentCodeFileId = 'file0' data.currentCodeFileId = 'file0'
@ -1411,9 +1419,16 @@ const state = createState({
createPage(data) { createPage(data) {
commands.createPage(data, true) commands.createPage(data, true)
}, },
renamePage(data, payload: { id: string; name: string }) {
commands.renamePage(data, payload.id, payload.name)
},
deletePage(data, payload: { id: string }) { deletePage(data, payload: { id: string }) {
commands.deletePage(data, payload.id) commands.deletePage(data, payload.id)
}, },
duplicatePage(data, payload: { id: string }) {
commands.duplicatePage(data, payload.id, true)
},
/* --------------------- Shapes --------------------- */ /* --------------------- Shapes --------------------- */
resetShapes(data) { resetShapes(data) {
const page = tld.getPage(data) const page = tld.getPage(data)
@ -1815,7 +1830,7 @@ const state = createState({
tld.getPageState(data).selectedIds = [selectedShape.id] tld.getPageState(data).selectedIds = [selectedShape.id]
}, },
clearEditingId(data) { clearEditingId(data) {
data.editingId = null data.editingId = undefined
}, },
/* ---------------------- Tool ---------------------- */ /* ---------------------- Tool ---------------------- */

View file

@ -1,4 +1,4 @@
import { Data, PageState, TLDocument } from 'types' import { Data, Page, PageState, TLDocument } from 'types'
import { decompress, compress } from 'utils' import { decompress, compress } from 'utils'
import state from './state' import state from './state'
import { uniqueId } from 'utils/utils' import { uniqueId } from 'utils/utils'
@ -254,6 +254,81 @@ 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) {
throw Error('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) {
throw Error('Could not load a page state with the id ' + pageId)
}
return pageState
}
/**
* Apply changes to a page in local storage.
*
* ### Example
*
*```ts
* storage.renamePageInLocalStorage(data, 'fileId', 'pageId', 'newPageName')
*```
*/
renamePageInLocalStorage(
data: Data,
fileId = data.document.id,
pageId = data.currentPageId,
name: string
) {
const page = this.getPageFromLocalStorage(data, fileId, pageId)
page.name = name
localStorage.setItem(
storageId(fileId, 'page', pageId),
compress(JSON.stringify(page))
)
}
loadPage(data: Data, fileId = data.document.id, pageId = data.currentPageId) { loadPage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
if (typeof localStorage === 'undefined') return if (typeof localStorage === 'undefined') return
@ -263,9 +338,18 @@ class Storage {
try { try {
// If we have a page in local storage, move it into state // If we have a page in local storage, move it into state
const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId)) const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId))
data.document.pages[pageId] = JSON.parse(decompress(savedPage))
if (savedPage === null) {
// Why would the page be null?
// TODO: Find out why the page would be null.
throw new Error('Could not find that page')
} else {
data.document.pages[pageId] = JSON.parse(decompress(savedPage))
}
} catch (e) { } catch (e) {
console.warn('Could not load a page with the id', pageId) if (fileId !== 'TESTING') {
throw new Error('Could not load a page with the id ' + pageId)
}
// If we don't have a page, create a new page // If we don't have a page, create a new page
data.document.pages[pageId] = { data.document.pages[pageId] = {
@ -303,8 +387,6 @@ class Storage {
JSON.stringify(data.pageStates[pageId]) JSON.stringify(data.pageStates[pageId])
) )
// Prepare new state
// Now clear out the other pages from state. // Now clear out the other pages from state.
Object.values(data.document.pages).forEach((page) => { Object.values(data.document.pages).forEach((page) => {
if (page.id !== data.currentPageId) { if (page.id !== data.currentPageId) {

View file

@ -24,6 +24,7 @@ const { styled, global, css, theme, getCssString } = createCss({
muted: '#777777', muted: '#777777',
input: '#f3f3f3', input: '#f3f3f3',
inputBorder: '#dddddd', inputBorder: '#dddddd',
warn: 'rgba(255, 100, 100, 1)',
lineError: 'rgba(255, 0, 0, .1)', lineError: 'rgba(255, 0, 0, .1)',
}, },
shadows: { shadows: {
@ -40,6 +41,7 @@ const { styled, global, css, theme, getCssString } = createCss({
2: '4px', 2: '4px',
3: '8px', 3: '8px',
4: '12px', 4: '12px',
5: '16px',
}, },
fontSizes: { fontSizes: {
0: '10px', 0: '10px',

View file

@ -1349,6 +1349,20 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@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": "@radix-ui/react-arrow@0.0.14":
version "0.0.14" version "0.0.14"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-0.0.14.tgz#70b2c66efbf3cde0c9dd0895417e39f6cdf31805" resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-0.0.14.tgz#70b2c66efbf3cde0c9dd0895417e39f6cdf31805"
@ -1420,10 +1434,10 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/react-dialog@^0.0.18": "@radix-ui/react-dialog@0.0.19", "@radix-ui/react-dialog@^0.0.19":
version "0.0.18" version "0.0.19"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-0.0.18.tgz#8d2d9f8816bb8447056031583a3a3cb2b6305281" resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-0.0.19.tgz#5a76fa380142a7a97c15c585ab071f63fba5297d"
integrity sha512-EH8yxFh3hQQ/hIPQsBzdJgx3oWTEmLu2a2x2PfRjxbDhcDIjcYJWdeEMjkTUjkBwpz3h6L/JWqnYJ2dqA65Deg== integrity sha512-7FbWaj/C/TDpfJ+VJ4wNAQIjENDNfwAqNvAfeb+TEtBjgjmsfRDgA1AMenlA5N1QuRtAokRMTHUs3ukW49oQ+g==
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.0.5" "@radix-ui/primitive" "0.0.5"
@ -1437,7 +1451,7 @@
"@radix-ui/react-portal" "0.0.14" "@radix-ui/react-portal" "0.0.14"
"@radix-ui/react-presence" "0.0.14" "@radix-ui/react-presence" "0.0.14"
"@radix-ui/react-primitive" "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" "@radix-ui/react-use-controllable-state" "0.0.6"
aria-hidden "^1.1.1" aria-hidden "^1.1.1"
react-remove-scroll "^2.4.0" react-remove-scroll "^2.4.0"