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">
import { layout } from '$lib/stores/layout.svelte.js';
import { SYMBOLS } from '$lib/symbols.js';
import { startPaletteDrag, startLabelDrag } from '$lib/canvas/interactions.js';
import { startPaletteDrag } from '$lib/canvas/interactions.js';
interface DeviceEntry {
id: string;
@ -12,7 +12,6 @@
let allDevices = $state<Record<string, DeviceEntry[]>>({});
let collapsed = $state(false);
let collapsedZones = $state<Set<string>>(new Set());
let activeTab = $state<'devices' | 'ids'>('devices');
let searchQuery = $state('');
// Load manifest
@ -31,18 +30,22 @@
return list.filter(d => !placedLabels.has(d.id));
});
// Apply search filter
// Apply search filter + always sort by type ascending
let filteredDevices = $derived.by(() => {
if (!searchQuery.trim()) return mcmDevices;
let devices = mcmDevices;
if (searchQuery.trim()) {
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
let svgGroups = $derived.by(() => {
const map = new Map<string, DeviceEntry[]>();
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, []);
map.get(name)!.push(d);
}
@ -71,32 +74,9 @@
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 {
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>
<div class="device-dock" class:collapsed>
@ -106,28 +86,17 @@
{#if !collapsed}
<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">
<span class="dock-title">Devices</span>
<span class="dock-count">{totalAll - totalCount}/{totalAll} placed</span>
</div>
<div class="dock-hint">Drop on empty space to place, on symbol to assign ID</div>
<div class="search-box">
<input
type="text"
placeholder="Search devices..."
placeholder="Search..."
bind:value={searchQuery}
/>
{#if searchQuery}
@ -151,7 +120,7 @@
{#each devices as device (device.id)}
<button
class="device-item"
title={device.id}
title="{device.id} — drag to place or assign"
onmousedown={(e) => onMousedown(e, device)}
>
<img
@ -166,49 +135,6 @@
{/if}
{/each}
</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>
{/if}
</div>
@ -264,54 +190,36 @@
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 {
font-size: 10px;
padding: 2px 6px 4px;
padding: 2px 6px 2px;
flex-shrink: 0;
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
}
.dock-title {
font-size: 10px;
font-weight: 600;
color: #8899aa;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dock-count {
font-size: 9px;
color: #667;
font-weight: 400;
}
.dock-hint {
font-size: 8px;
color: #445;
padding: 0 6px 4px;
flex-shrink: 0;
}
/* Search */
.search-box {
position: relative;
@ -469,66 +377,4 @@
overflow: hidden;
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>

View File

@ -471,6 +471,15 @@ function onMousemove(e: MouseEvent) {
const sym = dragState.symbolDef;
dragState.ghost.style.left = (e.clientX - sym.w / 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) {
@ -626,9 +635,23 @@ function onMouseup(e: MouseEvent) {
if (dragState.type === 'palette' && dragState.ghost && dragState.symbolDef) {
dragState.ghost.remove();
layout.labelDropTarget = null;
const sym = dragState.symbolDef;
const rot = sym.defaultRotation || 0;
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 dropY = pos.y - sym.h / 2;
@ -653,6 +676,7 @@ function onMouseup(e: MouseEvent) {
});
}
}
}
if (dragState.type === 'move' && dragState.placedId != null) {
if (dragState.dragActivated) {