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:
1 parent
517705bf55
commit
b57994c754
12 files changed
+1193
-176
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user