import { fcState } from './flowchartState.svelte'; import * as utils from './utils'; export class CanvasController { canvas: HTMLCanvasElement; container: HTMLDivElement; render: () => void; lastMouseDownTime = 0; lastTapTime = 0; constructor(canvas: HTMLCanvasElement, container: HTMLDivElement, render: () => void) { this.canvas = canvas; this.container = container; this.render = render; } getCanvasRect = () => { return this.canvas.getBoundingClientRect(); } screenToWorld = (sx: number, sy: number) => { const rect = this.getCanvasRect(); return { x: (sx - rect.left - fcState.panX) / fcState.zoom, y: (sy - rect.top - fcState.panY) / fcState.zoom }; } snapToGrid = (val: number) => { if (!fcState.snapGrid) return val; return Math.round(val / fcState.gridSize) * fcState.gridSize; } isHandleAtPos = (wx: number, wy: number) => { if (fcState.selectedIds.length === 1) { const shape = fcState.shapes.find(s => s.id === fcState.selectedIds[0]); if (shape) { const handles = utils.getResizeHandles(shape); const hitSize = 24 / fcState.zoom; // Larger touch target for (const h of handles) { if (utils.pointInRect(wx, wy, h.x - hitSize/2, h.y - hitSize/2, hitSize, hitSize)) { return h.id; } } } } return null; } getShapeAtPos = (wx: number, wy: number) => { for (let i = fcState.shapes.length - 1; i >= 0; i--) { const s = fcState.shapes[i]; if (utils.isPointInShape(s, wx, wy)) return s; } return null; } getArrowAtPos = (wx: number, wy: number) => { for (let i = fcState.arrows.length - 1; i >= 0; i--) { const a = fcState.arrows[i]; if (utils.isPointNearArrow(a, wx, wy, 8 / fcState.zoom, fcState.shapes)) return a; } return null; } isArrowHandleAtPos = (wx: number, wy: number) => { if (fcState.selectedIds.length === 1) { const arrow = fcState.arrows.find(a => a.id === fcState.selectedIds[0]); if (arrow && arrow.points) { for (let i = 0; i < arrow.points.length; i++) { const p = arrow.points[i]; // Use 15px for much better touch sensitivity on mobile if (utils.distance(wx, wy, p.x, p.y) <= 15 / fcState.zoom) { return { arrowId: arrow.id, pointIndex: i }; } } } } return null; } isArrowGhostHandleAtPos = (wx: number, wy: number) => { if (fcState.selectedIds.length === 1) { const arrow = fcState.arrows.find(a => a.id === fcState.selectedIds[0]); if (arrow) { const centers = utils.getArrowSegmentCenters(arrow, fcState.shapes); for (const c of centers) { // Use 15px for much better touch sensitivity on mobile if (utils.distance(wx, wy, c.x, c.y) <= 15 / fcState.zoom) { return { arrowId: arrow.id, pointIndex: c.index }; } } } } return null; } handleMouseDown = (e: MouseEvent) => { if (e.target !== this.canvas) return; // Debounce MUST be at the very top to prevent ghost clicks from triggering anything if (Date.now() - this.lastMouseDownTime < 300) return; this.lastMouseDownTime = Date.now(); // Double Tap detection on empty space const mx = e.clientX, my = e.clientY; const world = this.screenToWorld(mx, my); const shape = this.getShapeAtPos(world.x, world.y); const arrow = this.getArrowAtPos(world.x, world.y); if (!shape && !arrow) { const now = Date.now(); if (now - this.lastTapTime < 300) { // Double Tap on Empty Space -> Cancel everything fcState.selectedIds = []; fcState.arrowStartId = null; fcState.arrowTemp = null; fcState.arrowPointsTemp = []; this.render(); this.lastTapTime = 0; return; } this.lastTapTime = now; } else { this.lastTapTime = 0; } if (fcState.tool === 'hand') { fcState.isPanning = true; fcState.dragStart = { x: mx, y: my }; fcState.dragOffset = { x: fcState.panX, y: fcState.panY }; this.container.style.cursor = 'grabbing'; return; } if (fcState.tool === 'arrow' || fcState.tool === 'line') { if (shape) { if (fcState.arrowStartId) { if (fcState.arrowStartId !== shape.id || fcState.arrowPointsTemp.length > 0) { // Complete Connection const newArrow = { id: fcState.generateId(), type: 'arrow', fromId: fcState.arrowStartId, toId: shape.id, label: '', strokeColor: '#64748b', strokeWidth: 2, routing: fcState.arrowPointsTemp.length > 0 ? 'straight' : 'orthogonal', points: [...fcState.arrowPointsTemp] }; fcState.arrows.push(newArrow as any); fcState.selectedIds = [newArrow.id]; fcState.arrowStartId = null; fcState.arrowTemp = null; fcState.arrowPointsTemp = []; fcState.saveHistory(); this.render(); // Artificial delay to prevent accidental double-starts this.lastMouseDownTime = Date.now() + 100; return; } } else { // Start Connection fcState.arrowStartId = shape.id; fcState.arrowTemp = { x: world.x, y: world.y }; fcState.arrowPointsTemp = []; this.render(); return; } } else { if (fcState.arrowStartId) { // Add intermediate point fcState.arrowPointsTemp.push({ x: world.x, y: world.y }); this.render(); return; } } } if (['rect', 'roundrect', 'diamond', 'circle', 'parallelogram'].includes(fcState.tool)) { const sx = this.snapToGrid(world.x); const sy = this.snapToGrid(world.y); fcState.creatingShape = { type: fcState.tool as any, x: sx, y: sy, w: 0, h: 0 }; fcState.isDragging = true; this.render(); return; } if (fcState.tool === 'eraser') { if (shape) { fcState.arrows = fcState.arrows.filter(a => a.fromId !== shape.id && a.toId !== shape.id); fcState.shapes = fcState.shapes.filter(s => s.id !== shape.id); fcState.selectedIds = fcState.selectedIds.filter(id => id !== shape.id); fcState.saveHistory(); this.render(); } else { const arrow = this.getArrowAtPos(world.x, world.y); if (arrow) { fcState.arrows = fcState.arrows.filter(a => a.id !== arrow.id); fcState.saveHistory(); this.render(); } } return; } if (fcState.tool === 'select') { const handle = this.isHandleAtPos(world.x, world.y); if (handle && fcState.selectedIds.length === 1) { const shape = fcState.shapes.find(s => s.id === fcState.selectedIds[0]); if (shape) { fcState.isResizing = true; fcState.resizeHandle = handle; fcState.resizeStart = { x: world.x, y: world.y }; fcState.resizingShape = { ...shape }; return; } } const arrowHandle = this.isArrowHandleAtPos(world.x, world.y); if (arrowHandle) { fcState.isResizing = true; fcState.resizeHandle = `arrow_${arrowHandle.pointIndex}`; fcState.resizingShape = { id: arrowHandle.arrowId, type: 'arrowPoint' } as any; return; } const ghostHandle = this.isArrowGhostHandleAtPos(world.x, world.y); if (ghostHandle) { const arrow = fcState.arrows.find(a => a.id === ghostHandle.arrowId); if (arrow) { if (!arrow.points) arrow.points = []; arrow.points.splice(ghostHandle.pointIndex, 0, { x: world.x, y: world.y }); fcState.isResizing = true; fcState.resizeHandle = `arrow_${ghostHandle.pointIndex}`; fcState.resizingShape = { id: arrow.id, type: 'arrowPoint' } as any; return; } } if (shape) { if (!e.shiftKey) { if (!fcState.selectedIds.includes(shape.id)) { fcState.selectedIds = [shape.id]; } } else { const idx = fcState.selectedIds.indexOf(shape.id); if (idx >= 0) { fcState.selectedIds.splice(idx, 1); } else { fcState.selectedIds.push(shape.id); } } fcState.isDragging = true; fcState.dragStart = { x: world.x, y: world.y }; fcState.dragOffset = fcState.selectedIds.map(id => { const s = fcState.shapes.find(s => s.id === id); return s ? { id: s.id, ox: s.x, oy: s.y } : null; }).filter(Boolean); } else if (arrow) { fcState.selectedIds = [arrow.id]; fcState.isDragging = true; fcState.dragStart = { x: world.x, y: world.y }; fcState.dragOffset = [{ id: arrow.id, ox: 0, oy: 0 }]; } else { fcState.selectedIds = []; fcState.isPanning = true; fcState.dragStart = { x: mx, y: my }; fcState.dragOffset = { x: fcState.panX, y: fcState.panY }; } this.render(); } } handleMouseMove = (e: MouseEvent) => { if (e.target !== this.canvas) return; const mx = e.clientX, my = e.clientY; const world = this.screenToWorld(mx, my); if (fcState.isPanning) { fcState.panX = fcState.dragOffset.x + (mx - fcState.dragStart!.x); fcState.panY = fcState.dragOffset.y + (my - fcState.dragStart!.y); this.render(); return; } if (fcState.isResizing && fcState.resizingShape && fcState.resizeHandle) { if ((fcState.resizingShape as any).type === 'arrowPoint') { const arrow = fcState.arrows.find(a => a.id === fcState.resizingShape!.id); const pointIndex = parseInt(fcState.resizeHandle.split('_')[1]); if (arrow && arrow.points && arrow.points[pointIndex]) { arrow.points[pointIndex].x = this.snapToGrid(world.x); arrow.points[pointIndex].y = this.snapToGrid(world.y); } this.render(); return; } if (!fcState.resizeStart) return; const shape = fcState.shapes.find(s => s.id === fcState.resizingShape!.id); if (!shape) return; const dx = world.x - fcState.resizeStart.x; const dy = world.y - fcState.resizeStart.y; const orig = fcState.resizingShape; const handle = fcState.resizeHandle; let newX = orig.x, newY = orig.y, newW = orig.w, newH = orig.h; if (handle.includes('e')) { newW = Math.max(30, orig.w + dx); } if (handle.includes('w')) { newW = Math.max(30, orig.w - dx); newX = orig.x + dx; } if (handle.includes('s')) { newH = Math.max(30, orig.h + dy); } if (handle.includes('n')) { newH = Math.max(30, orig.h - dy); newY = orig.y + dy; } if (shape.type === 'circle') { shape.r = Math.min(newW, newH) / 2; shape.x = newX + newW / 2; shape.y = newY + newH / 2; } else { shape.x = newX; shape.y = newY; shape.w = newW; shape.h = newH; } this.render(); return; } if (fcState.isDragging && fcState.tool === 'select' && fcState.dragStart && fcState.dragOffset) { const dx = world.x - fcState.dragStart.x; const dy = world.y - fcState.dragStart.y; for (const off of fcState.dragOffset) { const shape = fcState.shapes.find(s => s.id === off.id); if (shape) { shape.x = this.snapToGrid(off.ox + dx); shape.y = this.snapToGrid(off.oy + dy); } } this.render(); return; } if (fcState.isDragging && fcState.creatingShape) { const dx = world.x - fcState.creatingShape.x!; const dy = world.y - fcState.creatingShape.y!; fcState.creatingShape.w = this.snapToGrid(dx); fcState.creatingShape.h = this.snapToGrid(dy); this.render(); return; } if ((fcState.isDragging || fcState.arrowStartId) && (fcState.tool === 'arrow' || fcState.tool === 'line')) { fcState.arrowTemp = { x: world.x, y: world.y }; this.render(); return; } } handleMouseUp = (e: MouseEvent) => { if (fcState.isPanning) { fcState.isPanning = false; fcState.dragStart = null; this.container.style.cursor = fcState.tool === 'hand' ? 'grab' : 'default'; } if (fcState.isResizing) { fcState.isResizing = false; fcState.resizeHandle = null; fcState.resizeStart = null; fcState.resizingShape = null; fcState.saveHistory(); } if (fcState.isDragging && fcState.tool === 'select') { fcState.isDragging = false; fcState.dragStart = null; fcState.saveHistory(); } if (fcState.isDragging && fcState.creatingShape) { const s = fcState.creatingShape; const aw = Math.abs(s.w!); const ah = Math.abs(s.h!); if (aw > 10 && ah > 10) { const newShape = { id: fcState.generateId(), type: s.type, x: s.w! < 0 ? s.x! + s.w! : s.x, y: s.h! < 0 ? s.y! + s.h! : s.y, w: s.type === 'circle' ? 80 : aw, h: s.type === 'circle' ? 80 : ah, r: s.type === 'circle' ? Math.min(aw, ah) / 2 : 0, text: s.type === 'diamond' ? 'Kondisi?' : '', fillColor: '#ffffff', strokeColor: '#2563eb', strokeWidth: 2 } as any; if (s.type === 'circle') { newShape.x = s.x! + newShape.r; newShape.y = s.y! + newShape.r; } fcState.shapes.push(newShape); fcState.selectedIds = [newShape.id]; fcState.tool = 'select'; fcState.saveHistory(); } fcState.creatingShape = null; fcState.isDragging = false; this.render(); } if (fcState.isDragging && (fcState.tool === 'arrow' || fcState.tool === 'line')) { const mx = e.clientX, my = e.clientY; const world = this.screenToWorld(mx, my); const toShape = this.getShapeAtPos(world.x, world.y); if (fcState.arrowStartId && toShape && toShape.id !== fcState.arrowStartId) { // Drag-to-Connect completed const newArrow = { id: fcState.generateId(), type: 'arrow', fromId: fcState.arrowStartId, toId: toShape.id, label: '', strokeColor: '#64748b', strokeWidth: 2, routing: 'orthogonal', points: [] } as any; fcState.arrows.push(newArrow); fcState.selectedIds = [newArrow.id]; fcState.tool = 'select'; fcState.saveHistory(); // CRITICAL: Clear these so it doesn't start a new line fcState.arrowStartId = null; fcState.arrowTemp = null; fcState.arrowPointsTemp = []; } fcState.isDragging = false; this.render(); } } handleDoubleClick = (e: MouseEvent) => { if (fcState.tool !== 'select') return; const mx = e.clientX, my = e.clientY; const world = this.screenToWorld(mx, my); const arrowHandle = this.isArrowHandleAtPos(world.x, world.y); if (arrowHandle) { const arrow = fcState.arrows.find(a => a.id === arrowHandle.arrowId); if (arrow && arrow.points) { arrow.points.splice(arrowHandle.pointIndex, 1); fcState.saveHistory(); this.render(); } } } handleWheel = (e: WheelEvent) => { e.preventDefault(); const mx = e.clientX, my = e.clientY; const rect = this.getCanvasRect(); const wx = (mx - rect.left - fcState.panX) / fcState.zoom; const wy = (my - rect.top - fcState.panY) / fcState.zoom; if (e.ctrlKey) { const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; fcState.zoom = Math.max(0.1, Math.min(5, fcState.zoom * zoomFactor)); fcState.panX = mx - rect.left - wx * fcState.zoom; fcState.panY = my - rect.top - wy * fcState.zoom; } else { fcState.panX -= e.deltaX; fcState.panY -= e.deltaY; } this.render(); } handleTouchStart = (e: TouchEvent) => { if (e.target !== this.canvas) return; e.preventDefault(); // Prevent ghost clicks if (e.touches.length === 1) { const touch = e.touches[0]; this.handleMouseDown({ clientX: touch.clientX, clientY: touch.clientY, target: e.target, shiftKey: false } as any); } } handleTouchMove = (e: TouchEvent) => { if (e.target !== this.canvas) return; e.preventDefault(); if (e.touches.length === 1) { const touch = e.touches[0]; this.handleMouseMove({ clientX: touch.clientX, clientY: touch.clientY, target: e.target } as any); } } handleTouchEnd = (e: TouchEvent) => { if (e.target !== this.canvas) return; e.preventDefault(); if (e.changedTouches.length > 0) { const touch = e.changedTouches[0]; this.handleMouseUp({ clientX: touch.clientX, clientY: touch.clientY, target: e.target } as any); } } }