You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.js 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. import React from "react";
  2. import ReactDOM from "react-dom";
  3. import rough from "roughjs/dist/rough.umd.js";
  4. import "./styles.css";
  5. var elements = [];
  6. function isInsideAnElement(x, y) {
  7. return (element) => {
  8. const x1 = getElementAbsoluteX1(element)
  9. const x2 = getElementAbsoluteX2(element)
  10. const y1 = getElementAbsoluteY1(element)
  11. const y2 = getElementAbsoluteY2(element)
  12. return (x >= x1 && x <= x2) && (y >= y1 && y <= y2)
  13. }
  14. }
  15. function newElement(type, x, y, width = 0, height = 0) {
  16. const element = {
  17. type: type,
  18. x: x,
  19. y: y,
  20. width: width,
  21. height: height,
  22. isSelected: false
  23. };
  24. return element;
  25. }
  26. function exportAsPNG({
  27. exportBackground,
  28. exportVisibleOnly,
  29. exportPadding = 10
  30. }) {
  31. if ( !elements.length ) return window.alert("Cannot export empty canvas.");
  32. // deselect & rerender
  33. clearSelection();
  34. drawScene();
  35. // calculate visible-area coords
  36. let subCanvasX1 = Infinity;
  37. let subCanvasX2 = 0;
  38. let subCanvasY1 = Infinity;
  39. let subCanvasY2 = 0;
  40. elements.forEach(element => {
  41. subCanvasX1 = Math.min(subCanvasX1, getElementAbsoluteX1(element));
  42. subCanvasX2 = Math.max(subCanvasX2, getElementAbsoluteX2(element));
  43. subCanvasY1 = Math.min(subCanvasY1, getElementAbsoluteY1(element));
  44. subCanvasY2 = Math.max(subCanvasY2, getElementAbsoluteY2(element));
  45. });
  46. // create temporary canvas from which we'll export
  47. const tempCanvas = document.createElement("canvas");
  48. const tempCanvasCtx = tempCanvas.getContext("2d");
  49. tempCanvas.style.display = "none";
  50. document.body.appendChild(tempCanvas);
  51. tempCanvas.width = exportVisibleOnly
  52. ? subCanvasX2 - subCanvasX1 + exportPadding * 2
  53. : canvas.width;
  54. tempCanvas.height = exportVisibleOnly
  55. ? subCanvasY2 - subCanvasY1 + exportPadding * 2
  56. : canvas.height;
  57. if (exportBackground) {
  58. tempCanvasCtx.fillStyle = "#FFF";
  59. tempCanvasCtx.fillRect(0, 0, canvas.width, canvas.height);
  60. }
  61. // copy our original canvas onto the temp canvas
  62. tempCanvasCtx.drawImage(
  63. canvas, // source
  64. exportVisibleOnly // sx
  65. ? subCanvasX1 - exportPadding
  66. : 0,
  67. exportVisibleOnly // sy
  68. ? subCanvasY1 - exportPadding
  69. : 0,
  70. exportVisibleOnly // sWidth
  71. ? subCanvasX2 - subCanvasX1 + exportPadding * 2
  72. : canvas.width,
  73. exportVisibleOnly // sHeight
  74. ? subCanvasY2 - subCanvasY1 + exportPadding * 2
  75. : canvas.height,
  76. 0, // dx
  77. 0, // dy
  78. exportVisibleOnly ? tempCanvas.width : canvas.width, // dWidth
  79. exportVisibleOnly ? tempCanvas.height : canvas.height // dHeight
  80. );
  81. // create a temporary <a> elem which we'll use to download the image
  82. const link = document.createElement("a");
  83. link.setAttribute("download", "excalibur.png");
  84. link.setAttribute("href", tempCanvas.toDataURL("image/png"));
  85. link.click();
  86. // clean up the DOM
  87. link.remove();
  88. if (tempCanvas !== canvas) tempCanvas.remove();
  89. }
  90. function rotate(x1, y1, x2, y2, angle) {
  91. // 𝑎′𝑥=(𝑎𝑥−𝑐𝑥)cos𝜃−(𝑎𝑦−𝑐𝑦)sin𝜃+𝑐𝑥
  92. // 𝑎′𝑦=(𝑎𝑥−𝑐𝑥)sin𝜃+(𝑎𝑦−𝑐𝑦)cos𝜃+𝑐𝑦.
  93. // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line
  94. return [
  95. (x1 - x2) * Math.cos(angle) - (y1 - y2) * Math.sin(angle) + x2,
  96. (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2
  97. ];
  98. }
  99. var generator = rough.generator();
  100. function generateDraw(element) {
  101. if (element.type === "selection") {
  102. element.draw = (rc, context) => {
  103. const fillStyle = context.fillStyle;
  104. context.fillStyle = "rgba(0, 0, 255, 0.10)";
  105. context.fillRect(element.x, element.y, element.width, element.height);
  106. context.fillStyle = fillStyle;
  107. };
  108. } else if (element.type === "rectangle") {
  109. const shape = generator.rectangle(0, 0, element.width, element.height);
  110. element.draw = (rc, context) => {
  111. context.translate(element.x, element.y);
  112. rc.draw(shape);
  113. context.translate(-element.x, -element.y);
  114. };
  115. } else if (element.type === "ellipse") {
  116. const shape = generator.ellipse(
  117. element.width / 2,
  118. element.height / 2,
  119. element.width,
  120. element.height
  121. );
  122. element.draw = (rc, context) => {
  123. context.translate(element.x, element.y);
  124. rc.draw(shape);
  125. context.translate(-element.x, -element.y);
  126. };
  127. } else if (element.type === "arrow") {
  128. const x1 = 0;
  129. const y1 = 0;
  130. const x2 = element.width;
  131. const y2 = element.height;
  132. const size = 30; // pixels
  133. const distance = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
  134. // Scale down the arrow until we hit a certain size so that it doesn't look weird
  135. const minSize = Math.min(size, distance / 2);
  136. const xs = x2 - ((x2 - x1) / distance) * minSize;
  137. const ys = y2 - ((y2 - y1) / distance) * minSize;
  138. const angle = 20; // degrees
  139. const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
  140. const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
  141. const shapes = [
  142. // \
  143. generator.line(x3, y3, x2, y2),
  144. // -----
  145. generator.line(x1, y1, x2, y2),
  146. // /
  147. generator.line(x4, y4, x2, y2)
  148. ];
  149. element.draw = (rc, context) => {
  150. context.translate(element.x, element.y);
  151. shapes.forEach(shape => rc.draw(shape));
  152. context.translate(-element.x, -element.y);
  153. };
  154. return;
  155. } else if (element.type === "text") {
  156. element.draw = (rc, context) => {
  157. const font = context.font;
  158. context.font = element.font;
  159. context.fillText(
  160. element.text,
  161. element.x,
  162. element.y + element.measure.actualBoundingBoxAscent
  163. );
  164. context.font = font;
  165. };
  166. } else {
  167. throw new Error("Unimplemented type " + element.type);
  168. }
  169. }
  170. // If the element is created from right to left, the width is going to be negative
  171. // This set of functions retrieves the absolute position of the 4 points.
  172. // We can't just always normalize it since we need to remember the fact that an arrow
  173. // is pointing left or right.
  174. function getElementAbsoluteX1(element) {
  175. return element.width >= 0 ? element.x : element.x + element.width;
  176. }
  177. function getElementAbsoluteX2(element) {
  178. return element.width >= 0 ? element.x + element.width : element.x;
  179. }
  180. function getElementAbsoluteY1(element) {
  181. return element.height >= 0 ? element.y : element.y + element.height;
  182. }
  183. function getElementAbsoluteY2(element) {
  184. return element.height >= 0 ? element.y + element.height : element.y;
  185. }
  186. function setSelection(selection) {
  187. const selectionX1 = getElementAbsoluteX1(selection);
  188. const selectionX2 = getElementAbsoluteX2(selection);
  189. const selectionY1 = getElementAbsoluteY1(selection);
  190. const selectionY2 = getElementAbsoluteY2(selection);
  191. elements.forEach(element => {
  192. const elementX1 = getElementAbsoluteX1(element);
  193. const elementX2 = getElementAbsoluteX2(element);
  194. const elementY1 = getElementAbsoluteY1(element);
  195. const elementY2 = getElementAbsoluteY2(element);
  196. element.isSelected =
  197. element.type !== "selection" &&
  198. selectionX1 <= elementX1 &&
  199. selectionY1 <= elementY1 &&
  200. selectionX2 >= elementX2 &&
  201. selectionY2 >= elementY2;
  202. });
  203. }
  204. function clearSelection() {
  205. elements.forEach(element => {
  206. element.isSelected = false;
  207. });
  208. }
  209. class App extends React.Component {
  210. componentDidMount() {
  211. this.onKeyDown = event => {
  212. if (event.key === "Backspace" && event.target.nodeName !== "INPUT") {
  213. for (var i = elements.length - 1; i >= 0; --i) {
  214. if (elements[i].isSelected) {
  215. elements.splice(i, 1);
  216. }
  217. }
  218. drawScene();
  219. event.preventDefault();
  220. } else if (
  221. event.key === "ArrowLeft" ||
  222. event.key === "ArrowRight" ||
  223. event.key === "ArrowUp" ||
  224. event.key === "ArrowDown"
  225. ) {
  226. const step = event.shiftKey ? 5 : 1;
  227. elements.forEach(element => {
  228. if (element.isSelected) {
  229. if (event.key === "ArrowLeft") element.x -= step;
  230. else if (event.key === "ArrowRight") element.x += step;
  231. else if (event.key === "ArrowUp") element.y -= step;
  232. else if (event.key === "ArrowDown") element.y += step;
  233. }
  234. });
  235. drawScene();
  236. event.preventDefault();
  237. }
  238. };
  239. document.addEventListener("keydown", this.onKeyDown, false);
  240. }
  241. componentWillUnmount() {
  242. document.removeEventListener("keydown", this.onKeyDown, false);
  243. }
  244. constructor() {
  245. super();
  246. this.state = {
  247. draggingElement: null,
  248. elementType: "selection",
  249. exportBackground: false,
  250. exportVisibleOnly: true,
  251. exportPadding: 10
  252. };
  253. }
  254. render() {
  255. const ElementOption = ({ type, children }) => {
  256. return (
  257. <label>
  258. <input
  259. type="radio"
  260. checked={this.state.elementType === type}
  261. onChange={() => {
  262. this.setState({ elementType: type });
  263. clearSelection();
  264. drawScene();
  265. }}
  266. />
  267. {children}
  268. </label>
  269. );
  270. };
  271. return <>
  272. <div className="exportWrapper">
  273. <button onClick={() => {
  274. exportAsPNG({
  275. exportBackground: this.state.exportBackground,
  276. exportVisibleOnly: this.state.exportVisibleOnly,
  277. exportPadding: this.state.exportPadding
  278. })
  279. }}>Export to png</button>
  280. <label>
  281. <input type="checkbox"
  282. checked={this.state.exportBackground}
  283. onChange={e => {
  284. this.setState({ exportBackground: e.target.checked })
  285. }}
  286. /> background
  287. </label>
  288. <label>
  289. <input type="checkbox"
  290. checked={this.state.exportVisibleOnly}
  291. onChange={e => {
  292. this.setState({ exportVisibleOnly: e.target.checked })
  293. }}
  294. />
  295. visible area only
  296. </label>
  297. (padding:
  298. <input type="number" value={this.state.exportPadding}
  299. onChange={e => {
  300. this.setState({ exportPadding: e.target.value });
  301. }}
  302. disabled={!this.state.exportVisibleOnly}/>
  303. px)
  304. </div>
  305. <div>
  306. {/* Can't use the <ElementOption> form because ElementOption is re-defined
  307. on every render, which would blow up and re-create the entire DOM tree,
  308. which in addition to being inneficient, messes up with browser text
  309. selection */}
  310. {ElementOption({ type: "rectangle", children: "Rectangle" })}
  311. {ElementOption({ type: "ellipse", children: "Ellipse" })}
  312. {ElementOption({ type: "arrow", children: "Arrow" })}
  313. {ElementOption({ type: "text", children: "Text" })}
  314. {ElementOption({ type: "selection", children: "Selection" })}
  315. <canvas
  316. id="canvas"
  317. width={window.innerWidth}
  318. height={window.innerHeight}
  319. onMouseDown={e => {
  320. const x = e.clientX - e.target.offsetLeft;
  321. const y = e.clientY - e.target.offsetTop;
  322. const element = newElement(this.state.elementType, x, y);
  323. let isDraggingElements = false;
  324. const cursorStyle = document.documentElement.style.cursor;
  325. if (this.state.elementType === "selection") {
  326. const selectedElement = elements.find(element => {
  327. const isSelected = isInsideAnElement(x, y)(element)
  328. if (isSelected) {
  329. element.isSelected = true
  330. }
  331. return isSelected
  332. })
  333. if (selectedElement) {
  334. this.setState({ draggingElement: selectedElement });
  335. } else {
  336. clearSelection()
  337. }
  338. isDraggingElements = elements.some(element => element.isSelected);
  339. if (isDraggingElements) {
  340. document.documentElement.style.cursor = "move";
  341. }
  342. }
  343. if (this.state.elementType === "text") {
  344. const text = prompt("What text do you want?");
  345. if (text === null) {
  346. return;
  347. }
  348. element.text = text;
  349. element.font = "20px Virgil";
  350. const font = context.font;
  351. context.font = element.font;
  352. element.measure = context.measureText(element.text);
  353. context.font = font;
  354. const height =
  355. element.measure.actualBoundingBoxAscent +
  356. element.measure.actualBoundingBoxDescent;
  357. // Center the text
  358. element.x -= element.measure.width / 2;
  359. element.y -= element.measure.actualBoundingBoxAscent;
  360. element.width = element.measure.width;
  361. element.height = height;
  362. }
  363. generateDraw(element);
  364. elements.push(element);
  365. if (this.state.elementType === "text") {
  366. this.setState({
  367. draggingElement: null,
  368. elementType: "selection"
  369. });
  370. element.isSelected = true;
  371. } else {
  372. this.setState({ draggingElement: element });
  373. }
  374. let lastX = x;
  375. let lastY = y;
  376. const onMouseMove = e => {
  377. if (isDraggingElements) {
  378. const selectedElements = elements.filter(el => el.isSelected);
  379. if (selectedElements.length) {
  380. const x = e.clientX - e.target.offsetLeft;
  381. const y = e.clientY - e.target.offsetTop;
  382. selectedElements.forEach(element => {
  383. element.x += x - lastX;
  384. element.y += y - lastY;
  385. });
  386. lastX = x;
  387. lastY = y;
  388. drawScene();
  389. return;
  390. }
  391. }
  392. // It is very important to read this.state within each move event,
  393. // otherwise we would read a stale one!
  394. const draggingElement = this.state.draggingElement;
  395. if (!draggingElement) return;
  396. let width = e.clientX - e.target.offsetLeft - draggingElement.x;
  397. let height = e.clientY - e.target.offsetTop - draggingElement.y;
  398. draggingElement.width = width;
  399. // Make a perfect square or circle when shift is enabled
  400. draggingElement.height = e.shiftKey ? width : height;
  401. generateDraw(draggingElement);
  402. if (this.state.elementType === "selection") {
  403. setSelection(draggingElement);
  404. }
  405. drawScene();
  406. };
  407. const onMouseUp = e => {
  408. const { draggingElement, elementType } = this.state
  409. window.removeEventListener("mousemove", onMouseMove);
  410. window.removeEventListener("mouseup", onMouseUp);
  411. document.documentElement.style.cursor = cursorStyle;
  412. // if no element is clicked, clear the selection and redraw
  413. if (draggingElement === null ) {
  414. clearSelection()
  415. drawScene();
  416. return
  417. }
  418. if (elementType === "selection") {
  419. if (isDraggingElements) {
  420. isDraggingElements = false;
  421. }
  422. elements.pop()
  423. } else {
  424. draggingElement.isSelected = true;
  425. }
  426. this.setState({
  427. draggingElement: null,
  428. elementType: "selection"
  429. });
  430. drawScene();
  431. };
  432. window.addEventListener("mousemove", onMouseMove);
  433. window.addEventListener("mouseup", onMouseUp);
  434. drawScene();
  435. }}
  436. />
  437. </div>
  438. </>;
  439. }
  440. }
  441. const rootElement = document.getElementById("root");
  442. ReactDOM.render(<App />, rootElement);
  443. const canvas = document.getElementById("canvas");
  444. const rc = rough.canvas(canvas);
  445. const context = canvas.getContext("2d");
  446. // Big hack to ensure that all the 1px lines are drawn at 1px instead of 2px
  447. // https://stackoverflow.com/questions/13879322/drawing-a-1px-thick-line-in-canvas-creates-a-2px-thick-line/13879402#comment90766599_13879402
  448. context.translate(0.5, 0.5);
  449. function drawScene() {
  450. ReactDOM.render(<App />, rootElement);
  451. context.clearRect(-0.5, -0.5, canvas.width, canvas.height);
  452. elements.forEach(element => {
  453. element.draw(rc, context);
  454. if (element.isSelected) {
  455. const margin = 4;
  456. const elementX1 = getElementAbsoluteX1(element);
  457. const elementX2 = getElementAbsoluteX2(element);
  458. const elementY1 = getElementAbsoluteY1(element);
  459. const elementY2 = getElementAbsoluteY2(element);
  460. const lineDash = context.getLineDash();
  461. context.setLineDash([8, 4]);
  462. context.strokeRect(
  463. elementX1 - margin,
  464. elementY1 - margin,
  465. elementX2 - elementX1 + margin * 2,
  466. elementY2 - elementY1 + margin * 2
  467. );
  468. context.setLineDash(lineDash);
  469. }
  470. });
  471. }
  472. drawScene();