feat(editor): add Figma-style page management
This commit is contained in:
parent
2d2d7250bb
commit
4880652b6e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export function usePageList() {
|
|||
switchPage: editor.switchPage,
|
||||
addPage: editor.addPage,
|
||||
deletePage: editor.deletePage,
|
||||
movePage: editor.movePage,
|
||||
renamePage: editor.renamePage
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue