const width = 800; const height = 600; let realWidth = 800; let realHeight = 600; const radius = 25; const greekLetterNames = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta', 'Eta', 'Theta', 'Iota', 'Kappa', 'Lambda', 'Mu', 'Nu', 'Xi', 'Omicron', 'Pi', 'Rho', 'Sigma', 'Tau', 'Upsilon', 'Phi', 'Chi', 'Psi', 'Omega']; const settings = { physics: true, speed: 2, snapToPadding: 6, hitTargetPadding: 6, colors: { default: '#000', active: '#00f', selectionBg: 'rgba(0,0,255,.75)', selectionText: '#ffffff', gridColor: 'rgba(0,0,0,.15)', getColor: (object) => selectedObject === object ? settings.colors.active : settings.colors.default }, drawGrid: true, snapToGrid: true, gridSize: 100, }; const elements = { documents: document.getElementById('document-tabs'), dragOverlay: document.getElementById('dragOverlay'), }; const contextmenu = document.getElementById('contextmenuCanvas'); const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); function onResize() { canvas.width = width * 2; canvas.height = height * 2; canvas.style.maxWidth = width + 'px'; canvas.style.height = canvas.clientWidth * height / width + 'px'; ctx.scale(2, 2); realWidth = canvas.clientWidth; realHeight = canvas.clientHeight; } const documents = []; let activeDocument = null; let assistantLines = []; function switchDocument(doc) { if (doc instanceof FSMDocument) { doc = documents.findIndex(document => document.id === doc.id); } if (doc < 0) { activeDocument = null; } if (typeof doc === 'number' && doc >= 0 && doc < documents.length) { activeDocument = doc; const docElements = document.getElementsByClassName('document-tab'); for (let el of docElements) { el.classList.remove('active'); } docElements[doc].classList.add('active'); resetCaret(); selectedObject = null; currentConnection = null; movingObject = false; originalClick = null; deltaMouseX = 0; deltaMouseY = 0; shiftPressed = false; } } function addDocument(doc) { documents.push(doc); const elem = document.createElement('li'); elem.classList.add('document-tab'); const label = elem.addChild('span', 'document-name', doc.name || 'Unbenannt'); const btn = elem.addChild('button', ['btn', 'btn-close'], 'X'); elem.addEventListener('click', (e) => { if (e.target === elem || e.target === label) switchDocument(doc); }); btn.addEventListener('click', () => { closeDocument(doc); }); doc.element = elem; elements.documents.appendChild(elem); } function closeDocument(doc) { if (doc instanceof FSMDocument) { doc = documents.findIndex(document => document.id === doc.id); } if (typeof doc === 'number') { documents.splice(doc, 1); const docElements = document.getElementsByClassName('document-tab'); docElements[doc].remove(); if (doc < activeDocument) { activeDocument--; } else if (doc === activeDocument) { if (activeDocument >= documents.length) { activeDocument = documents.length - 1; } } switchDocument(activeDocument); } } const animations = []; let isPaused = false; function convertLatexShortcuts(text) { greekLetterNames.forEach((name, i) => { text = text.replace(new RegExp('\\\\' + name, 'g'), String.fromCharCode(913 + i + (i > 16))); text = text.replace(new RegExp('\\\\' + name.toLowerCase(), 'g'), String.fromCharCode(945 + i + (i > 16))); }); for (let i = 0; i < 10; i++) { // TODO: Replace with more general way text = text.replace(new RegExp('_' + i, 'g'), String.fromCharCode(8320 + i)); } return text; } let caretTimer, caretVisible = true, caretPos = 0, caretSelection = null; function resetCaret() { clearInterval(caretTimer); caretTimer = setInterval(() => caretVisible = !caretVisible, 500); caretVisible = true; } function tick() { if (activeDocument !== null) { for (let i = 0; i < 10; i++) { documents[activeDocument].physicsTick(); } documents[activeDocument].tick(); } if (!isPaused) setTimeout(tick, 1000 / 30); } function draw() { ctx.clearRect(0, 0, width, height); ctx.save(); ctx.translate(0.5, 0.5); if (activeDocument !== null) { if (settings.drawGrid) drawGrid(); assistantLines.forEach(line => { ctx.strokeStyle = '#f0f'; ctx.beginPath(); if (!!line.x) { ctx.moveTo(line.x, 0); ctx.lineTo(line.x, height); } else { ctx.moveTo(0, line.y); ctx.lineTo(width, line.y); } ctx.stroke(); }); documents[activeDocument].draw(); if (!!currentConnection) { currentConnection.draw(); } animations.forEach(animation => animation.draw()); } else { ctx.fillStyle = '#eee'; ctx.fillRect(0, 0, width, height); ctx.fillStyle = '#000'; ctx.drawText('Bitte öffne ein Dokument oder erstelle ein neues...', width / 2, height / 2, null, false); } ctx.restore(); if (!isPaused) setTimeout(draw, 1000 / 60); } function drawGrid() { for (let i = settings.gridSize; i < width; i += settings.gridSize) { ctx.strokeStyle = settings.colors.gridColor; ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, height); ctx.stroke(); } for (let i = settings.gridSize; i < height; i += settings.gridSize) { ctx.strokeStyle = settings.colors.gridColor; ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(width, i); ctx.stroke(); } } function selectObject(x, y) { if (activeDocument === null) { return null; } const doc = documents[activeDocument]; for (let state of doc.states) { if (state.containsPoint(x, y)) { caretPos = state.text.length; return state; } } for (let connection of doc.connections) { if (connection.containsPoint(x, y)) { caretPos = connection.text.length; return connection; } } caretPos = 0; return null; } let cursorVisible = true, selectedObject = null, currentConnection = null, movingObject = false, originalClick = null, deltaMouseX = 0, deltaMouseY = 0, shiftPressed = false, ctrlPressed = false, changedKeys = [], changedValues = []; canvas.addEventListener('mousedown', (event) => { const mouse = cbRelMousePos(event); selectedObject = selectObject(mouse.x, mouse.y); movingObject = false; originalClick = mouse; if (!!selectedObject) { if ((shiftPressed || event.shiftKey) && selectedObject instanceof State) { currentConnection = new SelfConnection(selectedObject, mouse); } else { movingObject = true; deltaMouseX = deltaMouseY = 0; if (selectedObject.setMouseStart) { selectedObject.setMouseStart(mouse.x, mouse.y); } } resetCaret(); if (selectedObject instanceof SelfConnection) { changedKeys = ['anchorAngle']; changedValues = [selectedObject.anchorAngle]; } else if (selectedObject instanceof Connection) { changedKeys = ['parallelPart', 'perpendicularPart']; changedValues = [selectedObject.parallelPart, selectedObject.perpendicularPart]; } else if (selectedObject instanceof StartConnection) { changedKeys = ['deltaX', 'deltaY']; changedValues = [selectedObject.deltaX, selectedObject.deltaY]; } else if (selectedObject instanceof State) { changedKeys = ['x', 'y']; changedValues = [selectedObject.x, selectedObject.y]; } } else if (shiftPressed || event.shiftKey) { 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) { if (selectedObject instanceof State) { if (ctrlPressed || event.ctrlKey) { const prevX = changedValues[0]; const prevY = changedValues[1]; selectedObject.setAnchorPoint(mouse.x, mouse.y); const deltaX = Math.abs(selectedObject.x - prevX); const deltaY = Math.abs(selectedObject.y - prevY); if (deltaX > deltaY) { selectedObject.y = prevY; } else { selectedObject.x = prevX; } } else { selectedObject.setAnchorPoint(mouse.x, mouse.y); snapNode(selectedObject); } } else { selectedObject.setAnchorPoint(mouse.x, mouse.y); } } }); document.addEventListener('mouseup', () => { movingObject = false; if (!!selectedObject && activeDocument !== null) { documents[activeDocument].addChange(selectedObject.constructor.name.toLowerCase(), selectedObject.id, 'edit', changedKeys, changedValues); } if (!!currentConnection && activeDocument !== null) { if (!(currentConnection instanceof TemporaryConnection)) { selectedObject = currentConnection; documents[activeDocument].connections.push(currentConnection); resetCaret(); if (currentConnection instanceof Connection) { const parallelPart = currentConnection.parallelPart; const perpendicularPart = currentConnection.perpendicularPart; const connections = documents[activeDocument].getConnectionsBetweenStates(currentConnection.stateA, currentConnection.stateB).filter(conn => { return conn.parallelPart === parallelPart && conn.perpendicularPart === perpendicularPart; }); if (connections.length > 1) { let cx = (currentConnection.stateB.x + currentConnection.stateA.x) / 2; let cy = (currentConnection.stateB.y + currentConnection.stateA.y) / 2; const dx = currentConnection.stateB.x - currentConnection.stateA.x; const dy = currentConnection.stateB.y - currentConnection.stateA.y; const factorX = Math.sin(dx); const factorY = Math.cos(dy); const step = Math.sqrt(dx ** 2 + dy ** 2) * .25; cx -= (connections.length / 2 - .5) * step * factorX; cy -= (connections.length / 2 - .5) * step * factorY; connections.forEach(connection => { connection.setAnchorPoint(cx, cy); cx += step * factorX; cy += step * factorY; }); } } documents[activeDocument].addChange(selectedObject.constructor.name.toLowerCase(), selectedObject.id, 'add', Object.keys(selectedObject), Object.values(selectedObject)) } currentConnection = null; } assistantLines = []; }); canvas.addEventListener('dblclick', (event) => { const mouse = cbRelMousePos(event); if (activeDocument === null) return; selectedObject = selectObject(mouse.x, mouse.y); documents[activeDocument].onDblClick(mouse.x, mouse.y); }); document.addEventListener('click', (event) => { contextmenu.style.display = 'none'; }); document.addEventListener('contextmenu', (event) => { const mouse = cbRelMousePos(event); event.preventDefault(); if (activeDocument === null) return; // selectedObject = selectObject(mouse.x, mouse.y); if (event.target === contextmenu || event.target === canvas) { contextmenu.style.top = event.y + 'px'; contextmenu.style.left = event.x + 'px'; contextmenu.style.display = 'block'; } else { contextmenu.style.display = 'none'; } // documents[activeDocument].onRightClick(mouse.x, mouse.y); }); 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) { let start = caretPos - 1; let end = caretPos; if (caretSelection !== null) { if (caretSelection < caretPos) { start = caretSelection; } else { start = caretPos; end = caretSelection; } caretSelection = null; } else { caretPos--; caretPos = Math.max(caretPos, 0); } selectedObject.text = selectedObject.text.slice(0, start) + selectedObject.text.slice(end, selectedObject.text.length); resetCaret(); } event.preventDefault(); return false; case 37: // Left Arrow if (!(event.ctrlKey || event.metaKey) && !!selectedObject) { if ((shiftPressed || event.shiftKey) && caretSelection === null) caretSelection = caretPos; caretPos--; caretPos = Math.max(caretPos, 0); resetCaret(); if (!(shiftPressed || event.shiftKey) || caretSelection === caretPos) caretSelection = null; return false; } break; case 39: // Right Arrow if (!(event.ctrlKey || event.metaKey) && !!selectedObject) { if ((shiftPressed || event.shiftKey) && caretSelection === null) caretSelection = caretPos; caretPos++; caretPos = Math.min(caretPos, selectedObject.text.length); resetCaret(); if (!(shiftPressed || event.shiftKey) || caretSelection === caretPos) caretSelection = null; return false; } break; case 36: // Home if (!(event.ctrlKey || event.metaKey) && !!selectedObject) { if ((shiftPressed || event.shiftKey) && caretSelection === null) caretSelection = caretPos; caretPos = 0; resetCaret(); if (!(shiftPressed || event.shiftKey) || caretSelection === caretPos) caretSelection = null; return false; } break; case 35: // End if (!(event.ctrlKey || event.metaKey) && !!selectedObject) { if ((shiftPressed || event.shiftKey) && caretSelection === null) caretSelection = caretPos; caretPos = selectedObject.text.length; if (!(shiftPressed || event.shiftKey) || caretSelection === caretPos) caretSelection = null; return false; } break; case 46: // Delete if (activeDocument !== null) { documents[activeDocument].deleteCurrentObject(); } 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 (!!selectedObject && 'text' in selectedObject) { if (!event.metaKey && !event.altKey && !event.ctrlKey) { if (key >= 0x20 && key <= 0x7E) { let text = selectedObject.text; if (caretSelection !== null) { if (caretSelection > caretPos) { text = text.slice(0, caretPos) + text.slice(caretSelection, text.length); } else { text = text.slice(0, caretSelection) + text.slice(caretPos, text.length); caretPos = caretSelection; } caretSelection = null; } text = text.substring(0, caretPos) + String.fromCharCode(key) + text.substring(caretPos, text.length); caretPos++; resetCaret(); selectedObject.text = text; return false; } } } if (key === 8) { selectedObject = null; return false; } }); document.addEventListener('paste', (event) => { if (activeDocument !== null && selectedObject !== null) { const pastedText = event.clipboardData.getData('text/plain'); let start = caretPos; let end = caretPos; if (caretSelection !== null) { if (caretSelection < caretPos) { start = caretSelection; caretPos = caretSelection; } else { end = caretSelection; } caretSelection = null; } selectedObject.text = selectedObject.text.slice(0, start) + pastedText + selectedObject.text.slice(end, selectedObject.text.length); caretPos += pastedText.length; } }); window.addEventListener('keydown', (event) => { const keyCode = crossBrowserKey(event); const key = String.fromCharCode(keyCode).toLowerCase(); if ((event.ctrlKey || event.metaKey) && key === 's') { // Ctrl + S if (event.altKey || event.shiftKey) { // Ctrl + Alt + S modalExport.open(); } else { saveToLocalStorage(); } event.preventDefault(); return false; } if ((event.ctrlKey || event.metaKey) && key === 'z') { // Ctrl + Z if (activeDocument !== null) { documents[activeDocument].undo(1); event.preventDefault(); return false; } } if ((event.ctrlKey || event.metaKey) && key === 'y') { // Ctrl + Y if (activeDocument !== null) { documents[activeDocument].undo(-1); event.preventDefault(); return false; } } if ((event.ctrlKey || event.metaKey) && key === 'a') { // Ctrl + A if (activeDocument !== null && selectedObject !== null) { caretPos = selectedObject.text.length; caretSelection = 0; event.preventDefault(); return false; } } if ((event.ctrlKey || event.metaKey) && key === 'e') { // Ctrl + E modalExport.open(); event.preventDefault(); return false; } if ((event.ctrlKey || event.metaKey) && key === 'n') { // Ctrl + N const doc = new FSMDocument(); addDocument(doc); switchDocument(doc); event.preventDefault(); return false; } if ((event.ctrlKey || event.metaKey) && keyCode === 37) { // Ctrl + LeftArrow let activeDoc = activeDocument; activeDoc--; activeDoc = activeDoc < 0 ? documents.length - 1 : activeDoc % documents.length; switchDocument(activeDoc); event.preventDefault(); return false; } if ((event.ctrlKey || event.metaKey) && keyCode === 39) { // Ctrl + RightArrow let activeDoc = activeDocument; activeDoc++; activeDoc = activeDoc < 0 ? documents.length - 1 : activeDoc % documents.length; switchDocument(activeDoc); event.preventDefault(); return false; } }); let prevSelectedObject; window.addEventListener('blur', () => { prevSelectedObject = selectedObject; currentConnection = null; selectedObject = null; shiftPressed = false; }); window.addEventListener('focus', (s) => { selectedObject = prevSelectedObject; resetCaret(); }); 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 = convertLatexShortcuts(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); let drawText = true; if (isSelected && canvasHasFocus() && document.hasFocus()) { const caretX = x + this.measureText(text.substring(0, caretPos)).width; if (caretSelection !== null && caretPos !== caretSelection) { drawText = false; const selectionX = x + this.measureText(text.substring(0, caretSelection)).width; ctx.fillStyle = settings.colors.selectionBg; this.beginPath(); this.moveTo(selectionX, y - 10); this.lineTo(selectionX, y + 10); this.lineTo(caretX, y + 10); this.lineTo(caretX, y - 10); this.closePath(); this.fill(); let start = caretPos; let end = caretSelection; if (caretSelection < caretPos) { [start, end] = [end, start]; } const prevText = text.substring(0, start); const selectedText = text.substring(start, end); const followingText = text.substring(end, text.length); let textX = x; const textY = y + 6; ctx.fillStyle = settings.colors.active; this.fillText(prevText, textX, textY); textX += this.measureText(prevText).width; ctx.fillStyle = settings.colors.selectionText; this.fillText(selectedText, textX, textY); textX += this.measureText(selectedText).width; ctx.fillStyle = settings.colors.active; this.fillText(followingText, textX, textY); } if (caretVisible) { this.beginPath(); this.moveTo(caretX, y - 10); this.lineTo(caretX, y + 10); this.stroke(); this.closePath(); } } if (drawText) this.fillText(text, x, y + 6); } }; Node.prototype.addChild = function (name, classList, text) { const elem = document.createElement(name); if (classList instanceof Array) { elem.classList.add(...classList); } else if (!!classList) { elem.classList.add(classList); } if (!!text) { elem.innerText = text; } this.appendChild(elem); return elem; }; function snapNode(targetState) { let snapX = false, snapY = false; assistantLines = []; for (let state of documents[activeDocument].states) { if (state === targetState) continue; if (!snapX && Math.abs(targetState.x - state.x) < settings.snapToPadding) { targetState.x = state.x; assistantLines.push({x: state.x}); snapX = true; } if (!snapY && Math.abs(targetState.y - state.y) < settings.snapToPadding) { targetState.y = state.y; assistantLines.push({y: state.y}); snapY = true; } if (snapX && snapY) return; } if (settings.drawGrid && settings.snapToGrid) { for (let i = 100; i < width; i += 100) { if (!snapX && Math.abs(targetState.x - i) < settings.snapToPadding) { targetState.x = i; assistantLines.push({x: i}); snapX = true; } if (snapX && snapY) return; } for (let i = settings.gridSize; i < height; i += settings.gridSize) { if (!snapY && Math.abs(targetState.y - i) < settings.snapToPadding) { targetState.y = i; assistantLines.push({y: i}); snapY = true; } if (snapX && snapY) return; } } } 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) / realWidth * width, y: (mouse.y - elem.y) / realHeight * height, }; } function guid() { function s4() { return Math.floor((1 + Math.random()) * 0x10000) .toString(16) .substring(1); } return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); } window.addEventListener('load', () => { onResize(); const doc = new FSMDocument('Dokument1'); addDocument(doc); switchDocument(doc); tick(); draw(); }); window.addEventListener('beforeunload', (e) => { if (!!documents.find(item => item.unsaved)) { e.preventDefault(); e.returnValue(''); } }); window.addEventListener('resize', () => { onResize(); }); Object.prototype.hashCode = function () { let baseString = ''; Object.values(this).forEach(entry => { if (entry === null) return; if (typeof entry === 'object' && !(entry instanceof Node)) { baseString += entry.hashCode(); } else { baseString += entry; } }); let hash = 0; for (let i = 0; i < baseString.length; i++) { const char = baseString.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return hash; };