index.tsx 36 KB

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