Merge pull request #52 from tldraw/page-options-menu
Adds page options panel to rename / duplicate / delete pages
This commit is contained in:
commit
d026b49aeb
25 changed files with 846 additions and 79 deletions
|
@ -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": {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"document": {
|
"document": {
|
||||||
"id": "home",
|
"id": "TESTING",
|
||||||
"name": "My Document",
|
"name": "My Document",
|
||||||
"pages": {
|
"pages": {
|
||||||
"page1": {
|
"page1": {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
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', () => {
|
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', () => {
|
||||||
|
|
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.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)
|
||||||
|
})
|
||||||
|
})
|
55
__tests__/commands/rename-page.test.ts
Normal file
55
__tests__/commands/rename-page.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
'& > *': {
|
'& > *': {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
107
components/page-panel/page-options.tsx
Normal file
107
components/page-panel/page-options.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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 */
|
||||||
/* -------------------------------------------------- */
|
/* -------------------------------------------------- */
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
109
state/commands/duplicate-page.ts
Normal file
109
state/commands/duplicate-page.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
65
state/commands/rename-page.ts
Normal file
65
state/commands/rename-page.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 ---------------------- */
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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',
|
||||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue