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">
|
<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;
|
||||||
const q = searchQuery.trim().toLowerCase();
|
if (searchQuery.trim()) {
|
||||||
return mcmDevices.filter(d => d.id.toLowerCase().includes(q) || d.svg.toLowerCase().includes(q));
|
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
|
// 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,109 +86,55 @@
|
|||||||
|
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<div class="dock-content">
|
<div class="dock-content">
|
||||||
<!-- Tabs -->
|
<div class="dock-header">
|
||||||
<div class="tabs">
|
<span class="dock-title">Devices</span>
|
||||||
<button
|
<span class="dock-count">{totalAll - totalCount}/{totalAll} placed</span>
|
||||||
class="tab" class:active={activeTab === 'devices'}
|
|
||||||
onclick={() => activeTab = 'devices'}
|
|
||||||
>Devices</button>
|
|
||||||
<button
|
|
||||||
class="tab" class:active={activeTab === 'ids'}
|
|
||||||
onclick={() => activeTab = 'ids'}
|
|
||||||
>IDs</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ DEVICES TAB ============ -->
|
<div class="dock-hint">Drop on empty space to place, on symbol to assign ID</div>
|
||||||
{#if activeTab === 'devices'}
|
|
||||||
<div class="dock-header">
|
|
||||||
<span class="dock-count">{totalAll - totalCount}/{totalAll} placed</span>
|
|
||||||
</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}
|
||||||
<button class="clear-search" onclick={() => searchQuery = ''}>x</button>
|
<button class="clear-search" onclick={() => searchQuery = ''}>x</button>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if filteredDevices.length === 0}
|
|
||||||
<div class="dock-empty">{searchQuery ? 'No matches' : 'All placed'}</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="dock-list">
|
{#if filteredDevices.length === 0}
|
||||||
{#each svgGroups as [groupName, devices] (groupName)}
|
<div class="dock-empty">{searchQuery ? 'No matches' : 'All placed'}</div>
|
||||||
<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}
|
{/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>
|
</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>
|
||||||
|
|||||||
@ -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,31 +635,46 @@ 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);
|
||||||
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) {
|
// If dropped onto an existing symbol and we have a device label, assign the label
|
||||||
dropX = clamp(dropX, 0, layout.canvasW - sym.w);
|
const hitId = dragState.deviceLabel ? hitTest(pos.x, pos.y) : null;
|
||||||
dropY = clamp(dropY, 0, layout.canvasH - sym.h);
|
if (hitId !== null && dragState.deviceLabel) {
|
||||||
const valid = findValidPosition(-1, dropX, dropY, sym.w, sym.h, sym.id, rot, sym.curveAngle, sym.w2);
|
const target = layout.symbols.find(s => s.id === hitId);
|
||||||
layout.addSymbol({
|
if (target) {
|
||||||
symbolId: sym.id,
|
layout.pushUndo();
|
||||||
file: sym.file,
|
target.label = dragState.deviceLabel;
|
||||||
name: sym.name,
|
layout.markDirty();
|
||||||
label: dragState.deviceLabel || '',
|
layout.saveMcmState();
|
||||||
x: valid.x,
|
}
|
||||||
y: valid.y,
|
} else {
|
||||||
w: sym.w,
|
// Drop onto empty space: place a new symbol
|
||||||
h: sym.h,
|
let dropX = pos.x - sym.w / 2;
|
||||||
w2: sym.w2,
|
let dropY = pos.y - sym.h / 2;
|
||||||
rotation: rot,
|
|
||||||
curveAngle: sym.curveAngle,
|
if (dropX >= -sym.w && dropX <= layout.canvasW && dropY >= -sym.h && dropY <= layout.canvasH) {
|
||||||
epcWaypoints: isEpcType(sym.id) ? EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp })) : undefined,
|
dropX = clamp(dropX, 0, layout.canvasW - sym.w);
|
||||||
pdpCBs: undefined,
|
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