diff --git a/deck-frontend/src/main.ts b/deck-frontend/src/main.ts index d08978e..41a1dab 100644 --- a/deck-frontend/src/main.ts +++ b/deck-frontend/src/main.ts @@ -2,15 +2,16 @@ import { store } from '@/state/index.ts'; import { Router } from '@/router/index.ts'; import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts'; import { GridView, TreeView, GraphView, DetailPanel } from '@/views/index.ts'; -import { $, $$, delegateEvent, toast, createNameMap, resolveGroupName } from '@/utils/index.ts'; -import { CATS, EDGE_COLORS } from '@/config/index.ts'; -import type { BaseType, ViewType, CategoryKey, EdgeType } from '@/types/index.ts'; +import { $, $$, delegateEvent, createNameMap, resolveGroupName } from '@/utils/index.ts'; +import type { BaseType, ViewType } from '@/types/index.ts'; import './styles/main.css'; class App { private router: Router; private currentView: GridView | TreeView | GraphView | null = null; private detailPanel: DetailPanel | null = null; + private groupsCleanup: (() => void) | null = null; + private librariesCleanup: (() => void) | null = null; constructor() { this.router = new Router(store, () => this.init()); @@ -92,6 +93,9 @@ class App { const container = $('#groups-bar'); if (!container) return; + // Cleanup previous listener + this.groupsCleanup?.(); + const state = store.getState(); // Use hstTags for group name resolution (set_hst points to hst tags) const nameMap = createNameMap(state.hstTags, state.lang); @@ -122,7 +126,7 @@ class App { }).join('')} `; - delegateEvent(container, '.group-btn', 'click', (_, target) => { + this.groupsCleanup = delegateEvent(container, '.group-btn', 'click', (_, target) => { const group = target.dataset.group || 'all'; store.setState({ group }); this.renderGroups(); @@ -134,16 +138,12 @@ class App { const container = $('#left-panel'); if (!container) return; + // Cleanup previous listener + this.librariesCleanup?.(); + const state = store.getState(); - // Show graph options when in graph view - if (state.view === 'graph') { - container.classList.add('graph-mode'); - this.renderGraphOptions(container); - return; - } - - container.classList.remove('graph-mode'); + // Always show libraries (graph options are in GraphView sidebar) container.innerHTML = `
ALL @@ -160,7 +160,7 @@ class App { }).join('')} `; - delegateEvent(container, '.lib-icon', 'click', async (_, target) => { + this.librariesCleanup = delegateEvent(container, '.lib-icon', 'click', async (_, target) => { const library = target.dataset.lib || 'all'; const currentBase = store.getState().base; @@ -176,169 +176,6 @@ class App { }); } - private renderGraphOptions(container: HTMLElement): void { - const state = store.getState(); - const { graphFilters, graphSettings, tags, graphEdges } = state; - - // Count nodes and edges - const nodeCount = tags.length; - const edgeCount = graphEdges.length; - - container.innerHTML = ` -
- -
-
- Nodos - ${nodeCount} -
-
- Edges - ${edgeCount} -
-
- - -
-
Categorias
- ${Object.entries(CATS).map(([key, config]) => ` - - `).join('')} -
- - -
-
Relaciones
- ${Object.entries(EDGE_COLORS).map(([key, color]) => ` - - `).join('')} -
- - -
-
Visualizacion
- - -
-
- Nodo - ${graphSettings.nodeSize}px -
- -
-
-
- Distancia - ${graphSettings.linkDist}px -
- -
-
-
- `; - - // Bind events - this.bindGraphOptionEvents(container); - } - - private bindGraphOptionEvents(container: HTMLElement): void { - // Category checkboxes - container.querySelectorAll('[data-cat]').forEach(cb => { - cb.addEventListener('change', () => { - const cat = cb.dataset.cat as CategoryKey; - const state = store.getState(); - const newCats = new Set(state.graphFilters.cats); - if (cb.checked) { - newCats.add(cat); - } else { - newCats.delete(cat); - } - store.setState({ - graphFilters: { ...state.graphFilters, cats: newCats } - }); - this.renderView(); - }); - }); - - // Edge checkboxes - container.querySelectorAll('[data-edge]').forEach(cb => { - cb.addEventListener('change', () => { - const edge = cb.dataset.edge as EdgeType; - const state = store.getState(); - const newEdges = new Set(state.graphFilters.edges); - if (cb.checked) { - newEdges.add(edge); - } else { - newEdges.delete(edge); - } - store.setState({ - graphFilters: { ...state.graphFilters, edges: newEdges } - }); - this.renderView(); - }); - }); - - // Show images checkbox - const showImgCb = container.querySelector('#graph-show-img'); - showImgCb?.addEventListener('change', () => { - const state = store.getState(); - store.setState({ - graphSettings: { ...state.graphSettings, showImg: showImgCb.checked } - }); - this.renderView(); - }); - - // Show labels checkbox - const showLblCb = container.querySelector('#graph-show-lbl'); - showLblCb?.addEventListener('change', () => { - const state = store.getState(); - store.setState({ - graphSettings: { ...state.graphSettings, showLbl: showLblCb.checked } - }); - this.renderView(); - }); - - // Node size slider - const nodeSizeSlider = container.querySelector('#graph-node-size'); - const nodeSizeVal = container.querySelector('#node-size-val'); - nodeSizeSlider?.addEventListener('input', () => { - const size = parseInt(nodeSizeSlider.value, 10); - if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`; - const state = store.getState(); - store.setState({ - graphSettings: { ...state.graphSettings, nodeSize: size } - }); - this.renderView(); - }); - - // Link distance slider - const linkDistSlider = container.querySelector('#graph-link-dist'); - const linkDistVal = container.querySelector('#link-dist-val'); - linkDistSlider?.addEventListener('input', () => { - const dist = parseInt(linkDistSlider.value, 10); - if (linkDistVal) linkDistVal.textContent = `${dist}px`; - const state = store.getState(); - store.setState({ - graphSettings: { ...state.graphSettings, linkDist: dist } - }); - this.renderView(); - }); - } - private updateBaseButtons(): void { const state = store.getState(); $$('.base-btn').forEach(btn => { @@ -384,7 +221,6 @@ class App { this.router.updateHash(); this.detailPanel?.close(); this.updateViewTabs(); - this.renderLibraries(); // Update left panel (graph options vs libraries) this.renderView(); }); @@ -410,33 +246,18 @@ class App { }); } - // Selection mode + // Selection mode - DESACTIVADO TEMPORALMENTE const selBtn = $('#btn-sel'); if (selBtn) { - selBtn.addEventListener('click', () => { - const state = store.getState(); - store.setState({ - selectionMode: !state.selectionMode, - selected: state.selectionMode ? new Set() : state.selected - }); - selBtn.classList.toggle('active', !state.selectionMode); - this.updateSelectionCount(); - this.renderView(); - }); + selBtn.classList.add('disabled'); + selBtn.setAttribute('title', 'Próximamente'); } - // Get selected + // Get selected - DESACTIVADO TEMPORALMENTE const getBtn = $('#btn-get'); if (getBtn) { - getBtn.addEventListener('click', () => { - const state = store.getState(); - if (state.selected.size === 0) { - toast('No hay seleccionados'); - return; - } - navigator.clipboard.writeText([...state.selected].join('\n')) - .then(() => toast(`Copiados ${state.selected.size} mrfs`)); - }); + getBtn.classList.add('disabled'); + getBtn.setAttribute('title', 'Próximamente'); } // API modal @@ -451,16 +272,12 @@ class App { closeBtn?.addEventListener('click', () => apiModal.classList.remove('open')); } + // Keyboard shortcuts // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.detailPanel?.close(); $('#api-modal')?.classList.remove('open'); - if (store.getState().selectionMode) { - store.setState({ selectionMode: false, selected: new Set() }); - $('#btn-sel')?.classList.remove('active'); - this.renderView(); - } } if (e.key === '/' && (e.target as HTMLElement).tagName !== 'INPUT') { e.preventDefault(); @@ -468,14 +285,6 @@ class App { } }); } - - private updateSelectionCount(): void { - const counter = $('#sel-count'); - if (counter) { - const count = store.getState().selected.size; - counter.textContent = count > 0 ? `(${count})` : ''; - } - } } // Bootstrap diff --git a/deck-frontend/src/styles/main.css b/deck-frontend/src/styles/main.css index 84d3791..712dd25 100644 --- a/deck-frontend/src/styles/main.css +++ b/deck-frontend/src/styles/main.css @@ -127,6 +127,13 @@ body { font-weight: 600; } +/* Disabled state */ +.sel-btn.disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + /* === GROUPS BAR === */ .groups-bar { height: 44px; @@ -399,6 +406,120 @@ body { .node.selected circle { stroke: var(--accent); stroke-width: 4; } .link { stroke-opacity: 0.5; } +/* Graph Floating Sidebar */ +.graph-sidebar { + position: absolute; + top: 16px; + left: 16px; + width: 200px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px; + font-size: 0.8em; + max-height: calc(100% - 32px); + overflow-y: auto; + z-index: 10; +} +.graph-section { margin-bottom: 14px; } +.graph-section:last-child { margin-bottom: 0; } +.graph-section-title { + font-size: 0.75em; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} +.graph-stat { + display: flex; + justify-content: space-between; + font-size: 0.85em; + color: var(--text-muted); + margin-bottom: 4px; +} +.graph-stat-value { color: var(--text); font-weight: 600; } +.graph-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85em; + color: var(--text-muted); + margin-bottom: 6px; + cursor: pointer; +} +.graph-checkbox:hover { color: var(--text); } +.graph-checkbox input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent); + cursor: pointer; +} +.color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.graph-slider { margin-bottom: 10px; } +.graph-slider-label { + display: flex; + justify-content: space-between; + font-size: 0.8em; + color: var(--text-muted); + margin-bottom: 4px; +} +.graph-slider-value { color: var(--text); font-weight: 600; } +.graph-slider input[type="range"] { + width: 100%; + height: 4px; + background: var(--border); + border-radius: 2px; + -webkit-appearance: none; + cursor: pointer; +} +.graph-slider input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; +} + +/* Graph Controls (Zoom) */ +.graph-controls { + position: absolute; + top: 16px; + right: 16px; + display: flex; + gap: 6px; + z-index: 10; +} + +/* Graph Legend */ +.graph-legend { + position: absolute; + bottom: 16px; + left: 16px; + display: flex; + flex-wrap: wrap; + gap: 12px; + background: rgba(18, 18, 26, 0.9); + padding: 10px 14px; + border-radius: 8px; + z-index: 10; +} +.legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75em; + color: var(--text-muted); +} + /* === DETAIL PANEL === */ .detail-panel { width: 0; @@ -614,88 +735,3 @@ select { } select:focus { outline: none; border-color: var(--accent); } -/* === GRAPH OPTIONS PANEL === */ -.graph-options { - padding: 10px; - overflow-y: auto; - width: 180px; -} -.graph-section { - margin-bottom: 16px; -} -.graph-section-title { - font-size: 0.7em; - font-weight: 600; - color: var(--accent); - text-transform: uppercase; - letter-spacing: 1px; - margin-bottom: 8px; - padding-bottom: 4px; - border-bottom: 1px solid var(--border); -} -.graph-stat { - display: flex; - justify-content: space-between; - font-size: 0.75em; - color: var(--text-muted); - margin-bottom: 4px; -} -.graph-stat-value { - color: var(--text); - font-weight: 600; -} -.graph-checkbox { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.75em; - color: var(--text-muted); - margin-bottom: 6px; - cursor: pointer; -} -.graph-checkbox:hover { color: var(--text); } -.graph-checkbox input[type="checkbox"] { - width: 14px; - height: 14px; - accent-color: var(--accent); - cursor: pointer; -} -.graph-checkbox .color-dot { - width: 10px; - height: 10px; - border-radius: 50%; - margin-right: 4px; -} -.graph-slider { - margin-bottom: 12px; -} -.graph-slider-label { - display: flex; - justify-content: space-between; - font-size: 0.7em; - color: var(--text-muted); - margin-bottom: 4px; -} -.graph-slider-value { - color: var(--text); - font-weight: 600; -} -.graph-slider input[type="range"] { - width: 100%; - height: 4px; - background: var(--border); - border-radius: 2px; - -webkit-appearance: none; - cursor: pointer; -} -.graph-slider input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - width: 14px; - height: 14px; - background: var(--accent); - border-radius: 50%; - cursor: pointer; -} -.left-panel.graph-mode { - width: 180px; -} diff --git a/deck-frontend/src/utils/dom.ts b/deck-frontend/src/utils/dom.ts index 50cfa99..644cdb7 100644 --- a/deck-frontend/src/utils/dom.ts +++ b/deck-frontend/src/utils/dom.ts @@ -34,11 +34,13 @@ export function delegateEvent( selector: string, eventType: string, handler: (event: T, target: HTMLElement) => void -): void { - container.addEventListener(eventType, (event) => { +): () => void { + const listener = (event: Event) => { const target = (event.target as HTMLElement).closest(selector); if (target && container.contains(target)) { handler(event as T, target); } - }); + }; + container.addEventListener(eventType, listener); + return () => container.removeEventListener(eventType, listener); } diff --git a/deck-frontend/src/views/GraphView/GraphView.ts b/deck-frontend/src/views/GraphView/GraphView.ts index b708ca9..0c99b1e 100644 --- a/deck-frontend/src/views/GraphView/GraphView.ts +++ b/deck-frontend/src/views/GraphView/GraphView.ts @@ -10,10 +10,22 @@ type D3Module = typeof import('d3'); type D3Selection = any; // eslint-disable-next-line @typescript-eslint/no-explicit-any type D3Simulation = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type D3Zoom = any; + +interface GraphLink { + source: string | GraphNode; + target: string | GraphNode; + type: EdgeType; + weight: number; +} export class GraphView extends View { private d3: D3Module | null = null; private simulation: D3Simulation | null = null; + private zoom: D3Zoom | null = null; + private svg: D3Selection | null = null; + private g: D3Selection | null = null; private showDetail: (mrf: string) => void; constructor( @@ -62,22 +74,19 @@ export class GraphView extends View { const nodeMap = new Map(); filtered.forEach(tag => { + const tagCat = this.getTagCategory(tag.mrf); + if (!state.graphFilters.cats.has(tagCat)) return; + nodeMap.set(tag.mrf, { id: tag.mrf, ref: tag.alias || tag.ref || tag.mrf.slice(0, 8), name: getName(tag, state.lang), img: getImg(tag), - cat: 'hst' as CategoryKey + cat: tagCat }); }); // Build edges - interface GraphLink { - source: string | GraphNode; - target: string | GraphNode; - type: EdgeType; - weight: number; - } const edges: GraphLink[] = []; state.graphEdges.forEach(e => { @@ -113,27 +122,44 @@ export class GraphView extends View { return; } - // Clear and create SVG - this.container.innerHTML = ''; + // Create container structure + this.container.innerHTML = ` +
+
+ + + +
+
+ `; + + // Render sidebar and legend + this.renderSidebar(nodes.length, edges.length); + this.renderLegend(); + + // Create SVG const width = this.container.clientWidth; const height = this.container.clientHeight || 600; - const svg: D3Selection = d3.select(this.container) + this.svg = d3.select(this.container) .append('svg') .attr('width', '100%') .attr('height', '100%') .attr('viewBox', `0 0 ${width} ${height}`); - const g = svg.append('g'); + this.g = this.svg.append('g'); // Zoom - const zoom = d3.zoom() + this.zoom = d3.zoom() .scaleExtent([0.1, 4]) .on('zoom', (event: { transform: string }) => { - g.attr('transform', event.transform); + this.g.attr('transform', event.transform); }); - svg.call(zoom); + this.svg.call(this.zoom); + + // Bind zoom controls + this.bindZoomControls(); // Simulation // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -147,16 +173,16 @@ export class GraphView extends View { .force('collision', d3.forceCollide().radius(state.graphSettings.nodeSize + 5)); // Links - const link = g.append('g') + const link = this.g.append('g') .selectAll('line') .data(edges) .join('line') .attr('stroke', (d: GraphLink) => EDGE_COLORS[d.type] || '#999') - .attr('stroke-width', (d: GraphLink) => Math.sqrt(d.weight)) + .attr('stroke-width', (d: GraphLink) => Math.max(1.5, Math.sqrt(d.weight))) .attr('stroke-opacity', 0.6); // Nodes - const node = g.append('g') + const node = this.g.append('g') .selectAll('g') .data(nodes) .join('g') @@ -191,12 +217,13 @@ export class GraphView extends View { .attr('dy', nodeSize / 2 + 12) .attr('text-anchor', 'middle') .attr('font-size', 10) - .attr('fill', 'var(--text-primary)'); + .attr('fill', 'var(--text)'); } node.on('click', (_: MouseEvent, d: GraphNode) => { - if (state.selectionMode) { - const newSelected = new Set(state.selected); + const currentState = this.getState(); + if (currentState.selectionMode) { + const newSelected = new Set(currentState.selected); if (newSelected.has(d.id)) { newSelected.delete(d.id); } else { @@ -220,6 +247,200 @@ export class GraphView extends View { }); } + private renderSidebar(nodeCount: number, edgeCount: number): void { + const sidebar = this.container.querySelector('#graph-sidebar'); + if (!sidebar) return; + + const state = this.getState(); + const { graphFilters, graphSettings } = state; + + sidebar.innerHTML = ` +
+
+ Nodos + ${nodeCount} +
+
+ Edges + ${edgeCount} +
+
+ +
+
Categorias
+ ${Object.entries(CATS).map(([key, config]) => ` + + `).join('')} +
+ +
+
Relaciones
+ ${Object.entries(EDGE_COLORS).map(([key, color]) => ` + + `).join('')} +
+ +
+
Visualizacion
+ + +
+
+ Nodo + ${graphSettings.nodeSize}px +
+ +
+
+
+ Distancia + ${graphSettings.linkDist}px +
+ +
+
+ `; + + this.bindSidebarEvents(sidebar as HTMLElement); + } + + private bindSidebarEvents(sidebar: HTMLElement): void { + // Category checkboxes + sidebar.querySelectorAll('[data-cat]').forEach(cb => { + cb.onchange = () => { + const cat = cb.dataset.cat as CategoryKey; + const state = this.getState(); + const newCats = new Set(state.graphFilters.cats); + cb.checked ? newCats.add(cat) : newCats.delete(cat); + this.store.setState({ graphFilters: { ...state.graphFilters, cats: newCats } }); + this.render(); + }; + }); + + // Edge checkboxes + sidebar.querySelectorAll('[data-edge]').forEach(cb => { + cb.onchange = () => { + const edge = cb.dataset.edge as EdgeType; + const state = this.getState(); + const newEdges = new Set(state.graphFilters.edges); + cb.checked ? newEdges.add(edge) : newEdges.delete(edge); + this.store.setState({ graphFilters: { ...state.graphFilters, edges: newEdges } }); + this.render(); + }; + }); + + // Show images + const showImg = sidebar.querySelector('#graph-show-img'); + if (showImg) { + showImg.onchange = () => { + const state = this.getState(); + this.store.setState({ graphSettings: { ...state.graphSettings, showImg: showImg.checked } }); + this.render(); + }; + } + + // Show labels + const showLbl = sidebar.querySelector('#graph-show-lbl'); + if (showLbl) { + showLbl.onchange = () => { + const state = this.getState(); + this.store.setState({ graphSettings: { ...state.graphSettings, showLbl: showLbl.checked } }); + this.render(); + }; + } + + // Node size slider + const nodeSize = sidebar.querySelector('#graph-node-size'); + const nodeSizeVal = sidebar.querySelector('#node-size-val'); + if (nodeSize) { + nodeSize.oninput = () => { + const size = parseInt(nodeSize.value, 10); + if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`; + const state = this.getState(); + this.store.setState({ graphSettings: { ...state.graphSettings, nodeSize: size } }); + this.render(); + }; + } + + // Link distance slider + const linkDist = sidebar.querySelector('#graph-link-dist'); + const linkDistVal = sidebar.querySelector('#link-dist-val'); + if (linkDist) { + linkDist.oninput = () => { + const dist = parseInt(linkDist.value, 10); + if (linkDistVal) linkDistVal.textContent = `${dist}px`; + const state = this.getState(); + this.store.setState({ graphSettings: { ...state.graphSettings, linkDist: dist } }); + // Update simulation without full re-render + if (this.simulation) { + this.simulation.force('link').distance(dist); + this.simulation.alpha(0.3).restart(); + } + }; + } + } + + private renderLegend(): void { + const legend = this.container.querySelector('#graph-legend'); + if (!legend) return; + + const state = this.getState(); + const activeCats = Array.from(state.graphFilters.cats); + + legend.innerHTML = activeCats.map(cat => ` +
+ + ${CATS[cat]?.name || cat} +
+ `).join(''); + } + + private bindZoomControls(): void { + if (!this.d3 || !this.svg || !this.zoom) return; + const d3 = this.d3; + + // Fit to viewport + const fitBtn = this.container.querySelector('#graph-fit'); + if (fitBtn) { + fitBtn.onclick = () => { + this.svg.transition().duration(300).call( + this.zoom.transform, + d3.zoomIdentity.translate(0, 0).scale(1) + ); + }; + } + + // Zoom in + const zinBtn = this.container.querySelector('#graph-zin'); + if (zinBtn) { + zinBtn.onclick = () => { + this.svg.transition().duration(200).call(this.zoom.scaleBy, 1.5); + }; + } + + // Zoom out + const zoutBtn = this.container.querySelector('#graph-zout'); + if (zoutBtn) { + zoutBtn.onclick = () => { + this.svg.transition().duration(200).call(this.zoom.scaleBy, 0.67); + }; + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private createDrag(d3: D3Module, simulation: D3Simulation): any { return d3.drag() @@ -242,8 +463,20 @@ export class GraphView extends View { }); } + private getTagCategory(mrf: string): CategoryKey { + if (mrf.startsWith('spe_')) return 'spe'; + if (mrf.startsWith('vue_')) return 'vue'; + if (mrf.startsWith('vsn_')) return 'vsn'; + if (mrf.startsWith('msn_')) return 'msn'; + if (mrf.startsWith('flg_')) return 'flg'; + return 'hst'; + } + unmount(): void { this.simulation?.stop(); + this.svg = null; + this.g = null; + this.zoom = null; super.unmount(); } } diff --git a/deck-frontend/src/views/GridView/GridView.ts b/deck-frontend/src/views/GridView/GridView.ts index 48eef00..68db4ac 100644 --- a/deck-frontend/src/views/GridView/GridView.ts +++ b/deck-frontend/src/views/GridView/GridView.ts @@ -53,12 +53,14 @@ export class GridView extends View { } private bindEvents(): void { - const state = this.getState(); - - delegateEvent(this.container, '.card', 'click', (_, target) => { + this.cleanupListeners(); + const cleanup = delegateEvent(this.container, '.card', 'click', (_, target) => { const mrf = target.dataset.mrf; if (!mrf) return; + // Obtener estado FRESCO en cada click (no del closure) + const state = this.getState(); + if (state.selectionMode) { const newSelected = new Set(state.selected); if (newSelected.has(mrf)) { @@ -71,5 +73,6 @@ export class GridView extends View { this.showDetail(mrf); } }); + this.addCleanup(cleanup); } } diff --git a/deck-frontend/src/views/TreeView/TreeView.ts b/deck-frontend/src/views/TreeView/TreeView.ts index 9581c5d..a450daa 100644 --- a/deck-frontend/src/views/TreeView/TreeView.ts +++ b/deck-frontend/src/views/TreeView/TreeView.ts @@ -75,9 +75,9 @@ export class TreeView extends View { } private bindEvents(): void { - const state = this.getState(); + this.cleanupListeners(); - delegateEvent(this.container, '.tree-header', 'click', (_, target) => { + const cleanup1 = delegateEvent(this.container, '.tree-header', 'click', (_, target) => { const group = target.dataset.group; if (!group) return; @@ -88,11 +88,15 @@ export class TreeView extends View { } this.render(); }); + this.addCleanup(cleanup1); - delegateEvent(this.container, '.tree-item', 'click', (_, target) => { + const cleanup2 = delegateEvent(this.container, '.tree-item', 'click', (_, target) => { const mrf = target.dataset.mrf; if (!mrf) return; + // Obtener estado FRESCO en cada click (no del closure) + const state = this.getState(); + if (state.selectionMode) { const newSelected = new Set(state.selected); if (newSelected.has(mrf)) { @@ -105,5 +109,6 @@ export class TreeView extends View { this.showDetail(mrf); } }); + this.addCleanup(cleanup2); } } diff --git a/deck-frontend/src/views/View.ts b/deck-frontend/src/views/View.ts index 7697010..4aad287 100644 --- a/deck-frontend/src/views/View.ts +++ b/deck-frontend/src/views/View.ts @@ -5,6 +5,7 @@ export abstract class View { protected container: HTMLElement; protected store: Store; protected unsubscribe?: () => void; + protected cleanups: (() => void)[] = []; constructor(container: HTMLElement, store: Store) { this.container = container; @@ -20,9 +21,19 @@ export abstract class View { unmount(): void { this.unsubscribe?.(); + this.cleanupListeners(); this.container.innerHTML = ''; } + protected cleanupListeners(): void { + this.cleanups.forEach(fn => fn()); + this.cleanups = []; + } + + protected addCleanup(fn: () => void): void { + this.cleanups.push(fn); + } + protected getState(): AppState { return this.store.getState(); }