Merge device dock into single smart list: drop to place or assign ID

- Remove tabs, single unified device list grouped by type
- Drop on empty space → places new symbol with device ID
- Drop on existing symbol → assigns the device ID as label
- Drop-target highlight shown when hovering over existing symbols
- Always sorted ascending by device type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
igurielidze 2026-03-21 19:07:42 +04:00
parent d09ffd4a22
commit 4f1d680406
2 changed files with 115 additions and 245 deletions

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { layout } from '$lib/stores/layout.svelte.js'; import { layout } from '$lib/stores/layout.svelte.js';
import { SYMBOLS } from '$lib/symbols.js'; import { SYMBOLS } from '$lib/symbols.js';
import { startPaletteDrag, startLabelDrag } from '$lib/canvas/interactions.js'; import { startPaletteDrag } from '$lib/canvas/interactions.js';
interface DeviceEntry { interface DeviceEntry {
id: string; id: string;
@ -12,7 +12,6 @@
let allDevices = $state<Record<string, DeviceEntry[]>>({}); let allDevices = $state<Record<string, DeviceEntry[]>>({});
let collapsed = $state(false); let collapsed = $state(false);
let collapsedZones = $state<Set<string>>(new Set()); let collapsedZones = $state<Set<string>>(new Set());
let activeTab = $state<'devices' | 'ids'>('devices');
let searchQuery = $state(''); let searchQuery = $state('');
// Load manifest // Load manifest
@ -31,18 +30,22 @@
return list.filter(d => !placedLabels.has(d.id)); return list.filter(d => !placedLabels.has(d.id));
}); });
// Apply search filter // Apply search filter + always sort by type ascending
let filteredDevices = $derived.by(() => { let filteredDevices = $derived.by(() => {
if (!searchQuery.trim()) return mcmDevices; let devices = mcmDevices;
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase(); const q = searchQuery.trim().toLowerCase();
return mcmDevices.filter(d => d.id.toLowerCase().includes(q) || d.svg.toLowerCase().includes(q)); devices = devices.filter(d => d.id.toLowerCase().includes(q) || d.svg.toLowerCase().includes(q) || getSymbolName(d.svg).toLowerCase().includes(q));
}
devices = [...devices].sort((a, b) => a.svg.localeCompare(b.svg) || a.id.localeCompare(b.id));
return devices;
}); });
// Group by SVG type // Group by SVG type
let svgGroups = $derived.by(() => { let svgGroups = $derived.by(() => {
const map = new Map<string, DeviceEntry[]>(); const map = new Map<string, DeviceEntry[]>();
for (const d of filteredDevices) { for (const d of filteredDevices) {
const name = SYMBOLS.find(s => s.id === d.svg)?.name || d.svg; const name = getSymbolName(d.svg);
if (!map.has(name)) map.set(name, []); if (!map.has(name)) map.set(name, []);
map.get(name)!.push(d); map.get(name)!.push(d);
} }
@ -71,32 +74,9 @@
return SYMBOLS.find(s => s.id === svgId)?.file || ''; return SYMBOLS.find(s => s.id === svgId)?.file || '';
} }
// --- IDs tab ---
let idSearchQuery = $state('');
// Unassigned device IDs: all MCM device IDs minus already-used labels
let unassignedIds = $derived.by(() => {
const list = allDevices[layout.currentMcm] || [];
const placedLabels = new Set(layout.symbols.map(s => s.label).filter(Boolean));
let ids = list.filter(d => !placedLabels.has(d.id));
if (idSearchQuery.trim()) {
const q = idSearchQuery.trim().toLowerCase();
ids = ids.filter(d => d.id.toLowerCase().includes(q) || d.svg.toLowerCase().includes(q));
}
// Always sort ascending by device type, then by ID
ids.sort((a, b) => a.svg.localeCompare(b.svg) || a.id.localeCompare(b.id));
return ids;
});
function getSymbolName(svgId: string): string { function getSymbolName(svgId: string): string {
return SYMBOLS.find(s => s.id === svgId)?.name || svgId; return SYMBOLS.find(s => s.id === svgId)?.name || svgId;
} }
function onLabelMousedown(e: MouseEvent, deviceId: string) {
if (e.button !== 0) return;
e.preventDefault();
startLabelDrag(e, deviceId);
}
</script> </script>
<div class="device-dock" class:collapsed> <div class="device-dock" class:collapsed>
@ -106,28 +86,17 @@
{#if !collapsed} {#if !collapsed}
<div class="dock-content"> <div class="dock-content">
<!-- Tabs -->
<div class="tabs">
<button
class="tab" class:active={activeTab === 'devices'}
onclick={() => activeTab = 'devices'}
>Devices</button>
<button
class="tab" class:active={activeTab === 'ids'}
onclick={() => activeTab = 'ids'}
>IDs</button>
</div>
<!-- ============ DEVICES TAB ============ -->
{#if activeTab === 'devices'}
<div class="dock-header"> <div class="dock-header">
<span class="dock-title">Devices</span>
<span class="dock-count">{totalAll - totalCount}/{totalAll} placed</span> <span class="dock-count">{totalAll - totalCount}/{totalAll} placed</span>
</div> </div>
<div class="dock-hint">Drop on empty space to place, on symbol to assign ID</div>
<div class="search-box"> <div class="search-box">
<input <input
type="text" type="text"
placeholder="Search devices..." placeholder="Search..."
bind:value={searchQuery} bind:value={searchQuery}
/> />
{#if searchQuery} {#if searchQuery}
@ -151,7 +120,7 @@
{#each devices as device (device.id)} {#each devices as device (device.id)}
<button <button
class="device-item" class="device-item"
title={device.id} title="{device.id} — drag to place or assign"
onmousedown={(e) => onMousedown(e, device)} onmousedown={(e) => onMousedown(e, device)}
> >
<img <img
@ -166,49 +135,6 @@
{/if} {/if}
{/each} {/each}
</div> </div>
<!-- ============ IDS TAB ============ -->
{:else}
<div class="dock-header">
<span class="dock-count">{unassignedIds.length} unassigned</span>
</div>
<div class="search-box">
<input
type="text"
placeholder="Search IDs..."
bind:value={idSearchQuery}
/>
{#if idSearchQuery}
<button class="clear-search" onclick={() => idSearchQuery = ''}>x</button>
{/if}
</div>
<div class="ids-hint">Drag onto a symbol to assign</div>
{#if unassignedIds.length === 0}
<div class="dock-empty">{idSearchQuery ? 'No matches' : 'All IDs assigned'}</div>
{/if}
<div class="dock-list">
{#each unassignedIds as device (device.id)}
<button
class="id-drag-item"
title="Drag onto a symbol to assign: {device.id} ({getSymbolName(device.svg)})"
onmousedown={(e) => onLabelMousedown(e, device.id)}
>
<img
class="id-symbol-icon"
src={getSymbolFile(device.svg)}
alt={device.svg}
draggable="false"
/>
<span class="id-label">{device.id}</span>
<span class="id-type">{getSymbolName(device.svg)}</span>
</button>
{/each}
</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@ -264,54 +190,36 @@
min-height: 0; min-height: 0;
} }
/* Tabs */
.tabs {
display: flex;
gap: 2px;
flex-shrink: 0;
margin-bottom: 4px;
}
.tab {
flex: 1;
padding: 5px 0;
background: #0d2847;
border: 1px solid #1a1a5e;
border-radius: 4px 4px 0 0;
color: #667;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: all 0.15s;
}
.tab:hover {
color: #aaa;
}
.tab.active {
background: #0f3460;
color: #e94560;
border-bottom-color: #0f3460;
}
.dock-header { .dock-header {
font-size: 10px; font-size: 10px;
padding: 2px 6px 4px; padding: 2px 6px 2px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
align-items: center; align-items: center;
} }
.dock-title {
font-size: 10px;
font-weight: 600;
color: #8899aa;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dock-count { .dock-count {
font-size: 9px; font-size: 9px;
color: #667; color: #667;
font-weight: 400; font-weight: 400;
} }
.dock-hint {
font-size: 8px;
color: #445;
padding: 0 6px 4px;
flex-shrink: 0;
}
/* Search */ /* Search */
.search-box { .search-box {
position: relative; position: relative;
@ -469,66 +377,4 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* IDs tab */
.ids-hint {
font-size: 9px;
color: #556;
padding: 0 6px 4px;
flex-shrink: 0;
}
.id-drag-item {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
padding: 3px 6px;
margin-bottom: 1px;
background: #0f3460;
border: 1px solid #1a1a5e;
border-radius: 3px;
cursor: grab;
user-select: none;
transition: all 0.15s ease;
color: #e0e0e0;
font-size: 10px;
font-weight: 500;
text-align: left;
}
.id-symbol-icon {
width: 20px;
height: 14px;
object-fit: contain;
flex-shrink: 0;
filter: invert(1);
opacity: 0.7;
}
.id-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.id-type {
font-size: 8px;
color: #556;
white-space: nowrap;
flex-shrink: 0;
}
.id-drag-item:hover {
background: #1a1a5e;
border-color: #e94560;
transform: translateX(-2px);
}
.id-drag-item:active {
cursor: grabbing;
transform: scale(0.98);
}
</style> </style>

View File

@ -471,6 +471,15 @@ function onMousemove(e: MouseEvent) {
const sym = dragState.symbolDef; const sym = dragState.symbolDef;
dragState.ghost.style.left = (e.clientX - sym.w / 2) + 'px'; dragState.ghost.style.left = (e.clientX - sym.w / 2) + 'px';
dragState.ghost.style.top = (e.clientY - sym.h / 2) + 'px'; dragState.ghost.style.top = (e.clientY - sym.h / 2) + 'px';
// Show drop-target highlight when dragging a device over an existing symbol
if (dragState.deviceLabel) {
const pos = screenToCanvas(e.clientX, e.clientY);
const hoverId = hitTest(pos.x, pos.y);
if (layout.labelDropTarget !== hoverId) {
layout.labelDropTarget = hoverId;
layout.markDirty();
}
}
} }
if (dragState.type === 'assign-label' && dragState.ghost) { if (dragState.type === 'assign-label' && dragState.ghost) {
@ -626,9 +635,23 @@ function onMouseup(e: MouseEvent) {
if (dragState.type === 'palette' && dragState.ghost && dragState.symbolDef) { if (dragState.type === 'palette' && dragState.ghost && dragState.symbolDef) {
dragState.ghost.remove(); dragState.ghost.remove();
layout.labelDropTarget = null;
const sym = dragState.symbolDef; const sym = dragState.symbolDef;
const rot = sym.defaultRotation || 0; const rot = sym.defaultRotation || 0;
const pos = screenToCanvas(e.clientX, e.clientY); const pos = screenToCanvas(e.clientX, e.clientY);
// If dropped onto an existing symbol and we have a device label, assign the label
const hitId = dragState.deviceLabel ? hitTest(pos.x, pos.y) : null;
if (hitId !== null && dragState.deviceLabel) {
const target = layout.symbols.find(s => s.id === hitId);
if (target) {
layout.pushUndo();
target.label = dragState.deviceLabel;
layout.markDirty();
layout.saveMcmState();
}
} else {
// Drop onto empty space: place a new symbol
let dropX = pos.x - sym.w / 2; let dropX = pos.x - sym.w / 2;
let dropY = pos.y - sym.h / 2; let dropY = pos.y - sym.h / 2;
@ -653,6 +676,7 @@ function onMouseup(e: MouseEvent) {
}); });
} }
} }
}
if (dragState.type === 'move' && dragState.placedId != null) { if (dragState.type === 'move' && dragState.placedId != null) {
if (dragState.dragActivated) { if (dragState.dragActivated) {