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:
+
+
+ - Zustand hinzufügen: Doppelklick
+ - Übergang hinzufügen:
Shift
gedrückt halten und mit linker Maustaste ziehen
+ - Verschieben: klassisches Drag'n'drop
+ - Endzustand: Doppelklick auf einen bestehenden Zustand
+ - Index: Unterstrich vor eine Zahl hinzufügen (z.B.
q_0
)
+ - Griechischer Buchstabe: Name des Buchstaben mit Backslash (z.B.
\epsilon
)
+
+
+
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;
}