index.tsx 31 KB

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