feat: refactoring canvas flowchart

This commit is contained in:
a2nr 2026-05-01 12:15:20 +07:00
parent 6db1344622
commit 9cee4f2f74
2 changed files with 548 additions and 526 deletions

View File

@ -2,12 +2,16 @@
import { onMount } from 'svelte';
import { fcState } from './flowchartState.svelte';
import * as utils from './utils';
import { CanvasController } from './canvasEvents';
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let container: HTMLDivElement;
let lastMouseDownTime = 0;
let lastTapTime = 0;
let controller: CanvasController;
function getCanvasRect() {
return canvas.getBoundingClientRect();
}
function resizeCanvas() {
if (!canvas || !container) return;
@ -21,513 +25,6 @@
render();
}
function getCanvasRect() {
return canvas.getBoundingClientRect();
}
function screenToWorld(sx: number, sy: number) {
const rect = getCanvasRect();
return {
x: (sx - rect.left - fcState.panX) / fcState.zoom,
y: (sy - rect.top - fcState.panY) / fcState.zoom
};
}
function snapToGrid(val: number) {
if (!fcState.snapGrid) return val;
return Math.round(val / fcState.gridSize) * fcState.gridSize;
}
function 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;
}
function 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;
}
function 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;
}
function 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;
}
function 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;
}
function handleMouseDown(e: MouseEvent) {
if (e.target !== canvas) return;
// Debounce MUST be at the very top to prevent ghost clicks from triggering anything
if (Date.now() - lastMouseDownTime < 300) return;
lastMouseDownTime = Date.now();
// Double Tap detection on empty space
const mx = e.clientX, my = e.clientY;
const world = screenToWorld(mx, my);
const shape = getShapeAtPos(world.x, world.y);
const arrow = getArrowAtPos(world.x, world.y);
if (!shape && !arrow) {
const now = Date.now();
if (now - lastTapTime < 300) {
// Double Tap on Empty Space -> Cancel everything
fcState.selectedIds = [];
fcState.arrowStartId = null;
fcState.arrowTemp = null;
fcState.arrowPointsTemp = [];
render();
lastTapTime = 0;
return;
}
lastTapTime = now;
} else {
lastTapTime = 0;
}
if (fcState.tool === 'hand') {
fcState.isPanning = true;
fcState.dragStart = { x: mx, y: my };
fcState.dragOffset = { x: fcState.panX, y: fcState.panY };
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);
fcState.selectedIds = [newArrow.id];
fcState.arrowStartId = null;
fcState.arrowTemp = null;
fcState.arrowPointsTemp = [];
fcState.saveHistory();
render();
// Artificial delay to prevent accidental double-starts
lastMouseDownTime = Date.now() + 100;
return;
}
} else {
// Start Connection
fcState.arrowStartId = shape.id;
fcState.arrowTemp = { x: world.x, y: world.y };
fcState.arrowPointsTemp = [];
render();
return;
}
} else {
if (fcState.arrowStartId) {
// Add intermediate point
fcState.arrowPointsTemp.push({ x: world.x, y: world.y });
render();
return;
}
}
}
if (['rect', 'roundrect', 'diamond', 'circle', 'parallelogram'].includes(fcState.tool)) {
const sx = snapToGrid(world.x);
const sy = snapToGrid(world.y);
fcState.creatingShape = { type: fcState.tool as any, x: sx, y: sy, w: 0, h: 0 };
fcState.isDragging = true;
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();
render();
} else {
const arrow = getArrowAtPos(world.x, world.y);
if (arrow) {
fcState.arrows = fcState.arrows.filter(a => a.id !== arrow.id);
fcState.saveHistory();
render();
}
}
return;
}
if (fcState.tool === 'select') {
const handle = 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 = 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 = 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 };
}
render();
}
}
function handleMouseMove(e: MouseEvent) {
if (e.target !== canvas) return;
const mx = e.clientX, my = e.clientY;
const world = screenToWorld(mx, my);
if (fcState.isPanning) {
fcState.panX = fcState.dragOffset.x + (mx - fcState.dragStart!.x);
fcState.panY = fcState.dragOffset.y + (my - fcState.dragStart!.y);
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 = snapToGrid(world.x);
arrow.points[pointIndex].y = snapToGrid(world.y);
}
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;
}
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 = snapToGrid(off.ox + dx);
shape.y = snapToGrid(off.oy + dy);
}
}
render();
return;
}
if (fcState.isDragging && fcState.creatingShape) {
const dx = world.x - fcState.creatingShape.x!;
const dy = world.y - fcState.creatingShape.y!;
fcState.creatingShape.w = snapToGrid(dx);
fcState.creatingShape.h = snapToGrid(dy);
render();
return;
}
if ((fcState.isDragging || fcState.arrowStartId) && (fcState.tool === 'arrow' || fcState.tool === 'line')) {
fcState.arrowTemp = { x: world.x, y: world.y };
render();
return;
}
}
function handleMouseUp(e: MouseEvent) {
if (fcState.isPanning) {
fcState.isPanning = false;
fcState.dragStart = null;
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;
render();
}
if (fcState.isDragging && (fcState.tool === 'arrow' || fcState.tool === 'line')) {
const mx = e.clientX, my = e.clientY;
const world = screenToWorld(mx, my);
const toShape = 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;
render();
}
}
function handleDoubleClick(e: MouseEvent) {
if (fcState.tool !== 'select') return;
const mx = e.clientX, my = e.clientY;
const world = screenToWorld(mx, my);
const arrowHandle = 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();
render();
}
}
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
const mx = e.clientX, my = e.clientY;
const rect = 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;
}
render();
}
function handleTouchStart(e: TouchEvent) {
if (e.target !== canvas) return;
e.preventDefault(); // Prevent ghost clicks
if (e.touches.length === 1) {
const touch = e.touches[0];
handleMouseDown({
clientX: touch.clientX,
clientY: touch.clientY,
target: e.target,
shiftKey: false
} as any);
}
}
function handleTouchMove(e: TouchEvent) {
if (e.target !== canvas) return;
e.preventDefault();
if (e.touches.length === 1) {
const touch = e.touches[0];
handleMouseMove({
clientX: touch.clientX,
clientY: touch.clientY,
target: e.target
} as any);
}
}
function handleTouchEnd(e: TouchEvent) {
if (e.target !== canvas) return;
e.preventDefault();
if (e.changedTouches.length > 0) {
const touch = e.changedTouches[0];
handleMouseUp({
clientX: touch.clientX,
clientY: touch.clientY,
target: e.target
} as any);
}
}
// Basic Rendering
function render() {
if (!ctx) return;
@ -845,32 +342,34 @@
ctx.restore();
}
onMount(() => {
onMount(() => {
ctx = canvas.getContext('2d')!;
controller = new CanvasController(canvas, container, render);
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
canvas.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('wheel', handleWheel, { passive: false });
canvas.addEventListener('dblclick', handleDoubleClick);
canvas.addEventListener('mousedown', controller.handleMouseDown);
window.addEventListener('mousemove', controller.handleMouseMove);
window.addEventListener('mouseup', controller.handleMouseUp);
canvas.addEventListener('wheel', controller.handleWheel, { passive: false });
canvas.addEventListener('dblclick', controller.handleDoubleClick);
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', handleTouchEnd, { passive: false });
canvas.addEventListener('touchstart', controller.handleTouchStart, { passive: false });
canvas.addEventListener('touchmove', controller.handleTouchMove, { passive: false });
canvas.addEventListener('touchend', controller.handleTouchEnd, { passive: false });
const int = setInterval(render, 1000/60);
return () => {
window.removeEventListener('resize', resizeCanvas);
canvas.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('wheel', handleWheel);
canvas.removeEventListener('dblclick', handleDoubleClick);
canvas.removeEventListener('touchstart', handleTouchStart);
canvas.removeEventListener('touchmove', handleTouchMove);
canvas.removeEventListener('touchend', handleTouchEnd);
canvas.removeEventListener('mousedown', controller.handleMouseDown);
window.removeEventListener('mousemove', controller.handleMouseMove);
window.removeEventListener('mouseup', controller.handleMouseUp);
canvas.removeEventListener('wheel', controller.handleWheel);
canvas.removeEventListener('dblclick', controller.handleDoubleClick);
canvas.removeEventListener('touchstart', controller.handleTouchStart);
canvas.removeEventListener('touchmove', controller.handleTouchMove);
canvas.removeEventListener('touchend', controller.handleTouchEnd);
clearInterval(int);
};
});

View File

@ -0,0 +1,523 @@
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);
}
}
}