From 4880652b6ec5acd85ec31c3f3c1e8d793dabcc9c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 1 Jul 2026 20:29:10 +0300 Subject: [PATCH] feat(editor): add Figma-style page management --- CHANGELOG.md | 1 + packages/core/src/editor/pages.ts | 12 ++ .../src/primitives/PageList/PageListRoot.vue | 12 +- .../src/primitives/PageList/usePageList.ts | 1 + src/components/PagesPanel.vue | 199 +++++++++++++----- tests/e2e/pages/multi-page.spec.ts | 62 ++++++ 6 files changed, 237 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37639a3ac..18ea195b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changed +- Add Figma-style page management in the Pages panel with a context menu for renaming/deleting pages and drag-and-drop page reordering. - Add JSX authoring support for components, component sets, and instances. - Add design JSX variable helpers so color props can use `designVar()` / `defineVars()` references and emit graph variable bindings. - Add structured design JSX paint helpers for solid fills, multiple fills, and gradients. diff --git a/packages/core/src/editor/pages.ts b/packages/core/src/editor/pages.ts index f7c3b5534..98704b918 100644 --- a/packages/core/src/editor/pages.ts +++ b/packages/core/src/editor/pages.ts @@ -60,6 +60,17 @@ export function createPageActions(ctx: EditorContext) { } } + function movePage(pageId: string, index: number) { + const pages = ctx.graph.getPages() + const currentIndex = pages.findIndex((page) => page.id === pageId) + if (currentIndex === -1) return + + const nextIndex = Math.max(0, Math.min(index, pages.length - 1)) + if (nextIndex === currentIndex) return + + ctx.graph.insertChildAt(pageId, ctx.graph.rootId, nextIndex) + } + function renamePage(pageId: string, name: string) { ctx.graph.updateNode(pageId, { name }) } @@ -73,6 +84,7 @@ export function createPageActions(ctx: EditorContext) { switchPage, addPage, deletePage, + movePage, renamePage, setPageColor, clearPageViewports: pageViewportStore.clearPageViewports diff --git a/packages/vue/src/primitives/PageList/PageListRoot.vue b/packages/vue/src/primitives/PageList/PageListRoot.vue index 94bcb8baa..16be94124 100644 --- a/packages/vue/src/primitives/PageList/PageListRoot.vue +++ b/packages/vue/src/primitives/PageList/PageListRoot.vue @@ -12,9 +12,11 @@ const emit = defineEmits<{ switch: [pageId: string] rename: [pageId: string, name: string] delete: [pageId: string] + move: [pageId: string, index: number] }>() -const { pages, currentPageId, switchPage, addPage, renamePage, deletePage } = usePageList() +const { pages, currentPageId, switchPage, addPage, renamePage, deletePage, movePage } = + usePageList() const dividerPattern = computed(() => customDividerPattern ?? /^[-–—*\s]+$/) @@ -42,11 +44,17 @@ function handleDelete(pageId: string) { emit('delete', pageId) } +function handleMove(pageId: string, index: number) { + movePage(pageId, index) + emit('move', pageId, index) +} + const actions = { add: handleAdd, switch: handleSwitch, rename: handleRename, - delete: handleDelete + delete: handleDelete, + move: handleMove } diff --git a/packages/vue/src/primitives/PageList/usePageList.ts b/packages/vue/src/primitives/PageList/usePageList.ts index 2ec94f6d5..12e527552 100644 --- a/packages/vue/src/primitives/PageList/usePageList.ts +++ b/packages/vue/src/primitives/PageList/usePageList.ts @@ -22,6 +22,7 @@ export function usePageList() { switchPage: editor.switchPage, addPage: editor.addPage, deletePage: editor.deletePage, + movePage: editor.movePage, renamePage: editor.renamePage } } diff --git a/src/components/PagesPanel.vue b/src/components/PagesPanel.vue index a9e0469c0..c3a547872 100644 --- a/src/components/PagesPanel.vue +++ b/src/components/PagesPanel.vue @@ -1,18 +1,37 @@ @@ -57,44 +114,90 @@ function handlePageDblClick( data-test-id="pages-scroll" class="scrollbar-thin h-full overflow-x-hidden overflow-y-auto px-1 pb-1" > -
-
- - -
-
-
-
- -
+ + +
+
+
+ + +
+
+
+
+ +
+
+ + + + + + {{ pageMessages.rename }} + + + + {{ pageMessages.delete }} + + + +
diff --git a/tests/e2e/pages/multi-page.spec.ts b/tests/e2e/pages/multi-page.spec.ts index 937c9ca11..e6a18f972 100644 --- a/tests/e2e/pages/multi-page.spec.ts +++ b/tests/e2e/pages/multi-page.spec.ts @@ -34,6 +34,10 @@ function pageItems() { return editor.page.getByTestId('pages-item') } +function pageRows() { + return editor.page.getByTestId('pages-row') +} + function addPageButton() { return editor.page.getByTestId('pages-add') } @@ -205,5 +209,63 @@ test('cannot delete the last page', async () => { const after = await getPages() expect(after).toHaveLength(1) + await pageRows().first().click({ button: 'right' }) + await expect(editor.page.getByTestId('pages-context-delete')).toHaveAttribute('data-disabled', '') + + editor.canvas.assertNoErrors() +}) + +test('page context menu deletes a page', async () => { + await addPageButton().click() + await addPageButton().click() + await editor.canvas.waitForRender() + + const before = await getPages() + const deletedId = before[1].id + + await pageRows().nth(1).click({ button: 'right' }) + await editor.page.getByTestId('pages-context-delete').click() + await editor.canvas.waitForRender() + + const after = await getPages() + expect(after).toHaveLength(before.length - 1) + expect(after.some((page) => page.id === deletedId)).toBe(false) + + editor.canvas.assertNoErrors() +}) + +test('dragging a page row reorders pages', async () => { + const currentPageId = await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + while (store.graph.getPages().length < 3) store.addPage() + for (const [index, page] of store.graph.getPages().entries()) { + store.renamePage(page.id, `Order ${index + 1}`) + } + const page = store.graph.getPages()[1] + if (!page) throw new Error('Expected at least two pages') + store.switchPage(page.id) + return page.id + }) + await editor.canvas.waitForRender() + + await pageRows() + .first() + .dragTo(pageRows().nth(2), { + targetPosition: { x: 12, y: 18 } + }) + await editor.canvas.waitForRender() + + const pages = await getPages() + const names = pages.map((page) => page.name) + const currentAfterReorder = await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + return store.state.currentPageId + }) + + expect(names).toEqual(['Order 2', 'Order 3', 'Order 1']) + expect(currentAfterReorder).toBe(currentPageId) + editor.canvas.assertNoErrors() })