diff --git a/packages/core/src/utils/utils.ts b/packages/core/src/utils/utils.ts index dd55f788e..1b4d03b4b 100644 --- a/packages/core/src/utils/utils.ts +++ b/packages/core/src/utils/utils.ts @@ -1485,6 +1485,24 @@ left past the initial left edge) then swap points on that axis. static metaKey(e: KeyboardEvent | React.KeyboardEvent): boolean { return Utils.isDarwin() ? e.metaKey : e.ctrlKey } + + /** + * Get an incremented name (e.g. New page (2)) from a name (e.g. New page), based on an array of existing names. + * + * @param name The name to increment. + * @param others The array of existing names. + */ + static getIncrementedName(name: string, others: string[]) { + let result = name + + while (others.includes(result)) { + result = /\s\((\d+)\)$/.exec(result)?.[1] + ? result.replace(/\d+(?=\)$)/, (m) => (+m + 1).toString()) + : `${result} (1)` + } + + return result + } } export default Utils diff --git a/packages/tldraw/src/components/TopPanel/PageMenu/PageMenu.tsx b/packages/tldraw/src/components/TopPanel/PageMenu/PageMenu.tsx index 939e199d7..038e1cb0e 100644 --- a/packages/tldraw/src/components/TopPanel/PageMenu/PageMenu.tsx +++ b/packages/tldraw/src/components/TopPanel/PageMenu/PageMenu.tsx @@ -77,19 +77,68 @@ function PageMenuContent({ onClose }: { onClose: () => void }) { [app] ) + const [dragId, setDragId] = React.useState(null) + + const [dropIndex, setDropIndex] = React.useState(null) + + const handleDragStart = React.useCallback((ev: React.DragEvent) => { + setDragId(ev.currentTarget.id) + + ev.dataTransfer.effectAllowed = 'move' + }, []) + + const handleDrag = React.useCallback( + (ev: React.DragEvent) => { + ev.preventDefault() + + const dropBox = sortedPages.find((p) => p.id === ev.currentTarget.id) + + if (!dropBox) return + + const indices = sortedPages.map((p) => p.childIndex ?? 0).sort() + const index = indices.indexOf(dropBox.childIndex ?? 0) + + const rect = ev.currentTarget.getBoundingClientRect() + const ny = (ev.clientY - rect.top) / rect.height + const dropIndex = ny < 0.5 ? index : index + 1 + + setDropIndex(dropIndex) + }, + [dragId, sortedPages] + ) + + const handleDrop = React.useCallback(() => { + if (dragId !== null && dropIndex !== null) { + app.movePage(dragId, dropIndex) + } + + setDragId(null) + setDropIndex(null) + }, [dragId, dropIndex]) + return ( <> - {sortedPages.map((page) => ( - + {sortedPages.map((page, i) => ( + - {page.name || 'Page'} + {page.name || 'Page'} @@ -117,9 +166,11 @@ function PageMenuContent({ onClose }: { onClose: () => void }) { } const ButtonWithOptions = styled('div', { + position: 'relative', display: 'grid', gridTemplateColumns: '1fr auto', gridAutoFlow: 'column', + margin: 0, '& > *[data-shy="true"]': { opacity: 0, @@ -128,6 +179,39 @@ const ButtonWithOptions = styled('div', { '&:hover > *[data-shy="true"]': { opacity: 1, }, + + variants: { + isDropAbove: { + true: { + '&::after': { + content: '', + display: 'block', + position: 'absolute', + top: 0, + width: '100%', + height: '1px', + backgroundColor: '$selected', + zIndex: 999, + pointerEvents: 'none', + }, + }, + }, + isDropBelow: { + true: { + '&::after': { + content: '', + display: 'block', + position: 'absolute', + width: '100%', + height: '1px', + top: '100%', + backgroundColor: '$selected', + zIndex: 999, + pointerEvents: 'none', + }, + }, + }, + }, }) export const PageButton = styled(RowButton, { diff --git a/packages/tldraw/src/state/TldrawApp.ts b/packages/tldraw/src/state/TldrawApp.ts index 456f6a6cf..88d528b51 100644 --- a/packages/tldraw/src/state/TldrawApp.ts +++ b/packages/tldraw/src/state/TldrawApp.ts @@ -1743,6 +1743,19 @@ export class TldrawApp extends StateManager { return this.setState(Commands.changePage(this, pageId)) } + /** + * Move a page above another. + * @param pageId The page to move. + * @param index The page above which to move. + */ + movePage = (pageId: string, index: number): this => { + if (this.readOnly) return this + + if (this.page.childIndex === index) return this + + return this.setState(Commands.movePage(this, pageId, index)) + } + /** * Rename a page. * @param pageId The id of the page to rename. diff --git a/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap b/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap index 38c284c6f..9c20987d6 100644 --- a/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap +++ b/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap @@ -406,6 +406,7 @@ TldrawTestApp { "migrate": [Function], "moveBackward": [Function], "moveForward": [Function], + "movePage": [Function], "movePointer": [Function], "moveToBack": [Function], "moveToFront": [Function], diff --git a/packages/tldraw/src/state/commands/createPage/createPage.spec.ts b/packages/tldraw/src/state/commands/createPage/createPage.spec.ts index dd34cc5f2..79ef7b30e 100644 --- a/packages/tldraw/src/state/commands/createPage/createPage.spec.ts +++ b/packages/tldraw/src/state/commands/createPage/createPage.spec.ts @@ -30,4 +30,34 @@ describe('Create page command', () => { expect(app.page.id).toBe(nextId) expect(app.pageState).toEqual(nextPageState) }) + + it('increments page names', () => { + app.loadDocument(mockDocument) + + app.createPage() + + expect(app.page.name).toBe('New page') + + app.createPage() + + expect(app.page.name).toBe('New page (1)') + + app.createPage() + + expect(app.page.name).toBe('New page (2)') + + app.renamePage(app.page.id, 'New page!') + + app.createPage() + + expect(app.page.name).toBe('New page (2)') + + app.deletePage(app.page.id) + + expect(app.page.name).toBe('New page!') + + app.createPage(undefined, 'New page!') + + expect(app.page.name).toBe('New page! (1)') + }) }) diff --git a/packages/tldraw/src/state/commands/createPage/createPage.ts b/packages/tldraw/src/state/commands/createPage/createPage.ts index 38b2bf1fd..db4a168fd 100644 --- a/packages/tldraw/src/state/commands/createPage/createPage.ts +++ b/packages/tldraw/src/state/commands/createPage/createPage.ts @@ -10,17 +10,20 @@ export function createPage( ): TldrawCommand { const { currentPageId } = app - const topPage = Object.values(app.state.document.pages).sort( - (a, b) => (b.childIndex || 0) - (a.childIndex || 0) - )[0] + const pages = Object.values(app.state.document.pages).sort( + (a, b) => (a.childIndex ?? 0) - (b.childIndex ?? 0) + ) + + const topPage = pages[pages.length - 1] const nextChildIndex = topPage?.childIndex ? topPage?.childIndex + 1 : 1 - // TODO: Iterate the name better - const page: TDPage = { id: pageId, - name: pageName, + name: Utils.getIncrementedName( + pageName, + pages.map((p) => p.name ?? '') + ), childIndex: nextChildIndex, shapes: {}, bindings: {}, diff --git a/packages/tldraw/src/state/commands/index.ts b/packages/tldraw/src/state/commands/index.ts index 86b493ddb..e4b01ad57 100644 --- a/packages/tldraw/src/state/commands/index.ts +++ b/packages/tldraw/src/state/commands/index.ts @@ -10,6 +10,7 @@ export * from './duplicateShapes' export * from './flipShapes' export * from './groupShapes' export * from './moveShapesToPage' +export * from './movePage' export * from './reorderShapes' export * from './renamePage' export * from './resetBounds' diff --git a/packages/tldraw/src/state/commands/movePage/index.ts b/packages/tldraw/src/state/commands/movePage/index.ts new file mode 100644 index 000000000..0b7628a59 --- /dev/null +++ b/packages/tldraw/src/state/commands/movePage/index.ts @@ -0,0 +1 @@ +export * from './movePage' \ No newline at end of file diff --git a/packages/tldraw/src/state/commands/movePage/movePage.ts b/packages/tldraw/src/state/commands/movePage/movePage.ts new file mode 100644 index 000000000..fc163f962 --- /dev/null +++ b/packages/tldraw/src/state/commands/movePage/movePage.ts @@ -0,0 +1,43 @@ +import type { TDPage, TldrawCommand } from '~types' +import type { TldrawApp } from '../../internal' + +export function movePage(app: TldrawApp, pageId: string, index: number): TldrawCommand { + const { pages } = app.document + const currentIndex = pages[pageId].childIndex ?? 0 + const movingUp = index < currentIndex + const startToMove = Math.min(index, currentIndex) + const endToMove = Math.max(index, currentIndex) + + const pagesToMove = Object.values(pages).filter( + (page) => + page.childIndex !== undefined && + page.childIndex <= endToMove && + page.childIndex >= startToMove + ) + + return { + id: 'move_page', + before: { + document: { + pages: Object.fromEntries( + pagesToMove.map((p: TDPage) => { + return [p.id, { childIndex: p.childIndex }] + }) + ), + }, + }, + after: { + document: { + pages: Object.fromEntries( + pagesToMove.map((p) => { + if (p.childIndex == undefined) return [p.id, { childIndex: p.childIndex }] + return [ + p.id, + { childIndex: p.id == pageId ? index : p.childIndex + (movingUp ? 1 : -1) }, + ] + }) + ), + }, + }, + } +}