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:
parent
d09ffd4a22
commit
4f1d680406
@ -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;
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
return mcmDevices.filter(d => d.id.toLowerCase().includes(q) || d.svg.toLowerCase().includes(q));
|
||||
let devices = mcmDevices;
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
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,109 +86,55 @@
|
||||
|
||||
{#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 class="dock-header">
|
||||
<span class="dock-title">Devices</span>
|
||||
<span class="dock-count">{totalAll - totalCount}/{totalAll} placed</span>
|
||||
</div>
|
||||
|
||||
<!-- ============ DEVICES TAB ============ -->
|
||||
{#if activeTab === 'devices'}
|
||||
<div class="dock-header">
|
||||
<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..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button class="clear-search" onclick={() => searchQuery = ''}>x</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if filteredDevices.length === 0}
|
||||
<div class="dock-empty">{searchQuery ? 'No matches' : 'All placed'}</div>
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button class="clear-search" onclick={() => searchQuery = ''}>x</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="dock-list">
|
||||
{#each svgGroups as [groupName, devices] (groupName)}
|
||||
<button class="zone-toggle" onclick={() => toggleGroup(groupName)}>
|
||||
<span class="chevron" class:open={!collapsedZones.has(groupName)}></span>
|
||||
{groupName}
|
||||
<span class="zone-count">{devices.length}</span>
|
||||
</button>
|
||||
{#if !collapsedZones.has(groupName)}
|
||||
<div class="zone-items">
|
||||
{#each devices as device (device.id)}
|
||||
<button
|
||||
class="device-item"
|
||||
title={device.id}
|
||||
onmousedown={(e) => onMousedown(e, device)}
|
||||
>
|
||||
<img
|
||||
src={getSymbolFile(device.svg)}
|
||||
alt={device.svg}
|
||||
draggable="false"
|
||||
/>
|
||||
<span class="device-label">{device.id}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/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 filteredDevices.length === 0}
|
||||
<div class="dock-empty">{searchQuery ? 'No matches' : 'All placed'}</div>
|
||||
{/if}
|
||||
|
||||
<div class="dock-list">
|
||||
{#each svgGroups as [groupName, devices] (groupName)}
|
||||
<button class="zone-toggle" onclick={() => toggleGroup(groupName)}>
|
||||
<span class="chevron" class:open={!collapsedZones.has(groupName)}></span>
|
||||
{groupName}
|
||||
<span class="zone-count">{devices.length}</span>
|
||||
</button>
|
||||
{#if !collapsedZones.has(groupName)}
|
||||
<div class="zone-items">
|
||||
{#each devices as device (device.id)}
|
||||
<button
|
||||
class="device-item"
|
||||
title="{device.id} — drag to place or assign"
|
||||
onmousedown={(e) => onMousedown(e, device)}
|
||||
>
|
||||
<img
|
||||
src={getSymbolFile(device.svg)}
|
||||
alt={device.svg}
|
||||
draggable="false"
|
||||
/>
|
||||
<span class="device-label">{device.id}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@ -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,31 +635,46 @@ 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);
|
||||
let dropX = pos.x - sym.w / 2;
|
||||
let dropY = pos.y - sym.h / 2;
|
||||
|
||||
if (dropX >= -sym.w && dropX <= layout.canvasW && dropY >= -sym.h && dropY <= layout.canvasH) {
|
||||
dropX = clamp(dropX, 0, layout.canvasW - sym.w);
|
||||
dropY = clamp(dropY, 0, layout.canvasH - sym.h);
|
||||
const valid = findValidPosition(-1, dropX, dropY, sym.w, sym.h, sym.id, rot, sym.curveAngle, sym.w2);
|
||||
layout.addSymbol({
|
||||
symbolId: sym.id,
|
||||
file: sym.file,
|
||||
name: sym.name,
|
||||
label: dragState.deviceLabel || '',
|
||||
x: valid.x,
|
||||
y: valid.y,
|
||||
w: sym.w,
|
||||
h: sym.h,
|
||||
w2: sym.w2,
|
||||
rotation: rot,
|
||||
curveAngle: sym.curveAngle,
|
||||
epcWaypoints: isEpcType(sym.id) ? EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp })) : undefined,
|
||||
pdpCBs: undefined,
|
||||
});
|
||||
// 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;
|
||||
|
||||
if (dropX >= -sym.w && dropX <= layout.canvasW && dropY >= -sym.h && dropY <= layout.canvasH) {
|
||||
dropX = clamp(dropX, 0, layout.canvasW - sym.w);
|
||||
dropY = clamp(dropY, 0, layout.canvasH - sym.h);
|
||||
const valid = findValidPosition(-1, dropX, dropY, sym.w, sym.h, sym.id, rot, sym.curveAngle, sym.w2);
|
||||
layout.addSymbol({
|
||||
symbolId: sym.id,
|
||||
file: sym.file,
|
||||
name: sym.name,
|
||||
label: dragState.deviceLabel || '',
|
||||
x: valid.x,
|
||||
y: valid.y,
|
||||
w: sym.w,
|
||||
h: sym.h,
|
||||
w2: sym.w2,
|
||||
rotation: rot,
|
||||
curveAngle: sym.curveAngle,
|
||||
epcWaypoints: isEpcType(sym.id) ? EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp })) : undefined,
|
||||
pdpCBs: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user