const width = 800; const height = 600; const radius = 25; const settings = { physics: true, speed: 2, snapToPadding: 6, hitTargetPadding: 6, colors: { default: '#000', active: '#00f', getColor: (object) => selectedObject === object ? settings.colors.active : settings.colors.default } }; const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); canvas.width = width; canvas.height = height; const states = []; const connections = []; let isPaused = false; let caretTimer, caretVisible = true; function resetCaret() { clearInterval(caretTimer); caretTimer = setInterval(() => caretVisible = !caretVisible, 500); caretVisible = true; } function tick() { states.forEach(stateA => { states.forEach(stateB => { if (stateA !== stateB && stateA.intersects(stateB)) { const inter = stateA.intersection(stateB); const angle1 = stateA.directionTo(stateB); const angle2 = angle1 + Math.PI; const x1 = Math.cos(angle1) * inter + stateA.x; const y1 = Math.sin(angle1) * inter + stateA.y; const x2 = Math.cos(angle2) * inter + stateB.x; const y2 = Math.sin(angle2) * inter + stateB.y; stateA.moveTo(x1, y1); stateB.moveTo(x2, y2); } }); }); states.forEach(state => { if (state.v) { state.moveToStep(); } }); if (!isPaused) setTimeout(tick, 1000 / 30); } function draw() { ctx.clearRect(0, 0, width, height); ctx.save(); ctx.translate(0.5, 0.5); states.forEach(state => state.draw()); connections.forEach(connection => connection.draw()); if (!!currentConnection) { currentConnection.draw(); } ctx.restore(); if (!isPaused) setTimeout(draw, 1000 / 60); } function selectObject(x, y) { for (let state of states) { if (state.containsPoint(x, y)) { return state; } } for (let connection of connections) { if (connection.containsPoint(x, y)) { return connection; } } return null; } let cursorVisible = true, selectedObject = null, currentConnection = null, movingObject = false, originalClick = null, deltaMouseX = 0, deltaMouseY = 0, shiftPressed = false; canvas.addEventListener('mousedown', (event) => { const mouse = cbRelMousePos(event); selectedObject = selectObject(mouse.x, mouse.y); movingObject = false; originalClick = mouse; if (!!selectedObject) { if (shiftPressed && selectedObject instanceof State) { currentConnection = new SelfConnection(selectedObject, mouse); } else { movingObject = true; deltaMouseX = deltaMouseY = 0; if (selectedObject.setMouseStart) { selectedObject.setMouseStart(mouse.x, mouse.y); } } resetCaret(); } else if (shiftPressed) { currentConnection = new TemporaryConnection(mouse, mouse); } if (canvasHasFocus()) { return false; } else { resetCaret(); return true; } }); canvas.addEventListener('mousemove', (event) => { const mouse = cbRelMousePos(event); if (!!currentConnection) { let targetNode = selectObject(mouse.x, mouse.y); if (!(targetNode instanceof State)) { targetNode = null; } if (!selectedObject) { if (!!targetNode) { currentConnection = new StartConnection(targetNode, originalClick); } else { currentConnection = new TemporaryConnection(originalClick, mouse); } } else { if (targetNode === selectedObject) { currentConnection = new SelfConnection(selectedObject, mouse); } else if (!!targetNode) { currentConnection = new Connection(selectedObject, targetNode); } else { currentConnection = new TemporaryConnection(selectedObject.closestPointOnCircle(mouse.x, mouse.y), mouse) } } } if (movingObject) { selectedObject.setAnchorPoint(mouse.x, mouse.y); if (selectedObject instanceof State) { snapNode(selectedObject); } } }); canvas.addEventListener('mouseup', (event) => { movingObject = false; if (!!currentConnection) { if (!(currentConnection instanceof TemporaryConnection)) { selectedObject = currentConnection; connections.push(currentConnection); resetCaret(); } currentConnection = null; } }); canvas.addEventListener('dblclick', (event) => { const mouse = cbRelMousePos(event); selectedObject = selectObject(mouse.x, mouse.y); if (!selectedObject) { selectedObject = new State(mouse.x, mouse.y); states.push(selectedObject); resetCaret(); } else if (selectedObject instanceof State) { selectedObject.isAcceptState = !selectedObject.isAcceptState; } }); document.addEventListener('keydown', (event) => { const key = crossBrowserKey(event); if (!canvasHasFocus()) { return true; } switch (key) { case 16: // Shift shiftPressed = true; break; case 8: // Backspace if (!!selectedObject) { selectedObject.text = selectedObject.text.substr(0, selectedObject.text.length - 1); resetCaret(); } return false; case 46: // Delete if (!!selectedObject) { if(selectedObject instanceof State) { states.splice(states.findIndex(state => state === selectedObject), 1); } for (let i = 0; i < connections.length; i++) { const con = connections[i]; if (con === selectedObject || con.state === selectedObject || con.stateA === selectedObject || con.stateB === selectedObject) { connections.splice(i--, 1); } } selectedObject = null; } break; } }); document.addEventListener('keyup', (event) => { const key = crossBrowserKey(event); if (key === 16) { shiftPressed = false; } }); document.addEventListener('keypress', (event) => { const key = crossBrowserKey(event); if(!canvasHasFocus()) { return true; } if(key >= 0x20 && key <= 0x7E && !event.metaKey && !event.altKey && !event.ctrlKey && !!selectedObject && 'text' in selectedObject) { selectedObject.text += String.fromCharCode(key); resetCaret(); return false; } if(key === 8) { return false; } }); CanvasRenderingContext2D.prototype.drawArrow = function (x, y, angle) { const dx = Math.cos(angle); const dy = Math.sin(angle); this.beginPath(); this.moveTo(x, y); this.lineTo(x - 8 * dx + 5 * dy, y - 8 * dy - 5 * dx); this.lineTo(x - 8 * dx - 5 * dy, y - 8 * dy + 5 * dx); this.fill(); }; CanvasRenderingContext2D.prototype.drawText = function(originalText, x, y, angleOrNull, isSelected) { const text = originalText; this.font = '20px Roboto'; const width = this.measureText(text).width; x -= width / 2; // Centers the text if(!!angleOrNull) { const dx = Math.cos(angleOrNull), dy = Math.sin(angleOrNull), cornerPointX = (width / 2 + 5) * (dx > 0 ? 1 : -1), cornerPointY = (10 + 5) * (dy > 0 ? 1 : -1), slide = dy * (Math.abs(dy) ** 40) * cornerPointX - dx * (Math.abs(dx) ** 10) * cornerPointY; x += cornerPointX - dy * slide; y += cornerPointY + dx * slide; } // Draw text and caret if('advancedFillText' in this) { // this.advancedFillText() } else { x = Math.round(x); y = Math.round(y); this.fillText(text, x, y + 6); if(isSelected && caretVisible && canvasHasFocus() && document.hasFocus()) { x += width; this.beginPath(); this.moveTo(x, y - 10); this.lineTo(x, y + 10); this.stroke(); this.closePath(); } } }; function snapNode(targetState) { for (let state of states) { if (state === targetState) continue; if (Math.abs(targetState.x - state.x) < settings.snapToPadding) { targetState.x = state.x; } if (Math.abs(targetState.y - state.y) < settings.snapToPadding) { targetState.y = state.y; } } } function canvasHasFocus() { return (document.activeElement || document.body) === document.body; } function crossBrowserKey(event) { event = event || window.event; return event.which || event.keyCode; } function cbElementPos(event) { event = event || window.event; let obj = event.target || event.srcElement, x = 0, y = 0; while (obj.offsetParent) { x += obj.offsetLeft; y += obj.offsetTop; obj = obj.offsetParent; } return {x, y}; } function cbMousePos(event) { event = event || window.event; return { x: event.pageX || event.clientX + document.body.scrollLeft + document.documentElement.scrollLeft, y: event.pageY || event.clientY + document.body.scrollTop + document.documentElement.scrollTop, } } function cbRelMousePos(event) { const elem = cbElementPos(event), mouse = cbMousePos(event); return { x: mouse.x - elem.x, y: mouse.y - elem.y, }; } tick(); draw();