0.9.0: scenes — multi-page projects with right-side drawer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 07:17:06 +02:00
1 parent 517705bf55
commit b57994c754
12 files changed
+1193 -176

No files matched your search

+1 -2
View File
@@ -188,8 +188,7 @@ async function deleteButtonAsset(id: string) {
variant: 'danger',
})
if (!ok) return
// Remove the asset; any layers referencing it will render as nothing.
store.scene.assets = store.scene.assets.filter((a) => a.id !== id)
store.removeAsset(id)
if (store.activeButtonAssetId === id) store.exitButtonAssetEdit()
}
+367
View File
@@ -0,0 +1,367 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { PhCaretLeft, PhCaretRight, PhCopy, PhFilmSlate, PhPlus, PhTrash } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
const store = useSceneStore()
const open = ref(false)
interface MenuState {
pageId: string
x: number
y: number
}
const menu = ref<MenuState | null>(null)
const menuRef = ref<HTMLDivElement | null>(null)
function toggle() {
open.value = !open.value
}
async function addScene() {
const page = store.addScene()
// Let the new row mount before renaming so a canceled rename still
// leaves the user on the freshly-created (and now active) page.
await nextTick()
renameScene(page.id)
}
async function renameScene(id: string) {
const page = store.project.scenes.find((s) => s.id === id)
if (!page) return
const next = await promptDialog({
title: t('scenes.renameDialog.title'),
defaultValue: page.name,
confirmLabel: t('scenes.renameDialog.confirm'),
})
if (next === null) return
store.renameScene(id, next)
}
function duplicateScene(id: string) {
store.duplicateScene(id)
}
async function deleteScene(id: string) {
const page = store.project.scenes.find((s) => s.id === id)
if (!page) return
if (store.project.scenes.length <= 1) return
const ok = await confirmDialog({
title: t('scenes.deleteDialog.title'),
message: t('scenes.deleteDialog.message', { name: page.name }),
confirmLabel: t('scenes.deleteDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeScene(id)
}
function onRowClick(id: string) {
if (store.project.activeSceneId === id) return
store.switchScene(id)
}
function onRowContextMenu(e: MouseEvent, id: string) {
e.preventDefault()
e.stopPropagation()
menu.value = { pageId: id, x: e.clientX, y: e.clientY }
}
function closeMenu() {
menu.value = null
}
function onDocMouseDown(e: MouseEvent) {
if (!menu.value) return
if (menuRef.value && e.target instanceof Node && menuRef.value.contains(e.target)) return
closeMenu()
}
watch(menu, (m) => {
if (m) document.addEventListener('mousedown', onDocMouseDown)
else document.removeEventListener('mousedown', onDocMouseDown)
})
</script>
<template>
<aside class="scene-drawer" :class="{ open }">
<button
class="drawer-tab"
v-tooltip="open ? t('scenes.close') : t('scenes.open')"
@click="toggle"
>
<component :is="open ? PhCaretRight : PhCaretLeft" :size="14" />
</button>
<div class="drawer-panel">
<header class="drawer-header">
<PhFilmSlate :size="14" class="title-icon" />
<h3>{{ t('scenes.title') }}</h3>
<button class="add-btn" v-tooltip="t('scenes.add')" @click="addScene">
<PhPlus :size="13" weight="bold" />
</button>
</header>
<div class="scene-list">
<button
v-for="page in store.project.scenes"
:key="page.id"
class="scene-row"
:class="{ active: page.id === store.project.activeSceneId }"
@click="onRowClick(page.id)"
@dblclick="renameScene(page.id)"
@contextmenu="onRowContextMenu($event, page.id)"
>
<span class="scene-name">{{ page.name }}</span>
<span class="scene-meta">{{ page.layers.length }}</span>
</button>
</div>
</div>
<div
v-if="menu"
ref="menuRef"
class="scene-menu"
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
>
<button class="menu-item" @click="renameScene(menu.pageId); closeMenu()">
{{ t('scenes.rename') }}
</button>
<button class="menu-item" @click="duplicateScene(menu.pageId); closeMenu()">
<PhCopy :size="12" class="menu-icon" />
{{ t('scenes.duplicate') }}
</button>
<div class="menu-divider"></div>
<button
class="menu-item danger"
:disabled="store.project.scenes.length <= 1"
:title="store.project.scenes.length <= 1 ? t('scenes.locked') : undefined"
@click="deleteScene(menu.pageId); closeMenu()"
>
<PhTrash :size="12" class="menu-icon" />
{{ t('scenes.delete') }}
</button>
</div>
</aside>
</template>
<style scoped>
.scene-drawer {
display: flex;
align-items: stretch;
flex-shrink: 0;
height: 100%;
width: 5px;
transition: width 220ms var(--ease);
background: var(--border);
border-left: 1px solid var(--border);
position: relative;
overflow: hidden;
/* Mirror of ClipartDrawer: tab on the screen-edge side, panel
alongside it. Here the edge is the right-hand side, so the tab
flex order is reversed (panel first, tab second in DOM). */
flex-direction: row-reverse;
}
.scene-drawer:hover {
width: 24px;
background: var(--panel);
}
.scene-drawer.open {
width: 324px;
background: var(--panel);
}
.drawer-tab {
flex: 0 0 100%;
width: 100%;
min-width: 0;
padding: 0;
border: none;
background: transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
writing-mode: vertical-rl;
overflow: hidden;
transition: background 140ms var(--ease);
}
.drawer-tab :deep(svg) {
opacity: 0;
transition: opacity 140ms var(--ease);
}
.scene-drawer:hover .drawer-tab :deep(svg),
.scene-drawer.open .drawer-tab :deep(svg) {
opacity: 1;
}
.scene-drawer:hover .drawer-tab {
background: var(--panel-alt);
color: var(--text);
}
.scene-drawer.open .drawer-tab {
flex: 0 0 24px;
width: 24px;
background: var(--panel);
}
.drawer-panel {
/* Rigid 300px so the list layout doesn't reflow mid-animation. */
flex: 0 0 300px;
width: 300px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
overflow: hidden;
}
.drawer-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.title-icon {
color: var(--text-dim);
flex-shrink: 0;
}
.drawer-header h3 {
flex: 1;
margin: 0;
font-size: 13px;
font-weight: 600;
}
.add-btn {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--border);
background: transparent;
border-radius: var(--radius-sm);
color: var(--text-dim);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.add-btn:hover {
color: var(--text);
background: var(--panel-alt);
border-color: var(--accent);
}
.scene-list {
flex: 1;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.scene-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-alt);
color: var(--text);
font-size: 12px;
cursor: pointer;
text-align: left;
}
.scene-row:hover {
border-color: var(--accent);
}
.scene-row.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
}
.scene-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-meta {
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-size: 10px;
padding: 1px 6px;
border-radius: 999px;
background: var(--border);
}
.scene-row.active .scene-meta {
background: var(--accent);
color: var(--panel);
}
.scene-menu {
position: fixed;
z-index: 2000;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
padding: 4px;
min-width: 160px;
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: none;
background: transparent;
color: var(--text);
font-size: 12px;
cursor: pointer;
border-radius: var(--radius-sm);
text-align: left;
}
.menu-item:hover:not(:disabled) {
background: var(--panel-alt);
}
.menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-item.danger {
color: var(--danger, #c55);
}
.menu-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.menu-icon {
color: var(--text-dim);
flex-shrink: 0;
}
</style>
+4 -1
View File
@@ -69,7 +69,10 @@ function formatDate(ts: number): string {
}
function layerCount(p: StoredProject): number {
return p.scene?.layers?.length ?? 0
// Sum across all pages. Legacy `scene`-only records are migrated to
// `project` on read, so we only need to look at `project.scenes`.
if (!p.project) return 0
return p.project.scenes.reduce((n, s) => n + s.layers.length, 0)
}
function openProject(p: StoredProject) {