- Extract 25+ hardcoded colors/sizes to render-theme.ts - Extract pure hit-testing functions to hit-testing.ts (-104 lines from interactions.ts) - Remove 11 legacy re-exports from symbols.ts, use config objects directly - Fix preloadSymbolImages async/await anti-pattern - Extract ensureEpcWaypoints() helper (3x dedup) - Fix PDF not clearing on MCM switch - Fix MCM persistence on reload - Change defaults: grid off, grid size 2, min spacing 2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
468 lines
13 KiB
Svelte
468 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { layout } from '$lib/stores/layout.svelte.js';
|
|
import { exportSVG, loadLayoutJSON } from '$lib/export.js';
|
|
import { loadPdfFile, loadPdfFromPath, pdfZoomIn, pdfZoomOut, removePdf, toggleEditBackground, restorePdf } from '$lib/pdf.js';
|
|
import { discoverProjects } from '$lib/projects.js';
|
|
import { onMount } from 'svelte';
|
|
import Palette from './Palette.svelte';
|
|
|
|
let importFileEl = $state<HTMLInputElement>(null!);
|
|
let pdfFileEl = $state<HTMLInputElement>(null!);
|
|
let toolbarCollapsed = $state(false);
|
|
|
|
let projectOpen = $state(false);
|
|
let pdfOpen = $state(false);
|
|
let settingsOpen = $state(false);
|
|
|
|
onMount(async () => {
|
|
const projects = await discoverProjects();
|
|
const savedProject = localStorage.getItem('scada_lastProject');
|
|
const savedMcm = localStorage.getItem('scada_lastMcm');
|
|
|
|
if (projects.length > 0) {
|
|
layout.projects = projects;
|
|
const proj = (savedProject && projects.find(p => p.name === savedProject)) || projects[0];
|
|
layout.currentProject = proj.name;
|
|
const mcm = (savedMcm && proj.mcms.find(m => m.name === savedMcm)) || proj.mcms[0];
|
|
if (mcm) layout.currentMcm = mcm.name;
|
|
layout.loadMcmState();
|
|
} else {
|
|
layout.currentProject = savedProject || 'default';
|
|
layout.currentMcm = savedMcm || 'MCM01';
|
|
layout.loadMcmState();
|
|
}
|
|
localStorage.setItem('scada_lastProject', layout.currentProject);
|
|
localStorage.setItem('scada_lastMcm', layout.currentMcm);
|
|
await restorePdf();
|
|
});
|
|
|
|
function onProjectChange(e: Event) {
|
|
layout.saveMcmState();
|
|
const val = (e.target as HTMLSelectElement).value;
|
|
layout.currentProject = val;
|
|
const proj = layout.projects.find(p => p.name === val);
|
|
if (proj && proj.mcms.length > 0) {
|
|
layout.currentMcm = proj.mcms[0].name;
|
|
}
|
|
localStorage.setItem('scada_lastProject', layout.currentProject);
|
|
localStorage.setItem('scada_lastMcm', layout.currentMcm);
|
|
layout.loadMcmState();
|
|
autoLoadPdf();
|
|
}
|
|
|
|
function onMcmChange(e: Event) {
|
|
layout.saveMcmState();
|
|
layout.currentMcm = (e.target as HTMLSelectElement).value;
|
|
localStorage.setItem('scada_lastMcm', layout.currentMcm);
|
|
layout.loadMcmState();
|
|
autoLoadPdf();
|
|
}
|
|
|
|
async function autoLoadPdf() {
|
|
const proj = layout.projects.find(p => p.name === layout.currentProject);
|
|
if (!proj) { removePdf(); return; }
|
|
const mcm = proj.mcms.find(m => m.name === layout.currentMcm);
|
|
if (!mcm?.pdfPath) { removePdf(); return; }
|
|
await loadPdfFromPath(mcm.pdfPath);
|
|
}
|
|
|
|
function onCanvasSizeChange() {
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
}
|
|
|
|
function onGridSizeChange() {
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
}
|
|
|
|
function onShowGridChange() {
|
|
layout.markDirty();
|
|
}
|
|
|
|
function onMinSpacingChange() {
|
|
layout.saveMcmState();
|
|
}
|
|
|
|
function clearCanvas() {
|
|
if (!confirm('Clear all placed symbols?')) return;
|
|
layout.clearAll();
|
|
}
|
|
|
|
async function onImportFile(e: Event) {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
try {
|
|
await loadLayoutJSON(file);
|
|
} catch (err) {
|
|
alert('Invalid layout file: ' + (err instanceof Error ? err.message : String(err)));
|
|
}
|
|
(e.target as HTMLInputElement).value = '';
|
|
}
|
|
|
|
async function onPdfFile(e: Event) {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
await loadPdfFile(file);
|
|
(e.target as HTMLInputElement).value = '';
|
|
}
|
|
|
|
let currentMcms = $derived(
|
|
layout.projects.find(p => p.name === layout.currentProject)?.mcms ?? []
|
|
);
|
|
</script>
|
|
|
|
<div class="toolbar" class:collapsed={toolbarCollapsed}>
|
|
<button class="collapse-btn" onclick={() => toolbarCollapsed = !toolbarCollapsed}>
|
|
{toolbarCollapsed ? '>' : '<'}
|
|
</button>
|
|
|
|
{#if !toolbarCollapsed}
|
|
<div class="toolbar-content">
|
|
<!-- Project section (collapsible) -->
|
|
<button class="section-toggle" onclick={() => projectOpen = !projectOpen}>
|
|
<span class="toggle-arrow">{projectOpen ? '\u25BC' : '\u25B6'}</span>
|
|
Project
|
|
<span class="section-summary">{layout.currentProject || '...'} / {layout.currentMcm || '...'}</span>
|
|
</button>
|
|
{#if projectOpen}
|
|
<div class="section-body">
|
|
{#if layout.projects.length > 0}
|
|
<div class="setting">
|
|
<label for="projectSelect">Project:</label>
|
|
<select id="projectSelect" value={layout.currentProject} onchange={onProjectChange}>
|
|
{#each layout.projects as proj (proj.name)}
|
|
<option value={proj.name}>{proj.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
<div class="setting">
|
|
<label for="mcmSelect">MCM:</label>
|
|
<select id="mcmSelect" value={layout.currentMcm} onchange={onMcmChange}>
|
|
{#each currentMcms as mcm (mcm.name)}
|
|
<option value={mcm.name}>{mcm.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{:else}
|
|
<div class="setting">
|
|
<label for="mcmSelect">MCM:</label>
|
|
<select id="mcmSelect" value={layout.currentMcm} onchange={onMcmChange}>
|
|
<option value="MCM08">MCM08</option>
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Background PDF section (collapsible) -->
|
|
<button class="section-toggle" onclick={() => pdfOpen = !pdfOpen}>
|
|
<span class="toggle-arrow">{pdfOpen ? '\u25BC' : '\u25B6'}</span>
|
|
Background PDF
|
|
{#if layout.pdfLoaded}
|
|
<span class="section-summary">{Math.round(layout.pdfScale * 100)}%</span>
|
|
{/if}
|
|
</button>
|
|
{#if pdfOpen}
|
|
<div class="section-body">
|
|
<div class="setting btn-row">
|
|
<button onclick={() => pdfFileEl.click()}>Load PDF</button>
|
|
<input bind:this={pdfFileEl} type="file" accept=".pdf" style="display:none" onchange={onPdfFile}>
|
|
</div>
|
|
<div class="setting btn-row">
|
|
<button onclick={pdfZoomIn}>PDF +</button>
|
|
<button onclick={pdfZoomOut}>PDF -</button>
|
|
</div>
|
|
<div class="setting btn-row">
|
|
<button
|
|
class:edit-bg-active={layout.editingBackground}
|
|
onclick={toggleEditBackground}
|
|
>
|
|
{layout.editingBackground ? 'Done' : 'Edit BG'}
|
|
</button>
|
|
<button onclick={removePdf}>Remove</button>
|
|
</div>
|
|
{#if layout.pdfLoaded}
|
|
<div class="pdf-info">
|
|
scale {Math.round(layout.pdfScale * 100)}% pos: {Math.round(layout.pdfOffsetX)},{Math.round(layout.pdfOffsetY)}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Settings section (collapsible) -->
|
|
<button class="section-toggle" onclick={() => settingsOpen = !settingsOpen}>
|
|
<span class="toggle-arrow">{settingsOpen ? '\u25BC' : '\u25B6'}</span>
|
|
Settings
|
|
</button>
|
|
{#if settingsOpen}
|
|
<div class="section-body">
|
|
<div class="setting canvas-size-row">
|
|
<div>
|
|
<label for="canvasW">W:</label>
|
|
<input type="number" id="canvasW" bind:value={layout.canvasW} min="800" max="7680" step="10" onchange={onCanvasSizeChange}>
|
|
</div>
|
|
<div>
|
|
<label for="canvasH">H:</label>
|
|
<input type="number" id="canvasH" bind:value={layout.canvasH} min="600" max="4320" step="10" onchange={onCanvasSizeChange}>
|
|
</div>
|
|
</div>
|
|
<div class="setting">
|
|
<label for="gridSize">Grid Size (px):</label>
|
|
<input
|
|
type="number" id="gridSize"
|
|
bind:value={layout.gridSize}
|
|
min="5" max="100" step="5"
|
|
onchange={onGridSizeChange}
|
|
>
|
|
</div>
|
|
<div class="setting">
|
|
<label for="minSpacing">Min Edge Spacing (px):</label>
|
|
<input
|
|
type="number" id="minSpacing"
|
|
bind:value={layout.minSpacing}
|
|
min="0" max="100" step="5"
|
|
onchange={onMinSpacingChange}
|
|
>
|
|
</div>
|
|
<div class="setting">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" bind:checked={layout.showGrid} onchange={onShowGridChange}>
|
|
Show Grid
|
|
</label>
|
|
</div>
|
|
<div class="setting">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" bind:checked={layout.snapEnabled}>
|
|
Snap to Grid
|
|
</label>
|
|
</div>
|
|
<div class="setting btn-row">
|
|
<button onclick={exportSVG}>Save SVG</button>
|
|
<button onclick={() => importFileEl.click()}>Load JSON</button>
|
|
<button onclick={clearCanvas}>Clear</button>
|
|
<input bind:this={importFileEl} type="file" accept=".json" style="display:none" onchange={onImportFile}>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Symbols section (always open, takes remaining space) -->
|
|
<div class="symbols-header">Symbols</div>
|
|
<Palette />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.toolbar {
|
|
width: 240px;
|
|
min-width: 240px;
|
|
background: #16213e;
|
|
border-right: 2px solid #0f3460;
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: relative;
|
|
transition: width 0.2s ease, min-width 0.2s ease;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.toolbar.collapsed {
|
|
width: 32px;
|
|
min-width: 32px;
|
|
}
|
|
|
|
.collapse-btn {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 4px;
|
|
width: 24px;
|
|
height: 24px;
|
|
background: #0f3460;
|
|
border: 1px solid #1a1a5e;
|
|
color: #e0e0e0;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
z-index: 10;
|
|
font-size: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.collapse-btn:hover {
|
|
background: #e94560;
|
|
}
|
|
|
|
.toolbar-content {
|
|
padding: 6px;
|
|
overflow: hidden;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* Collapsible section toggle */
|
|
.section-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
width: 100%;
|
|
padding: 5px 6px;
|
|
margin-bottom: 2px;
|
|
background: #0d2847;
|
|
border: 1px solid #1a1a5e;
|
|
border-radius: 4px;
|
|
color: #e94560;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
text-align: left;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.section-toggle:hover {
|
|
background: #122d52;
|
|
}
|
|
|
|
.toggle-arrow {
|
|
font-size: 8px;
|
|
width: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.section-summary {
|
|
margin-left: auto;
|
|
font-size: 9px;
|
|
color: #667;
|
|
font-weight: 400;
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.section-body {
|
|
padding: 4px 2px 6px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Symbols header (always visible) */
|
|
.symbols-header {
|
|
font-size: 10px;
|
|
color: #e94560;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
font-weight: 600;
|
|
padding: 5px 6px 3px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.setting {
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.setting label {
|
|
display: block;
|
|
font-size: 10px;
|
|
margin-bottom: 2px;
|
|
color: #8899aa;
|
|
}
|
|
|
|
.checkbox-label {
|
|
display: flex !important;
|
|
align-items: center;
|
|
gap: 6px;
|
|
cursor: pointer;
|
|
font-size: 11px !important;
|
|
color: #ccc !important;
|
|
}
|
|
|
|
.setting input[type="number"],
|
|
.setting select {
|
|
width: 100%;
|
|
padding: 4px 6px;
|
|
background: #0f3460;
|
|
border: 1px solid #1a1a5e;
|
|
color: #e0e0e0;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
transition: border-color 0.15s;
|
|
outline: none;
|
|
}
|
|
|
|
.setting input[type="number"]:focus,
|
|
.setting select:focus {
|
|
border-color: #e94560;
|
|
}
|
|
|
|
.setting select {
|
|
cursor: pointer;
|
|
appearance: none;
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238899aa'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
|
|
background-repeat: no-repeat;
|
|
background-position: right 6px center;
|
|
padding-right: 24px;
|
|
}
|
|
|
|
.setting input[type="checkbox"] {
|
|
accent-color: #e94560;
|
|
width: 13px;
|
|
height: 13px;
|
|
}
|
|
|
|
.btn-row {
|
|
display: flex;
|
|
gap: 3px;
|
|
}
|
|
|
|
.btn-row button,
|
|
.setting > button {
|
|
flex: 1;
|
|
padding: 5px 6px;
|
|
background: #0f3460;
|
|
border: 1px solid #1a1a5e;
|
|
color: #e0e0e0;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
transition: all 0.15s ease;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.btn-row button:hover,
|
|
.setting > button:hover {
|
|
background: #e94560;
|
|
border-color: #e94560;
|
|
}
|
|
|
|
.btn-row button:active,
|
|
.setting > button:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.edit-bg-active {
|
|
background: #e94560 !important;
|
|
border-color: #e94560 !important;
|
|
color: #fff !important;
|
|
}
|
|
|
|
.canvas-size-row {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.canvas-size-row > div {
|
|
flex: 1;
|
|
}
|
|
|
|
.pdf-info {
|
|
font-size: 9px;
|
|
color: #667;
|
|
padding: 2px 0;
|
|
}
|
|
</style>
|