Sprint 5 — Etapa 2: resumen
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

Fix 0 (skeleton carry-over): el guard de Etapa 1 era isLoading && !currentProcess — fallaba al navegar directo entre dos workspaces distintos, porque el proceso anterior seguía en el store mientras el nuevo cargaba. Cambié a if (isLoading) return <WorkspaceSkeleton /> sin condición adicional, exactamente como pedía el brief.

Fix 1 (fit-viewport): una línea — el botón llamaba canvas.zoom('fit-viewport') sin 'auto' (la carga inicial sí lo tenía). Agregado.

Fix 2 (controles de zoom): agregué ZoomIn/ZoomOut de Lucide (no Tabler — ya está en el stack del proyecto, son visualmente equivalentes) en la esquina inferior derecha, apilados verticalmente, mismo estilo que el botón de encuadre (32×32px, fondo blanco semitransparente). Factor 1.2x, límites 0.1x–4x tal como especificaba el brief.

Verificación:

Build limpio
Lint: 2 warnings any nuevos en BpmnCanvas.tsx, consistentes con las 9 que ya existían en ese mismo archivo (el propio snippet del brief los prescribe así; bpmn-js tipa débilmente viewer.get()) — no es una categoría nueva de problema, lo marco para que quede explícito
Vitest: 521/521 verdes
E2E smoke: 9/9 verdes
Falta tu validación visual — en particular probar con Slow 3G que aparece el skeleton (no el spinner), y confirmar que el encuadre centra el diagrama y los botones de zoom no tapan nada.
This commit is contained in:
2026-06-19 15:46:08 -03:00
parent a2763ab755
commit 889929329e
4 changed files with 210 additions and 21 deletions

View File

@@ -1,6 +1,10 @@
import { useEffect, useRef, useCallback } from 'react'
import { useEffect, useRef, useCallback, type CSSProperties } from 'react'
import BpmnViewer from 'bpmn-js/lib/Viewer'
import { Maximize2 } from 'lucide-react'
import { Maximize2, ZoomIn, ZoomOut } from 'lucide-react'
const ZOOM_STEP = 1.2
const ZOOM_MIN = 0.1
const ZOOM_MAX = 4
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
@@ -158,34 +162,56 @@ export function BpmnCanvas({
}
}, [selectedElementId])
function handleZoomIn() {
const canvas = viewerRef.current?.get('canvas') as any
if (!canvas) return
canvas.zoom(Math.min(canvas.zoom() * ZOOM_STEP, ZOOM_MAX))
}
function handleZoomOut() {
const canvas = viewerRef.current?.get('canvas') as any
if (!canvas) return
canvas.zoom(Math.max(canvas.zoom() / ZOOM_STEP, ZOOM_MIN))
}
const controlButtonStyle: CSSProperties = {
width: 32,
height: 32,
borderRadius: 6,
background: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}
return (
<div className={className} style={{ position: 'relative', width: '100%', height: '100%' }}>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
{/* Encuadrar — esquina superior derecha */}
<button
onClick={() => {
const canvas = viewerRef.current?.get('canvas') as any
canvas?.zoom('fit-viewport')
canvas?.zoom('fit-viewport', 'auto')
}}
title="Encuadrar diagrama"
style={{
position: 'absolute',
top: 12,
right: 12,
width: 32,
height: 32,
borderRadius: 6,
background: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
zIndex: 10,
}}
style={{ position: 'absolute', top: 12, right: 12, zIndex: 10, ...controlButtonStyle }}
>
<Maximize2 className="h-4 w-4 text-muted-foreground" />
</button>
{/* Zoom in/out — esquina inferior derecha, no interfiere con el botón de encuadre */}
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 10, display: 'flex', flexDirection: 'column', gap: 4 }}>
<button onClick={handleZoomIn} title="Acercar" style={controlButtonStyle}>
<ZoomIn className="h-4 w-4 text-muted-foreground" />
</button>
<button onClick={handleZoomOut} title="Alejar" style={controlButtonStyle}>
<ZoomOut className="h-4 w-4 text-muted-foreground" />
</button>
</div>
</div>
)
}

View File

@@ -151,7 +151,9 @@ export function WorkspacePage() {
if (isLoading && !currentProcess) {
// isLoading manda siempre, incluso si currentProcess todavía tiene el proceso
// anterior en el store (navegación directa entre dos workspaces distintos).
if (isLoading) {
return <WorkspaceSkeleton />
}