feat(editor): add Figma-style page management

This commit is contained in:
Danila Poyarkov 2026-07-01 20:29:10 +03:00
parent 2d2d7250bb
commit 4880652b6e
6 changed files with 237 additions and 50 deletions

View file

@ -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.

View file

@ -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

View file

@ -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
}
</script>

View file

@ -22,6 +22,7 @@ export function usePageList() {
switchPage: editor.switchPage,
addPage: editor.addPage,
deletePage: editor.deletePage,
movePage: editor.movePage,
renamePage: editor.renamePage
}
}

View file

@ -1,18 +1,37 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { templateRef } from '@vueuse/core'
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuTrigger
} from 'reka-ui'
import type { SceneNode } from '@open-pencil/scene-graph'
import { PageListRoot, useI18n, useInlineRename } from '@open-pencil/vue'
import Tip from '@/components/ui/Tip.vue'
import { useMenuUI } from '@/components/ui/menu'
type PageItem = Pick<SceneNode, 'id' | 'name' | 'childIds'>
interface PageActions {
rename: (pageId: string, name: string) => void
delete: (pageId: string) => void
move: (pageId: string, index: number) => void
}
const pageInput = templateRef<HTMLInputElement>('pageInput')
const rename = useInlineRename((id, name) => pageActions.value?.rename(id, name))
const { panels } = useI18n()
const { panels, pages: pageMessages } = useI18n()
const menuCls = useMenuUI({ content: 'min-w-36 shadow-[0_8px_30px_rgb(0_0_0/0.4)]' })
const pageActions = ref<{
rename: (pageId: string, name: string) => void
} | null>(null)
const pageActions = ref<Pick<PageActions, 'rename'> | null>(null)
const draggingPageId = ref<string | null>(null)
const dragTargetPageId = ref<string | null>(null)
const dragPlacement = ref<'above' | 'below' | null>(null)
function setPageActions(renamePage: (pageId: string, name: string) => void) {
pageActions.value = { rename: renamePage }
@ -22,16 +41,54 @@ watch(pageInput, (input) => {
if (input) void rename.focusInput(input)
})
function startRename(pg: { id: string; name: string }) {
function startRename(pg: PageItem, renamePage: (pageId: string, name: string) => void) {
setPageActions(renamePage)
rename.start(pg.id, pg.name)
}
function handlePageDblClick(
pg: { id: string; name: string },
renamePage: (pageId: string, name: string) => void
function isDraggingTarget(pg: PageItem, placement: 'above' | 'below') {
return dragTargetPageId.value === pg.id && dragPlacement.value === placement
}
function resetDragState() {
draggingPageId.value = null
dragTargetPageId.value = null
dragPlacement.value = null
}
function onDragStart(event: DragEvent, pageId: string) {
draggingPageId.value = pageId
event.dataTransfer?.setData('text/plain', pageId)
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
}
function onDragOver(event: DragEvent, pageId: string) {
if (!draggingPageId.value || draggingPageId.value === pageId) return
event.preventDefault()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
dragTargetPageId.value = pageId
dragPlacement.value = event.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
function onDrop(
event: DragEvent,
targetPageId: string,
pages: PageItem[],
movePage: PageActions['move']
) {
setPageActions(renamePage)
startRename(pg)
event.preventDefault()
const sourcePageId = draggingPageId.value ?? event.dataTransfer?.getData('text/plain')
const placement = dragPlacement.value
resetDragState()
if (!sourcePageId || !placement || sourcePageId === targetPageId) return
const orderedIds = pages.map((page) => page.id).filter((id) => id !== sourcePageId)
const targetIndex = orderedIds.indexOf(targetPageId)
if (targetIndex === -1) return
movePage(sourcePageId, targetIndex + (placement === 'below' ? 1 : 0))
}
</script>
@ -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"
>
<div v-for="pg in pages" :key="pg.id">
<div
v-if="rename.editingId.value === pg.id"
class="flex w-full items-center gap-1.5 rounded px-2 py-1"
>
<icon-lucide-file class="size-3 shrink-0 opacity-70" />
<input
ref="pageInput"
data-test-id="pages-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="pg.name"
@blur="rename.commit(pg.id, $event)"
@keydown.stop="rename.onKeydown"
/>
</div>
<div
v-else-if="isDivider(pg)"
class="my-1 flex items-center px-2"
@dblclick="startRename(pg)"
>
<div class="h-px flex-1 bg-border" />
</div>
<button
v-else
data-test-id="pages-item"
class="flex w-full cursor-pointer items-center gap-1.5 rounded border-none px-2 py-1 text-left text-xs"
:class="
pg.id === currentPageId
? 'bg-hover text-surface'
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
"
@click="actions.switch(pg.id)"
@dblclick="handlePageDblClick(pg, actions.rename)"
>
<icon-lucide-file class="size-3 shrink-0" />
<span class="truncate">{{ pg.name }}</span>
</button>
</div>
<ContextMenuRoot v-for="pg in pages" :key="pg.id" :modal="false">
<ContextMenuTrigger as-child>
<div
data-test-id="pages-row"
class="relative"
:data-page-id="pg.id"
draggable="true"
@dragstart="onDragStart($event, pg.id)"
@dragover="onDragOver($event, pg.id)"
@dragleave="dragTargetPageId === pg.id && (dragTargetPageId = null)"
@drop="onDrop($event, pg.id, pages, actions.move)"
@dragend="resetDragState"
>
<div
v-if="isDraggingTarget(pg, 'above')"
data-test-id="pages-drop-indicator"
class="pointer-events-none absolute inset-x-1 top-0 z-10 h-px bg-accent"
/>
<div
v-if="rename.editingId.value === pg.id"
class="flex w-full items-center gap-1.5 rounded px-2 py-1"
>
<icon-lucide-file class="size-3 shrink-0 opacity-70" />
<input
ref="pageInput"
data-test-id="pages-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="pg.name"
@blur="rename.commit(pg.id, $event)"
@keydown.stop="rename.onKeydown"
/>
</div>
<div
v-else-if="isDivider(pg)"
data-test-id="pages-divider"
class="my-1 flex cursor-pointer items-center px-2"
@dblclick="startRename(pg, actions.rename)"
>
<div class="h-px flex-1 bg-border" />
</div>
<button
v-else
data-test-id="pages-item"
class="flex w-full cursor-pointer items-center gap-1.5 rounded border-none px-2 py-1 text-left text-xs"
:class="
pg.id === currentPageId
? 'bg-hover text-surface'
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
"
@click="actions.switch(pg.id)"
@dblclick="startRename(pg, actions.rename)"
>
<icon-lucide-file class="size-3 shrink-0" />
<span class="truncate">{{ pg.name }}</span>
</button>
<div
v-if="isDraggingTarget(pg, 'below')"
data-test-id="pages-drop-indicator"
class="pointer-events-none absolute inset-x-1 bottom-0 z-10 h-px bg-accent"
/>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent :class="menuCls.content" :side-offset="2" align="start">
<ContextMenuItem
data-test-id="pages-context-rename"
:class="menuCls.item"
@select="startRename(pg, actions.rename)"
>
<icon-lucide-pencil :class="menuCls.icon" />
<span>{{ pageMessages.rename }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="pages-context-delete"
:class="menuCls.item"
:disabled="pages.length <= 1"
@select="actions.delete(pg.id)"
>
<icon-lucide-trash-2 :class="menuCls.icon" />
<span>{{ pageMessages.delete }}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</div>
</div>
</div>

View file

@ -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()
})