<!--
Copyright 2025 The Ray Optics Simulation authors and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<template>
<div id="sidebar" :class="{ 'sidebar-visible': showSidebar }" :style="{ width: sidebarWidth + 'px' }" :data-width="sidebarWidth">
<div id="sidebarMobileHeightDiff" class="d-none d-lg-block sidebar-mobile-height-diff"></div>
<div id="jsonEditorContainer">
<div class="sidebar-tabs" role="tablist" aria-label="Sidebar tabs">
<div class="sidebar-tabs-left">
<button
type="button"
class="sidebar-tab"
:class="{ active: activeTab === 'visual' }"
role="tab"
:aria-selected="activeTab === 'visual'"
@click="setActiveTab('visual')"
>
{{ $t('simulator:sidebar.tabs.visual') }}<sup style="color: #fff9;">Beta</sup>
</button>
<button
type="button"
class="sidebar-tab"
:class="{ active: activeTab === 'code' }"
role="tab"
:aria-selected="activeTab === 'code'"
@click="setActiveTab('code')"
>
{{ $t('simulator:sidebar.tabs.code') }}
</button>
<button
type="button"
class="sidebar-tab"
:class="{ active: activeTab === 'ai' }"
role="tab"
:aria-selected="activeTab === 'ai'"
@click="setActiveTab('ai')"
>
{{ $t('simulator:sidebar.tabs.ai') }}
</button>
</div>
<button
type="button"
class="sidebar-collapse-btn"
aria-label="Hide sidebar"
@click.stop="hideSidebar"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
<path d="M12 2.8L7.2 8 12 13.2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.2 2.8L2.4 8 7.2 13.2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="sidebar-tab-content">
<!-- Keep VisualTab alive while the drawer is open (tab switches); unmount when collapsed to save work. -->
<VisualTab v-if="showSidebar" v-show="activeTab === 'visual'" />
<div id="jsonEditor" v-show="activeTab === 'code'"></div>
<AITab v-show="activeTab === 'ai'" />
</div>
</div>
<div
class="resize-handle"
@mousedown="startResize"
@touchstart="startResize"
></div>
</div>
<!-- Hidden state hover region -->
<div
class="drawer-hover-region"
:class="{ 'drawer-hover-region-active': !showSidebar }"
>
<div class="d-none d-lg-block sidebar-mobile-height-diff"></div>
<button
type="button"
class="drawer-toggle-expand"
:class="{ 'drawer-toggle-expand--hint': expandHintPeek }"
aria-label="Show sidebar"
@click="expandSidebar"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 2.8L8.8 8 4 13.2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.8 2.8L13.6 8 8.8 13.2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</template>
<script>
/**
* @module Sidebar
* @description The Vue component for the sidebar containing the JSON editor.
*/
import { usePreferencesStore } from '../store/preferences'
import { toRef, ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { jsonEditorService } from '../services/jsonEditor'
import VisualTab from './sidebar/VisualTab.vue'
import AITab from './sidebar/AITab.vue'
export default {
name: 'Sidebar',
components: { VisualTab, AITab },
setup() {
const preferences = usePreferencesStore()
const sidebarWidth = toRef(preferences, 'sidebarWidth')
const showSidebar = toRef(preferences, 'showSidebar')
const activeTab = toRef(preferences, 'sidebarTab')
const isResizing = ref(false)
const startX = ref(0)
const startWidth = ref(0)
/** Briefly show the expand control after closing the sidebar so users notice where to reopen it. */
const EXPAND_HINT_VISIBLE_MS = 1200
const expandHintPeek = ref(false)
let expandHintHideTimer = null
const clearExpandHintTimer = () => {
if (expandHintHideTimer !== null) {
clearTimeout(expandHintHideTimer)
expandHintHideTimer = null
}
}
// Keyboard event handler to prevent propagation
const handleKeyboardEvent = (e) => {
// Stop the event from propagating to the body
e.stopPropagation()
}
const startResize = (e) => {
e.preventDefault() // Prevent default touch behavior
const initialWidth = sidebarWidth.value
isResizing.value = true
// Handle both mouse and touch events
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX
startX.value = clientX
startWidth.value = initialWidth
// Add both mouse and touch event listeners
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
document.addEventListener('touchmove', handleResize, { passive: false })
document.addEventListener('touchend', stopResize)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
}
const handleResize = (e) => {
if (!isResizing.value) return
// Prevent default touch behavior
if (e.type === 'touchmove') {
e.preventDefault()
}
// Handle both mouse and touch events
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX
const deltaX = clientX - startX.value
const newWidth = Math.max(250, Math.min(800, startWidth.value + deltaX))
sidebarWidth.value = newWidth
// Trigger Ace editor resize with a small delay
setTimeout(() => {
if (jsonEditorService.aceEditor) {
jsonEditorService.aceEditor.resize()
}
}, 0)
}
const stopResize = () => {
isResizing.value = false
// Remove both mouse and touch event listeners
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
document.removeEventListener('touchmove', handleResize)
document.removeEventListener('touchend', stopResize)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
const hideSidebar = () => {
showSidebar.value = false
}
const expandSidebar = () => {
showSidebar.value = true
// Helps Ace re-measure if it was off-screen during the transition.
setTimeout(() => {
if (jsonEditorService.aceEditor) {
jsonEditorService.aceEditor.resize()
}
}, 350)
}
const setActiveTab = (tab) => {
activeTab.value = tab
}
const handleOpenVisualModuleEditor = async (event) => {
const moduleName = event?.detail?.moduleName
if (!moduleName) return
showSidebar.value = true
activeTab.value = 'visual'
await nextTick()
document.dispatchEvent(new CustomEvent('selectVisualModuleTab', { detail: { moduleName } }))
}
const handleOpenVisualCreateModule = async (event) => {
const moduleName = event?.detail?.moduleName
if (!moduleName) return
showSidebar.value = true
activeTab.value = 'visual'
await nextTick()
document.dispatchEvent(new CustomEvent('applyVisualNewModule', { detail: { moduleName } }))
}
// If we show the code editor after being hidden or after being on another tab,
// ask Ace to re-measure.
const resizeAceSoon = async () => {
await nextTick()
setTimeout(() => {
if (jsonEditorService.aceEditor) {
jsonEditorService.aceEditor.resize()
}
}, 0)
}
watch(activeTab, (tab) => {
if (tab === 'code') {
resizeAceSoon()
}
})
watch(showSidebar, (isShown) => {
if (isShown && activeTab.value === 'code') {
// Wait for the drawer slide-in transition as well.
setTimeout(() => resizeAceSoon(), 320)
}
if (isShown) {
clearExpandHintTimer()
expandHintPeek.value = false
} else {
clearExpandHintTimer()
expandHintPeek.value = true
expandHintHideTimer = window.setTimeout(() => {
expandHintHideTimer = null
expandHintPeek.value = false
}, EXPAND_HINT_VISIBLE_MS)
}
})
onMounted(() => {
document.addEventListener('openVisualModuleEditor', handleOpenVisualModuleEditor)
document.addEventListener('openVisualCreateModule', handleOpenVisualCreateModule)
// Add keyboard event listeners to prevent propagation from JSON editor
const jsonEditor = document.getElementById('jsonEditor')
if (jsonEditor) {
jsonEditor.addEventListener('keydown', handleKeyboardEvent, false)
jsonEditor.addEventListener('keyup', handleKeyboardEvent, false)
jsonEditor.addEventListener('keypress', handleKeyboardEvent, false)
}
})
onUnmounted(() => {
clearExpandHintTimer()
document.removeEventListener('openVisualModuleEditor', handleOpenVisualModuleEditor)
document.removeEventListener('openVisualCreateModule', handleOpenVisualCreateModule)
// Clean up event listeners if component is destroyed during resize
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
document.removeEventListener('touchmove', handleResize)
document.removeEventListener('touchend', stopResize)
// Clean up keyboard event listeners
const jsonEditor = document.getElementById('jsonEditor')
if (jsonEditor) {
jsonEditor.removeEventListener('keydown', handleKeyboardEvent, false)
jsonEditor.removeEventListener('keyup', handleKeyboardEvent, false)
jsonEditor.removeEventListener('keypress', handleKeyboardEvent, false)
}
})
return {
showSidebar,
sidebarWidth,
activeTab,
expandHintPeek,
startResize,
hideSidebar,
expandSidebar,
setActiveTab
}
}
}
</script>
<style scoped>
#sidebar {
position: absolute;
z-index: -2;
top: 46px;
left: 0;
max-width: 100%;
height: calc(100% - 46px);
display: flex;
flex-direction: column;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
pointer-events: none;
}
#sidebar.sidebar-visible {
transform: translateX(0);
pointer-events: auto;
}
.sidebar-mobile-height-diff {
height: 22px;
}
#jsonEditorContainer {
width: 100%;
flex-grow: 1;
background-color:rgba(45, 51, 57,0.8);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
position: relative;
display: flex;
flex-direction: column;
}
.sidebar-tabs {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 6px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.sidebar-tabs-left {
display: flex;
gap: 4px;
}
.sidebar-collapse-btn {
margin-left: auto;
appearance: none;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.7);
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.sidebar-collapse-btn:hover {
background: rgba(60, 65, 70, 0.55);
color: rgba(255, 255, 255, 0.95);
}
.sidebar-collapse-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.22);
outline-offset: 2px;
}
.sidebar-collapse-btn svg {
display: block;
width: 14px;
height: 14px;
}
.sidebar-tab {
appearance: none;
border: none;
background: rgba(60, 65, 70, 0.35);
color: rgba(255, 255, 255, 0.75);
font-size: 12px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.sidebar-tab:hover {
background: rgba(60, 65, 70, 0.55);
color: rgba(255, 255, 255, 0.9);
}
.sidebar-tab.active {
background: rgba(90, 95, 100, 0.9);
color: rgba(255, 255, 255, 0.95);
}
.sidebar-tab:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.22);
outline-offset: 2px;
}
.sidebar-tab-content {
flex-grow: 1;
min-height: 0;
position: relative;
}
#jsonEditor {
width: 100%;
height: 100%
}
.resize-handle {
position: absolute;
top: 0;
right: 0;
width: 4px;
height: 100%;
cursor: ew-resize;
background-color: transparent;
transition: background-color 0.2s ease;
}
.resize-handle:hover {
background-color: rgba(255, 255, 255, 0.2);
}
/* Drawer toggle button on resize handle */
.drawer-toggle {
position: absolute;
top: 50%;
right: -5px;
transform: translateY(-50%);
background-color: rgb(80, 84, 88);
border: none;
border-radius: 4px;
width: 14px;
height: 40px;
padding: 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease, width 0.2s ease, right 0.2s ease;
pointer-events: auto;
opacity: 0;
}
.resize-handle:hover .drawer-toggle,
.drawer-toggle:hover,
.drawer-toggle:focus-visible {
opacity: 1;
}
.drawer-toggle:hover,
.drawer-toggle:focus-visible {
background-color: rgb(90, 95, 100);
color: rgba(255, 255, 255, 0.9);
width: 16px;
right: -6px;
}
/* Hidden state hover region */
.drawer-hover-region {
position: absolute;
top: 46px;
left: 0;
width: 10px;
height: calc(100% - 46px);
z-index: 1000;
opacity: 0;
pointer-events: none;
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding-top: 8px;
}
.drawer-hover-region-active {
opacity: 1;
pointer-events: auto;
}
.drawer-toggle-expand {
position: relative;
left: 0;
background-color: rgb(55, 60, 65);
border: none;
border-radius: 0 4px 4px 0;
width: 20px;
height: 40px;
margin-top: 18px;
padding: 0 3px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.25);
cursor: pointer;
/* Ease-out fades when the post-collapse hint ends; hover still feels responsive via width/color. */
transition:
opacity 0.4s ease-out,
background-color 0.4s ease-out,
color 0.4s ease-out,
width 0.2s ease-out;
opacity: 0;
}
.drawer-toggle-expand svg {
display: block;
flex-shrink: 0;
width: 14px;
height: 14px;
}
.drawer-toggle-expand.drawer-toggle-expand--hint {
opacity: 1;
background-color: rgba(52, 56, 60, 0.72);
color: rgba(255, 255, 255, 0.52);
width: 22px;
}
.drawer-hover-region:hover .drawer-toggle-expand,
.drawer-toggle-expand:focus-visible {
opacity: 1;
background-color: rgba(60, 65, 70, 0.85);
color: rgba(255, 255, 255, 0.7);
width: 22px;
}
.drawer-toggle-expand:hover,
.drawer-toggle-expand:focus-visible {
background-color: rgba(70, 75, 80, 0.95);
color: rgba(255, 255, 255, 0.9);
width: 24px;
}
.drawer-toggle-expand:active {
background-color: rgba(85, 90, 95, 1);
}
</style>