From 23c24ded96ad910d26ef43bf1c768b623e26d8a5 Mon Sep 17 00:00:00 2001 From: KingOfDog Date: Wed, 3 Apr 2019 16:15:15 +0200 Subject: [PATCH] General improvements --- index.html | 147 ++++++++++- js/FSMDocument.js | 86 ------ js/components/connection.js | 17 +- js/components/self-connection.js | 13 +- js/components/start-connection.js | 19 +- js/components/state.js | 15 +- js/export/export.js | 2 +- js/fsm-document.js | 187 ++++++++++++++ js/main.js | 417 +++++++++++++++++++++++++++--- js/menu.js | 74 +++++- js/simulate.js | 3 +- 11 files changed, 828 insertions(+), 152 deletions(-) delete mode 100644 js/FSMDocument.js create mode 100644 js/fsm-document.js diff --git a/index.html b/index.html index 9f8020e..cc7b7f0 100644 --- a/index.html +++ b/index.html @@ -30,7 +30,7 @@ padding: 0 40px; } - input { + input:not([type=checkbox]) { width: 100%; font-size: 16px; padding: 8px 16px; @@ -44,6 +44,11 @@ background-color: rgba(0, 0, 0, .2); } + label { + display: block; + margin-bottom: 8px; + } + /** * Buttons */ @@ -127,7 +132,7 @@ .action-button { background: #fff; - margin: 0 8px; + margin: 4px 8px; } .action-button .fa { @@ -189,7 +194,7 @@ .upload-btn .upload-input { display: none; } - + /** * Canvas */ @@ -199,11 +204,109 @@ margin: 0 auto; background: #fff; border-radius: 10px; + width: calc(100% - 20px); } #previewImage { width: 100%; } + + .explanation { + max-width: 800px; + margin: 10px auto; + padding: 0 20px; + } + + /** + * Context Menu + */ + + .contextmenu { + position: absolute; + top: 0; + left: 0; + display: none; + border-radius: 10px; + border: 1px solid rgba(0, 0, 0, .33); + box-shadow: 0 0 4px rgba(0, 0, 0, .15); + margin: 0; + padding: 0; + list-style: none; + min-width: 150px; + background: #fff; + } + + .contextmenu > li { + padding: 8px 16px; + cursor: pointer; + } + + .contextmenu > li:first-child { + border-top-left-radius: 10px; + border-top-right-radius: 10px; + } + + .contextmenu > li:last-child { + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + } + + .contextmenu > li:hover { + background: rgba(0, 0, 0, .1); + } + + .contextmenu > li:active { + background: rgba(0, 0, 0, .2); + } + + /** + * Drag Overlay + */ + + #dragOverlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, .75); + padding: 20px; + color: #fff; + pointer-events: none; + opacity: 0; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + -webkit-transition: opacity .25s; + -moz-transition: opacity .25s; + -ms-transition: opacity .25s; + -o-transition: opacity .25s; + transition: opacity .25s; + } + + #dragOverlay.visible { + opacity: 1; + pointer-events: all; + cursor: pointer; + } + + #dragOverlay .border { + width: 100%; + height: 100%; + border-radius: 10px; + border: 4px dashed white; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + } + + #dragOverlay .content { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + } @@ -235,7 +338,43 @@ +
+ +

Oben befindet sich der sogenannter FSM-Designer. Dieser ermöglicht es unter anderem deterministische endliche Automaten zu erstellen.

+ +

Funktionsweise:

+ + + +

Diese Anwendung wurde mithilfe von HTML5 Canvas und JavaScript ECMA2016 erstellt.

+ +

Der Source Code ist open source auf Gitea verfügbar (demnächst womöglich auch über Github).

+ +

Made with by KingOfDog.

+ +
+ + + +
+
+
+

Datei ablegen (.fsm)

+

Dokument importieren und weiterarbeiten!

+
+
+ + @@ -245,7 +384,7 @@ - + diff --git a/js/FSMDocument.js b/js/FSMDocument.js deleted file mode 100644 index 3452186..0000000 --- a/js/FSMDocument.js +++ /dev/null @@ -1,86 +0,0 @@ -class FSMDocument { - - constructor(name) { - this.id = guid(); - this.name = null; - this.createdAt = Date.now(); - this.lastModified = Date.now(); - this.element = null; - this.unsaved = true; - this.lastSavedHash = ''; - this.states = []; - this.connections = []; - } - - tick() { - this.states.forEach(stateA => { - this.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); - } - }); - }); - - this.states.forEach(state => { - if (state.v) { - state.moveToStep(); - } - }); - - if(!this.unsaved) { - if(this.hashCode() !== this.lastSavedHash) { - this.unsaved = true; - this.element.classList.add('unsaved'); - } - } - } - - draw() { - this.states.forEach(state => state.draw()); - this.connections.forEach(connection => connection.draw()); - } - - onDblClick(x, y) { - if (!selectedObject) { - selectedObject = new State(x, y); - this.states.push(selectedObject); - resetCaret(); - } else if (selectedObject instanceof State) { - selectedObject.isAcceptState = !selectedObject.isAcceptState; - } - } - - onRightClick(x, y) { - if(!selectedObject) { - - } - } - - deleteCurrentObject() { - if (!!selectedObject) { - if (selectedObject instanceof State) { - this.states.splice(this.states.findIndex(state => state === selectedObject), 1); - } - - for (let i = 0; i < this.connections.length; i++) { - const con = this.connections[i]; - if (con === selectedObject || con.state === selectedObject || con.stateA === selectedObject || con.stateB === selectedObject) { - this.connections.splice(i--, 1); - } - } - - selectedObject = null; - } - } - -} diff --git a/js/components/connection.js b/js/components/connection.js index 30380e5..bc375f1 100644 --- a/js/components/connection.js +++ b/js/components/connection.js @@ -1,15 +1,26 @@ class Connection { constructor(stateA, stateB) { + this.id = guid(); this.stateA = stateA; this.stateB = stateB; - this.text = ''; + this._text = ''; this.lineAngleAdjust = 0; this.parallelPart = 0.5; this.perpendicularPart = 0; } + get text() { + return this._text; + } + + set text(value) { + documents[activeDocument].addChange('connection', this.id, 'edit', ['_text'], [this._text], true); + this._text = value; + documents[activeDocument].addChange('connection', this.id, 'edit', ['_text'], [this._text]); + } + getAnchorPoint() { const dx = this.stateB.x - this.stateA.x; const dy = this.stateB.y - this.stateA.y; @@ -103,11 +114,11 @@ class Connection { ctx.closePath(); // Draws the text onto the line - if(stuff.isCircle) { + if (stuff.isCircle) { let startAngle = stuff.startAngle, endAngle = stuff.endAngle; - if(endAngle < startAngle) { + if (endAngle < startAngle) { endAngle += Math.PI * 2; } diff --git a/js/components/self-connection.js b/js/components/self-connection.js index 6b70c88..717eec8 100644 --- a/js/components/self-connection.js +++ b/js/components/self-connection.js @@ -1,16 +1,27 @@ class SelfConnection { constructor(state, mouse) { + this.id = guid(); this.state = state; this.anchorAngle = 0; this.mouseOffsetAngle = 0; - this.text = ''; + this._text = ''; if(mouse) { this.setAnchorPoint(mouse.x, mouse.y); } } + get text() { + return this._text; + } + + set text(value) { + documents[activeDocument].addChange('selfconnection', this.id, 'edit', ['_text'], [this._text], true); + this._text = value; + documents[activeDocument].addChange('selfconnection', this.id, 'edit', ['_text'], [this._text]); + } + setMouseStart(x, y) { this.mouseOffsetAngle = this.anchorAngle - Math.atan2(y - this.state.y, x - this.state.x); } diff --git a/js/components/start-connection.js b/js/components/start-connection.js index f5f44bc..048c730 100644 --- a/js/components/start-connection.js +++ b/js/components/start-connection.js @@ -1,25 +1,36 @@ class StartConnection { constructor(state, start) { + this.id = guid(); this.state = state; this.deltaX = 0; this.deltaY = 0; - this.text = ''; + this._text = ''; - if(start) { + if (start) { this.setAnchorPoint(start.x, start.y); } } + get text() { + return this._text; + } + + set text(value) { + documents[activeDocument].addChange('startconnection', this.id, 'edit', ['_text'], [this._text], true); + this._text = value; + documents[activeDocument].addChange('startconnection', this.id, 'edit', ['_text'], [this._text]); + } + setAnchorPoint(x, y) { this.deltaX = x - this.state.x; this.deltaY = y - this.state.y; - if(Math.abs(this.deltaX) < settings.snapToPadding) { + if (Math.abs(this.deltaX) < settings.snapToPadding) { this.deltaX = 0; } - if(Math.abs(this.deltaY) < settings.snapToPadding) { + if (Math.abs(this.deltaY) < settings.snapToPadding) { this.deltaY = 0; } } diff --git a/js/components/state.js b/js/components/state.js index 3e0acd1..3534ad8 100644 --- a/js/components/state.js +++ b/js/components/state.js @@ -10,7 +10,17 @@ class State { this.isActive = false; this.activeTime = 0; this.isAcceptState = false; - this.text = ''; + this._text = ''; + } + + get text() { + return this._text; + } + + set text(value) { + documents[activeDocument].addChange('state', this.id, 'edit', ['_text'], [this._text], true); + this._text = value; + documents[activeDocument].addChange('state', this.id, 'edit', ['_text'], [this._text]); } setMouseStart(x, y) { @@ -58,7 +68,7 @@ class State { } ctx.fillStyle = settings.colors.getColor(this); - const width = ctx.measureText(this.text).width; + const width = ctx.measureText(convertLatexShortcuts(this.text)).width; if(width < radius * .9) ctx.drawText(this.text, this.x, this.y, null, selectedObject === this); else @@ -134,5 +144,4 @@ class State { containsPoint(x, y) { return (x - this.x) ** 2 + (y - this.y) ** 2 < radius ** 2; } - } diff --git a/js/export/export.js b/js/export/export.js index 302ab47..b2f39fe 100644 --- a/js/export/export.js +++ b/js/export/export.js @@ -67,7 +67,7 @@ function parseFromJson(json) { break; case 'SelfConnection': - connection = Object.assign(new SelfConnection(null, {x: 0, y: 0}), connection); + connection = Object.assign(new SelfConnection(null), connection); connection.state = doc.states.find(state => state.id === connection.state); diff --git a/js/fsm-document.js b/js/fsm-document.js new file mode 100644 index 0000000..086f57c --- /dev/null +++ b/js/fsm-document.js @@ -0,0 +1,187 @@ +class FSMDocument { + + constructor(name) { + this.id = guid(); + this.name = name || null; + this.createdAt = Date.now(); + this.lastModified = Date.now(); + this.element = null; + this.unsaved = true; + this.lastSavedHash = ''; + this.states = []; + this.connections = []; + this.changes = []; + this.changesIndex = -1; + } + + physicsTick() { + this.states.forEach(stateA => { + this.states.forEach(stateB => { + if (stateA !== stateB && stateA.intersects(stateB)) { + const inter = stateA.intersection(stateB); + const angle1 = stateA.directionTo(stateB); + const angle2 = angle1 + Math.PI; + + stateA.x += Math.cos(angle1) * inter; + stateA.y += Math.sin(angle1) * inter; + stateB.x += Math.cos(angle2) * inter; + stateB.y += Math.sin(angle2) * inter; + } + }); + }); + } + + tick() { + this.states.forEach(state => { + if (state.v) { + state.moveToStep(); + } + }); + + if (!this.unsaved) { + if (this.hashCode() !== this.lastSavedHash) { + this.unsaved = true; + this.element.classList.add('unsaved'); + } + } + } + + draw() { + this.states.forEach(state => state.draw()); + this.connections.forEach(connection => connection.draw()); + } + + onDblClick(x, y) { + if (!selectedObject) { + selectedObject = new State(x, y); + this.states.push(selectedObject); + this.addChange('state', selectedObject.id, 'add', Object.keys(selectedObject), Object.values(selectedObject)); + resetCaret(); + } else if (selectedObject instanceof State) { + selectedObject.isAcceptState = !selectedObject.isAcceptState; + this.addChange('state', selectedObject.id, 'edit', ['isAcceptState'], [!selectedObject.isAcceptState]); + } + } + + onRightClick(x, y) { + if (!selectedObject) { + + } + } + + deleteCurrentObject() { + if (!!selectedObject) { + if (selectedObject instanceof State) { + this.states.splice(this.states.findIndex(state => state === selectedObject), 1); + } + + for (let i = 0; i < this.connections.length; i++) { + const con = this.connections[i]; + if (con === selectedObject || con.state === selectedObject || con.stateA === selectedObject || con.stateB === selectedObject) { + this.connections.splice(i--, 1); + } + } + + selectedObject = null; + } + } + + addChange(objectType, id, action, fields, oldValues, tryCombiningChanges) { + if (this.changesIndex < this.changes.length - 1) { + this.changes.splice(this.changesIndex + 1, this.changes.length - this.changesIndex - 1); + } + + if (tryCombiningChanges) { + let prevChange = this.changes[this.changesIndex]; + + while (!!prevChange && prevChange.type === objectType && prevChange.id === id && prevChange.action === action && prevChange.fields.length === fields.length) { + oldValues = prevChange.prevValues; + this.changes.splice(this.changesIndex, 1); + this.changesIndex--; + prevChange = this.changes[this.changesIndex]; + } + } + + this.changes.push({ + type: objectType, + id, + action, + fields, + prevValues: oldValues, + date: Date.now() + }); + this.changesIndex++; + } + + undo(stepAmount) { + if (stepAmount === 0) + return; + + const loopAmount = Math.abs(stepAmount); + const isRedo = stepAmount < 0; + + for (let i = 0; i < loopAmount; i++) { + if (this.changes.length === 0 || (!isRedo && this.changesIndex < 0) || (isRedo && this.changesIndex >= this.changes.length - 1)) { + return; + } + + let change; + if (isRedo) { + this.changesIndex++; + change = this.changes[this.changesIndex]; + } else { + change = Object.assign({}, this.changes[this.changesIndex]); + this.changesIndex--; + + change.action = change.action === 'add' ? 'remove' : change.action === 'remove' ? 'add' : 'edit'; + } + + if (change.type === 'state') { + if (change.action === 'remove') { + this.states.splice(this.states.findIndex(item => item.id === change.id), 1); + return; + } + + let state; + if (change.action === 'add') + state = new State(); + else if (change.action === 'edit') + state = this.states.find(item => item.id === change.id); + + change.fields.forEach((field, index) => { + state[field] = change.prevValues[index]; + }); + + if (change.action === 'add') + this.states.push(state); + } + + if (change.type.indexOf('connection') !== -1) { + if (change.action === 'remove') { + this.connections.splice(this.connections.findIndex(item => item.id === change.id), 1); + return; + } + + let connection; + if (change.action === 'add') { + if(change.type === 'startconnection') + connection = new StartConnection(); + else if(change.type === 'selfconnection') + connection = new SelfConnection(); + else + connection = new Connection(); + } else if (change.action === 'edit') { + connection = this.connections.find(item => item.id === change.id); + } + + change.fields.forEach((field, index) => { + connection[field] = change.prevValues[index]; + }); + + if (change.action === 'add') + this.connections.push(connection); + } + } + } + +} diff --git a/js/main.js b/js/main.js index 23cf7de..8a37d7e 100644 --- a/js/main.js +++ b/js/main.js @@ -1,7 +1,11 @@ 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, @@ -10,29 +14,47 @@ const settings = { 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'); -canvas.width = width; -canvas.height = height; +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) { + if (doc < 0) { activeDocument = null; } @@ -66,7 +88,7 @@ function addDocument(doc) { const btn = elem.addChild('button', ['btn', 'btn-close'], 'X'); elem.addEventListener('click', (e) => { - if(e.target === elem || e.target === label) + if (e.target === elem || e.target === label) switchDocument(doc); }); @@ -90,10 +112,10 @@ function closeDocument(doc) { const docElements = document.getElementsByClassName('document-tab'); docElements[doc].remove(); - if(doc < activeDocument) { + if (doc < activeDocument) { activeDocument--; - } else if(doc === activeDocument) { - if(activeDocument >= documents.length) { + } else if (doc === activeDocument) { + if (activeDocument >= documents.length) { activeDocument = documents.length - 1; } } @@ -106,6 +128,11 @@ 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)); @@ -116,7 +143,8 @@ function convertLatexShortcuts(text) { let caretTimer, caretVisible = true, - caretPos = 0; + caretPos = 0, + caretSelection = null; function resetCaret() { clearInterval(caretTimer); @@ -126,6 +154,9 @@ function resetCaret() { function tick() { if (activeDocument !== null) { + for (let i = 0; i < 10; i++) { + documents[activeDocument].physicsTick(); + } documents[activeDocument].tick(); } @@ -139,6 +170,22 @@ function draw() { 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) { @@ -160,6 +207,24 @@ function draw() { 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; @@ -169,12 +234,14 @@ function selectObject(x, y) { 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; } } @@ -191,7 +258,10 @@ let cursorVisible = true, originalClick = null, deltaMouseX = 0, deltaMouseY = 0, - shiftPressed = false; + shiftPressed = false, + ctrlPressed = false, + changedKeys = [], + changedValues = []; canvas.addEventListener('mousedown', (event) => { const mouse = cbRelMousePos(event); @@ -200,7 +270,7 @@ canvas.addEventListener('mousedown', (event) => { originalClick = mouse; if (!!selectedObject) { - if (shiftPressed && selectedObject instanceof State) { + if ((shiftPressed || event.shiftKey) && selectedObject instanceof State) { currentConnection = new SelfConnection(selectedObject, mouse); } else { movingObject = true; @@ -210,7 +280,21 @@ canvas.addEventListener('mousedown', (event) => { } } resetCaret(); - } else if (shiftPressed) { + + 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); } @@ -249,25 +333,52 @@ canvas.addEventListener('mousemove', (event) => { } if (movingObject) { - selectedObject.setAnchorPoint(mouse.x, mouse.y); if (selectedObject instanceof State) { - snapNode(selectedObject); + 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); } } }); -canvas.addEventListener('mouseup', () => { +document.addEventListener('mouseup', () => { movingObject = false; + if (!!selectedObject && activeDocument !== null) { + if (selectedObject instanceof State) { + documents[activeDocument].addChange('state', selectedObject.id, 'edit', changedKeys, changedValues); + } else if (selectedObject instanceof Connection || selectedObject instanceof SelfConnection || selectedObject instanceof StartConnection) { + 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(); + + documents[activeDocument].addChange(selectedObject.constructor.name.toLowerCase(), selectedObject.id, 'add', Object.keys(selectedObject), Object.values(selectedObject)) } currentConnection = null; } + + assistantLines = []; }); canvas.addEventListener('dblclick', (event) => { @@ -281,17 +392,29 @@ canvas.addEventListener('dblclick', (event) => { documents[activeDocument].onDblClick(mouse.x, mouse.y); }); -canvas.addEventListener('contextmenu', (event) => { +document.addEventListener('click', (event) => { + contextmenu.style.display = 'none'; +}); + +document.addEventListener('contextmenu', (event) => { const mouse = cbRelMousePos(event); event.preventDefault(); - if(activeDocument === null) + if (activeDocument === null) return; - selectedObject = selectObject(mouse.x, mouse.y); + // selectedObject = selectObject(mouse.x, mouse.y); - documents[activeDocument].onRightClick(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) => { @@ -307,9 +430,24 @@ document.addEventListener('keydown', (event) => { break; case 8: // Backspace if (!!selectedObject) { - selectedObject.text = selectedObject.text.substr(0, caretPos - 1) + selectedObject.text.substr(caretPos, selectedObject.text.length); - caretPos--; - caretPos = Math.max(caretPos, 0); + 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(); } @@ -318,36 +456,60 @@ document.addEventListener('keydown', (event) => { 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 (!(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 (!(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; } @@ -376,13 +538,23 @@ document.addEventListener('keypress', (event) => { } if (!!selectedObject && 'text' in selectedObject) { - const text = selectedObject.text; - if (!event.metaKey && !event.altKey && !event.ctrlKey) { if (key >= 0x20 && key <= 0x7E) { - selectedObject.text = text.substring(0, caretPos) + String.fromCharCode(key) + text.substring(caretPos, text.length); + 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; } @@ -390,10 +562,34 @@ document.addEventListener('keypress', (event) => { } 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(); @@ -408,6 +604,34 @@ window.addEventListener('keydown', (event) => { 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(); @@ -415,6 +639,15 @@ window.addEventListener('keydown', (event) => { 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--; @@ -438,6 +671,19 @@ window.addEventListener('keydown', (event) => { } }); +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); @@ -474,18 +720,64 @@ CanvasRenderingContext2D.prototype.drawText = function (originalText, x, y, angl } else { x = Math.round(x); y = Math.round(y); - this.fillText(text, x, y + 6); - if (isSelected && caretVisible && canvasHasFocus() && document.hasFocus()) { - x += this.measureText(text.substring(0, caretPos)).width; - this.beginPath(); + let drawText = true; - this.moveTo(x, y - 10); - this.lineTo(x, y + 10); + if (isSelected && canvasHasFocus() && document.hasFocus()) { + const caretX = x + this.measureText(text.substring(0, caretPos)).width; - this.stroke(); - this.closePath(); + 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); } }; @@ -508,16 +800,51 @@ Node.prototype.addChild = function (name, classList, text) { }; function snapNode(targetState) { + let snapX = false, + snapY = false; + assistantLines = []; + for (let state of documents[activeDocument].states) { if (state === targetState) continue; - if (Math.abs(targetState.x - state.x) < settings.snapToPadding) { + if (!snapX && Math.abs(targetState.x - state.x) < settings.snapToPadding) { targetState.x = state.x; + assistantLines.push({x: state.x}); + snapX = true; } - if (Math.abs(targetState.y - state.y) < settings.snapToPadding) { + 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; } } } @@ -556,8 +883,8 @@ function cbRelMousePos(event) { const elem = cbElementPos(event), mouse = cbMousePos(event); return { - x: mouse.x - elem.x, - y: mouse.y - elem.y, + x: (mouse.x - elem.x) / realWidth * width, + y: (mouse.y - elem.y) / realHeight * height, }; } @@ -573,6 +900,7 @@ function guid() { } window.addEventListener('load', () => { + onResize(); const doc = new FSMDocument('Dokument1'); addDocument(doc); switchDocument(doc); @@ -581,6 +909,17 @@ window.addEventListener('load', () => { 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 => { diff --git a/js/menu.js b/js/menu.js index 3ab1f0a..0866fb1 100644 --- a/js/menu.js +++ b/js/menu.js @@ -11,7 +11,7 @@ modalSave.addFooterBtn('Speichern', 'btn', () => { const input = document.getElementById('saveName'); const name = input.value; - if(name.trim().length > 0) { + if (name.trim().length > 0) { documents[activeDocument].name = name; saveToLocalStorage(); modalSave.close(); @@ -30,7 +30,7 @@ const modalOpen = new tingle.modal({ html += ''; modalOpen.setContent(html); - for(let element of document.getElementsByClassName('file-list-item')) { + for (let element of document.getElementsByClassName('file-list-item')) { element.addEventListener('click', () => { const entry = entries.find(entry => entry.document.id === element.getAttribute('data-id')); addDocument(entry.document); @@ -44,8 +44,10 @@ const modalOpen = new tingle.modal({ const modalExport = new tingle.modal({ footer: true, beforeOpen: () => { - const el = document.getElementById('previewImage'); - el.src = canvas.toDataURL('image/png'); + setTimeout(() => { + const el = document.getElementById('previewImage'); + el.src = canvas.toDataURL('image/png'); + }, 100); } }); modalExport.setContent('

Dokument exportieren

'); @@ -69,7 +71,8 @@ modalImport.addFooterBtn('Laden', 'btn', () => { const el = document.getElementById('importUpload'); const file = el.files[0]; - if(file) { + if (file) { + console.log(file.type); const reader = new FileReader(); // TODO: Check for file type etc. reader.readAsText(file, 'UTF-8'); @@ -89,19 +92,20 @@ modalImport.addFooterBtn('Laden', 'btn', () => { const modalSimulate = new tingle.modal({ footer: true, }); -modalSimulate.setContent('

Maschine simulieren

'); +modalSimulate.setContent('

Maschine simulieren


'); modalSimulate.addFooterBtn('Simulation starten', 'btn', () => { modalSimulate.close(); const input = document.getElementById('simulationInput').value; - simulate(input); + const singleCharMode = document.getElementById('singleCharMode').checked; + simulate(input, singleCharMode); }); document.getElementById('saveBtn').addEventListener('click', () => { - if(activeDocument === null) { + if (activeDocument === null) { return; } - if(documents[activeDocument].name !== null) { + if (documents[activeDocument].name !== null) { saveToLocalStorage(); } else { modalSave.open(); @@ -109,23 +113,75 @@ document.getElementById('saveBtn').addEventListener('click', () => { }); document.getElementById('addBtn').addEventListener('click', () => { + selectedObject = null; + currentConnection = null; const doc = new FSMDocument(null); addDocument(doc); switchDocument(doc); }); document.getElementById('openBtn').addEventListener('click', () => { + selectedObject = null; + currentConnection = null; modalOpen.open(); }); document.getElementById('importBtn').addEventListener('click', () => { + selectedObject = null; + currentConnection = null; modalImport.open(); }); document.getElementById('exportBtn').addEventListener('click', () => { + selectedObject = null; + currentConnection = null; modalExport.open(); }); document.getElementById('simulateBtn').addEventListener('click', () => { + selectedObject = null; + currentConnection = null; modalSimulate.open(); }); + +document.addEventListener('dragover', (event) => { + elements.dragOverlay.classList.add('visible'); + event.preventDefault(); +}); + +document.addEventListener('dragexit', (event) => { + elements.dragOverlay.classList.remove('visible'); +}); + +document.addEventListener('drop', (event) => { + const item = event.dataTransfer.items[0]; + if (item.kind === 'file') { + const file = item.getAsFile(); + + const lastDot = file.name.lastIndexOf('.'); + const ext = file.name.slice(lastDot + 1); + + if (ext === 'fsm') { + const reader = new FileReader(); + reader.readAsText(file, 'UTF-8'); + reader.onload = (evt) => { + const json = evt.target.result; + // TODO: Add error handling + try { + importFromJson(json); + } catch (e) { + alert('Ein Fehler ist beim Einlesen der Datei aufgetreten. Die Datei scheint nicht im richtigen Format zu sein.') + } + }; + reader.onerror = (evt) => { + alert('Ein Fehler beim Einlesen der Datei ist aufgetreten. Womöglich muss hier ein Bug behoben werden... :('); + }; + } else { + alert('Dieses Dateiformat wird nicht unterstützt.') + } + } + + elements.dragOverlay.classList.remove('visible'); + event.preventDefault(); + return false; +}); diff --git a/js/simulate.js b/js/simulate.js index 2f24d22..e5637b1 100644 --- a/js/simulate.js +++ b/js/simulate.js @@ -1,8 +1,7 @@ let simulationStates = []; -let singleCharMode = true; let simulationStepDuration = 500; -function simulate(word) { +function simulate(word, singleCharMode = true) { if(activeDocument === null) { return false; }