diff --git a/components/page-panel/page-panel.tsx b/components/page-panel/page-panel.tsx index bd5bcf6f4..d8bd7e2e0 100644 --- a/components/page-panel/page-panel.tsx +++ b/components/page-panel/page-panel.tsx @@ -1,15 +1,28 @@ import styled from 'styles' +import * as ContextMenu from '@radix-ui/react-context-menu' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' -import * as RadioGroup from '@radix-ui/react-radio-group' +import * as Dialog from '@radix-ui/react-dialog' + import { IconWrapper, RowButton } from 'components/shared' -import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons' +import { CheckIcon, ChevronDownIcon, PlusIcon } from '@radix-ui/react-icons' import * as Panel from '../panel' import state, { useSelector } from 'state' -import { getPage } from 'utils/utils' +import { useEffect, useRef, useState } from 'react' export default function PagePanel() { - const currentPageId = useSelector((s) => s.data.currentPageId) + const rIsOpen = useRef(false) + const [isOpen, setIsOpen] = useState(false) + + useEffect(() => { + if (rIsOpen.current !== isOpen) { + rIsOpen.current = isOpen + } + }, [isOpen]) + const documentPages = useSelector((s) => s.data.document.pages) + const currentPageId = useSelector((s) => s.data.currentPageId) + + if (!documentPages[currentPageId]) return null const sorted = Object.values(documentPages).sort( (a, b) => a.childIndex - b.childIndex @@ -17,7 +30,14 @@ export default function PagePanel() { return ( - + { + if (rIsOpen.current !== isOpen) { + setIsOpen(isOpen) + } + }} + > {documentPages[currentPageId].name} @@ -30,19 +50,56 @@ export default function PagePanel() { + onValueChange={(id) => { + setIsOpen(false) state.send('CHANGED_CURRENT_PAGE', { id }) - } + }} > {sorted.map(({ id, name }) => ( - - {name} - - - - + + + + {name} + + + + + + + + state.send('RENAMED_PAGE', { id })} + > + Rename + + { + setIsOpen(false) + state.send('DELETED_PAGE', { id }) + }} + > + Delete + + + + ))} + + { + setIsOpen(false) + state.send('CREATED_PAGE') + }} + > + Create Page + + + + @@ -58,6 +115,7 @@ const PanelRoot = styled('div', { overflow: 'hidden', position: 'relative', display: 'flex', + flexDirection: 'column', alignItems: 'center', pointerEvents: 'all', padding: '2px', @@ -65,6 +123,7 @@ const PanelRoot = styled('div', { backgroundColor: '$panel', border: '1px solid $panel', boxShadow: '0px 2px 4px rgba(0,0,0,.2)', + userSelect: 'none', }) const Content = styled(Panel.Content, { @@ -87,6 +146,9 @@ const StyledRadioItem = styled(DropdownMenu.RadioItem, { '&:hover': { backgroundColor: '$hover', }, + '&:focus-within': { + backgroundColor: '$hover', + }, }) const OuterContainer = styled('div', { @@ -100,3 +162,56 @@ const OuterContainer = styled('div', { zIndex: 200, height: 44, }) + +const StyledContextMenuContent = styled(ContextMenu.Content, { + padding: '2px', + borderRadius: '4px', + backgroundColor: '$panel', + border: '1px solid $panel', + boxShadow: '0px 2px 4px rgba(0,0,0,.2)', +}) + +const StyledContextMenuItem = styled(ContextMenu.Item, { + height: 32, + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 12px 0 12px', + cursor: 'pointer', + borderRadius: '4px', + fontSize: '$1', + fontFamily: '$ui', + backgroundColor: 'transparent', + outline: 'none', + '&:hover': { + backgroundColor: '$hover', + }, +}) + +const StyledOverlay = styled(Dialog.Overlay, { + backgroundColor: 'rgba(0, 0, 0, .15)', + position: 'fixed', + top: 0, + right: 0, + bottom: 0, + left: 0, +}) + +const StyledContent = styled(Dialog.Content, { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + minWidth: 200, + maxWidth: 'fit-content', + maxHeight: '85vh', + padding: 20, + marginTop: '-5vh', + backgroundColor: 'white', + borderRadius: 6, + + '&:focus': { + outline: 'none', + }, +}) diff --git a/components/shared.tsx b/components/shared.tsx index de05dfc45..0ece9f927 100644 --- a/components/shared.tsx +++ b/components/shared.tsx @@ -78,21 +78,10 @@ export const RowButton = styled('button', { fontSize: '$1', justifyContent: 'space-between', padding: '4px 6px 4px 12px', + borderRadius: 4, - '&::before': { - content: "''", - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - pointerEvents: 'none', - zIndex: -1, - }, - - '&:hover::before': { + '&:hover': { backgroundColor: '$hover', - borderRadius: 4, }, '& label': { diff --git a/package.json b/package.json index 08e37f92a..417edcee4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dependencies": { "@monaco-editor/react": "^4.1.3", "@radix-ui/react-checkbox": "^0.0.15", + "@radix-ui/react-context-menu": "^0.0.19", + "@radix-ui/react-dialog": "^0.0.17", "@radix-ui/react-dropdown-menu": "^0.0.19", "@radix-ui/react-icons": "^1.0.3", "@radix-ui/react-radio-group": "^0.0.16", diff --git a/state/commands/change-page.ts b/state/commands/change-page.ts index 7231c40ce..145337eab 100644 --- a/state/commands/change-page.ts +++ b/state/commands/change-page.ts @@ -5,7 +5,7 @@ import { getPage, getSelectedShapes } from 'utils/utils' import { getShapeUtils } from 'lib/shape-utils' import * as vec from 'utils/vec' -export default function nudgeCommand(data: Data, pageId: string) { +export default function changePage(data: Data, pageId: string) { const { currentPageId: prevPageId } = data history.execute( diff --git a/state/commands/create-page.ts b/state/commands/create-page.ts new file mode 100644 index 000000000..ae31e7d40 --- /dev/null +++ b/state/commands/create-page.ts @@ -0,0 +1,59 @@ +import Command from './command' +import history from '../history' +import { Data, Page } from 'types' +import { v4 as uuid } from 'uuid' +import { current } from 'immer' + +export default function createPage(data: Data) { + const snapshot = getSnapshot(data) + + history.execute( + data, + new Command({ + name: 'change_page', + category: 'canvas', + do(data) { + data.selectedIds.clear() + const { page, pageState } = snapshot + data.document.pages[page.id] = page + data.pageStates[page.id] = pageState + data.currentPageId = page.id + }, + undo(data) { + data.selectedIds.clear() + const { page, currentPageId } = snapshot + delete data.document.pages[page.id] + delete data.pageStates[page.id] + data.currentPageId = currentPageId + }, + }) + ) +} + +function getSnapshot(data: Data) { + const { currentPageId } = current(data) + + const pages = Object.values(data.document.pages) + const unchanged = pages.filter((page) => page.name.startsWith('Page ')) + const id = uuid() + + const page: Page = { + type: 'page', + id, + name: `Page ${unchanged.length + 1}`, + childIndex: pages.length, + shapes: {}, + } + const pageState = { + camera: { + point: [0, 0], + zoom: 1, + }, + } + + return { + currentPageId, + page, + pageState, + } +} diff --git a/state/commands/delete-page.ts b/state/commands/delete-page.ts new file mode 100644 index 000000000..06a08bc13 --- /dev/null +++ b/state/commands/delete-page.ts @@ -0,0 +1,59 @@ +import Command from './command' +import history from '../history' +import { Data } from 'types' +import { current } from 'immer' +import { getPage, getSelectedShapes } from 'utils/utils' +import { getShapeUtils } from 'lib/shape-utils' +import * as vec from 'utils/vec' + +export default function changePage(data: Data, pageId: string) { + const snapshot = getSnapshot(data, pageId) + + history.execute( + data, + new Command({ + name: 'change_page', + category: 'canvas', + do(data) { + data.currentPageId = snapshot.nextPageId + delete data.document.pages[pageId] + delete data.pageStates[pageId] + }, + undo(data) { + data.currentPageId = snapshot.currentPageId + data.document.pages[pageId] = snapshot.page + data.pageStates[pageId] = snapshot.pageState + }, + }) + ) +} + +function getSnapshot(data: Data, pageId: string) { + const cData = current(data) + const { currentPageId, document } = cData + + const page = document.pages[pageId] + const pageState = cData.pageStates[pageId] + + const isCurrent = currentPageId === pageId + + const nextIndex = isCurrent + ? page.childIndex === 0 + ? 1 + : page.childIndex - 1 + : document.pages[currentPageId].childIndex + + const nextPageId = isCurrent + ? Object.values(document.pages).find( + (page) => page.childIndex === nextIndex + )!.id + : cData.currentPageId + + return { + nextPageId, + isCurrent, + currentPageId, + page, + pageState, + } +} diff --git a/state/commands/index.ts b/state/commands/index.ts index a12c43c17..650893ba8 100644 --- a/state/commands/index.ts +++ b/state/commands/index.ts @@ -1,7 +1,9 @@ import align from './align' import arrow from './arrow' import changePage from './change-page' +import createPage from './create-page' import deleteSelected from './delete-selected' +import deletePage from './delete-page' import direct from './direct' import distribute from './distribute' import draw from './draw' @@ -23,6 +25,8 @@ const commands = { align, arrow, changePage, + createPage, + deletePage, deleteSelected, direct, distribute, diff --git a/state/state.ts b/state/state.ts index 4efe91792..ae8a08f45 100644 --- a/state/state.ts +++ b/state/state.ts @@ -151,6 +151,8 @@ const state = createState({ DISABLED_PEN_LOCK: 'disablePenLock', CLEARED_PAGE: ['selectAll', 'deleteSelection'], CHANGED_CURRENT_PAGE: ['clearSelectedIds', 'setCurrentPage'], + CREATED_PAGE: ['clearSelectedIds', 'createPage'], + DELETED_PAGE: { unless: 'hasOnlyOnePage', do: 'deletePage' }, }, initial: 'selecting', states: { @@ -742,12 +744,21 @@ const state = createState({ isPenLocked(data) { return data.settings.isPenLocked }, + hasOnlyOnePage(data) { + return Object.keys(data.document.pages).length === 1 + }, }, actions: { /* ---------------------- Pages --------------------- */ setCurrentPage(data, payload: { id: string }) { commands.changePage(data, payload.id) }, + createPage(data) { + commands.createPage(data) + }, + deletePage(data, payload: { id: string }) { + commands.deletePage(data, payload.id) + }, /* --------------------- Shapes --------------------- */ createShape(data, payload, type: ShapeType) { @@ -1325,7 +1336,7 @@ const state = createState({ }, restoreSavedData(data) { - history.load(data) + // history.load(data) }, clearBoundsRotation(data) { diff --git a/yarn.lock b/yarn.lock index 8a7fc3f11..63c693511 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1314,6 +1314,19 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-context-menu@^0.0.19": + version "0.0.19" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context-menu/-/react-context-menu-0.0.19.tgz#47ef194d7bc925ff7f998889be9fd373ce4bd9a1" + integrity sha512-FR2jXeFqxD9n+1AC81+7jfMbnz80kdQNMfgIcPQL1S0M5SODADdDiTKKfok4Blc1nYWe7nEwBTYauMirF7avSQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "0.0.5" + "@radix-ui/react-context" "0.0.5" + "@radix-ui/react-menu" "0.0.18" + "@radix-ui/react-polymorphic" "0.0.11" + "@radix-ui/react-primitive" "0.0.13" + "@radix-ui/react-use-callback-ref" "0.0.5" + "@radix-ui/react-context@0.0.5": version "0.0.5" resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-0.0.5.tgz#7c15f46795d7765dabfaf6f9c53791ad28c521c2" @@ -1321,6 +1334,27 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-dialog@^0.0.17": + version "0.0.17" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-0.0.17.tgz#736e37626c4e9e20d46546ddbbb8869a352578f0" + integrity sha512-DOSW8SdniyVLro+MvF0owaEEa8MUYGMuvuuSQpFnD/hA+K0pKzyTSjEuC7OeflPCImBFEbmKhgRoeWypfMZZOA== + 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-dismissable-layer" "0.0.13" + "@radix-ui/react-focus-guards" "0.0.7" + "@radix-ui/react-focus-scope" "0.0.13" + "@radix-ui/react-id" "0.0.6" + "@radix-ui/react-polymorphic" "0.0.11" + "@radix-ui/react-portal" "0.0.13" + "@radix-ui/react-presence" "0.0.14" + "@radix-ui/react-primitive" "0.0.13" + "@radix-ui/react-use-controllable-state" "0.0.6" + aria-hidden "^1.1.1" + react-remove-scroll "^2.4.0" + "@radix-ui/react-dismissable-layer@0.0.13": version "0.0.13" resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.0.13.tgz#7c4be6170a14d8a66c48680a8a8c987bc29bcf05"