From fd67e2791d431b07f68576ff851c3e319b250bb7 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 14 Jul 2021 11:42:16 +0100 Subject: [PATCH 1/4] Adds page options panel to rename / duplicate / delete pages --- __tests__/commands/create-page.test.ts | 38 ++++++++ __tests__/commands/delete-page.test.ts | 49 +++++++--- __tests__/commands/duplicate-page.test.ts | 109 ++++++++++++++++++++++ __tests__/test-utils.ts | 6 +- components/editor.tsx | 12 ++- components/menu/menu.tsx | 2 +- components/page-panel/page-options.tsx | 93 ++++++++++++++++++ components/page-panel/page-panel.tsx | 20 ++-- components/shared.tsx | 99 ++++++++++++++++++-- hooks/useKeyboardEvents.ts | 14 +-- package.json | 3 +- state/commands/create-page.ts | 25 +++-- state/commands/delete-page.ts | 32 ++++++- state/commands/duplicate-page.ts | 103 ++++++++++++++++++++ state/commands/index.ts | 2 + state/state.ts | 33 +++++-- state/storage.ts | 70 +++++++++++++- styles/stitches.config.ts | 2 + yarn.lock | 24 ++++- 19 files changed, 662 insertions(+), 74 deletions(-) create mode 100644 __tests__/commands/create-page.test.ts create mode 100644 __tests__/commands/duplicate-page.test.ts create mode 100644 components/page-panel/page-options.tsx create mode 100644 state/commands/duplicate-page.ts diff --git a/__tests__/commands/create-page.test.ts b/__tests__/commands/create-page.test.ts new file mode 100644 index 000000000..abdf36b65 --- /dev/null +++ b/__tests__/commands/create-page.test.ts @@ -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) + }) + }) +}) diff --git a/__tests__/commands/delete-page.test.ts b/__tests__/commands/delete-page.test.ts index 872f3d03f..6c263e2aa 100644 --- a/__tests__/commands/delete-page.test.ts +++ b/__tests__/commands/delete-page.test.ts @@ -2,23 +2,44 @@ import TestState from '../test-utils' describe('delete page command', () => { const tt = new TestState() - tt.resetDocumentState() + tt.resetDocumentState().save() - describe('when last page is selected', () => { - it('does command', () => { - // TODO - null - }) + it('does command', () => { + tt.restore().send('CREATED_PAGE') + expect(Object.keys(tt.data.document.pages).length).toBe(2) - it('un-does command', () => { - // TODO - null - }) + const pageId = Object.keys(tt.data.document.pages)[1] + tt.send('DELETED_PAGE', { id: pageId }) - it('re-does command', () => { - // TODO - null - }) + expect(Object.keys(tt.data.document.pages).length).toBe(1) + + const firstPageId = Object.keys(tt.data.document.pages)[0] + expect(tt.data.currentPageId).toBe(firstPageId) + }) + + it('un-does command', () => { + tt.restore().send('CREATED_PAGE') + expect(Object.keys(tt.data.document.pages).length).toBe(2) + + const pageId = Object.keys(tt.data.document.pages)[1] + tt.send('DELETED_PAGE', { id: pageId }).undo() + + expect(Object.keys(tt.data.document.pages).length).toBe(2) + + expect(tt.data.currentPageId).toBe(pageId) + }) + + it('re-does command', () => { + tt.restore().send('CREATED_PAGE') + expect(Object.keys(tt.data.document.pages).length).toBe(2) + + const pageId = Object.keys(tt.data.document.pages)[1] + tt.send('DELETED_PAGE', { id: pageId }).undo().redo() + + expect(Object.keys(tt.data.document.pages).length).toBe(1) + + const firstPageId = Object.keys(tt.data.document.pages)[0] + expect(tt.data.currentPageId).toBe(firstPageId) }) describe('when first page is selected', () => { diff --git a/__tests__/commands/duplicate-page.test.ts b/__tests__/commands/duplicate-page.test.ts new file mode 100644 index 000000000..2ac4fd97c --- /dev/null +++ b/__tests__/commands/duplicate-page.test.ts @@ -0,0 +1,109 @@ +import { ShapeType } from 'types' +import TestState from '../test-utils' + +describe('duplicate page command', () => { + const tt = new TestState() + tt.resetDocumentState() + .createShape( + { + type: ShapeType.Rectangle, + point: [0, 0], + size: [100, 100], + childIndex: 1, + }, + 'rect1' + ) + .save() + + describe('duplicates a page', () => { + it('does, undoes, and redoes command', () => { + tt.restore() + + expect(Object.keys(tt.data.document.pages).length).toBe(1) + const pageId = Object.keys(tt.data.document.pages)[0] + expect(tt.getShape('rect1').parentId).toBe(pageId) + + tt.send('DUPLICATED_PAGE', { id: pageId }) + + expect(Object.keys(tt.data.document.pages).length).toBe(2) + + const newPageId = Object.keys(tt.data.document.pages)[1] + + expect(tt.data.currentPageId).toBe(newPageId) + + expect(tt.getShape('rect1').parentId).toBe(newPageId) + + tt.undo() + + expect(Object.keys(tt.data.document.pages).length).toBe(1) + expect(tt.data.currentPageId).toBe(Object.keys(tt.data.document.pages)[0]) + + expect(tt.getShape('rect1').parentId).toBe(pageId) + + tt.redo() + + expect(Object.keys(tt.data.document.pages).length).toBe(2) + expect(tt.data.currentPageId).toBe(Object.keys(tt.data.document.pages)[1]) + + expect(tt.getShape('rect1').parentId).toBe(newPageId) + }) + }) + + describe('duplicates a page other than the current page', () => { + tt.restore() + .reset() + .send('CREATED_PAGE') + .createShape( + { + type: ShapeType.Rectangle, + point: [0, 0], + size: [100, 100], + childIndex: 1, + }, + 'rect2' + ) + .send('CHANGED_PAGE', { id: 'page1' }) + + const firstPageId = Object.keys(tt.data.document.pages)[0] + + // We should be back on the first page + expect(tt.data.currentPageId).toBe(firstPageId) + + // But we should have two pages + expect(Object.keys(tt.data.document.pages).length).toBe(2) + + const secondPageId = Object.keys(tt.data.document.pages)[1] + + // Now we duplicate the second page + tt.send('DUPLICATED_PAGE', { id: secondPageId }) + + // We should now have three pages + expect(Object.keys(tt.data.document.pages).length).toBe(3) + + // The third page should also have a shape named rect2 + const thirdPageId = Object.keys(tt.data.document.pages)[2] + + // We should have changed pages to the third page + expect(tt.data.currentPageId).toBe(thirdPageId) + + // And it should be the parent of the third page + expect(tt.getShape('rect2').parentId).toBe(thirdPageId) + + tt.undo() + + // We should still be on the first page, but we should + // have only two pages; the third page should be deleted + expect(Object.keys(tt.data.document.pages).length).toBe(2) + expect(tt.data.document.pages[thirdPageId]).toBe(undefined) + expect(tt.data.currentPageId).toBe(firstPageId) + + tt.redo() + + // We should be back on the third page + expect(Object.keys(tt.data.document.pages).length).toBe(3) + expect(tt.data.document.pages[thirdPageId]).toBeTruthy() + expect(tt.data.currentPageId).toBe(Object.keys(tt.data.document.pages)[2]) + + expect(tt.getShape('rect2').parentId).toBe(thirdPageId) + }) +}) diff --git a/__tests__/test-utils.ts b/__tests__/test-utils.ts index 2f8741ab4..5234ee700 100644 --- a/__tests__/test-utils.ts +++ b/__tests__/test-utils.ts @@ -91,7 +91,11 @@ class TestState { */ createShape(props: Partial, id = uniqueId()): TestState { const shape = createShape(props.type, props) - getShapeUtils(shape).setProperty(shape, 'id', id) + + getShapeUtils(shape) + .setProperty(shape, 'id', id) + .setProperty(shape, 'parentId', this.data.currentPageId) + this.data.document.pages[this.data.currentPageId].shapes[shape.id] = shape return this } diff --git a/components/editor.tsx b/components/editor.tsx index c941bea1e..4f9e9c3cc 100644 --- a/components/editor.tsx +++ b/components/editor.tsx @@ -10,14 +10,21 @@ import PagePanel from './page-panel/page-panel' import CodePanel from './code-panel/code-panel' import DebugPanel from './debug-panel/debug-panel' import ControlsPanel from './controls-panel/controls-panel' +import { useEffect, useRef } from 'react' export default function Editor({ roomId }: { roomId?: string }): JSX.Element { - useKeyboardEvents() + const rLayout = useRef(null) + + useEffect(() => { + rLayout.current?.focus() + }, []) + + useKeyboardEvents(rLayout) useLoadOnMount(roomId) useStateTheme() return ( - + @@ -57,6 +64,7 @@ const Layout = styled('main', { alignItems: 'flex-start', justifyContent: 'flex-start', boxSizing: 'border-box', + outline: 'none', pointerEvents: 'none', '& > *': { diff --git a/components/menu/menu.tsx b/components/menu/menu.tsx index 1c1feddee..e94c109a9 100644 --- a/components/menu/menu.tsx +++ b/components/menu/menu.tsx @@ -32,7 +32,7 @@ function Menu() { - + New Project diff --git a/components/page-panel/page-options.tsx b/components/page-panel/page-options.tsx new file mode 100644 index 000000000..d63aea39a --- /dev/null +++ b/components/page-panel/page-options.tsx @@ -0,0 +1,93 @@ +import * as Dialog from '@radix-ui/react-alert-dialog' +import { MixerVerticalIcon } from '@radix-ui/react-icons' +import { + breakpoints, + IconButton, + DialogOverlay, + DialogContent, + RowButton, + MenuTextInput, + DialogInputWrapper, + Divider, +} from 'components/shared' +import state, { useSelector } from 'state' +import { Page } from 'types' + +export default function PageOptions({ page }: { page: Page }): JSX.Element { + const hasOnlyOnePage = useSelector( + (s) => Object.keys(s.data.document.pages).length <= 1 + ) + + function handleNameChange(e: React.ChangeEvent) { + state.send('CHANGED_PAGE_NAME', { + id: page.id, + name: e.currentTarget.value, + }) + } + + function handleDuplicate() { + state.send('DUPLICATED_PAGE', { id: page.id }) + } + + function handleDelete() { + state.send('DELETED_PAGE', { id: page.id }) + } + + function handleOpenChange() { + if (page.name.length === 0) { + state.send('CHANGED_PAGE_NAME', { + id: page.id, + name: 'Page', + }) + } + } + + function stopPropagation(e: React.KeyboardEvent) { + e.stopPropagation() + } + + return ( + + + + + + + + + + + + Duplicate + + + Delete + + + + Cancel + + + + ) +} diff --git a/components/page-panel/page-panel.tsx b/components/page-panel/page-panel.tsx index aa0dd85ff..5870bdd31 100644 --- a/components/page-panel/page-panel.tsx +++ b/components/page-panel/page-panel.tsx @@ -7,10 +7,10 @@ import { RowButton, MenuContent, FloatingContainer, - IconButton, IconWrapper, } from 'components/shared' -import { MixerVerticalIcon, PlusIcon, CheckIcon } from '@radix-ui/react-icons' +import PageOptions from './page-options' +import { PlusIcon, CheckIcon } from '@radix-ui/react-icons' import state, { useSelector } from 'state' import { useEffect, useRef, useState } from 'react' @@ -48,7 +48,7 @@ export default function PagePanel(): JSX.Element { {documentPages[currentPageId].name} - + { @@ -56,24 +56,22 @@ export default function PagePanel(): JSX.Element { state.send('CHANGED_PAGE', { id }) }} > - {sorted.map(({ id, name }) => ( - + {sorted.map((page) => ( + - {name} + {page.name} - + - - - + ))} diff --git a/components/shared.tsx b/components/shared.tsx index 53da6b6d7..e6ff9bc45 100644 --- a/components/shared.tsx +++ b/components/shared.tsx @@ -120,7 +120,7 @@ export const RowButton = styled('button', { }, '&:disabled': { - opacity: 0.1, + opacity: 0.3, }, variants: { @@ -163,9 +163,9 @@ export const RowButton = styled('button', { }, }, }, - disabled: { + warn: { true: { - opacity: 0.3, + color: '$warn', }, }, isActive: { @@ -517,22 +517,72 @@ export function Kbd({ children }: { children: React.ReactNode }): JSX.Element { return {children} } +/* -------------------------------------------------- */ +/* Dialog */ +/* -------------------------------------------------- */ + +export const DialogContent = styled('div', { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + minWidth: 240, + maxWidth: 'fit-content', + maxHeight: '85vh', + marginTop: '-5vh', + pointerEvents: 'all', + backgroundColor: '$panel', + border: '1px solid $panel', + padding: '$0', + boxShadow: '$4', + borderRadius: '4px', + font: '$ui', + + '&:focus': { + outline: 'none', + }, +}) + +export const DialogOverlay = styled('div', { + backgroundColor: 'rgba(0, 0, 0, .15)', + position: 'fixed', + top: 0, + right: 0, + bottom: 0, + left: 0, +}) + +export const DialogInputWrapper = styled('div', { + padding: '$4 $2', +}) + +export const DialogTitleRow = styled('div', { + display: 'flex', + padding: '0 0 0 $4', + alignItems: 'center', + justifyContent: 'space-between', + + h3: { + fontSize: '$1', + }, +}) + /* -------------------------------------------------- */ /* Menus */ /* -------------------------------------------------- */ export const MenuContent = styled('div', { position: 'relative', - backgroundColor: '$panel', - borderRadius: '4px', overflow: 'hidden', - pointerEvents: 'all', userSelect: 'none', zIndex: 180, + minWidth: 180, + pointerEvents: 'all', + backgroundColor: '$panel', border: '1px solid $panel', padding: '$0', boxShadow: '$4', - minWidth: 180, + borderRadius: '4px', font: '$ui', }) @@ -545,6 +595,41 @@ export const Divider = styled('div', { marginLeft: '-$2', }) +export function MenuButton({ + warn, + onSelect, + children, + disabled = false, +}: { + warn?: boolean + onSelect?: () => void + disabled?: boolean + children: React.ReactNode +}): JSX.Element { + return ( + + {children} + + ) +} + +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 */ /* -------------------------------------------------- */ diff --git a/hooks/useKeyboardEvents.ts b/hooks/useKeyboardEvents.ts index 7a3b8b7f7..ca09697c0 100644 --- a/hooks/useKeyboardEvents.ts +++ b/hooks/useKeyboardEvents.ts @@ -1,11 +1,13 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { useEffect } from 'react' +import { MutableRefObject, useEffect } from 'react' import state from 'state' import inputs from 'state/inputs' import { ColorStyle, MoveType, SizeStyle } from 'types' import { metaKey } from 'utils' -export default function useKeyboardEvents() { +export default function useKeyboardEvents( + ref: MutableRefObject +) { useEffect(() => { function handleKeyDown(e: KeyboardEvent) { const info = inputs.keydown(e) @@ -365,11 +367,11 @@ export default function useKeyboardEvents() { } } - document.body.addEventListener('keydown', handleKeyDown) - document.body.addEventListener('keyup', handleKeyUp) + ref.current?.addEventListener('keydown', handleKeyDown) + ref.current?.addEventListener('keyup', handleKeyUp) return () => { - document.body.removeEventListener('keydown', handleKeyDown) - document.body.removeEventListener('keyup', handleKeyUp) + ref.current?.removeEventListener('keydown', handleKeyDown) + ref.current?.removeEventListener('keyup', handleKeyUp) } }, []) } diff --git a/package.json b/package.json index e051ce73e..6ebcd3689 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,10 @@ "@liveblocks/node": "^0.3.0", "@liveblocks/react": "^0.8.0", "@monaco-editor/react": "^4.2.1", + "@radix-ui/react-alert-dialog": "^0.0.19", "@radix-ui/react-checkbox": "^0.0.16", "@radix-ui/react-context-menu": "^0.0.23", - "@radix-ui/react-dialog": "^0.0.18", + "@radix-ui/react-dialog": "^0.0.19", "@radix-ui/react-dropdown-menu": "^0.0.21", "@radix-ui/react-hover-card": "^0.0.3", "@radix-ui/react-icons": "^1.0.3", diff --git a/state/commands/create-page.ts b/state/commands/create-page.ts index 25afe50cf..c6628b415 100644 --- a/state/commands/create-page.ts +++ b/state/commands/create-page.ts @@ -15,26 +15,33 @@ export default function createPage(data: Data, goToPage = true): void { category: 'canvas', do(data) { const { page, pageState, currentPageId } = snapshot + + storage.savePage(data, data.document.id, currentPageId) + data.document.pages[page.id] = page data.pageStates[page.id] = pageState if (goToPage) { + storage.savePage(data, data.document.id, currentPageId) + storage.loadPage(data, data.document.id, page.id) data.currentPageId = page.id - } else { - data.currentPageId = currentPageId - } + data.currentParentId = page.id - storage.savePage(data, data.document.id, page.id) - storage.saveDocumentToLocalStorage(data) - tld.setZoomCSS(tld.getPageState(data).camera.zoom) + tld.setZoomCSS(tld.getPageState(data).camera.zoom) + } }, undo(data) { const { page, currentPageId } = snapshot delete data.document.pages[page.id] delete data.pageStates[page.id] - data.currentPageId = currentPageId - storage.saveDocumentToLocalStorage(data) - tld.setZoomCSS(tld.getPageState(data).camera.zoom) + + if (goToPage) { + storage.loadPage(data, data.document.id, currentPageId) + data.currentPageId = currentPageId + data.currentParentId = currentPageId + + tld.setZoomCSS(tld.getPageState(data).camera.zoom) + } }, }) ) diff --git a/state/commands/delete-page.ts b/state/commands/delete-page.ts index 6f63d0a56..f21f75e5c 100644 --- a/state/commands/delete-page.ts +++ b/state/commands/delete-page.ts @@ -14,23 +14,38 @@ export default function deletePage(data: Data, pageId: string): void { name: 'delete_page', category: 'canvas', do(data) { + storage.saveAppStateToLocalStorage(data) + storage.saveDocumentToLocalStorage(data) + data.currentPageId = snapshot.nextPageId + data.currentParentId = snapshot.nextPageId + delete data.document.pages[pageId] delete data.pageStates[pageId] - storage.loadPage(data, snapshot.nextPageId) + + if (snapshot.isCurrent) { + storage.loadPage(data, snapshot.nextPageId) + } }, undo(data) { + storage.saveAppStateToLocalStorage(data) + storage.saveDocumentToLocalStorage(data) + data.currentPageId = snapshot.currentPageId + data.currentParentId = snapshot.currentParentId data.document.pages[pageId] = snapshot.page data.pageStates[pageId] = snapshot.pageState - storage.loadPage(data, snapshot.currentPageId) + + if (snapshot.isCurrent) { + storage.loadPage(data, snapshot.currentPageId) + } }, }) ) } function getSnapshot(data: Data, pageId: string) { - const { currentPageId, document } = data + const { currentPageId, currentParentId, document } = data const page = deepClone(tld.getPage(data)) @@ -38,14 +53,23 @@ function getSnapshot(data: Data, pageId: string) { const isCurrent = data.currentPageId === pageId + const pageIds = Object.keys(document.pages) + + const pageIndex = pageIds.indexOf(pageId) + const nextPageId = isCurrent - ? Object.values(document.pages).filter((page) => page.id !== pageId)[0]?.id // TODO: should be at nextIndex + ? pageIndex === 0 + ? pageIds[1] + : pageIndex === pageIds.length - 1 + ? pageIds[pageIndex - 1] + : pageIds[pageIndex + 1] : currentPageId return { nextPageId, isCurrent, currentPageId, + currentParentId, page, pageState, } diff --git a/state/commands/duplicate-page.ts b/state/commands/duplicate-page.ts new file mode 100644 index 000000000..b6aae9385 --- /dev/null +++ b/state/commands/duplicate-page.ts @@ -0,0 +1,103 @@ +import Command from './command' +import history from '../history' +import { Data, Page } from 'types' +import { deepClone, uniqueId } from 'utils/utils' +import tld from 'utils/tld' +import storage from 'state/storage' +import { getShapeUtils } from 'state/shape-utils' + +export default function duplicatePage( + data: Data, + id: string, + goToPage = true +): void { + const snapshot = getSnapshot(data, id) + + history.execute( + data, + new Command({ + name: 'create_page', + category: 'canvas', + do(data) { + const { from, to } = snapshot + + data.document.pages[to.pageId] = to.page + data.pageStates[to.pageId] = to.pageState + + storage.savePage(data, data.document.id, to.pageId) + + if (goToPage) { + storage.savePage(data, data.document.id, from.pageId) + storage.loadPage(data, data.document.id, to.pageId) + data.currentPageId = to.pageId + data.currentParentId = to.pageId + + tld.setZoomCSS(tld.getPageState(data).camera.zoom) + } + }, + undo(data) { + const { from, to } = snapshot + delete data.document.pages[to.pageId] + delete data.pageStates[to.pageId] + + if (goToPage) { + storage.loadPage(data, data.document.id, from.pageId) + data.currentPageId = from.pageId + data.currentParentId = from.pageId + + tld.setZoomCSS(tld.getPageState(data).camera.zoom) + } + }, + }) + ) +} + +function getSnapshot(data: Data, id: string) { + const { currentPageId } = data + + const oldPage: Page = + id === currentPageId + ? data.document.pages[id] + : storage.getPageFromLocalStorage(data, data.document.id, id) + + const newPage: Page = deepClone(oldPage) + + newPage.id = uniqueId() + + // Iterate the page's name + const lastNameChar = oldPage.name[oldPage.name.length - 1] + + if (Number.isNaN(Number(lastNameChar))) { + newPage.name = `${oldPage.name} 1` + } else { + newPage.name = `${oldPage.name.slice(0, -1)}${Number(lastNameChar) + 1}` + } + + Object.values(newPage.shapes).forEach((shape) => { + if (shape.parentId === oldPage.id) { + getShapeUtils(shape).setProperty(shape, 'parentId', newPage.id) + } + }) + + const oldPageState = + id === currentPageId + ? data.pageStates[id] + : storage.getPageStateFromLocalStorage(data, data.document.id, id) + + const newPageState = deepClone(oldPageState) + + newPageState.id = newPage.id + + return { + currentPageId, + from: { + pageId: currentPageId, + pageState: deepClone(data.pageStates[currentPageId]), + }, + to: { + pageId: newPage.id, + page: newPage, + pageState: newPageState, + }, + } +} diff --git a/state/commands/index.ts b/state/commands/index.ts index 176d02537..8c9ef5059 100644 --- a/state/commands/index.ts +++ b/state/commands/index.ts @@ -2,6 +2,7 @@ import align from './align' import changePage from './change-page' import createPage from './create-page' import deletePage from './delete-page' +import duplicatePage from './duplicate-page' import deleteShapes from './delete-shapes' import createShapes from './create-shapes' import distribute from './distribute' @@ -34,6 +35,7 @@ const commands = { createShapes, deletePage, deleteShapes, + duplicatePage, distribute, doublePointHandle, draw, diff --git a/state/state.ts b/state/state.ts index 897c297b0..70b5a7752 100644 --- a/state/state.ts +++ b/state/state.ts @@ -246,10 +246,6 @@ const state = createState({ unless: ['isReadOnly', 'isInSession'], do: ['clearSelectedIds', 'createPage'], }, - DELETED_PAGE: { - unlessAny: ['isReadOnly', 'isInSession', 'hasOnlyOnePage'], - do: 'deletePage', - }, SELECTED_SELECT_TOOL: { unless: 'isInSession', to: 'selecting', @@ -324,6 +320,18 @@ const state = createState({ unless: 'isInSession', do: 'changePage', }, + CHANGED_PAGE_NAME: { + unlessAny: ['isReadOnly', 'isInSession'], + do: 'changePageName', + }, + DUPLICATED_PAGE: { + unlessAny: ['isReadOnly', 'isInSession'], + do: 'duplicatePage', + }, + DELETED_PAGE: { + unlessAny: ['isReadOnly', 'isInSession', 'hasOnlyOnePage'], + do: 'deletePage', + }, ZOOMED_TO_ACTUAL: { if: 'hasSelection', do: 'zoomCameraToSelectionActual', @@ -1280,7 +1288,7 @@ const state = createState({ return data.settings.isPenLocked }, hasOnlyOnePage(data) { - return Object.keys(data.document.pages).length === 1 + return Object.keys(data.document.pages).length <= 1 }, selectionIncludesGroups(data) { return tld @@ -1361,9 +1369,9 @@ const state = createState({ const newPageId = 'page1' data.document.id = newDocumentId - data.pointedId = null - data.hoveredId = null - data.editingId = null + data.pointedId = undefined + data.hoveredId = undefined + data.editingId = undefined data.currentPageId = newPageId data.currentParentId = newPageId data.currentCodeFileId = 'file0' @@ -1411,9 +1419,16 @@ const state = createState({ createPage(data) { commands.createPage(data, true) }, + changePageName(data, payload: { id: string; name: string }) { + data.document.pages[payload.id].name = payload.name + }, deletePage(data, payload: { id: string }) { commands.deletePage(data, payload.id) }, + duplicatePage(data, payload: { id: string }) { + commands.duplicatePage(data, payload.id, true) + }, + /* --------------------- Shapes --------------------- */ resetShapes(data) { const page = tld.getPage(data) @@ -1815,7 +1830,7 @@ const state = createState({ tld.getPageState(data).selectedIds = [selectedShape.id] }, clearEditingId(data) { - data.editingId = null + data.editingId = undefined }, /* ---------------------- Tool ---------------------- */ diff --git a/state/storage.ts b/state/storage.ts index 0f2185b30..40626606e 100644 --- a/state/storage.ts +++ b/state/storage.ts @@ -1,4 +1,4 @@ -import { Data, PageState, TLDocument } from 'types' +import { Data, Page, PageState, TLDocument } from 'types' import { decompress, compress } from 'utils' import state from './state' import { uniqueId } from 'utils/utils' @@ -254,6 +254,56 @@ class Storage { ) } + getPageFromLocalStorage( + data: Data, + fileId = data.document.id, + pageId = data.currentPageId + ): Page { + if (typeof window === 'undefined') return + if (typeof localStorage === 'undefined') return + + let page: Page + + try { + const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId)) + if (savedPage === null) { + throw Error('That page is not in local storage.') + } + + page = JSON.parse(decompress(savedPage)) + } catch (e) { + console.warn('Could not load a page with the id', pageId) + } + + return page + } + + getPageStateFromLocalStorage( + data: Data, + fileId = data.document.id, + pageId = data.currentPageId + ): PageState { + if (typeof window === 'undefined') return + if (typeof localStorage === 'undefined') return + + let pageState: PageState + + try { + const savedPageState = localStorage.getItem( + storageId(fileId, 'pageState', pageId) + ) + if (savedPageState === null) { + throw Error('That page state is not in local storage.') + } + + pageState = JSON.parse(decompress(savedPageState)) + } catch (e) { + console.warn('Could not load a page state with the id', pageId) + } + + return pageState + } + loadPage(data: Data, fileId = data.document.id, pageId = data.currentPageId) { if (typeof window === 'undefined') return if (typeof localStorage === 'undefined') return @@ -263,7 +313,21 @@ class Storage { try { // If we have a page in local storage, move it into state const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId)) - 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. + + data.document.pages[pageId] = { + id: pageId, + type: 'page', + childIndex: Object.keys(data.document.pages).length, + name: 'New Page', + shapes: {}, + } + } else { + data.document.pages[pageId] = JSON.parse(decompress(savedPage)) + } } catch (e) { console.warn('Could not load a page with the id', pageId) @@ -303,8 +367,6 @@ class Storage { JSON.stringify(data.pageStates[pageId]) ) - // Prepare new state - // Now clear out the other pages from state. Object.values(data.document.pages).forEach((page) => { if (page.id !== data.currentPageId) { diff --git a/styles/stitches.config.ts b/styles/stitches.config.ts index 1bb88e5f0..70a7e73d4 100644 --- a/styles/stitches.config.ts +++ b/styles/stitches.config.ts @@ -24,6 +24,7 @@ const { styled, global, css, theme, getCssString } = createCss({ muted: '#777777', input: '#f3f3f3', inputBorder: '#dddddd', + warn: 'rgba(255, 100, 100, 1)', lineError: 'rgba(255, 0, 0, .1)', }, shadows: { @@ -40,6 +41,7 @@ const { styled, global, css, theme, getCssString } = createCss({ 2: '4px', 3: '8px', 4: '12px', + 5: '16px', }, fontSizes: { 0: '10px', diff --git a/yarn.lock b/yarn.lock index 75b38c921..9c21bd938 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1349,6 +1349,20 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-alert-dialog@^0.0.19": + version "0.0.19" + resolved "https://registry.yarnpkg.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-0.0.19.tgz#5b69bfe063cdb13f49630ad2705e71228505d147" + integrity sha512-SJRUT2s0/WLCvCEbfuKL5EM6QNXjZQkX9ZgkwKvgRNYu5zYEmCmlCUWDJbPIX1Y7w/a6tuEm24f3Uywd8VcBxw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "0.0.5" + "@radix-ui/react-compose-refs" "0.0.5" + "@radix-ui/react-context" "0.0.5" + "@radix-ui/react-dialog" "0.0.19" + "@radix-ui/react-polymorphic" "0.0.12" + "@radix-ui/react-primitive" "0.0.14" + "@radix-ui/react-slot" "0.0.12" + "@radix-ui/react-arrow@0.0.14": version "0.0.14" resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-0.0.14.tgz#70b2c66efbf3cde0c9dd0895417e39f6cdf31805" @@ -1420,10 +1434,10 @@ dependencies: "@babel/runtime" "^7.13.10" -"@radix-ui/react-dialog@^0.0.18": - version "0.0.18" - resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-0.0.18.tgz#8d2d9f8816bb8447056031583a3a3cb2b6305281" - integrity sha512-EH8yxFh3hQQ/hIPQsBzdJgx3oWTEmLu2a2x2PfRjxbDhcDIjcYJWdeEMjkTUjkBwpz3h6L/JWqnYJ2dqA65Deg== +"@radix-ui/react-dialog@0.0.19", "@radix-ui/react-dialog@^0.0.19": + version "0.0.19" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-0.0.19.tgz#5a76fa380142a7a97c15c585ab071f63fba5297d" + integrity sha512-7FbWaj/C/TDpfJ+VJ4wNAQIjENDNfwAqNvAfeb+TEtBjgjmsfRDgA1AMenlA5N1QuRtAokRMTHUs3ukW49oQ+g== dependencies: "@babel/runtime" "^7.13.10" "@radix-ui/primitive" "0.0.5" @@ -1437,7 +1451,7 @@ "@radix-ui/react-portal" "0.0.14" "@radix-ui/react-presence" "0.0.14" "@radix-ui/react-primitive" "0.0.14" - "@radix-ui/react-slot" "0.0.11" + "@radix-ui/react-slot" "0.0.12" "@radix-ui/react-use-controllable-state" "0.0.6" aria-hidden "^1.1.1" react-remove-scroll "^2.4.0" From 43276c700f849425f9d44168b8bc80adcffef421 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 14 Jul 2021 12:39:34 +0100 Subject: [PATCH 2/4] Fix bug with loading pages --- __tests__/__mocks__/data.json | 129 +++++++++++++++---- __tests__/__mocks__/document.json | 129 +++++++++++++++---- __tests__/__snapshots__/project.test.ts.snap | 4 +- __tests__/commands/delete-page.test.ts | 6 +- __tests__/commands/duplicate-page.test.ts | 2 +- __tests__/commands/rename-page.test.ts | 55 ++++++++ components/page-panel/page-options.tsx | 28 +++- components/shared.tsx | 1 + state/commands/create-page.ts | 9 +- state/commands/delete-page.ts | 10 +- state/commands/duplicate-page.ts | 8 +- state/commands/index.ts | 2 + state/commands/move-to-page.ts | 4 +- state/commands/rename-page.ts | 65 ++++++++++ state/state.ts | 8 +- state/storage.ts | 42 ++++-- 16 files changed, 414 insertions(+), 88 deletions(-) create mode 100644 __tests__/commands/rename-page.test.ts create mode 100644 state/commands/rename-page.ts diff --git a/__tests__/__mocks__/data.json b/__tests__/__mocks__/data.json index f277123bf..94cabea04 100644 --- a/__tests__/__mocks__/data.json +++ b/__tests__/__mocks__/data.json @@ -26,7 +26,7 @@ "currentCodeFileId": "file0", "codeControls": {}, "document": { - "id": "home", + "id": "TESTING", "name": "My Document", "pages": { "page1": { @@ -41,8 +41,14 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 3, - "point": [171.47, 288.63], - "size": [176.22, 192.26], + "point": [ + 171.47, + 288.63 + ], + "size": [ + 176.22, + 192.26 + ], "radius": 2, "rotation": 0, "style": { @@ -58,8 +64,14 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 4, - "point": [511.7, 404.19], - "size": [181.08999999999992, 150.40999999999997], + "point": [ + 511.7, + 404.19 + ], + "size": [ + 181.08999999999992, + 150.40999999999997 + ], "radius": 2, "rotation": 0, "style": { @@ -75,8 +87,14 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 5, - "point": [384.09, 378.45], - "size": [95.20999999999992, 91.1799999999999], + "point": [ + 384.09, + 378.45 + ], + "size": [ + 95.20999999999992, + 91.1799999999999 + ], "radius": 2, "rotation": 0, "style": { @@ -92,7 +110,10 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 6, - "point": [162.45, 679.23], + "point": [ + 162.45, + 679.23 + ], "radiusX": 102.99999999999997, "radiusY": 102.99999999999994, "rotation": 0, @@ -109,7 +130,10 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 7, - "point": [517.18, 783.54], + "point": [ + 517.18, + 783.54 + ], "radiusX": 102.99999999999997, "radiusY": 102.99999999999994, "rotation": 0, @@ -126,7 +150,10 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 8, - "point": [398.99, 810.79], + "point": [ + 398.99, + 810.79 + ], "radiusX": 45.484999999999985, "radiusY": 45.48499999999996, "rotation": 0, @@ -143,24 +170,36 @@ "name": "Arrow", "parentId": "page1", "childIndex": 9, - "point": [252.85, 1057.5], + "point": [ + 252.85, + 1057.5 + ], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [0, 0] + "point": [ + 0, + 0 + ] }, "end": { "id": "end", "index": 1, - "point": [0.09000000000000341, 208] + "point": [ + 0.09000000000000341, + 208 + ] }, "bend": { "id": "bend", "index": 2, - "point": [0.045000000000001705, 104] + "point": [ + 0.045000000000001705, + 104 + ] } }, "decorations": { @@ -181,24 +220,36 @@ "name": "Arrow", "parentId": "page1", "childIndex": 10, - "point": [616.9, 1124.3], + "point": [ + 616.9, + 1124.3 + ], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [0, 0] + "point": [ + 0, + 0 + ] }, "end": { "id": "end", "index": 1, - "point": [2.4500000000000455, 185.20000000000005] + "point": [ + 2.4500000000000455, + 185.20000000000005 + ] }, "bend": { "id": "bend", "index": 2, - "point": [1.2250000000000227, 92.60000000000002] + "point": [ + 1.2250000000000227, + 92.60000000000002 + ] } }, "decorations": { @@ -219,24 +270,36 @@ "name": "Arrow", "parentId": "page1", "childIndex": 11, - "point": [425.18, 1143.2], + "point": [ + 425.18, + 1143.2 + ], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [0, 0] + "point": [ + 0, + 0 + ] }, "end": { "id": "end", "index": 1, - "point": [1.8500000000000227, 95.70000000000005] + "point": [ + 1.8500000000000227, + 95.70000000000005 + ] }, "bend": { "id": "bend", "index": 2, - "point": [0.9250000000000114, 47.85000000000002] + "point": [ + 0.9250000000000114, + 47.85000000000002 + ] } }, "decorations": { @@ -257,7 +320,10 @@ "name": "Text", "parentId": "page1", "childIndex": 12, - "point": [207.16, 1422.4], + "point": [ + 207.16, + 1422.4 + ], "rotation": 0, "style": { "color": "Black", @@ -274,7 +340,10 @@ "name": "Text", "parentId": "page1", "childIndex": 13, - "point": [389.57, 1496.5], + "point": [ + 389.57, + 1496.5 + ], "rotation": 0, "style": { "color": "Black", @@ -291,7 +360,10 @@ "name": "Text", "parentId": "page1", "childIndex": 14, - "point": [564.06, 1558.1], + "point": [ + 564.06, + 1558.1 + ], "rotation": 0, "style": { "color": "Black", @@ -317,10 +389,13 @@ "page1": { "id": "page1", "camera": { - "point": [0, -145], + "point": [ + 0, + -145 + ], "zoom": 1 }, "selectedIds": {} } } -} +} \ No newline at end of file diff --git a/__tests__/__mocks__/document.json b/__tests__/__mocks__/document.json index 9350b3b1a..6d901ec07 100644 --- a/__tests__/__mocks__/document.json +++ b/__tests__/__mocks__/document.json @@ -1,6 +1,6 @@ { "document": { - "id": "home", + "id": "TESTING", "name": "My Document", "pages": { "page1": { @@ -15,8 +15,14 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 3, - "point": [100, 100], - "size": [100, 100], + "point": [ + 100, + 100 + ], + "size": [ + 100, + 100 + ], "radius": 2, "rotation": 0, "style": { @@ -32,8 +38,14 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 4, - "point": [500, 400], - "size": [200, 200], + "point": [ + 500, + 400 + ], + "size": [ + 200, + 200 + ], "radius": 2, "rotation": 0, "style": { @@ -49,8 +61,14 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 5, - "point": [384.09, 378.45], - "size": [95.20999999999992, 91.1799999999999], + "point": [ + 384.09, + 378.45 + ], + "size": [ + 95.20999999999992, + 91.1799999999999 + ], "radius": 2, "rotation": 0, "style": { @@ -66,7 +84,10 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 6, - "point": [162.45, 679.23], + "point": [ + 162.45, + 679.23 + ], "radiusX": 102.99999999999997, "radiusY": 102.99999999999994, "rotation": 0, @@ -83,7 +104,10 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 7, - "point": [517.18, 783.54], + "point": [ + 517.18, + 783.54 + ], "radiusX": 102.99999999999997, "radiusY": 102.99999999999994, "rotation": 0, @@ -100,7 +124,10 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 8, - "point": [398.99, 810.79], + "point": [ + 398.99, + 810.79 + ], "radiusX": 45.484999999999985, "radiusY": 45.48499999999996, "rotation": 0, @@ -117,24 +144,36 @@ "name": "Arrow", "parentId": "page1", "childIndex": 9, - "point": [252.85, 1057.5], + "point": [ + 252.85, + 1057.5 + ], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [0, 0] + "point": [ + 0, + 0 + ] }, "end": { "id": "end", "index": 1, - "point": [0.09000000000000341, 208] + "point": [ + 0.09000000000000341, + 208 + ] }, "bend": { "id": "bend", "index": 2, - "point": [0.045000000000001705, 104] + "point": [ + 0.045000000000001705, + 104 + ] } }, "decorations": { @@ -155,24 +194,36 @@ "name": "Arrow", "parentId": "page1", "childIndex": 10, - "point": [616.9, 1124.3], + "point": [ + 616.9, + 1124.3 + ], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [0, 0] + "point": [ + 0, + 0 + ] }, "end": { "id": "end", "index": 1, - "point": [2.4500000000000455, 185.20000000000005] + "point": [ + 2.4500000000000455, + 185.20000000000005 + ] }, "bend": { "id": "bend", "index": 2, - "point": [1.2250000000000227, 92.60000000000002] + "point": [ + 1.2250000000000227, + 92.60000000000002 + ] } }, "decorations": { @@ -193,24 +244,36 @@ "name": "Arrow", "parentId": "page1", "childIndex": 11, - "point": [425.18, 1143.2], + "point": [ + 425.18, + 1143.2 + ], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [0, 0] + "point": [ + 0, + 0 + ] }, "end": { "id": "end", "index": 1, - "point": [1.8500000000000227, 95.70000000000005] + "point": [ + 1.8500000000000227, + 95.70000000000005 + ] }, "bend": { "id": "bend", "index": 2, - "point": [0.9250000000000114, 47.85000000000002] + "point": [ + 0.9250000000000114, + 47.85000000000002 + ] } }, "decorations": { @@ -231,7 +294,10 @@ "name": "Text", "parentId": "page1", "childIndex": 12, - "point": [207.16, 1422.4], + "point": [ + 207.16, + 1422.4 + ], "rotation": 0, "style": { "color": "Black", @@ -248,7 +314,10 @@ "name": "Text", "parentId": "page1", "childIndex": 13, - "point": [389.57, 1496.5], + "point": [ + 389.57, + 1496.5 + ], "rotation": 0, "style": { "color": "Black", @@ -265,7 +334,10 @@ "name": "Text", "parentId": "page1", "childIndex": 14, - "point": [564.06, 1558.1], + "point": [ + 564.06, + 1558.1 + ], "rotation": 0, "style": { "color": "Black", @@ -290,9 +362,12 @@ "pageState": { "id": "page1", "camera": { - "point": [0, -145], + "point": [ + 0, + -145 + ], "zoom": 1 }, "selectedIds": {} } -} +} \ No newline at end of file diff --git a/__tests__/__snapshots__/project.test.ts.snap b/__tests__/__snapshots__/project.test.ts.snap index ea5db5a39..9c0ee0649 100644 --- a/__tests__/__snapshots__/project.test.ts.snap +++ b/__tests__/__snapshots__/project.test.ts.snap @@ -50,7 +50,7 @@ for (let i = 0; i < count; i++) { "name": "index.ts", }, }, - "id": "home", + "id": "TESTING", "name": "My Document", "pages": Object { "page1": Object { @@ -454,7 +454,7 @@ for (let i = 0; i < count; i++) { "name": "index.ts", }, }, - "id": "home", + "id": "TESTING", "name": "My Document", "pages": Object { "page1": Object { diff --git a/__tests__/commands/delete-page.test.ts b/__tests__/commands/delete-page.test.ts index 6c263e2aa..0c32877ba 100644 --- a/__tests__/commands/delete-page.test.ts +++ b/__tests__/commands/delete-page.test.ts @@ -5,7 +5,7 @@ describe('delete page command', () => { tt.resetDocumentState().save() it('does command', () => { - tt.restore().send('CREATED_PAGE') + 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] @@ -18,7 +18,7 @@ describe('delete page command', () => { }) it('un-does command', () => { - tt.restore().send('CREATED_PAGE') + 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] @@ -30,7 +30,7 @@ describe('delete page command', () => { }) it('re-does command', () => { - tt.restore().send('CREATED_PAGE') + 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] diff --git a/__tests__/commands/duplicate-page.test.ts b/__tests__/commands/duplicate-page.test.ts index 2ac4fd97c..255b26f23 100644 --- a/__tests__/commands/duplicate-page.test.ts +++ b/__tests__/commands/duplicate-page.test.ts @@ -17,7 +17,7 @@ describe('duplicate page command', () => { describe('duplicates a page', () => { it('does, undoes, and redoes command', () => { - tt.restore() + tt.reset().restore() expect(Object.keys(tt.data.document.pages).length).toBe(1) const pageId = Object.keys(tt.data.document.pages)[0] diff --git a/__tests__/commands/rename-page.test.ts b/__tests__/commands/rename-page.test.ts new file mode 100644 index 000000000..da44771cf --- /dev/null +++ b/__tests__/commands/rename-page.test.ts @@ -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('New Page') + + // 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('New Page') + + // tt.redo() + + // expect(tt.data.document.pages[secondPageId].name).toBe('My Second Page') + // }) +}) diff --git a/components/page-panel/page-options.tsx b/components/page-panel/page-options.tsx index d63aea39a..49e97fddb 100644 --- a/components/page-panel/page-options.tsx +++ b/components/page-panel/page-options.tsx @@ -10,6 +10,7 @@ import { DialogInputWrapper, Divider, } from 'components/shared' +import { useState } from 'react' import state, { useSelector } from 'state' import { Page } from 'types' @@ -18,11 +19,10 @@ export default function PageOptions({ page }: { page: Page }): JSX.Element { (s) => Object.keys(s.data.document.pages).length <= 1 ) + const [name, setName] = useState(page.name) + function handleNameChange(e: React.ChangeEvent) { - state.send('CHANGED_PAGE_NAME', { - id: page.id, - name: e.currentTarget.value, - }) + setName(e.currentTarget.value) } function handleDuplicate() { @@ -33,13 +33,24 @@ export default function PageOptions({ page }: { page: Page }): JSX.Element { state.send('DELETED_PAGE', { id: page.id }) } - function handleOpenChange() { + function handleOpenChange(isOpen: boolean) { + if (isOpen) return + if (page.name.length === 0) { - state.send('CHANGED_PAGE_NAME', { + 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) { @@ -64,7 +75,7 @@ export default function PageOptions({ page }: { page: Page }): JSX.Element { onKeyUp={stopPropagation} > - + + + Save + Cancel diff --git a/components/shared.tsx b/components/shared.tsx index e6ff9bc45..78d4a7a9f 100644 --- a/components/shared.tsx +++ b/components/shared.tsx @@ -105,6 +105,7 @@ export const RowButton = styled('button', { justifyContent: 'space-between', padding: '4px 8px 4px 12px', borderRadius: 4, + userSelect: 'none', '& label': { fontWeight: '$1', diff --git a/state/commands/create-page.ts b/state/commands/create-page.ts index c6628b415..c8ddaa798 100644 --- a/state/commands/create-page.ts +++ b/state/commands/create-page.ts @@ -21,14 +21,18 @@ export default function createPage(data: Data, goToPage = true): void { data.document.pages[page.id] = page data.pageStates[page.id] = pageState + storage.savePage(data, data.document.id, page.id) + if (goToPage) { - storage.savePage(data, data.document.id, currentPageId) storage.loadPage(data, data.document.id, page.id) data.currentPageId = page.id data.currentParentId = page.id tld.setZoomCSS(tld.getPageState(data).camera.zoom) } + + storage.saveAppStateToLocalStorage(data) + storage.saveDocumentToLocalStorage(data) }, undo(data) { const { page, currentPageId } = snapshot @@ -42,6 +46,9 @@ export default function createPage(data: Data, goToPage = true): void { tld.setZoomCSS(tld.getPageState(data).camera.zoom) } + + storage.saveAppStateToLocalStorage(data) + storage.saveDocumentToLocalStorage(data) }, }) ) diff --git a/state/commands/delete-page.ts b/state/commands/delete-page.ts index f21f75e5c..1c0cc30b7 100644 --- a/state/commands/delete-page.ts +++ b/state/commands/delete-page.ts @@ -24,8 +24,11 @@ export default function deletePage(data: Data, pageId: string): void { delete data.pageStates[pageId] if (snapshot.isCurrent) { - storage.loadPage(data, snapshot.nextPageId) + storage.loadPage(data, data.document.id, snapshot.nextPageId) } + + storage.saveAppStateToLocalStorage(data) + storage.saveDocumentToLocalStorage(data) }, undo(data) { storage.saveAppStateToLocalStorage(data) @@ -37,8 +40,11 @@ export default function deletePage(data: Data, pageId: string): void { data.pageStates[pageId] = snapshot.pageState if (snapshot.isCurrent) { - storage.loadPage(data, snapshot.currentPageId) + storage.loadPage(data, data.document.id, snapshot.currentPageId) } + + storage.saveAppStateToLocalStorage(data) + storage.saveDocumentToLocalStorage(data) }, }) ) diff --git a/state/commands/duplicate-page.ts b/state/commands/duplicate-page.ts index b6aae9385..6bd2e3be8 100644 --- a/state/commands/duplicate-page.ts +++ b/state/commands/duplicate-page.ts @@ -16,7 +16,7 @@ export default function duplicatePage( history.execute( data, new Command({ - name: 'create_page', + name: 'duplicate_page', category: 'canvas', do(data) { const { from, to } = snapshot @@ -34,6 +34,9 @@ export default function duplicatePage( tld.setZoomCSS(tld.getPageState(data).camera.zoom) } + + storage.saveAppStateToLocalStorage(data) + storage.saveDocumentToLocalStorage(data) }, undo(data) { const { from, to } = snapshot @@ -47,6 +50,9 @@ export default function duplicatePage( tld.setZoomCSS(tld.getPageState(data).camera.zoom) } + + storage.saveAppStateToLocalStorage(data) + storage.saveDocumentToLocalStorage(data) }, }) ) diff --git a/state/commands/index.ts b/state/commands/index.ts index 8c9ef5059..68498a688 100644 --- a/state/commands/index.ts +++ b/state/commands/index.ts @@ -26,6 +26,7 @@ import toggle from './toggle' import transform from './transform' import transformSingle from './transform-single' import translate from './translate' +import renamePage from './rename-page' import ungroup from './ungroup' const commands = { @@ -57,6 +58,7 @@ const commands = { transform, transformSingle, translate, + renamePage, ungroup, } diff --git a/state/commands/move-to-page.ts b/state/commands/move-to-page.ts index 822446e01..302412f5d 100644 --- a/state/commands/move-to-page.ts +++ b/state/commands/move-to-page.ts @@ -65,7 +65,7 @@ export default function moveToPageCommand(data: Data, newPageId: string): void { storage.savePage(data, data.document.id, fromPageId) // Load the "to" page - storage.loadPage(data, toPageId) + storage.loadPage(data, data.document.id, toPageId) // The page we're moving the shapes to 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.loadPage(data, toPageId) + storage.loadPage(data, data.document.id, toPageId) const toPage = tld.getPage(data) diff --git a/state/commands/rename-page.ts b/state/commands/rename-page.ts new file mode 100644 index 000000000..2bcc74fd1 --- /dev/null +++ b/state/commands/rename-page.ts @@ -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, + }, + } +} diff --git a/state/state.ts b/state/state.ts index 70b5a7752..f72c3a402 100644 --- a/state/state.ts +++ b/state/state.ts @@ -320,9 +320,9 @@ const state = createState({ unless: 'isInSession', do: 'changePage', }, - CHANGED_PAGE_NAME: { + RENAMED_PAGE: { unlessAny: ['isReadOnly', 'isInSession'], - do: 'changePageName', + do: 'renamePage', }, DUPLICATED_PAGE: { unlessAny: ['isReadOnly', 'isInSession'], @@ -1419,8 +1419,8 @@ const state = createState({ createPage(data) { commands.createPage(data, true) }, - changePageName(data, payload: { id: string; name: string }) { - data.document.pages[payload.id].name = payload.name + renamePage(data, payload: { id: string; name: string }) { + commands.renamePage(data, payload.id, payload.name) }, deletePage(data, payload: { id: string }) { commands.deletePage(data, payload.id) diff --git a/state/storage.ts b/state/storage.ts index 40626606e..dd8846ee4 100644 --- a/state/storage.ts +++ b/state/storage.ts @@ -272,7 +272,7 @@ class Storage { page = JSON.parse(decompress(savedPage)) } catch (e) { - console.warn('Could not load a page with the id', pageId) + throw Error('Could not load a page with the id ' + pageId) } return page @@ -298,12 +298,37 @@ class Storage { pageState = JSON.parse(decompress(savedPageState)) } catch (e) { - console.warn('Could not load a page state with the id', pageId) + 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) { if (typeof window === 'undefined') return if (typeof localStorage === 'undefined') return @@ -317,19 +342,14 @@ class Storage { if (savedPage === null) { // Why would the page be null? // TODO: Find out why the page would be null. - - data.document.pages[pageId] = { - id: pageId, - type: 'page', - childIndex: Object.keys(data.document.pages).length, - name: 'New Page', - shapes: {}, - } + throw new Error('Could not find that page') } else { data.document.pages[pageId] = JSON.parse(decompress(savedPage)) } } 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 data.document.pages[pageId] = { From 6ce1f0b5f1a692607f9e06e72d5a198e8d8704aa Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 14 Jul 2021 12:40:18 +0100 Subject: [PATCH 3/4] Fixes tests --- __tests__/__mocks__/data.json | 127 +++++-------------------- __tests__/__mocks__/document.json | 127 +++++-------------------- __tests__/commands/rename-page.test.ts | 32 +++---- 3 files changed, 68 insertions(+), 218 deletions(-) diff --git a/__tests__/__mocks__/data.json b/__tests__/__mocks__/data.json index 94cabea04..decf560c6 100644 --- a/__tests__/__mocks__/data.json +++ b/__tests__/__mocks__/data.json @@ -41,14 +41,8 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 3, - "point": [ - 171.47, - 288.63 - ], - "size": [ - 176.22, - 192.26 - ], + "point": [171.47, 288.63], + "size": [176.22, 192.26], "radius": 2, "rotation": 0, "style": { @@ -64,14 +58,8 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 4, - "point": [ - 511.7, - 404.19 - ], - "size": [ - 181.08999999999992, - 150.40999999999997 - ], + "point": [511.7, 404.19], + "size": [181.08999999999992, 150.40999999999997], "radius": 2, "rotation": 0, "style": { @@ -87,14 +75,8 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 5, - "point": [ - 384.09, - 378.45 - ], - "size": [ - 95.20999999999992, - 91.1799999999999 - ], + "point": [384.09, 378.45], + "size": [95.20999999999992, 91.1799999999999], "radius": 2, "rotation": 0, "style": { @@ -110,10 +92,7 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 6, - "point": [ - 162.45, - 679.23 - ], + "point": [162.45, 679.23], "radiusX": 102.99999999999997, "radiusY": 102.99999999999994, "rotation": 0, @@ -130,10 +109,7 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 7, - "point": [ - 517.18, - 783.54 - ], + "point": [517.18, 783.54], "radiusX": 102.99999999999997, "radiusY": 102.99999999999994, "rotation": 0, @@ -150,10 +126,7 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 8, - "point": [ - 398.99, - 810.79 - ], + "point": [398.99, 810.79], "radiusX": 45.484999999999985, "radiusY": 45.48499999999996, "rotation": 0, @@ -170,36 +143,24 @@ "name": "Arrow", "parentId": "page1", "childIndex": 9, - "point": [ - 252.85, - 1057.5 - ], + "point": [252.85, 1057.5], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [ - 0, - 0 - ] + "point": [0, 0] }, "end": { "id": "end", "index": 1, - "point": [ - 0.09000000000000341, - 208 - ] + "point": [0.09000000000000341, 208] }, "bend": { "id": "bend", "index": 2, - "point": [ - 0.045000000000001705, - 104 - ] + "point": [0.045000000000001705, 104] } }, "decorations": { @@ -220,36 +181,24 @@ "name": "Arrow", "parentId": "page1", "childIndex": 10, - "point": [ - 616.9, - 1124.3 - ], + "point": [616.9, 1124.3], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [ - 0, - 0 - ] + "point": [0, 0] }, "end": { "id": "end", "index": 1, - "point": [ - 2.4500000000000455, - 185.20000000000005 - ] + "point": [2.4500000000000455, 185.20000000000005] }, "bend": { "id": "bend", "index": 2, - "point": [ - 1.2250000000000227, - 92.60000000000002 - ] + "point": [1.2250000000000227, 92.60000000000002] } }, "decorations": { @@ -270,36 +219,24 @@ "name": "Arrow", "parentId": "page1", "childIndex": 11, - "point": [ - 425.18, - 1143.2 - ], + "point": [425.18, 1143.2], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [ - 0, - 0 - ] + "point": [0, 0] }, "end": { "id": "end", "index": 1, - "point": [ - 1.8500000000000227, - 95.70000000000005 - ] + "point": [1.8500000000000227, 95.70000000000005] }, "bend": { "id": "bend", "index": 2, - "point": [ - 0.9250000000000114, - 47.85000000000002 - ] + "point": [0.9250000000000114, 47.85000000000002] } }, "decorations": { @@ -320,10 +257,7 @@ "name": "Text", "parentId": "page1", "childIndex": 12, - "point": [ - 207.16, - 1422.4 - ], + "point": [207.16, 1422.4], "rotation": 0, "style": { "color": "Black", @@ -340,10 +274,7 @@ "name": "Text", "parentId": "page1", "childIndex": 13, - "point": [ - 389.57, - 1496.5 - ], + "point": [389.57, 1496.5], "rotation": 0, "style": { "color": "Black", @@ -360,10 +291,7 @@ "name": "Text", "parentId": "page1", "childIndex": 14, - "point": [ - 564.06, - 1558.1 - ], + "point": [564.06, 1558.1], "rotation": 0, "style": { "color": "Black", @@ -389,13 +317,10 @@ "page1": { "id": "page1", "camera": { - "point": [ - 0, - -145 - ], + "point": [0, -145], "zoom": 1 }, "selectedIds": {} } } -} \ No newline at end of file +} diff --git a/__tests__/__mocks__/document.json b/__tests__/__mocks__/document.json index 6d901ec07..2c8a7b213 100644 --- a/__tests__/__mocks__/document.json +++ b/__tests__/__mocks__/document.json @@ -15,14 +15,8 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 3, - "point": [ - 100, - 100 - ], - "size": [ - 100, - 100 - ], + "point": [100, 100], + "size": [100, 100], "radius": 2, "rotation": 0, "style": { @@ -38,14 +32,8 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 4, - "point": [ - 500, - 400 - ], - "size": [ - 200, - 200 - ], + "point": [500, 400], + "size": [200, 200], "radius": 2, "rotation": 0, "style": { @@ -61,14 +49,8 @@ "name": "Rectangle", "parentId": "page1", "childIndex": 5, - "point": [ - 384.09, - 378.45 - ], - "size": [ - 95.20999999999992, - 91.1799999999999 - ], + "point": [384.09, 378.45], + "size": [95.20999999999992, 91.1799999999999], "radius": 2, "rotation": 0, "style": { @@ -84,10 +66,7 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 6, - "point": [ - 162.45, - 679.23 - ], + "point": [162.45, 679.23], "radiusX": 102.99999999999997, "radiusY": 102.99999999999994, "rotation": 0, @@ -104,10 +83,7 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 7, - "point": [ - 517.18, - 783.54 - ], + "point": [517.18, 783.54], "radiusX": 102.99999999999997, "radiusY": 102.99999999999994, "rotation": 0, @@ -124,10 +100,7 @@ "name": "Ellipse", "parentId": "page1", "childIndex": 8, - "point": [ - 398.99, - 810.79 - ], + "point": [398.99, 810.79], "radiusX": 45.484999999999985, "radiusY": 45.48499999999996, "rotation": 0, @@ -144,36 +117,24 @@ "name": "Arrow", "parentId": "page1", "childIndex": 9, - "point": [ - 252.85, - 1057.5 - ], + "point": [252.85, 1057.5], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [ - 0, - 0 - ] + "point": [0, 0] }, "end": { "id": "end", "index": 1, - "point": [ - 0.09000000000000341, - 208 - ] + "point": [0.09000000000000341, 208] }, "bend": { "id": "bend", "index": 2, - "point": [ - 0.045000000000001705, - 104 - ] + "point": [0.045000000000001705, 104] } }, "decorations": { @@ -194,36 +155,24 @@ "name": "Arrow", "parentId": "page1", "childIndex": 10, - "point": [ - 616.9, - 1124.3 - ], + "point": [616.9, 1124.3], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [ - 0, - 0 - ] + "point": [0, 0] }, "end": { "id": "end", "index": 1, - "point": [ - 2.4500000000000455, - 185.20000000000005 - ] + "point": [2.4500000000000455, 185.20000000000005] }, "bend": { "id": "bend", "index": 2, - "point": [ - 1.2250000000000227, - 92.60000000000002 - ] + "point": [1.2250000000000227, 92.60000000000002] } }, "decorations": { @@ -244,36 +193,24 @@ "name": "Arrow", "parentId": "page1", "childIndex": 11, - "point": [ - 425.18, - 1143.2 - ], + "point": [425.18, 1143.2], "rotation": 0, "bend": 0, "handles": { "start": { "id": "start", "index": 0, - "point": [ - 0, - 0 - ] + "point": [0, 0] }, "end": { "id": "end", "index": 1, - "point": [ - 1.8500000000000227, - 95.70000000000005 - ] + "point": [1.8500000000000227, 95.70000000000005] }, "bend": { "id": "bend", "index": 2, - "point": [ - 0.9250000000000114, - 47.85000000000002 - ] + "point": [0.9250000000000114, 47.85000000000002] } }, "decorations": { @@ -294,10 +231,7 @@ "name": "Text", "parentId": "page1", "childIndex": 12, - "point": [ - 207.16, - 1422.4 - ], + "point": [207.16, 1422.4], "rotation": 0, "style": { "color": "Black", @@ -314,10 +248,7 @@ "name": "Text", "parentId": "page1", "childIndex": 13, - "point": [ - 389.57, - 1496.5 - ], + "point": [389.57, 1496.5], "rotation": 0, "style": { "color": "Black", @@ -334,10 +265,7 @@ "name": "Text", "parentId": "page1", "childIndex": 14, - "point": [ - 564.06, - 1558.1 - ], + "point": [564.06, 1558.1], "rotation": 0, "style": { "color": "Black", @@ -362,12 +290,9 @@ "pageState": { "id": "page1", "camera": { - "point": [ - 0, - -145 - ], + "point": [0, -145], "zoom": 1 }, "selectedIds": {} } -} \ No newline at end of file +} diff --git a/__tests__/commands/rename-page.test.ts b/__tests__/commands/rename-page.test.ts index da44771cf..1f70cab01 100644 --- a/__tests__/commands/rename-page.test.ts +++ b/__tests__/commands/rename-page.test.ts @@ -26,30 +26,30 @@ describe('rename page command', () => { }) }) - // describe('renames a page other than the current page', () => { - // tt.restore() - // .reset() - // .send('CREATED_PAGE') - // .send('CHANGED_PAGE', { id: 'page1' }) + 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(Object.keys(tt.data.document.pages).length).toBe(2) - // expect(tt.data.currentPageId).toBe('page1') + expect(tt.data.currentPageId).toBe('page1') - // const secondPageId = Object.keys(tt.data.document.pages)[1] + const secondPageId = Object.keys(tt.data.document.pages)[1] - // expect(tt.data.document.pages[secondPageId].name).toBe('New Page') + expect(tt.data.document.pages[secondPageId].name).toBe('Page 2') - // tt.send('RENAMED_PAGE', { id: secondPageId, name: 'My Second Page' }) + tt.send('RENAMED_PAGE', { id: secondPageId, name: 'My Second Page' }) - // expect(tt.data.document.pages[secondPageId].name).toBe('My Second Page') + expect(tt.data.document.pages[secondPageId].name).toBe('My Second Page') - // tt.undo() + tt.undo() - // expect(tt.data.document.pages[secondPageId].name).toBe('New Page') + expect(tt.data.document.pages[secondPageId].name).toBe('Page 2') - // tt.redo() + tt.redo() - // expect(tt.data.document.pages[secondPageId].name).toBe('My Second Page') - // }) + expect(tt.data.document.pages[secondPageId].name).toBe('My Second Page') + }) }) From 84b6dfe7df661725ecd693f6bc53ec91761a6d7f Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 14 Jul 2021 12:46:19 +0100 Subject: [PATCH 4/4] Fixes padding on page button --- components/page-panel/page-panel.tsx | 2 +- components/shared.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/components/page-panel/page-panel.tsx b/components/page-panel/page-panel.tsx index 5870bdd31..1a9c3e260 100644 --- a/components/page-panel/page-panel.tsx +++ b/components/page-panel/page-panel.tsx @@ -44,7 +44,7 @@ export default function PagePanel(): JSX.Element { }} > - + {documentPages[currentPageId].name} diff --git a/components/shared.tsx b/components/shared.tsx index 78d4a7a9f..c5b098484 100644 --- a/components/shared.tsx +++ b/components/shared.tsx @@ -146,6 +146,9 @@ export const RowButton = styled('button', { }, }, variant: { + noIcon: { + padding: '4px 12px', + }, pageButton: { display: 'grid', gridTemplateColumns: '24px auto',