Improve the primitives sidebar
This commit is contained in:
parent
dfeed5d1a6
commit
bfcacb184f
6 changed files with 209 additions and 36 deletions
8
TODO.md
8
TODO.md
|
|
@ -76,10 +76,10 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
|
||||||
- [x] Apply theme to all components
|
- [x] Apply theme to all components
|
||||||
|
|
||||||
### Sidebar Behavior
|
### Sidebar Behavior
|
||||||
- [ ] Create collapsible sidebar component
|
- [x] Create collapsible sidebar component
|
||||||
- [ ] Add toggle buttons for left/right sidebars
|
- [x] Add toggle buttons for left/right sidebars
|
||||||
- [ ] Persist sidebar state to localStorage
|
- [x] Persist sidebar state to localStorage
|
||||||
- [ ] Handle responsive layout for narrow screens
|
- [x] Handle responsive layout for narrow screens
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,45 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import PrimitivesSidebar from './PrimitivesSidebar.vue'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'rapidmodel-sidebar-state'
|
||||||
|
|
||||||
|
const leftCollapsed = ref(false)
|
||||||
|
|
||||||
|
const leftSidebarWidth = computed(() =>
|
||||||
|
leftCollapsed.value ? '0' : 'var(--sidebar-width)'
|
||||||
|
)
|
||||||
|
|
||||||
|
function loadSidebarState() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const state = JSON.parse(stored)
|
||||||
|
leftCollapsed.value = state.leftCollapsed ?? false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors, use defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSidebarState() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
leftCollapsed: leftCollapsed.value,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(leftCollapsed, saveSidebarState)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSidebarState()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -9,17 +50,23 @@
|
||||||
</slot>
|
</slot>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<aside class="left-sidebar">
|
<div class="main-area">
|
||||||
<slot name="left-sidebar" />
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="viewport">
|
<main class="viewport">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<PrimitivesSidebar
|
||||||
|
v-model:collapsed="leftCollapsed"
|
||||||
|
class="left-sidebar"
|
||||||
|
:style="{ width: leftSidebarWidth }"
|
||||||
|
>
|
||||||
|
<slot name="left-sidebar" />
|
||||||
|
</PrimitivesSidebar>
|
||||||
|
|
||||||
<aside class="right-sidebar">
|
<aside class="right-sidebar">
|
||||||
<slot name="right-sidebar" />
|
<slot name="right-sidebar" />
|
||||||
</aside>
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="status-bar">
|
<footer class="status-bar">
|
||||||
<slot name="status-bar" />
|
<slot name="status-bar" />
|
||||||
|
|
@ -29,20 +76,16 @@
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-layout {
|
.app-layout {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-areas:
|
flex-direction: column;
|
||||||
'header header header'
|
|
||||||
'left-sidebar viewport right-sidebar'
|
|
||||||
'status-bar status-bar status-bar';
|
|
||||||
grid-template-columns: var(--sidebar-width) 1fr var(--sidebar-width);
|
|
||||||
grid-template-rows: var(--header-height) 1fr var(--status-bar-height);
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
grid-area: header;
|
flex-shrink: 0;
|
||||||
|
height: var(--header-height);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 var(--space-md);
|
padding: 0 var(--space-md);
|
||||||
|
|
@ -56,27 +99,41 @@
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-sidebar {
|
.main-area {
|
||||||
grid-area: left-sidebar;
|
flex: 1;
|
||||||
background: var(--color-bg-secondary);
|
position: relative;
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewport {
|
.viewport {
|
||||||
grid-area: viewport;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.left-sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.right-sidebar {
|
.right-sidebar {
|
||||||
grid-area: right-sidebar;
|
position: absolute;
|
||||||
background: var(--color-bg-secondary);
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: rgba(30, 30, 46, 0.85);
|
||||||
border-left: 1px solid var(--color-border);
|
border-left: 1px solid var(--color-border);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
grid-area: status-bar;
|
flex-shrink: 0;
|
||||||
|
height: var(--status-bar-height);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 var(--space-md);
|
padding: 0 var(--space-md);
|
||||||
|
|
@ -85,4 +142,18 @@
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive: auto-collapse left sidebar on narrow screens */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.left-sidebar {
|
||||||
|
width: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.left-sidebar,
|
||||||
|
.right-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
103
src/components/PrimitivesSidebar.vue
Normal file
103
src/components/PrimitivesSidebar.vue
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
collapsed?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
collapsed: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:collapsed': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isCollapsed = computed({
|
||||||
|
get: () => props.collapsed,
|
||||||
|
set: (value) => emit('update:collapsed', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isCollapsed.value = !isCollapsed.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="primitives-sidebar-container">
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
:class="{ 'toggle--floating': isCollapsed }"
|
||||||
|
:title="isCollapsed ? 'Expand primitives toolbox' : 'Collapse primitives toolbox'"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<span class="toggle-icon">{{ isCollapsed ? '»' : '«' }}</span>
|
||||||
|
</button>
|
||||||
|
<aside v-show="!isCollapsed" class="primitives-sidebar">
|
||||||
|
<div class="content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.primitives-sidebar-container {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primitives-sidebar {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(30, 30, 46, 0.85);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-xs);
|
||||||
|
right: var(--space-xs);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle--floating {
|
||||||
|
top: var(--space-sm);
|
||||||
|
left: var(--space-sm);
|
||||||
|
right: auto;
|
||||||
|
background: rgba(30, 30, 46, 0.85);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle:hover {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
padding-top: calc(var(--space-xs) + 24px + var(--space-xs));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -189,8 +189,8 @@ describe('useSceneStore', () => {
|
||||||
const list = store.objectList
|
const list = store.objectList
|
||||||
|
|
||||||
expect(list).toHaveLength(2)
|
expect(list).toHaveLength(2)
|
||||||
expect(list[0].name).toBe('A')
|
expect(list[0]?.name).toBe('A')
|
||||||
expect(list[1].name).toBe('B')
|
expect(list[1]?.name).toBe('B')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,9 @@ export const useSceneStore = defineStore('scene', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeObject(id: string): boolean {
|
function removeObject(id: string): boolean {
|
||||||
const index = objects.value.findIndex((o) => o.id === id)
|
const obj = objects.value.find((o) => o.id === id)
|
||||||
if (index === -1) return false
|
if (!obj) return false
|
||||||
|
|
||||||
const obj = objects.value[index]
|
|
||||||
if (threeScene) {
|
if (threeScene) {
|
||||||
threeScene.remove(obj.mesh)
|
threeScene.remove(obj.mesh)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ button {
|
||||||
background-color: var(--color-bg-tertiary);
|
background-color: var(--color-bg-tertiary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-fast);
|
transition: background-color var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue