diff --git a/.github/images/image.png b/.github/images/image.png new file mode 100644 index 0000000..67fa8bd Binary files /dev/null and b/.github/images/image.png differ diff --git a/.gitignore b/.gitignore index 1b3145e..30d74d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -/www/fsm.js +test \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bafa1f4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +MIT License + + Copyright (c) 2010 Evan Wallace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Additional Contributions by John (2025) + +All contributions made by John are also licensed under the MIT License. diff --git a/README.md b/README.md index ecdb64b..98f9fde 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ # Finite State Machine Designer -http://madebyevan.com/fsm/ +![Finite State Machine Image](./.github/images/image.png) + +**Original Website**: [http://madebyevan.com/fsm/](http://madebyevan.com/fsm/) + +**Updated Website**: [https://raredrops.github.io/fsm](https://raredrops.github.io/fsm/) + +## Description + +The Finite State Machine Designer is a JavaScript-based application that allows users to create and manipulate finite state machines (FSMs) visually. It provides functionality for converting LaTeX commands into their corresponding Unicode characters, rendering mathematical symbols, and exporting drawings in LaTeX format. + +## Features + +- **LaTeX Command Conversion**: Converts common LaTeX commands (e.g., Greek letters, mathematical operators) into Unicode characters. +- **Drawing Capabilities**: Allows users to create and manipulate drawings using a canvas, with support for exporting to LaTeX. +- **Export to LaTeX**: Provides functionality to export drawings as LaTeX code, suitable for use in LaTeX documents. +- **Customizable**: Easily extendable to add more LaTeX shortcuts or drawing features. + +## Changelog + +* **1.1.0** - Added LaTeX typing functionalities, including support for Greek letters and mathematical operators. +* **1.0.0** - Initial release of the Finite State Machine Designer by Evan Wallace(Original Creator). + +## Contributing + +Contributions are welcome! If you have suggestions for improvements or new features, please open an issue or submit a pull request. + +## License + +MIT © [RareDrop](https://github.com/RareDrops/fsm) + +## Acknowledgments + +- Thanks to [Evan Wallace](http://madebyevan.com) for creating this amazing tool! diff --git a/build.py b/build.py index 51ac835..0ddc3db 100755 --- a/build.py +++ b/build.py @@ -1,4 +1,5 @@ #!/usr/bin/python +# -*- coding: utf-8 -*- import os, time, sys @@ -7,11 +8,19 @@ def sources(): return [os.path.join(base, f) for base, folders, files in os.walk(path) for f in files if f.endswith('.js')] def build(): - path = './www/fsm.js' - data = '\n'.join(open(file, 'r').read() for file in sources()) - with open(path, 'w') as f: - f.write(data) - print 'built %s (%u bytes)' % (path, len(data)) + path = './public/fsm.js' + try: + data = '\n'.join(open(file, 'r', encoding='utf-8').read() for file in sources()) + with open(path, 'w', encoding='utf-8') as f: + f.write(data) + print('built %s (%u bytes)' % (path, len(data))) + except Exception as e: + print('Error:', str(e)) + # Try alternative encoding if utf-8 fails + data = '\n'.join(open(file, 'r', encoding='latin-1').read() for file in sources()) + with open(path, 'w', encoding='utf-8') as f: + f.write(data) + print('built %s (%u bytes) using latin-1 fallback' % (path, len(data))) def stat(): return [os.stat(file).st_mtime for file in sources()] diff --git a/index.html b/index.html new file mode 100644 index 0000000..9575166 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + + Finite State Machine Designer + + + + + \ No newline at end of file diff --git a/latex_shortcut_support.txt b/latex_shortcut_support.txt new file mode 100644 index 0000000..95586f4 --- /dev/null +++ b/latex_shortcut_support.txt @@ -0,0 +1,23 @@ + const greekLetters = { + '\\alpha': '\u03B1', '\\beta': '\u03B2', '\\gamma': '\u03B3', '\\delta': '\u03B4', '\\epsilon': '\u03B5', + '\\zeta': '\u03B6', '\\eta': '\u03B7', '\\theta': '\u03B8', '\\iota': '\u03B9', '\\kappa': '\u03BA', + '\\lambda': '\u03BB', '\\mu': '\u03BC', '\\nu': '\u03BD', '\\xi': '\u03BE', '\\pi': '\u03C0', + '\\rho': '\u03C1', '\\sigma': '\u03C3', '\\tau': '\u03C4', '\\upsilon': '\u03C5', '\\phi': '\u03C6', + '\\chi': '\u03C7', '\\psi': '\u03C8', '\\omega': '\u03C9', + '\\Gamma': '\u0393', '\\Delta': '\u0394', '\\Theta': '\u0398', '\\Lambda': '\u039B', '\\Xi': '\u039E', + '\\Pi': '\u03A0', '\\Sigma': '\u03A3', '\\Phi': '\u03A6', '\\Psi': '\u03A8', '\\Omega': '\u03A9' + }; + + // Mathematical operators + const operators = { + '\\times': '\u00D7', '\\div': '\u00F7', '\\pm': '\u00B1', '\\mp': '\u2213', + '\\leq': '\u2264', '\\geq': '\u2265', '\\neq': '\u2260', '\\approx': '\u2248', + '\\infty': '\u221E', '\\sum': '\u2211', '\\prod': '\u220F', '\\int': '\u222B', + // Set theory symbols + '\\cup': '\u222A', '\\cap': '\u2229', '\\subset': '\u2282', '\\supset': '\u2283', + '\\subseteq': '\u2286', '\\supseteq': '\u2287', '\\in': '\u2208', '\\notin': '\u2209', + '\\emptyset': '\u2205', '\\varnothing': '\u2205', + // Additional operators + '\\cdot': '\u22C5', '\\bullet': '\u2022', '\\circ': '\u2218', '\\oplus': '\u2295', + '\\otimes': '\u2297', '\\setminus': '\u2216', '\\subsetneq': '\u228A', '\\supsetneq': '\u228B' + }; \ No newline at end of file diff --git a/public/fsm.js b/public/fsm.js new file mode 100644 index 0000000..0f1256b --- /dev/null +++ b/public/fsm.js @@ -0,0 +1,1118 @@ +/* + Finite State Machine Designer (http://madebyevan.com/fsm/) + License: MIT License (see below) + + Copyright (c) 2010 Evan Wallace + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. +*/ + +function Link(a, b) { + this.nodeA = a; + this.nodeB = b; + this.text = ''; + this.lineAngleAdjust = 0; // value to add to textAngle when link is straight line + + // make anchor point relative to the locations of nodeA and nodeB + this.parallelPart = 0.5; // percentage from nodeA to nodeB + this.perpendicularPart = 0; // pixels from line between nodeA and nodeB +} + +Link.prototype.getAnchorPoint = function() { + var dx = this.nodeB.x - this.nodeA.x; + var dy = this.nodeB.y - this.nodeA.y; + var scale = Math.sqrt(dx * dx + dy * dy); + return { + 'x': this.nodeA.x + dx * this.parallelPart - dy * this.perpendicularPart / scale, + 'y': this.nodeA.y + dy * this.parallelPart + dx * this.perpendicularPart / scale + }; +}; + +Link.prototype.setAnchorPoint = function(x, y) { + var dx = this.nodeB.x - this.nodeA.x; + var dy = this.nodeB.y - this.nodeA.y; + var scale = Math.sqrt(dx * dx + dy * dy); + this.parallelPart = (dx * (x - this.nodeA.x) + dy * (y - this.nodeA.y)) / (scale * scale); + this.perpendicularPart = (dx * (y - this.nodeA.y) - dy * (x - this.nodeA.x)) / scale; + // snap to a straight line + if(this.parallelPart > 0 && this.parallelPart < 1 && Math.abs(this.perpendicularPart) < snapToPadding) { + this.lineAngleAdjust = (this.perpendicularPart < 0) * Math.PI; + this.perpendicularPart = 0; + } +}; + +Link.prototype.getEndPointsAndCircle = function() { + if(this.perpendicularPart == 0) { + var midX = (this.nodeA.x + this.nodeB.x) / 2; + var midY = (this.nodeA.y + this.nodeB.y) / 2; + var start = this.nodeA.closestPointOnCircle(midX, midY); + var end = this.nodeB.closestPointOnCircle(midX, midY); + return { + 'hasCircle': false, + 'startX': start.x, + 'startY': start.y, + 'endX': end.x, + 'endY': end.y, + }; + } + var anchor = this.getAnchorPoint(); + var circle = circleFromThreePoints(this.nodeA.x, this.nodeA.y, this.nodeB.x, this.nodeB.y, anchor.x, anchor.y); + var isReversed = (this.perpendicularPart > 0); + var reverseScale = isReversed ? 1 : -1; + var startAngle = Math.atan2(this.nodeA.y - circle.y, this.nodeA.x - circle.x) - reverseScale * nodeRadius / circle.radius; + var endAngle = Math.atan2(this.nodeB.y - circle.y, this.nodeB.x - circle.x) + reverseScale * nodeRadius / circle.radius; + var startX = circle.x + circle.radius * Math.cos(startAngle); + var startY = circle.y + circle.radius * Math.sin(startAngle); + var endX = circle.x + circle.radius * Math.cos(endAngle); + var endY = circle.y + circle.radius * Math.sin(endAngle); + return { + 'hasCircle': true, + 'startX': startX, + 'startY': startY, + 'endX': endX, + 'endY': endY, + 'startAngle': startAngle, + 'endAngle': endAngle, + 'circleX': circle.x, + 'circleY': circle.y, + 'circleRadius': circle.radius, + 'reverseScale': reverseScale, + 'isReversed': isReversed, + }; +}; + +Link.prototype.draw = function(c) { + var stuff = this.getEndPointsAndCircle(); + // draw arc + c.beginPath(); + if(stuff.hasCircle) { + c.arc(stuff.circleX, stuff.circleY, stuff.circleRadius, stuff.startAngle, stuff.endAngle, stuff.isReversed); + } else { + c.moveTo(stuff.startX, stuff.startY); + c.lineTo(stuff.endX, stuff.endY); + } + c.stroke(); + // draw the head of the arrow + if(stuff.hasCircle) { + drawArrow(c, stuff.endX, stuff.endY, stuff.endAngle - stuff.reverseScale * (Math.PI / 2)); + } else { + drawArrow(c, stuff.endX, stuff.endY, Math.atan2(stuff.endY - stuff.startY, stuff.endX - stuff.startX)); + } + // draw the text + if(stuff.hasCircle) { + var startAngle = stuff.startAngle; + var endAngle = stuff.endAngle; + if(endAngle < startAngle) { + endAngle += Math.PI * 2; + } + var textAngle = (startAngle + endAngle) / 2 + stuff.isReversed * Math.PI; + var textX = stuff.circleX + stuff.circleRadius * Math.cos(textAngle); + var textY = stuff.circleY + stuff.circleRadius * Math.sin(textAngle); + drawText(c, this.text, textX, textY, textAngle, selectedObject == this); + } else { + var textX = (stuff.startX + stuff.endX) / 2; + var textY = (stuff.startY + stuff.endY) / 2; + var textAngle = Math.atan2(stuff.endX - stuff.startX, stuff.startY - stuff.endY); + drawText(c, this.text, textX, textY, textAngle + this.lineAngleAdjust, selectedObject == this); + } +}; + +Link.prototype.containsPoint = function(x, y) { + var stuff = this.getEndPointsAndCircle(); + if(stuff.hasCircle) { + var dx = x - stuff.circleX; + var dy = y - stuff.circleY; + var distance = Math.sqrt(dx*dx + dy*dy) - stuff.circleRadius; + if(Math.abs(distance) < hitTargetPadding) { + var angle = Math.atan2(dy, dx); + var startAngle = stuff.startAngle; + var endAngle = stuff.endAngle; + if(stuff.isReversed) { + var temp = startAngle; + startAngle = endAngle; + endAngle = temp; + } + if(endAngle < startAngle) { + endAngle += Math.PI * 2; + } + if(angle < startAngle) { + angle += Math.PI * 2; + } else if(angle > endAngle) { + angle -= Math.PI * 2; + } + return (angle > startAngle && angle < endAngle); + } + } else { + var dx = stuff.endX - stuff.startX; + var dy = stuff.endY - stuff.startY; + var length = Math.sqrt(dx*dx + dy*dy); + var percent = (dx * (x - stuff.startX) + dy * (y - stuff.startY)) / (length * length); + var distance = (dx * (y - stuff.startY) - dy * (x - stuff.startX)) / length; + return (percent > 0 && percent < 1 && Math.abs(distance) < hitTargetPadding); + } + return false; +}; + +function Node(x, y) { + this.x = x; + this.y = y; + this.mouseOffsetX = 0; + this.mouseOffsetY = 0; + this.isAcceptState = false; + this.text = ''; +} + +Node.prototype.setMouseStart = function(x, y) { + this.mouseOffsetX = this.x - x; + this.mouseOffsetY = this.y - y; +}; + +Node.prototype.setAnchorPoint = function(x, y) { + this.x = x + this.mouseOffsetX; + this.y = y + this.mouseOffsetY; +}; + +Node.prototype.draw = function(c) { + // draw the circle + c.beginPath(); + c.arc(this.x, this.y, nodeRadius, 0, 2 * Math.PI, false); + c.stroke(); + + // draw the text + drawText(c, this.text, this.x, this.y, null, selectedObject == this); + + // draw a double circle for an accept state + if(this.isAcceptState) { + c.beginPath(); + c.arc(this.x, this.y, nodeRadius - 6, 0, 2 * Math.PI, false); + c.stroke(); + } +}; + +Node.prototype.closestPointOnCircle = function(x, y) { + var dx = x - this.x; + var dy = y - this.y; + var scale = Math.sqrt(dx * dx + dy * dy); + return { + 'x': this.x + dx * nodeRadius / scale, + 'y': this.y + dy * nodeRadius / scale, + }; +}; + +Node.prototype.containsPoint = function(x, y) { + return (x - this.x)*(x - this.x) + (y - this.y)*(y - this.y) < nodeRadius*nodeRadius; +}; + +function SelfLink(node, mouse) { + this.node = node; + this.anchorAngle = 0; + this.mouseOffsetAngle = 0; + this.text = ''; + + if(mouse) { + this.setAnchorPoint(mouse.x, mouse.y); + } +} + +SelfLink.prototype.setMouseStart = function(x, y) { + this.mouseOffsetAngle = this.anchorAngle - Math.atan2(y - this.node.y, x - this.node.x); +}; + +SelfLink.prototype.setAnchorPoint = function(x, y) { + this.anchorAngle = Math.atan2(y - this.node.y, x - this.node.x) + this.mouseOffsetAngle; + // snap to 90 degrees + var snap = Math.round(this.anchorAngle / (Math.PI / 2)) * (Math.PI / 2); + if(Math.abs(this.anchorAngle - snap) < 0.1) this.anchorAngle = snap; + // keep in the range -pi to pi so our containsPoint() function always works + if(this.anchorAngle < -Math.PI) this.anchorAngle += 2 * Math.PI; + if(this.anchorAngle > Math.PI) this.anchorAngle -= 2 * Math.PI; +}; + +SelfLink.prototype.getEndPointsAndCircle = function() { + var circleX = this.node.x + 1.5 * nodeRadius * Math.cos(this.anchorAngle); + var circleY = this.node.y + 1.5 * nodeRadius * Math.sin(this.anchorAngle); + var circleRadius = 0.75 * nodeRadius; + var startAngle = this.anchorAngle - Math.PI * 0.8; + var endAngle = this.anchorAngle + Math.PI * 0.8; + var startX = circleX + circleRadius * Math.cos(startAngle); + var startY = circleY + circleRadius * Math.sin(startAngle); + var endX = circleX + circleRadius * Math.cos(endAngle); + var endY = circleY + circleRadius * Math.sin(endAngle); + return { + 'hasCircle': true, + 'startX': startX, + 'startY': startY, + 'endX': endX, + 'endY': endY, + 'startAngle': startAngle, + 'endAngle': endAngle, + 'circleX': circleX, + 'circleY': circleY, + 'circleRadius': circleRadius + }; +}; + +SelfLink.prototype.draw = function(c) { + var stuff = this.getEndPointsAndCircle(); + // draw arc + c.beginPath(); + c.arc(stuff.circleX, stuff.circleY, stuff.circleRadius, stuff.startAngle, stuff.endAngle, false); + c.stroke(); + // draw the text on the loop farthest from the node + var textX = stuff.circleX + stuff.circleRadius * Math.cos(this.anchorAngle); + var textY = stuff.circleY + stuff.circleRadius * Math.sin(this.anchorAngle); + drawText(c, this.text, textX, textY, this.anchorAngle, selectedObject == this); + // draw the head of the arrow + drawArrow(c, stuff.endX, stuff.endY, stuff.endAngle + Math.PI * 0.4); +}; + +SelfLink.prototype.containsPoint = function(x, y) { + var stuff = this.getEndPointsAndCircle(); + var dx = x - stuff.circleX; + var dy = y - stuff.circleY; + var distance = Math.sqrt(dx*dx + dy*dy) - stuff.circleRadius; + return (Math.abs(distance) < hitTargetPadding); +}; + +function StartLink(node, start) { + this.node = node; + this.deltaX = 0; + this.deltaY = 0; + this.text = ''; + + if(start) { + this.setAnchorPoint(start.x, start.y); + } +} + +StartLink.prototype.setAnchorPoint = function(x, y) { + this.deltaX = x - this.node.x; + this.deltaY = y - this.node.y; + + if(Math.abs(this.deltaX) < snapToPadding) { + this.deltaX = 0; + } + + if(Math.abs(this.deltaY) < snapToPadding) { + this.deltaY = 0; + } +}; + +StartLink.prototype.getEndPoints = function() { + var startX = this.node.x + this.deltaX; + var startY = this.node.y + this.deltaY; + var end = this.node.closestPointOnCircle(startX, startY); + return { + 'startX': startX, + 'startY': startY, + 'endX': end.x, + 'endY': end.y, + }; +}; + +StartLink.prototype.draw = function(c) { + var stuff = this.getEndPoints(); + + // draw the line + c.beginPath(); + c.moveTo(stuff.startX, stuff.startY); + c.lineTo(stuff.endX, stuff.endY); + c.stroke(); + + // draw the text at the end without the arrow + var textAngle = Math.atan2(stuff.startY - stuff.endY, stuff.startX - stuff.endX); + drawText(c, this.text, stuff.startX, stuff.startY, textAngle, selectedObject == this); + + // draw the head of the arrow + drawArrow(c, stuff.endX, stuff.endY, Math.atan2(-this.deltaY, -this.deltaX)); +}; + +StartLink.prototype.containsPoint = function(x, y) { + var stuff = this.getEndPoints(); + var dx = stuff.endX - stuff.startX; + var dy = stuff.endY - stuff.startY; + var length = Math.sqrt(dx*dx + dy*dy); + var percent = (dx * (x - stuff.startX) + dy * (y - stuff.startY)) / (length * length); + var distance = (dx * (y - stuff.startY) - dy * (x - stuff.startX)) / length; + return (percent > 0 && percent < 1 && Math.abs(distance) < hitTargetPadding); +}; + +function TemporaryLink(from, to) { + this.from = from; + this.to = to; +} + +TemporaryLink.prototype.draw = function(c) { + // draw the line + c.beginPath(); + c.moveTo(this.to.x, this.to.y); + c.lineTo(this.from.x, this.from.y); + c.stroke(); + + // draw the head of the arrow + drawArrow(c, this.to.x, this.to.y, Math.atan2(this.to.y - this.from.y, this.to.x - this.from.x)); +}; + +// draw using this instead of a canvas and call toLaTeX() afterward +function ExportAsLaTeX() { + this._points = []; + this._texData = ''; + this._scale = 0.1; // to convert pixels to document space (TikZ breaks if the numbers get too big, above 500?) + + this.toLaTeX = function() { + return '\\documentclass[12pt]{article}\n' + + '\\usepackage{tikz}\n' + + '\n' + + '\\begin{document}\n' + + '\n' + + '\\begin{center}\n' + + '\\begin{tikzpicture}[scale=0.2]\n' + + '\\tikzstyle{every node}+=[inner sep=0pt]\n' + + this._texData + + '\\end{tikzpicture}\n' + + '\\end{center}\n' + + '\n' + + '\\end{document}\n'; + }; + + this.beginPath = function() { + this._points = []; + }; + this.arc = function(x, y, radius, startAngle, endAngle, isReversed) { + x *= this._scale; + y *= this._scale; + radius *= this._scale; + if(endAngle - startAngle == Math.PI * 2) { + this._texData += '\\draw [' + this.strokeStyle + '] (' + fixed(x, 3) + ',' + fixed(-y, 3) + ') circle (' + fixed(radius, 3) + ');\n'; + } else { + if(isReversed) { + var temp = startAngle; + startAngle = endAngle; + endAngle = temp; + } + if(endAngle < startAngle) { + endAngle += Math.PI * 2; + } + // TikZ needs the angles to be in between -2pi and 2pi or it breaks + if(Math.min(startAngle, endAngle) < -2*Math.PI) { + startAngle += 2*Math.PI; + endAngle += 2*Math.PI; + } else if(Math.max(startAngle, endAngle) > 2*Math.PI) { + startAngle -= 2*Math.PI; + endAngle -= 2*Math.PI; + } + startAngle = -startAngle; + endAngle = -endAngle; + this._texData += '\\draw [' + this.strokeStyle + '] (' + fixed(x + radius * Math.cos(startAngle), 3) + ',' + fixed(-y + radius * Math.sin(startAngle), 3) + ') arc (' + fixed(startAngle * 180 / Math.PI, 5) + ':' + fixed(endAngle * 180 / Math.PI, 5) + ':' + fixed(radius, 3) + ');\n'; + } + }; + this.moveTo = this.lineTo = function(x, y) { + x *= this._scale; + y *= this._scale; + this._points.push({ 'x': x, 'y': y }); + }; + this.stroke = function() { + if(this._points.length == 0) return; + this._texData += '\\draw [' + this.strokeStyle + ']'; + for(var i = 0; i < this._points.length; i++) { + var p = this._points[i]; + this._texData += (i > 0 ? ' --' : '') + ' (' + fixed(p.x, 2) + ',' + fixed(-p.y, 2) + ')'; + } + this._texData += ';\n'; + }; + this.fill = function() { + if(this._points.length == 0) return; + this._texData += '\\fill [' + this.strokeStyle + ']'; + for(var i = 0; i < this._points.length; i++) { + var p = this._points[i]; + this._texData += (i > 0 ? ' --' : '') + ' (' + fixed(p.x, 2) + ',' + fixed(-p.y, 2) + ')'; + } + this._texData += ';\n'; + }; + this.measureText = function(text) { + var c = canvas.getContext('2d'); + c.font = '20px "Times New Romain", serif'; + return c.measureText(text); + }; + this.advancedFillText = function(text, originalText, x, y, angleOrNull) { + if(text.replace(' ', '').length > 0) { + var nodeParams = ''; + // x and y start off as the center of the text, but will be moved to one side of the box when angleOrNull != null + if(angleOrNull != null) { + var width = this.measureText(text).width; + var dx = Math.cos(angleOrNull); + var dy = Math.sin(angleOrNull); + if(Math.abs(dx) > Math.abs(dy)) { + if(dx > 0) nodeParams = '[right] ', x -= width / 2; + else nodeParams = '[left] ', x += width / 2; + } else { + if(dy > 0) nodeParams = '[below] ', y -= 10; + else nodeParams = '[above] ', y += 10; + } + } + x *= this._scale; + y *= this._scale; + this._texData += '\\draw (' + fixed(x, 2) + ',' + fixed(-y, 2) + ') node ' + nodeParams + '{$' + originalText.replace(/ /g, '\\mbox{ }') + '$};\n'; + } + }; + + this.translate = this.save = this.restore = this.clearRect = function(){}; +} + +// draw using this instead of a canvas and call toSVG() afterward +function ExportAsSVG() { + this.fillStyle = 'black'; + this.strokeStyle = 'black'; + this.lineWidth = 1; + this.font = '12px Arial, sans-serif'; + this._points = []; + this._svgData = ''; + this._transX = 0; + this._transY = 0; + + this.toSVG = function() { + return '\n\n\n\n' + this._svgData + '\n'; + }; + + this.beginPath = function() { + this._points = []; + }; + this.arc = function(x, y, radius, startAngle, endAngle, isReversed) { + x += this._transX; + y += this._transY; + var style = 'stroke="' + this.strokeStyle + '" stroke-width="' + this.lineWidth + '" fill="none"'; + + if(endAngle - startAngle == Math.PI * 2) { + this._svgData += '\t\n'; + } else { + if(isReversed) { + var temp = startAngle; + startAngle = endAngle; + endAngle = temp; + } + + if(endAngle < startAngle) { + endAngle += Math.PI * 2; + } + + var startX = x + radius * Math.cos(startAngle); + var startY = y + radius * Math.sin(startAngle); + var endX = x + radius * Math.cos(endAngle); + var endY = y + radius * Math.sin(endAngle); + var useGreaterThan180 = (Math.abs(endAngle - startAngle) > Math.PI); + var goInPositiveDirection = 1; + + this._svgData += '\t\n'; + } + }; + this.moveTo = this.lineTo = function(x, y) { + x += this._transX; + y += this._transY; + this._points.push({ 'x': x, 'y': y }); + }; + this.stroke = function() { + if(this._points.length == 0) return; + this._svgData += '\t\n'; + }; + this.fill = function() { + if(this._points.length == 0) return; + this._svgData += '\t\n'; + }; + this.measureText = function(text) { + var c = canvas.getContext('2d'); + c.font = '20px "Times New Romain", serif'; + return c.measureText(text); + }; + this.fillText = function(text, x, y) { + x += this._transX; + y += this._transY; + if(text.replace(' ', '').length > 0) { + this._svgData += '\t' + textToXML(text) + '\n'; + } + }; + this.translate = function(x, y) { + this._transX = x; + this._transY = y; + }; + + this.save = this.restore = this.clearRect = function(){}; +} + +function convertLatexShortcuts(text) { + // Greek letters + const greekLetters = { + '\\alpha': '\u03B1', '\\beta': '\u03B2', '\\gamma': '\u03B3', '\\delta': '\u03B4', '\\epsilon': '\u03B5', + '\\zeta': '\u03B6', '\\eta': '\u03B7', '\\theta': '\u03B8', '\\iota': '\u03B9', '\\kappa': '\u03BA', + '\\lambda': '\u03BB', '\\mu': '\u03BC', '\\nu': '\u03BD', '\\xi': '\u03BE', '\\pi': '\u03C0', + '\\rho': '\u03C1', '\\sigma': '\u03C3', '\\tau': '\u03C4', '\\upsilon': '\u03C5', '\\phi': '\u03C6', + '\\chi': '\u03C7', '\\psi': '\u03C8', '\\omega': '\u03C9', + '\\Gamma': '\u0393', '\\Delta': '\u0394', '\\Theta': '\u0398', '\\Lambda': '\u039B', '\\Xi': '\u039E', + '\\Pi': '\u03A0', '\\Sigma': '\u03A3', '\\Phi': '\u03A6', '\\Psi': '\u03A8', '\\Omega': '\u03A9' + }; + + // Mathematical operators + const operators = { + '\\times': '\u00D7', '\\div': '\u00F7', '\\pm': '\u00B1', '\\mp': '\u2213', + '\\leq': '\u2264', '\\geq': '\u2265', '\\neq': '\u2260', '\\approx': '\u2248', + '\\infty': '\u221E', '\\sum': '\u2211', '\\prod': '\u220F', '\\int': '\u222B', + // Set theory symbols + '\\cup': '\u222A', '\\cap': '\u2229', '\\subset': '\u2282', '\\supset': '\u2283', + '\\subseteq': '\u2286', '\\supseteq': '\u2287', '\\in': '\u2208', '\\notin': '\u2209', + '\\emptyset': '\u2205', '\\varnothing': '\u2205', + // Additional operators + '\\cdot': '\u22C5', '\\bullet': '\u2022', '\\circ': '\u2218', '\\oplus': '\u2295', + '\\otimes': '\u2297', '\\setminus': '\u2216', '\\subsetneq': '\u228A', '\\supsetneq': '\u228B' + }; + + // Replace Greek letters + for (const [latex, unicode] of Object.entries(greekLetters)) { + text = text.replace(new RegExp('\\'+latex, 'g'), unicode); + } + + // Replace operators + for (const [latex, unicode] of Object.entries(operators)) { + text = text.replace(new RegExp('\\'+latex, 'g'), unicode); + } + + // subscripts + for(var i = 0; i < 10; i++) { + text = text.replace(new RegExp('_' + i, 'g'), String.fromCharCode(8320 + i)); + } + + return text; +} + +function textToXML(text) { + text = text.replace(/&/g, '&').replace(//g, '>'); + var result = ''; + for(var i = 0; i < text.length; i++) { + var c = text.charCodeAt(i); + if(c >= 0x20 && c <= 0x7E) { + result += text[i]; + } else { + result += '&#' + c + ';'; + } + } + return result; +} + +function drawArrow(c, x, y, angle) { + var dx = Math.cos(angle); + var dy = Math.sin(angle); + c.beginPath(); + c.moveTo(x, y); + c.lineTo(x - 8 * dx + 5 * dy, y - 8 * dy - 5 * dx); + c.lineTo(x - 8 * dx - 5 * dy, y - 8 * dy + 5 * dx); + c.fill(); +} + +function canvasHasFocus() { + return (document.activeElement || document.body) == document.body; +} + +function drawText(c, originalText, x, y, angleOrNull, isSelected) { + text = convertLatexShortcuts(originalText); + c.font = '20px "Times New Roman", serif'; + var width = c.measureText(text).width; + + // center the text + x -= width / 2; + + // position the text intelligently if given an angle + if(angleOrNull != null) { + var cos = Math.cos(angleOrNull); + var sin = Math.sin(angleOrNull); + var cornerPointX = (width / 2 + 5) * (cos > 0 ? 1 : -1); + var cornerPointY = (10 + 5) * (sin > 0 ? 1 : -1); + var slide = sin * Math.pow(Math.abs(sin), 40) * cornerPointX - cos * Math.pow(Math.abs(cos), 10) * cornerPointY; + x += cornerPointX - sin * slide; + y += cornerPointY + cos * slide; + } + + // draw text and caret (round the coordinates so the caret falls on a pixel) + if('advancedFillText' in c) { + c.advancedFillText(text, originalText, x + width / 2, y, angleOrNull); + } else { + x = Math.round(x); + y = Math.round(y); + c.fillText(text, x, y + 6); + if(isSelected && caretVisible && canvasHasFocus() && document.hasFocus()) { + x += width; + c.beginPath(); + c.moveTo(x, y - 10); + c.lineTo(x, y + 10); + c.stroke(); + } + } +} + +var caretTimer; +var caretVisible = true; + +function resetCaret() { + clearInterval(caretTimer); + caretTimer = setInterval('caretVisible = !caretVisible; draw()', 500); + caretVisible = true; +} + +var canvas; +var nodeRadius = 30; +var nodes = []; +var links = []; + +var cursorVisible = true; +var snapToPadding = 6; // pixels +var hitTargetPadding = 6; // pixels +var selectedObject = null; // either a Link or a Node +var currentLink = null; // a Link +var movingObject = false; +var originalClick; + +function drawUsing(c) { + c.clearRect(0, 0, canvas.width, canvas.height); + c.save(); + c.translate(0.5, 0.5); + + for(var i = 0; i < nodes.length; i++) { + c.lineWidth = 1; + c.fillStyle = c.strokeStyle = (nodes[i] == selectedObject) ? 'blue' : 'black'; + nodes[i].draw(c); + } + for(var i = 0; i < links.length; i++) { + c.lineWidth = 1; + c.fillStyle = c.strokeStyle = (links[i] == selectedObject) ? 'blue' : 'black'; + links[i].draw(c); + } + if(currentLink != null) { + c.lineWidth = 1; + c.fillStyle = c.strokeStyle = 'black'; + currentLink.draw(c); + } + + c.restore(); +} + +function draw() { + drawUsing(canvas.getContext('2d')); + saveBackup(); +} + +function selectObject(x, y) { + for(var i = 0; i < nodes.length; i++) { + if(nodes[i].containsPoint(x, y)) { + return nodes[i]; + } + } + for(var i = 0; i < links.length; i++) { + if(links[i].containsPoint(x, y)) { + return links[i]; + } + } + return null; +} + +function snapNode(node) { + for(var i = 0; i < nodes.length; i++) { + if(nodes[i] == node) continue; + + if(Math.abs(node.x - nodes[i].x) < snapToPadding) { + node.x = nodes[i].x; + } + + if(Math.abs(node.y - nodes[i].y) < snapToPadding) { + node.y = nodes[i].y; + } + } +} + +window.onload = function() { + canvas = document.getElementById('canvas'); + restoreBackup(); + draw(); + + canvas.onmousedown = function(e) { + var mouse = crossBrowserRelativeMousePos(e); + selectedObject = selectObject(mouse.x, mouse.y); + movingObject = false; + originalClick = mouse; + + if(selectedObject != null) { + if(shift && selectedObject instanceof Node) { + currentLink = new SelfLink(selectedObject, mouse); + } else { + movingObject = true; + deltaMouseX = deltaMouseY = 0; + if(selectedObject.setMouseStart) { + selectedObject.setMouseStart(mouse.x, mouse.y); + } + } + resetCaret(); + } else if(shift) { + currentLink = new TemporaryLink(mouse, mouse); + } + + draw(); + + if(canvasHasFocus()) { + // disable drag-and-drop only if the canvas is already focused + return false; + } else { + // otherwise, let the browser switch the focus away from wherever it was + resetCaret(); + return true; + } + }; + + canvas.ondblclick = function(e) { + var mouse = crossBrowserRelativeMousePos(e); + selectedObject = selectObject(mouse.x, mouse.y); + + if(selectedObject == null) { + selectedObject = new Node(mouse.x, mouse.y); + nodes.push(selectedObject); + resetCaret(); + draw(); + } else if(selectedObject instanceof Node) { + selectedObject.isAcceptState = !selectedObject.isAcceptState; + draw(); + } + }; + + canvas.onmousemove = function(e) { + var mouse = crossBrowserRelativeMousePos(e); + + if(currentLink != null) { + var targetNode = selectObject(mouse.x, mouse.y); + if(!(targetNode instanceof Node)) { + targetNode = null; + } + + if(selectedObject == null) { + if(targetNode != null) { + currentLink = new StartLink(targetNode, originalClick); + } else { + currentLink = new TemporaryLink(originalClick, mouse); + } + } else { + if(targetNode == selectedObject) { + currentLink = new SelfLink(selectedObject, mouse); + } else if(targetNode != null) { + currentLink = new Link(selectedObject, targetNode); + } else { + currentLink = new TemporaryLink(selectedObject.closestPointOnCircle(mouse.x, mouse.y), mouse); + } + } + draw(); + } + + if(movingObject) { + selectedObject.setAnchorPoint(mouse.x, mouse.y); + if(selectedObject instanceof Node) { + snapNode(selectedObject); + } + draw(); + } + }; + + canvas.onmouseup = function(e) { + movingObject = false; + + if(currentLink != null) { + if(!(currentLink instanceof TemporaryLink)) { + selectedObject = currentLink; + links.push(currentLink); + resetCaret(); + } + currentLink = null; + draw(); + } + }; +} + +var shift = false; + +document.onkeydown = function(e) { + var key = crossBrowserKey(e); + + if(key == 16) { + shift = true; + } else if(!canvasHasFocus()) { + // don't read keystrokes when other things have focus + return true; + } else if(key == 8) { // backspace key + if(selectedObject != null && 'text' in selectedObject) { + selectedObject.text = selectedObject.text.substr(0, selectedObject.text.length - 1); + resetCaret(); + draw(); + } + + // backspace is a shortcut for the back button, but do NOT want to change pages + return false; + } else if(key == 46) { // delete key + if(selectedObject != null) { + for(var i = 0; i < nodes.length; i++) { + if(nodes[i] == selectedObject) { + nodes.splice(i--, 1); + } + } + for(var i = 0; i < links.length; i++) { + if(links[i] == selectedObject || links[i].node == selectedObject || links[i].nodeA == selectedObject || links[i].nodeB == selectedObject) { + links.splice(i--, 1); + } + } + selectedObject = null; + draw(); + } + } +}; + +document.onkeyup = function(e) { + var key = crossBrowserKey(e); + + if(key == 16) { + shift = false; + } +}; + +document.onkeypress = function(e) { + // don't read keystrokes when other things have focus + var key = crossBrowserKey(e); + if(!canvasHasFocus()) { + // don't read keystrokes when other things have focus + return true; + } else if(key >= 0x20 && key <= 0x7E && !e.metaKey && !e.altKey && !e.ctrlKey && selectedObject != null && 'text' in selectedObject) { + selectedObject.text += String.fromCharCode(key); + resetCaret(); + draw(); + + // don't let keys do their actions (like space scrolls down the page) + return false; + } else if(key == 8) { + // backspace is a shortcut for the back button, but do NOT want to change pages + return false; + } +}; + +function crossBrowserKey(e) { + e = e || window.event; + return e.which || e.keyCode; +} + +function crossBrowserElementPos(e) { + e = e || window.event; + var obj = e.target || e.srcElement; + var x = 0, y = 0; + while(obj.offsetParent) { + x += obj.offsetLeft; + y += obj.offsetTop; + obj = obj.offsetParent; + } + return { 'x': x, 'y': y }; +} + +function crossBrowserMousePos(e) { + e = e || window.event; + return { + 'x': e.pageX || e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft, + 'y': e.pageY || e.clientY + document.body.scrollTop + document.documentElement.scrollTop, + }; +} + +function crossBrowserRelativeMousePos(e) { + var element = crossBrowserElementPos(e); + var mouse = crossBrowserMousePos(e); + return { + 'x': mouse.x - element.x, + 'y': mouse.y - element.y + }; +} + +function output(text) { + var element = document.getElementById('output'); + element.style.display = 'block'; + element.value = text; +} + +function saveAsPNG() { + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(canvas.getContext('2d')); + selectedObject = oldSelectedObject; + var pngData = canvas.toDataURL('image/png'); + document.location.href = pngData; +} + +function saveAsSVG() { + var exporter = new ExportAsSVG(); + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(exporter); + selectedObject = oldSelectedObject; + var svgData = exporter.toSVG(); + output(svgData); + // Chrome isn't ready for this yet, the 'Save As' menu item is disabled + // document.location.href = 'data:image/svg+xml;base64,' + btoa(svgData); +} + +function saveAsLaTeX() { + var exporter = new ExportAsLaTeX(); + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(exporter); + selectedObject = oldSelectedObject; + var texData = exporter.toLaTeX(); + output(texData); +} + +function det(a, b, c, d, e, f, g, h, i) { + return a*e*i + b*f*g + c*d*h - a*f*h - b*d*i - c*e*g; +} + +function circleFromThreePoints(x1, y1, x2, y2, x3, y3) { + var a = det(x1, y1, 1, x2, y2, 1, x3, y3, 1); + var bx = -det(x1*x1 + y1*y1, y1, 1, x2*x2 + y2*y2, y2, 1, x3*x3 + y3*y3, y3, 1); + var by = det(x1*x1 + y1*y1, x1, 1, x2*x2 + y2*y2, x2, 1, x3*x3 + y3*y3, x3, 1); + var c = -det(x1*x1 + y1*y1, x1, y1, x2*x2 + y2*y2, x2, y2, x3*x3 + y3*y3, x3, y3); + return { + 'x': -bx / (2*a), + 'y': -by / (2*a), + 'radius': Math.sqrt(bx*bx + by*by - 4*a*c) / (2*Math.abs(a)) + }; +} + +function fixed(number, digits) { + return number.toFixed(digits).replace(/0+$/, '').replace(/\.$/, ''); +} + +function restoreBackup() { + if(!localStorage || !JSON) { + return; + } + + try { + var backup = JSON.parse(localStorage['fsm']); + + for(var i = 0; i < backup.nodes.length; i++) { + var backupNode = backup.nodes[i]; + var node = new Node(backupNode.x, backupNode.y); + node.isAcceptState = backupNode.isAcceptState; + node.text = backupNode.text; + nodes.push(node); + } + for(var i = 0; i < backup.links.length; i++) { + var backupLink = backup.links[i]; + var link = null; + if(backupLink.type == 'SelfLink') { + link = new SelfLink(nodes[backupLink.node]); + link.anchorAngle = backupLink.anchorAngle; + link.text = backupLink.text; + } else if(backupLink.type == 'StartLink') { + link = new StartLink(nodes[backupLink.node]); + link.deltaX = backupLink.deltaX; + link.deltaY = backupLink.deltaY; + link.text = backupLink.text; + } else if(backupLink.type == 'Link') { + link = new Link(nodes[backupLink.nodeA], nodes[backupLink.nodeB]); + link.parallelPart = backupLink.parallelPart; + link.perpendicularPart = backupLink.perpendicularPart; + link.text = backupLink.text; + link.lineAngleAdjust = backupLink.lineAngleAdjust; + } + if(link != null) { + links.push(link); + } + } + } catch(e) { + localStorage['fsm'] = ''; + } +} + +function saveBackup() { + if(!localStorage || !JSON) { + return; + } + + var backup = { + 'nodes': [], + 'links': [], + }; + for(var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var backupNode = { + 'x': node.x, + 'y': node.y, + 'text': node.text, + 'isAcceptState': node.isAcceptState, + }; + backup.nodes.push(backupNode); + } + for(var i = 0; i < links.length; i++) { + var link = links[i]; + var backupLink = null; + if(link instanceof SelfLink) { + backupLink = { + 'type': 'SelfLink', + 'node': nodes.indexOf(link.node), + 'text': link.text, + 'anchorAngle': link.anchorAngle, + }; + } else if(link instanceof StartLink) { + backupLink = { + 'type': 'StartLink', + 'node': nodes.indexOf(link.node), + 'text': link.text, + 'deltaX': link.deltaX, + 'deltaY': link.deltaY, + }; + } else if(link instanceof Link) { + backupLink = { + 'type': 'Link', + 'nodeA': nodes.indexOf(link.nodeA), + 'nodeB': nodes.indexOf(link.nodeB), + 'text': link.text, + 'lineAngleAdjust': link.lineAngleAdjust, + 'parallelPart': link.parallelPart, + 'perpendicularPart': link.perpendicularPart, + }; + } + if(backupLink != null) { + backup.links.push(backupLink); + } + } + + localStorage['fsm'] = JSON.stringify(backup); +} diff --git a/www/index.html b/public/index.html old mode 100755 new mode 100644 similarity index 90% rename from www/index.html rename to public/index.html index 31583c6..ef4b562 --- a/www/index.html +++ b/public/index.html @@ -1,121 +1,122 @@ - - - Finite State Machine Designer - by Evan Wallace - - - - - -

Finite State Machine Designer

- - Your browser does not support
the HTML5 <canvas> element
-
-
-

Export as: PNG | SVG | LaTeX

- -

The big white box above is the FSM designer.  Here's how to use it:

-
    -
  • Add a state: double-click on the canvas
  • -
  • Add an arrow: shift-drag on the canvas
  • -
  • Move something: drag it around
  • -
  • Delete something: click it and press the delete key (not the backspace key)
  • -
    -
  • Make accept state: double-click on an existing state
  • -
  • Type numeric subscript: put an underscore before the number (like "S_0")
  • -
  • Type greek letter: put a backslash before it (like "\beta")
  • -
-

This was made in HTML5 and JavaScript using the canvas element.

-
-

Created by Evan Wallace in 2010

- + + + Finite State Machine Designer + + + + + +

Finite State Machine Designer

+ + Your browser does not support
the HTML5 <canvas> element
+
+
+

Export as: PNG | SVG | LaTeX

+ +

The big white box above is the FSM designer.  Here's how to use it:

+
    +
  • Add a state: double-click on the canvas
  • +
  • Add an arrow: shift-drag on the canvas
  • +
  • Move something: drag it around
  • +
  • Delete something: click it and press the delete key (not the backspace key)
  • +
    +
  • Make accept state: double-click on an existing state
  • +
  • Type numeric subscript: put an underscore before the number (like "S_0")
  • +
  • Type greek letter: put a backslash before it (like "\beta")
  • +
  • Type LaTeX characters: put a backslash before it (like "\cup")
  • +
+

This was made in HTML5 and JavaScript using the canvas element.
CHANGELOG: Visit GitHub Repository's README

+
+

Created by Evan Wallace in 2010
Updated by Rare Drop in 2025

+ diff --git a/src/main/fsm.js b/src/main/fsm.js index 39b03fe..9cfb1b5 100644 --- a/src/main/fsm.js +++ b/src/main/fsm.js @@ -1,11 +1,38 @@ -var 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' ]; - function convertLatexShortcuts(text) { - // html greek characters - for(var i = 0; i < greekLetterNames.length; i++) { - var name = greekLetterNames[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))); + // Add more latex shortcuts here if needed + // Greek letters + const greekLetters = { + '\\alpha': '\u03B1', '\\beta': '\u03B2', '\\gamma': '\u03B3', '\\delta': '\u03B4', '\\epsilon': '\u03B5', + '\\zeta': '\u03B6', '\\eta': '\u03B7', '\\theta': '\u03B8', '\\iota': '\u03B9', '\\kappa': '\u03BA', + '\\lambda': '\u03BB', '\\mu': '\u03BC', '\\nu': '\u03BD', '\\xi': '\u03BE', '\\pi': '\u03C0', + '\\rho': '\u03C1', '\\sigma': '\u03C3', '\\tau': '\u03C4', '\\upsilon': '\u03C5', '\\phi': '\u03C6', + '\\chi': '\u03C7', '\\psi': '\u03C8', '\\omega': '\u03C9', + '\\Gamma': '\u0393', '\\Delta': '\u0394', '\\Theta': '\u0398', '\\Lambda': '\u039B', '\\Xi': '\u039E', + '\\Pi': '\u03A0', '\\Sigma': '\u03A3', '\\Phi': '\u03A6', '\\Psi': '\u03A8', '\\Omega': '\u03A9' + }; + + // Mathematical operators + const operators = { + '\\times': '\u00D7', '\\div': '\u00F7', '\\pm': '\u00B1', '\\mp': '\u2213', + '\\leq': '\u2264', '\\geq': '\u2265', '\\neq': '\u2260', '\\approx': '\u2248', + '\\infty': '\u221E', '\\sum': '\u2211', '\\prod': '\u220F', '\\int': '\u222B', + // Set theory symbols + '\\cup': '\u222A', '\\cap': '\u2229', '\\subset': '\u2282', '\\supset': '\u2283', + '\\subseteq': '\u2286', '\\supseteq': '\u2287', '\\in': '\u2208', '\\notin': '\u2209', + '\\emptyset': '\u2205', '\\varnothing': '\u2205', + // Additional operators + '\\cdot': '\u22C5', '\\bullet': '\u2022', '\\circ': '\u2218', '\\oplus': '\u2295', + '\\otimes': '\u2297', '\\setminus': '\u2216', '\\subsetneq': '\u228A', '\\supsetneq': '\u228B' + }; + + // Replace Greek letters + for (const [latex, unicode] of Object.entries(greekLetters)) { + text = text.replace(new RegExp('\\'+latex, 'g'), unicode); + } + + // Replace operators + for (const [latex, unicode] of Object.entries(operators)) { + text = text.replace(new RegExp('\\'+latex, 'g'), unicode); } // subscripts @@ -15,20 +42,20 @@ function convertLatexShortcuts(text) { return text; } - -function textToXML(text) { - text = text.replace(/&/g, '&').replace(//g, '>'); - var result = ''; - for(var i = 0; i < text.length; i++) { - var c = text.charCodeAt(i); - if(c >= 0x20 && c <= 0x7E) { - result += text[i]; - } else { - result += '&#' + c + ';'; - } - } - return result; -} + +function textToXML(text) { + text = text.replace(/&/g, '&').replace(//g, '>'); + var result = ''; + for(var i = 0; i < text.length; i++) { + var c = text.charCodeAt(i); + if(c >= 0x20 && c <= 0x7E) { + result += text[i]; + } else { + result += '&#' + c + ';'; + } + } + return result; +} function drawArrow(c, x, y, angle) { var dx = Math.cos(angle); @@ -39,12 +66,12 @@ function drawArrow(c, x, y, angle) { c.lineTo(x - 8 * dx - 5 * dy, y - 8 * dy + 5 * dx); c.fill(); } - -function canvasHasFocus() { - return (document.activeElement || document.body) == document.body; -} -function drawText(c, originalText, x, y, angleOrNull, isSelected) { +function canvasHasFocus() { + return (document.activeElement || document.body) == document.body; +} + +function drawText(c, originalText, x, y, angleOrNull, isSelected) { text = convertLatexShortcuts(originalText); c.font = '20px "Times New Roman", serif'; var width = c.measureText(text).width; @@ -63,13 +90,13 @@ function drawText(c, originalText, x, y, angleOrNull, isSelected) { y += cornerPointY + cos * slide; } - // draw text and caret (round the coordinates so the caret falls on a pixel) + // draw text and caret (round the coordinates so the caret falls on a pixel) if('advancedFillText' in c) { - c.advancedFillText(text, originalText, x + width / 2, y, angleOrNull); + c.advancedFillText(text, originalText, x + width / 2, y, angleOrNull); } else { x = Math.round(x); - y = Math.round(y); - c.fillText(text, x, y + 6); + y = Math.round(y); + c.fillText(text, x, y + 6); if(isSelected && caretVisible && canvasHasFocus() && document.hasFocus()) { x += width; c.beginPath(); @@ -101,8 +128,8 @@ var selectedObject = null; // either a Link or a Node var currentLink = null; // a Link var movingObject = false; var originalClick; - -function drawUsing(c) { + +function drawUsing(c) { c.clearRect(0, 0, canvas.width, canvas.height); c.save(); c.translate(0.5, 0.5); @@ -124,12 +151,12 @@ function drawUsing(c) { } c.restore(); -} - -function draw() { - drawUsing(canvas.getContext('2d')); - saveBackup(); -} +} + +function draw() { + drawUsing(canvas.getContext('2d')); + saveBackup(); +} function selectObject(x, y) { for(var i = 0; i < nodes.length; i++) { @@ -160,7 +187,7 @@ function snapNode(node) { } window.onload = function() { - canvas = document.getElementById('canvas'); + canvas = document.getElementById('canvas'); restoreBackup(); draw(); @@ -186,14 +213,14 @@ window.onload = function() { } draw(); - - if(canvasHasFocus()) { - // disable drag-and-drop only if the canvas is already focused - return false; + + if(canvasHasFocus()) { + // disable drag-and-drop only if the canvas is already focused + return false; } else { - // otherwise, let the browser switch the focus away from wherever it was + // otherwise, let the browser switch the focus away from wherever it was resetCaret(); - return true; + return true; } }; @@ -265,13 +292,13 @@ window.onload = function() { var shift = false; -document.onkeydown = function(e) { - var key = crossBrowserKey(e); +document.onkeydown = function(e) { + var key = crossBrowserKey(e); if(key == 16) { shift = true; - } else if(!canvasHasFocus()) { - // don't read keystrokes when other things have focus + } else if(!canvasHasFocus()) { + // don't read keystrokes when other things have focus return true; } else if(key == 8) { // backspace key if(selectedObject != null && 'text' in selectedObject) { @@ -279,7 +306,7 @@ document.onkeydown = function(e) { resetCaret(); draw(); } - + // backspace is a shortcut for the back button, but do NOT want to change pages return false; } else if(key == 46) { // delete key @@ -310,9 +337,9 @@ document.onkeyup = function(e) { document.onkeypress = function(e) { // don't read keystrokes when other things have focus - var key = crossBrowserKey(e); - if(!canvasHasFocus()) { - // don't read keystrokes when other things have focus + var key = crossBrowserKey(e); + if(!canvasHasFocus()) { + // don't read keystrokes when other things have focus return true; } else if(key >= 0x20 && key <= 0x7E && !e.metaKey && !e.altKey && !e.ctrlKey && selectedObject != null && 'text' in selectedObject) { selectedObject.text += String.fromCharCode(key); @@ -321,7 +348,7 @@ document.onkeypress = function(e) { // don't let keys do their actions (like space scrolls down the page) return false; - } else if(key == 8) { + } else if(key == 8) { // backspace is a shortcut for the back button, but do NOT want to change pages return false; } @@ -360,40 +387,40 @@ function crossBrowserRelativeMousePos(e) { 'y': mouse.y - element.y }; } - -function output(text) { - var element = document.getElementById('output'); - element.style.display = 'block'; - element.value = text; -} + +function output(text) { + var element = document.getElementById('output'); + element.style.display = 'block'; + element.value = text; +} function saveAsPNG() { - var oldSelectedObject = selectedObject; - selectedObject = null; - drawUsing(canvas.getContext('2d')); - selectedObject = oldSelectedObject; + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(canvas.getContext('2d')); + selectedObject = oldSelectedObject; var pngData = canvas.toDataURL('image/png'); document.location.href = pngData; -} - -function saveAsSVG() { +} + +function saveAsSVG() { var exporter = new ExportAsSVG(); - var oldSelectedObject = selectedObject; - selectedObject = null; - drawUsing(exporter); - selectedObject = oldSelectedObject; - var svgData = exporter.toSVG(); - output(svgData); - // Chrome isn't ready for this yet, the 'Save As' menu item is disabled - // document.location.href = 'data:image/svg+xml;base64,' + btoa(svgData); + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(exporter); + selectedObject = oldSelectedObject; + var svgData = exporter.toSVG(); + output(svgData); + // Chrome isn't ready for this yet, the 'Save As' menu item is disabled + // document.location.href = 'data:image/svg+xml;base64,' + btoa(svgData); } - -function saveAsLaTeX() { + +function saveAsLaTeX() { var exporter = new ExportAsLaTeX(); - var oldSelectedObject = selectedObject; - selectedObject = null; - drawUsing(exporter); - selectedObject = oldSelectedObject; - var texData = exporter.toLaTeX(); - output(texData); + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(exporter); + selectedObject = oldSelectedObject; + var texData = exporter.toLaTeX(); + output(texData); }