index.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832
  1. import React from "react";
  2. import ReactDOM from "react-dom";
  3. import rough from "roughjs/bin/wrappers/rough";
  4. import { RoughCanvas } from "roughjs/bin/canvas";
  5. import "./styles.css";
  6. type ExcaliburElement = ReturnType<typeof newElement>;
  7. type ExcaliburTextElement = ExcaliburElement & {
  8. type: "text";
  9. font: string;
  10. text: string;
  11. actualBoundingBoxAscent: number;
  12. };
  13. var elements = Array.of<ExcaliburElement>();
  14. // https://stackoverflow.com/a/6853926/232122
  15. function distanceBetweenPointAndSegment(
  16. x: number,
  17. y: number,
  18. x1: number,
  19. y1: number,
  20. x2: number,
  21. y2: number
  22. ) {
  23. const A = x - x1;
  24. const B = y - y1;
  25. const C = x2 - x1;
  26. const D = y2 - y1;
  27. const dot = A * C + B * D;
  28. const lenSquare = C * C + D * D;
  29. let param = -1;
  30. if (lenSquare !== 0) {
  31. // in case of 0 length line
  32. param = dot / lenSquare;
  33. }
  34. let xx, yy;
  35. if (param < 0) {
  36. xx = x1;
  37. yy = y1;
  38. } else if (param > 1) {
  39. xx = x2;
  40. yy = y2;
  41. } else {
  42. xx = x1 + param * C;
  43. yy = y1 + param * D;
  44. }
  45. const dx = x - xx;
  46. const dy = y - yy;
  47. return Math.sqrt(dx * dx + dy * dy);
  48. }
  49. function hitTest(element: ExcaliburElement, x: number, y: number): boolean {
  50. // For shapes that are composed of lines, we only enable point-selection when the distance
  51. // of the click is less than x pixels of any of the lines that the shape is composed of
  52. const lineThreshold = 10;
  53. if (
  54. element.type === "rectangle" ||
  55. // There doesn't seem to be a closed form solution for the distance between
  56. // a point and an ellipse, let's assume it's a rectangle for now...
  57. element.type === "ellipse"
  58. ) {
  59. const x1 = getElementAbsoluteX1(element);
  60. const x2 = getElementAbsoluteX2(element);
  61. const y1 = getElementAbsoluteY1(element);
  62. const y2 = getElementAbsoluteY2(element);
  63. // (x1, y1) --A-- (x2, y1)
  64. // |D |B
  65. // (x1, y2) --C-- (x2, y2)
  66. return (
  67. distanceBetweenPointAndSegment(x, y, x1, y1, x2, y1) < lineThreshold || // A
  68. distanceBetweenPointAndSegment(x, y, x2, y1, x2, y2) < lineThreshold || // B
  69. distanceBetweenPointAndSegment(x, y, x2, y2, x1, y2) < lineThreshold || // C
  70. distanceBetweenPointAndSegment(x, y, x1, y2, x1, y1) < lineThreshold // D
  71. );
  72. } else if (element.type === "arrow") {
  73. let [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
  74. // The computation is done at the origin, we need to add a translation
  75. x -= element.x;
  76. y -= element.y;
  77. return (
  78. // \
  79. distanceBetweenPointAndSegment(x, y, x3, y3, x2, y2) < lineThreshold ||
  80. // -----
  81. distanceBetweenPointAndSegment(x, y, x1, y1, x2, y2) < lineThreshold ||
  82. // /
  83. distanceBetweenPointAndSegment(x, y, x4, y4, x2, y2) < lineThreshold
  84. );
  85. } else if (element.type === "text") {
  86. const x1 = getElementAbsoluteX1(element);
  87. const x2 = getElementAbsoluteX2(element);
  88. const y1 = getElementAbsoluteY1(element);
  89. const y2 = getElementAbsoluteY2(element);
  90. return x >= x1 && x <= x2 && y >= y1 && y <= y2;
  91. } else {
  92. throw new Error("Unimplemented type " + element.type);
  93. }
  94. }
  95. function newElement(
  96. type: string,
  97. x: number,
  98. y: number,
  99. strokeColor: string,
  100. backgroundColor: string,
  101. width = 0,
  102. height = 0
  103. ) {
  104. const element = {
  105. type: type,
  106. x: x,
  107. y: y,
  108. width: width,
  109. height: height,
  110. isSelected: false,
  111. strokeColor: strokeColor,
  112. backgroundColor: backgroundColor,
  113. draw(rc: RoughCanvas, context: CanvasRenderingContext2D) {}
  114. };
  115. return element;
  116. }
  117. function renderScene(
  118. rc: RoughCanvas,
  119. context: CanvasRenderingContext2D,
  120. // null indicates transparent bg
  121. viewBackgroundColor: string | null
  122. ) {
  123. const fillStyle = context.fillStyle;
  124. if (typeof viewBackgroundColor === "string") {
  125. context.fillStyle = viewBackgroundColor;
  126. context.fillRect(-0.5, -0.5, canvas.width, canvas.height);
  127. } else {
  128. context.clearRect(-0.5, -0.5, canvas.width, canvas.height);
  129. }
  130. context.fillStyle = fillStyle;
  131. elements.forEach(element => {
  132. element.draw(rc, context);
  133. if (element.isSelected) {
  134. const margin = 4;
  135. const elementX1 = getElementAbsoluteX1(element);
  136. const elementX2 = getElementAbsoluteX2(element);
  137. const elementY1 = getElementAbsoluteY1(element);
  138. const elementY2 = getElementAbsoluteY2(element);
  139. const lineDash = context.getLineDash();
  140. context.setLineDash([8, 4]);
  141. context.strokeRect(
  142. elementX1 - margin,
  143. elementY1 - margin,
  144. elementX2 - elementX1 + margin * 2,
  145. elementY2 - elementY1 + margin * 2
  146. );
  147. context.setLineDash(lineDash);
  148. }
  149. });
  150. }
  151. function exportAsPNG({
  152. exportBackground,
  153. exportVisibleOnly,
  154. exportPadding = 10,
  155. viewBackgroundColor
  156. }: {
  157. exportBackground: boolean;
  158. exportVisibleOnly: boolean;
  159. exportPadding?: number;
  160. viewBackgroundColor: string;
  161. }) {
  162. if (!elements.length) return window.alert("Cannot export empty canvas.");
  163. // deselect & rerender
  164. clearSelection();
  165. ReactDOM.render(<App />, rootElement, () => {
  166. // calculate visible-area coords
  167. let subCanvasX1 = Infinity;
  168. let subCanvasX2 = 0;
  169. let subCanvasY1 = Infinity;
  170. let subCanvasY2 = 0;
  171. elements.forEach(element => {
  172. subCanvasX1 = Math.min(subCanvasX1, getElementAbsoluteX1(element));
  173. subCanvasX2 = Math.max(subCanvasX2, getElementAbsoluteX2(element));
  174. subCanvasY1 = Math.min(subCanvasY1, getElementAbsoluteY1(element));
  175. subCanvasY2 = Math.max(subCanvasY2, getElementAbsoluteY2(element));
  176. });
  177. // create temporary canvas from which we'll export
  178. const tempCanvas = document.createElement("canvas");
  179. const tempCanvasCtx = tempCanvas.getContext("2d")!;
  180. tempCanvas.style.display = "none";
  181. document.body.appendChild(tempCanvas);
  182. tempCanvas.width = exportVisibleOnly
  183. ? subCanvasX2 - subCanvasX1 + exportPadding * 2
  184. : canvas.width;
  185. tempCanvas.height = exportVisibleOnly
  186. ? subCanvasY2 - subCanvasY1 + exportPadding * 2
  187. : canvas.height;
  188. // if we're exporting without bg, we need to rerender the scene without it
  189. // (it's reset again, below)
  190. if (!exportBackground) {
  191. renderScene(rc, context, null);
  192. }
  193. // copy our original canvas onto the temp canvas
  194. tempCanvasCtx.drawImage(
  195. canvas, // source
  196. exportVisibleOnly // sx
  197. ? subCanvasX1 - exportPadding
  198. : 0,
  199. exportVisibleOnly // sy
  200. ? subCanvasY1 - exportPadding
  201. : 0,
  202. exportVisibleOnly // sWidth
  203. ? subCanvasX2 - subCanvasX1 + exportPadding * 2
  204. : canvas.width,
  205. exportVisibleOnly // sHeight
  206. ? subCanvasY2 - subCanvasY1 + exportPadding * 2
  207. : canvas.height,
  208. 0, // dx
  209. 0, // dy
  210. exportVisibleOnly ? tempCanvas.width : canvas.width, // dWidth
  211. exportVisibleOnly ? tempCanvas.height : canvas.height // dHeight
  212. );
  213. // reset transparent bg back to original
  214. if (!exportBackground) {
  215. renderScene(rc, context, viewBackgroundColor);
  216. }
  217. // create a temporary <a> elem which we'll use to download the image
  218. const link = document.createElement("a");
  219. link.setAttribute("download", "excalibur.png");
  220. link.setAttribute("href", tempCanvas.toDataURL("image/png"));
  221. link.click();
  222. // clean up the DOM
  223. link.remove();
  224. if (tempCanvas !== canvas) tempCanvas.remove();
  225. });
  226. }
  227. function rotate(x1: number, y1: number, x2: number, y2: number, angle: number) {
  228. // 𝑎′𝑥=(𝑎𝑥−𝑐𝑥)cos𝜃−(𝑎𝑦−𝑐𝑦)sin𝜃+𝑐𝑥
  229. // 𝑎′𝑦=(𝑎𝑥−𝑐𝑥)sin𝜃+(𝑎𝑦−𝑐𝑦)cos𝜃+𝑐𝑦.
  230. // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line
  231. return [
  232. (x1 - x2) * Math.cos(angle) - (y1 - y2) * Math.sin(angle) + x2,
  233. (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2
  234. ];
  235. }
  236. // Casting second argument (DrawingSurface) to any,
  237. // because it is requred by TS definitions and not required at runtime
  238. var generator = rough.generator(null, null as any);
  239. function isTextElement(
  240. element: ExcaliburElement
  241. ): element is ExcaliburTextElement {
  242. return element.type === "text";
  243. }
  244. function getArrowPoints(element: ExcaliburElement) {
  245. const x1 = 0;
  246. const y1 = 0;
  247. const x2 = element.width;
  248. const y2 = element.height;
  249. const size = 30; // pixels
  250. const distance = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
  251. // Scale down the arrow until we hit a certain size so that it doesn't look weird
  252. const minSize = Math.min(size, distance / 2);
  253. const xs = x2 - ((x2 - x1) / distance) * minSize;
  254. const ys = y2 - ((y2 - y1) / distance) * minSize;
  255. const angle = 20; // degrees
  256. const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
  257. const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
  258. return [x1, y1, x2, y2, x3, y3, x4, y4];
  259. }
  260. function generateDraw(element: ExcaliburElement) {
  261. if (element.type === "selection") {
  262. element.draw = (rc, context) => {
  263. const fillStyle = context.fillStyle;
  264. context.fillStyle = "rgba(0, 0, 255, 0.10)";
  265. context.fillRect(element.x, element.y, element.width, element.height);
  266. context.fillStyle = fillStyle;
  267. };
  268. } else if (element.type === "rectangle") {
  269. const shape = generator.rectangle(0, 0, element.width, element.height, {
  270. stroke: element.strokeColor,
  271. fill: element.backgroundColor
  272. });
  273. element.draw = (rc, context) => {
  274. context.translate(element.x, element.y);
  275. rc.draw(shape);
  276. context.translate(-element.x, -element.y);
  277. };
  278. } else if (element.type === "ellipse") {
  279. const shape = generator.ellipse(
  280. element.width / 2,
  281. element.height / 2,
  282. element.width,
  283. element.height,
  284. { stroke: element.strokeColor, fill: element.backgroundColor }
  285. );
  286. element.draw = (rc, context) => {
  287. context.translate(element.x, element.y);
  288. rc.draw(shape);
  289. context.translate(-element.x, -element.y);
  290. };
  291. } else if (element.type === "arrow") {
  292. const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
  293. const shapes = [
  294. // \
  295. generator.line(x3, y3, x2, y2, { stroke: element.strokeColor }),
  296. // -----
  297. generator.line(x1, y1, x2, y2, { stroke: element.strokeColor }),
  298. // /
  299. generator.line(x4, y4, x2, y2, { stroke: element.strokeColor })
  300. ];
  301. element.draw = (rc, context) => {
  302. context.translate(element.x, element.y);
  303. shapes.forEach(shape => rc.draw(shape));
  304. context.translate(-element.x, -element.y);
  305. };
  306. return;
  307. } else if (isTextElement(element)) {
  308. element.draw = (rc, context) => {
  309. const font = context.font;
  310. context.font = element.font;
  311. context.fillText(
  312. element.text,
  313. element.x,
  314. element.y + element.actualBoundingBoxAscent
  315. );
  316. context.font = font;
  317. };
  318. } else {
  319. throw new Error("Unimplemented type " + element.type);
  320. }
  321. }
  322. // If the element is created from right to left, the width is going to be negative
  323. // This set of functions retrieves the absolute position of the 4 points.
  324. // We can't just always normalize it since we need to remember the fact that an arrow
  325. // is pointing left or right.
  326. function getElementAbsoluteX1(element: ExcaliburElement) {
  327. return element.width >= 0 ? element.x : element.x + element.width;
  328. }
  329. function getElementAbsoluteX2(element: ExcaliburElement) {
  330. return element.width >= 0 ? element.x + element.width : element.x;
  331. }
  332. function getElementAbsoluteY1(element: ExcaliburElement) {
  333. return element.height >= 0 ? element.y : element.y + element.height;
  334. }
  335. function getElementAbsoluteY2(element: ExcaliburElement) {
  336. return element.height >= 0 ? element.y + element.height : element.y;
  337. }
  338. function setSelection(selection: ExcaliburElement) {
  339. const selectionX1 = getElementAbsoluteX1(selection);
  340. const selectionX2 = getElementAbsoluteX2(selection);
  341. const selectionY1 = getElementAbsoluteY1(selection);
  342. const selectionY2 = getElementAbsoluteY2(selection);
  343. elements.forEach(element => {
  344. const elementX1 = getElementAbsoluteX1(element);
  345. const elementX2 = getElementAbsoluteX2(element);
  346. const elementY1 = getElementAbsoluteY1(element);
  347. const elementY2 = getElementAbsoluteY2(element);
  348. element.isSelected =
  349. element.type !== "selection" &&
  350. selectionX1 <= elementX1 &&
  351. selectionY1 <= elementY1 &&
  352. selectionX2 >= elementX2 &&
  353. selectionY2 >= elementY2;
  354. });
  355. }
  356. function clearSelection() {
  357. elements.forEach(element => {
  358. element.isSelected = false;
  359. });
  360. }
  361. function deleteSelectedElements() {
  362. for (var i = elements.length - 1; i >= 0; --i) {
  363. if (elements[i].isSelected) {
  364. elements.splice(i, 1);
  365. }
  366. }
  367. }
  368. type AppState = {
  369. draggingElement: ExcaliburElement | null;
  370. elementType: string;
  371. exportBackground: boolean;
  372. exportVisibleOnly: boolean;
  373. exportPadding: number;
  374. currentItemStrokeColor: string;
  375. currentItemBackgroundColor: string;
  376. viewBackgroundColor: string;
  377. };
  378. const KEYS = {
  379. ARROW_LEFT: "ArrowLeft",
  380. ARROW_RIGHT: "ArrowRight",
  381. ARROW_DOWN: "ArrowDown",
  382. ARROW_UP: "ArrowUp",
  383. ESCAPE: "Escape",
  384. DELETE: "Delete",
  385. BACKSPACE: "Backspace"
  386. };
  387. function isArrowKey(keyCode: string) {
  388. return (
  389. keyCode === KEYS.ARROW_LEFT ||
  390. keyCode === KEYS.ARROW_RIGHT ||
  391. keyCode === KEYS.ARROW_DOWN ||
  392. keyCode === KEYS.ARROW_UP
  393. );
  394. }
  395. const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
  396. const ELEMENT_TRANSLATE_AMOUNT = 1;
  397. class App extends React.Component<{}, AppState> {
  398. public componentDidMount() {
  399. document.addEventListener("keydown", this.onKeyDown, false);
  400. }
  401. public componentWillUnmount() {
  402. document.removeEventListener("keydown", this.onKeyDown, false);
  403. }
  404. public state: AppState = {
  405. draggingElement: null,
  406. elementType: "selection",
  407. exportBackground: false,
  408. exportVisibleOnly: true,
  409. exportPadding: 10,
  410. currentItemStrokeColor: "#000000",
  411. currentItemBackgroundColor: "#ffffff",
  412. viewBackgroundColor: "#ffffff"
  413. };
  414. private onKeyDown = (event: KeyboardEvent) => {
  415. if ((event.target as HTMLElement).nodeName === "INPUT") {
  416. return;
  417. }
  418. if (event.key === KEYS.ESCAPE) {
  419. clearSelection();
  420. this.forceUpdate();
  421. event.preventDefault();
  422. } else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) {
  423. deleteSelectedElements();
  424. this.forceUpdate();
  425. event.preventDefault();
  426. } else if (isArrowKey(event.key)) {
  427. const step = event.shiftKey
  428. ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
  429. : ELEMENT_TRANSLATE_AMOUNT;
  430. elements.forEach(element => {
  431. if (element.isSelected) {
  432. if (event.key === KEYS.ARROW_LEFT) element.x -= step;
  433. else if (event.key === KEYS.ARROW_RIGHT) element.x += step;
  434. else if (event.key === KEYS.ARROW_UP) element.y -= step;
  435. else if (event.key === KEYS.ARROW_DOWN) element.y += step;
  436. }
  437. });
  438. this.forceUpdate();
  439. event.preventDefault();
  440. } else if (event.key === "a" && event.metaKey) {
  441. elements.forEach(element => {
  442. element.isSelected = true;
  443. });
  444. this.forceUpdate();
  445. event.preventDefault();
  446. }
  447. };
  448. private renderOption({
  449. type,
  450. children
  451. }: {
  452. type: string;
  453. children: React.ReactNode;
  454. }) {
  455. return (
  456. <label>
  457. <input
  458. type="radio"
  459. checked={this.state.elementType === type}
  460. onChange={() => {
  461. this.setState({ elementType: type });
  462. clearSelection();
  463. this.forceUpdate();
  464. }}
  465. />
  466. {children}
  467. </label>
  468. );
  469. }
  470. public render() {
  471. return (
  472. <div
  473. onCut={e => {
  474. e.clipboardData.setData(
  475. "text/plain",
  476. JSON.stringify(elements.filter(element => element.isSelected))
  477. );
  478. deleteSelectedElements();
  479. this.forceUpdate();
  480. e.preventDefault();
  481. }}
  482. onCopy={e => {
  483. e.clipboardData.setData(
  484. "text/plain",
  485. JSON.stringify(elements.filter(element => element.isSelected))
  486. );
  487. e.preventDefault();
  488. }}
  489. onPaste={e => {
  490. const paste = e.clipboardData.getData("text");
  491. let parsedElements;
  492. try {
  493. parsedElements = JSON.parse(paste);
  494. } catch (e) {}
  495. if (
  496. Array.isArray(parsedElements) &&
  497. parsedElements.length > 0 &&
  498. parsedElements[0].type // need to implement a better check here...
  499. ) {
  500. clearSelection();
  501. parsedElements.forEach(parsedElement => {
  502. parsedElement.x += 10;
  503. parsedElement.y += 10;
  504. generateDraw(parsedElement);
  505. elements.push(parsedElement);
  506. });
  507. this.forceUpdate();
  508. }
  509. e.preventDefault();
  510. }}
  511. >
  512. <fieldset>
  513. <legend>Shapes</legend>
  514. {this.renderOption({ type: "rectangle", children: "Rectangle" })}
  515. {this.renderOption({ type: "ellipse", children: "Ellipse" })}
  516. {this.renderOption({ type: "arrow", children: "Arrow" })}
  517. {this.renderOption({ type: "text", children: "Text" })}
  518. {this.renderOption({ type: "selection", children: "Selection" })}
  519. </fieldset>
  520. <canvas
  521. id="canvas"
  522. width={window.innerWidth}
  523. height={window.innerHeight - 200}
  524. onMouseDown={e => {
  525. const x = e.clientX - (e.target as HTMLElement).offsetLeft;
  526. const y = e.clientY - (e.target as HTMLElement).offsetTop;
  527. const element = newElement(
  528. this.state.elementType,
  529. x,
  530. y,
  531. this.state.currentItemStrokeColor,
  532. this.state.currentItemBackgroundColor
  533. );
  534. let isDraggingElements = false;
  535. const cursorStyle = document.documentElement.style.cursor;
  536. if (this.state.elementType === "selection") {
  537. const hitElement = elements.find(element => {
  538. return hitTest(element, x, y);
  539. });
  540. // If we click on something
  541. if (hitElement) {
  542. if (hitElement.isSelected) {
  543. // If that element is not already selected, do nothing,
  544. // we're likely going to drag it
  545. } else {
  546. // We unselect every other elements unless shift is pressed
  547. if (!e.shiftKey) {
  548. clearSelection();
  549. }
  550. // No matter what, we select it
  551. hitElement.isSelected = true;
  552. }
  553. } else {
  554. // If we don't click on anything, let's remove all the selected elements
  555. clearSelection();
  556. }
  557. isDraggingElements = elements.some(element => element.isSelected);
  558. if (isDraggingElements) {
  559. document.documentElement.style.cursor = "move";
  560. }
  561. }
  562. if (isTextElement(element)) {
  563. const text = prompt("What text do you want?");
  564. if (text === null) {
  565. return;
  566. }
  567. element.text = text;
  568. element.font = "20px Virgil";
  569. const font = context.font;
  570. context.font = element.font;
  571. const {
  572. actualBoundingBoxAscent,
  573. actualBoundingBoxDescent,
  574. width
  575. } = context.measureText(element.text);
  576. element.actualBoundingBoxAscent = actualBoundingBoxAscent;
  577. context.font = font;
  578. const height = actualBoundingBoxAscent + actualBoundingBoxDescent;
  579. // Center the text
  580. element.x -= width / 2;
  581. element.y -= actualBoundingBoxAscent;
  582. element.width = width;
  583. element.height = height;
  584. }
  585. generateDraw(element);
  586. elements.push(element);
  587. if (this.state.elementType === "text") {
  588. this.setState({
  589. draggingElement: null,
  590. elementType: "selection"
  591. });
  592. element.isSelected = true;
  593. } else {
  594. this.setState({ draggingElement: element });
  595. }
  596. let lastX = x;
  597. let lastY = y;
  598. const onMouseMove = (e: MouseEvent) => {
  599. const target = e.target;
  600. if (!(target instanceof HTMLElement)) {
  601. return;
  602. }
  603. if (isDraggingElements) {
  604. const selectedElements = elements.filter(el => el.isSelected);
  605. if (selectedElements.length) {
  606. const x = e.clientX - target.offsetLeft;
  607. const y = e.clientY - target.offsetTop;
  608. selectedElements.forEach(element => {
  609. element.x += x - lastX;
  610. element.y += y - lastY;
  611. });
  612. lastX = x;
  613. lastY = y;
  614. this.forceUpdate();
  615. return;
  616. }
  617. }
  618. // It is very important to read this.state within each move event,
  619. // otherwise we would read a stale one!
  620. const draggingElement = this.state.draggingElement;
  621. if (!draggingElement) return;
  622. let width = e.clientX - target.offsetLeft - draggingElement.x;
  623. let height = e.clientY - target.offsetTop - draggingElement.y;
  624. draggingElement.width = width;
  625. // Make a perfect square or circle when shift is enabled
  626. draggingElement.height = e.shiftKey ? width : height;
  627. generateDraw(draggingElement);
  628. if (this.state.elementType === "selection") {
  629. setSelection(draggingElement);
  630. }
  631. this.forceUpdate();
  632. };
  633. const onMouseUp = (e: MouseEvent) => {
  634. const { draggingElement, elementType } = this.state;
  635. window.removeEventListener("mousemove", onMouseMove);
  636. window.removeEventListener("mouseup", onMouseUp);
  637. document.documentElement.style.cursor = cursorStyle;
  638. // if no element is clicked, clear the selection and redraw
  639. if (draggingElement === null) {
  640. clearSelection();
  641. this.forceUpdate();
  642. return;
  643. }
  644. if (elementType === "selection") {
  645. if (isDraggingElements) {
  646. isDraggingElements = false;
  647. }
  648. elements.pop();
  649. } else {
  650. draggingElement.isSelected = true;
  651. }
  652. this.setState({
  653. draggingElement: null,
  654. elementType: "selection"
  655. });
  656. this.forceUpdate();
  657. };
  658. window.addEventListener("mousemove", onMouseMove);
  659. window.addEventListener("mouseup", onMouseUp);
  660. this.forceUpdate();
  661. }}
  662. />
  663. <fieldset>
  664. <legend>Colors</legend>
  665. <label>
  666. <input
  667. type="color"
  668. value={this.state.viewBackgroundColor}
  669. onChange={e => {
  670. this.setState({ viewBackgroundColor: e.target.value });
  671. }}
  672. />
  673. Background
  674. </label>
  675. <label>
  676. <input
  677. type="color"
  678. value={this.state.currentItemStrokeColor}
  679. onChange={e => {
  680. this.setState({ currentItemStrokeColor: e.target.value });
  681. }}
  682. />
  683. Shape Stroke
  684. </label>
  685. <label>
  686. <input
  687. type="color"
  688. value={this.state.currentItemBackgroundColor}
  689. onChange={e => {
  690. this.setState({ currentItemBackgroundColor: e.target.value });
  691. }}
  692. />
  693. Shape Background
  694. </label>
  695. </fieldset>
  696. <fieldset>
  697. <legend>Export</legend>
  698. <button
  699. onClick={() => {
  700. exportAsPNG({
  701. exportBackground: this.state.exportBackground,
  702. exportVisibleOnly: this.state.exportVisibleOnly,
  703. exportPadding: this.state.exportPadding,
  704. viewBackgroundColor: this.state.viewBackgroundColor
  705. });
  706. }}
  707. >
  708. Export to png
  709. </button>
  710. <label>
  711. <input
  712. type="checkbox"
  713. checked={this.state.exportBackground}
  714. onChange={e => {
  715. this.setState({ exportBackground: e.target.checked });
  716. }}
  717. />
  718. background
  719. </label>
  720. <label>
  721. <input
  722. type="checkbox"
  723. checked={this.state.exportVisibleOnly}
  724. onChange={e => {
  725. this.setState({ exportVisibleOnly: e.target.checked });
  726. }}
  727. />
  728. visible area only
  729. </label>
  730. (padding:
  731. <input
  732. type="number"
  733. value={this.state.exportPadding}
  734. onChange={e => {
  735. this.setState({ exportPadding: Number(e.target.value) });
  736. }}
  737. disabled={!this.state.exportVisibleOnly}
  738. />
  739. px)
  740. </fieldset>
  741. </div>
  742. );
  743. }
  744. componentDidUpdate() {
  745. renderScene(rc, context, this.state.viewBackgroundColor);
  746. }
  747. }
  748. const rootElement = document.getElementById("root");
  749. ReactDOM.render(<App />, rootElement);
  750. const canvas = document.getElementById("canvas") as HTMLCanvasElement;
  751. const rc = rough.canvas(canvas);
  752. const context = canvas.getContext("2d")!;
  753. // Big hack to ensure that all the 1px lines are drawn at 1px instead of 2px
  754. // https://stackoverflow.com/questions/13879322/drawing-a-1px-thick-line-in-canvas-creates-a-2px-thick-line/13879402#comment90766599_13879402
  755. context.translate(0.5, 0.5);
  756. ReactDOM.render(<App />, rootElement);