App.tsx 84 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882
  1. import React from "react";
  2. import rough from "roughjs/bin/rough";
  3. import { RoughCanvas } from "roughjs/bin/canvas";
  4. import { simplify, Point } from "points-on-curve";
  5. import { FlooredNumber, SocketUpdateData } from "../types";
  6. import {
  7. newElement,
  8. newTextElement,
  9. duplicateElement,
  10. resizeTest,
  11. isInvisiblySmallElement,
  12. isTextElement,
  13. textWysiwyg,
  14. getCommonBounds,
  15. getCursorForResizingElement,
  16. getPerfectElementSize,
  17. normalizeDimensions,
  18. getElementMap,
  19. getDrawingVersion,
  20. getSyncableElements,
  21. newLinearElement,
  22. resizeElements,
  23. getElementWithResizeHandler,
  24. canResizeMutlipleElements,
  25. getResizeOffsetXY,
  26. getResizeArrowDirection,
  27. getResizeHandlerFromCoords,
  28. isNonDeletedElement,
  29. } from "../element";
  30. import {
  31. deleteSelectedElements,
  32. getElementsWithinSelection,
  33. isOverScrollBars,
  34. getElementAtPosition,
  35. getElementContainingPosition,
  36. getNormalizedZoom,
  37. getSelectedElements,
  38. globalSceneState,
  39. isSomeElementSelected,
  40. calculateScrollCenter,
  41. } from "../scene";
  42. import {
  43. decryptAESGEM,
  44. saveToLocalStorage,
  45. loadScene,
  46. loadFromBlob,
  47. SOCKET_SERVER,
  48. SocketUpdateDataSource,
  49. exportCanvas,
  50. } from "../data";
  51. import Portal from "./Portal";
  52. import { renderScene } from "../renderer";
  53. import { AppState, GestureEvent, Gesture } from "../types";
  54. import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
  55. import { distance2d, isPathALoop } from "../math";
  56. import {
  57. isWritableElement,
  58. isInputLike,
  59. isToolIcon,
  60. debounce,
  61. distance,
  62. resetCursor,
  63. viewportCoordsToSceneCoords,
  64. sceneCoordsToViewportCoords,
  65. setCursorForShape,
  66. } from "../utils";
  67. import {
  68. KEYS,
  69. isArrowKey,
  70. getResizeCenterPointKey,
  71. getResizeWithSidesSameLengthKey,
  72. } from "../keys";
  73. import { findShapeByKey, shapesShortcutKeys } from "../shapes";
  74. import { createHistory, SceneHistory } from "../history";
  75. import ContextMenu from "./ContextMenu";
  76. import { ActionManager } from "../actions/manager";
  77. import "../actions";
  78. import { actions } from "../actions/register";
  79. import { ActionResult } from "../actions/types";
  80. import { getDefaultAppState } from "../appState";
  81. import { t } from "../i18n";
  82. import {
  83. copyToAppClipboard,
  84. getClipboardContent,
  85. probablySupportsClipboardBlob,
  86. probablySupportsClipboardWriteText,
  87. } from "../clipboard";
  88. import { normalizeScroll } from "../scene";
  89. import { getCenter, getDistance } from "../gesture";
  90. import { createUndoAction, createRedoAction } from "../actions/actionHistory";
  91. import {
  92. CURSOR_TYPE,
  93. ELEMENT_SHIFT_TRANSLATE_AMOUNT,
  94. ELEMENT_TRANSLATE_AMOUNT,
  95. POINTER_BUTTON,
  96. DRAGGING_THRESHOLD,
  97. TEXT_TO_CENTER_SNAP_THRESHOLD,
  98. LINE_CONFIRM_THRESHOLD,
  99. SCENE,
  100. EVENT,
  101. ENV,
  102. } from "../constants";
  103. import {
  104. INITAL_SCENE_UPDATE_TIMEOUT,
  105. TAP_TWICE_TIMEOUT,
  106. } from "../time_constants";
  107. import LayerUI from "./LayerUI";
  108. import { ScrollBars, SceneState } from "../scene/types";
  109. import { generateCollaborationLink, getCollaborationLinkData } from "../data";
  110. import { mutateElement, newElementWith } from "../element/mutateElement";
  111. import { invalidateShapeForElement } from "../renderer/renderElement";
  112. import { unstable_batchedUpdates } from "react-dom";
  113. import { SceneStateCallbackRemover } from "../scene/globalScene";
  114. import { isLinearElement } from "../element/typeChecks";
  115. import { actionFinalize } from "../actions";
  116. import {
  117. restoreUsernameFromLocalStorage,
  118. saveUsernameToLocalStorage,
  119. } from "../data/localStorage";
  120. import throttle from "lodash.throttle";
  121. import {
  122. getSelectedGroupIds,
  123. selectGroupsForSelectedElements,
  124. isElementInGroup,
  125. getSelectedGroupIdForElement,
  126. } from "../groups";
  127. /**
  128. * @param func handler taking at most single parameter (event).
  129. */
  130. const withBatchedUpdates = <
  131. TFunction extends ((event: any) => void) | (() => void)
  132. >(
  133. func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
  134. ) =>
  135. ((event) => {
  136. unstable_batchedUpdates(func as TFunction, event);
  137. }) as TFunction;
  138. const { history } = createHistory();
  139. let didTapTwice: boolean = false;
  140. let tappedTwiceTimer = 0;
  141. let cursorX = 0;
  142. let cursorY = 0;
  143. let isHoldingSpace: boolean = false;
  144. let isPanning: boolean = false;
  145. let isDraggingScrollBar: boolean = false;
  146. let currentScrollBars: ScrollBars = { horizontal: null, vertical: null };
  147. let lastPointerUp: ((event: any) => void) | null = null;
  148. const gesture: Gesture = {
  149. pointers: new Map(),
  150. lastCenter: null,
  151. initialDistance: null,
  152. initialScale: null,
  153. };
  154. const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
  155. class App extends React.Component<any, AppState> {
  156. canvas: HTMLCanvasElement | null = null;
  157. rc: RoughCanvas | null = null;
  158. portal: Portal = new Portal(this);
  159. lastBroadcastedOrReceivedSceneVersion: number = -1;
  160. broadcastedElementVersions: Map<string, number> = new Map();
  161. removeSceneCallback: SceneStateCallbackRemover | null = null;
  162. actionManager: ActionManager;
  163. canvasOnlyActions = ["selectAll"];
  164. public state: AppState = {
  165. ...getDefaultAppState(),
  166. isLoading: true,
  167. };
  168. constructor(props: any) {
  169. super(props);
  170. this.actionManager = new ActionManager(
  171. this.syncActionResult,
  172. () => this.state,
  173. () => globalSceneState.getElementsIncludingDeleted(),
  174. );
  175. this.actionManager.registerAll(actions);
  176. this.actionManager.registerAction(createUndoAction(history));
  177. this.actionManager.registerAction(createRedoAction(history));
  178. }
  179. public render() {
  180. const { zenModeEnabled } = this.state;
  181. const canvasDOMWidth = window.innerWidth;
  182. const canvasDOMHeight = window.innerHeight;
  183. const canvasScale = window.devicePixelRatio;
  184. const canvasWidth = canvasDOMWidth * canvasScale;
  185. const canvasHeight = canvasDOMHeight * canvasScale;
  186. return (
  187. <div className="container">
  188. <LayerUI
  189. canvas={this.canvas}
  190. appState={this.state}
  191. setAppState={this.setAppState}
  192. actionManager={this.actionManager}
  193. elements={globalSceneState.getElements()}
  194. onRoomCreate={this.openPortal}
  195. onRoomDestroy={this.closePortal}
  196. onUsernameChange={(username) => {
  197. saveUsernameToLocalStorage(username);
  198. this.setState({
  199. username,
  200. });
  201. }}
  202. onLockToggle={this.toggleLock}
  203. zenModeEnabled={zenModeEnabled}
  204. toggleZenMode={this.toggleZenMode}
  205. />
  206. <main>
  207. <canvas
  208. id="canvas"
  209. style={{
  210. width: canvasDOMWidth,
  211. height: canvasDOMHeight,
  212. }}
  213. width={canvasWidth}
  214. height={canvasHeight}
  215. ref={this.handleCanvasRef}
  216. onContextMenu={this.handleCanvasContextMenu}
  217. onPointerDown={this.handleCanvasPointerDown}
  218. onDoubleClick={this.handleCanvasDoubleClick}
  219. onPointerMove={this.handleCanvasPointerMove}
  220. onPointerUp={this.removePointer}
  221. onPointerCancel={this.removePointer}
  222. onDrop={this.handleCanvasOnDrop}
  223. >
  224. {t("labels.drawingCanvas")}
  225. </canvas>
  226. </main>
  227. </div>
  228. );
  229. }
  230. private syncActionResult = withBatchedUpdates((res: ActionResult) => {
  231. if (this.unmounted) {
  232. return;
  233. }
  234. let editingElement: AppState["editingElement"] | null = null;
  235. if (res.elements) {
  236. res.elements.forEach((element) => {
  237. if (
  238. this.state.editingElement?.id === element.id &&
  239. this.state.editingElement !== element &&
  240. isNonDeletedElement(element)
  241. ) {
  242. editingElement = element;
  243. }
  244. });
  245. globalSceneState.replaceAllElements(res.elements);
  246. if (res.commitToHistory) {
  247. history.resumeRecording();
  248. }
  249. }
  250. if (res.appState || editingElement) {
  251. if (res.commitToHistory) {
  252. history.resumeRecording();
  253. }
  254. this.setState(
  255. (state) => ({
  256. ...res.appState,
  257. editingElement:
  258. editingElement || res.appState?.editingElement || null,
  259. isCollaborating: state.isCollaborating,
  260. collaborators: state.collaborators,
  261. }),
  262. () => {
  263. if (res.syncHistory) {
  264. history.setCurrentState(
  265. this.state,
  266. globalSceneState.getElementsIncludingDeleted(),
  267. );
  268. }
  269. },
  270. );
  271. }
  272. });
  273. // Lifecycle
  274. private onBlur = withBatchedUpdates(() => {
  275. isHoldingSpace = false;
  276. this.saveDebounced();
  277. this.saveDebounced.flush();
  278. });
  279. private onUnload = () => {
  280. this.destroySocketClient();
  281. this.onBlur();
  282. };
  283. private disableEvent: EventHandlerNonNull = (event) => {
  284. event.preventDefault();
  285. };
  286. private onFontLoaded = () => {
  287. globalSceneState.getElementsIncludingDeleted().forEach((element) => {
  288. if (isTextElement(element)) {
  289. invalidateShapeForElement(element);
  290. }
  291. });
  292. this.onSceneUpdated();
  293. };
  294. private initializeScene = async () => {
  295. const searchParams = new URLSearchParams(window.location.search);
  296. const id = searchParams.get("id");
  297. const jsonMatch = window.location.hash.match(
  298. /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
  299. );
  300. const isCollaborationScene = getCollaborationLinkData(window.location.href);
  301. if (!isCollaborationScene) {
  302. let scene: ResolutionType<typeof loadScene> | undefined;
  303. // Backwards compatibility with legacy url format
  304. if (id) {
  305. scene = await loadScene(id);
  306. } else if (jsonMatch) {
  307. scene = await loadScene(jsonMatch[1], jsonMatch[2]);
  308. } else {
  309. scene = await loadScene(null);
  310. }
  311. if (scene) {
  312. this.syncActionResult(scene);
  313. }
  314. }
  315. if (this.state.isLoading) {
  316. this.setState({ isLoading: false });
  317. }
  318. // run this last else the `isLoading` state
  319. if (isCollaborationScene) {
  320. this.initializeSocketClient({ showLoadingState: true });
  321. }
  322. };
  323. private unmounted = false;
  324. public async componentDidMount() {
  325. if (
  326. process.env.NODE_ENV === "test" ||
  327. process.env.NODE_ENV === "development"
  328. ) {
  329. const setState = this.setState.bind(this);
  330. Object.defineProperties(window.h, {
  331. state: {
  332. configurable: true,
  333. get: () => {
  334. return this.state;
  335. },
  336. },
  337. setState: {
  338. configurable: true,
  339. value: (...args: Parameters<typeof setState>) => {
  340. return this.setState(...args);
  341. },
  342. },
  343. app: {
  344. configurable: true,
  345. value: this,
  346. },
  347. });
  348. }
  349. this.removeSceneCallback = globalSceneState.addCallback(
  350. this.onSceneUpdated,
  351. );
  352. document.addEventListener(EVENT.COPY, this.onCopy);
  353. document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
  354. document.addEventListener(EVENT.CUT, this.onCut);
  355. document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
  356. document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true });
  357. document.addEventListener(
  358. EVENT.MOUSE_MOVE,
  359. this.updateCurrentCursorPosition,
  360. );
  361. window.addEventListener(EVENT.RESIZE, this.onResize, false);
  362. window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
  363. window.addEventListener(EVENT.BLUR, this.onBlur, false);
  364. window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
  365. window.addEventListener(EVENT.DROP, this.disableEvent, false);
  366. // rerender text elements on font load to fix #637 && #1553
  367. document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
  368. // Safari-only desktop pinch zoom
  369. document.addEventListener(
  370. EVENT.GESTURE_START,
  371. this.onGestureStart as any,
  372. false,
  373. );
  374. document.addEventListener(
  375. EVENT.GESTURE_CHANGE,
  376. this.onGestureChange as any,
  377. false,
  378. );
  379. document.addEventListener(
  380. EVENT.GESTURE_END,
  381. this.onGestureEnd as any,
  382. false,
  383. );
  384. window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
  385. this.initializeScene();
  386. }
  387. public componentWillUnmount() {
  388. this.unmounted = true;
  389. this.removeSceneCallback!();
  390. document.removeEventListener(EVENT.COPY, this.onCopy);
  391. document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
  392. document.removeEventListener(EVENT.CUT, this.onCut);
  393. document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
  394. document.removeEventListener(
  395. EVENT.MOUSE_MOVE,
  396. this.updateCurrentCursorPosition,
  397. false,
  398. );
  399. document.removeEventListener(EVENT.KEYUP, this.onKeyUp);
  400. window.removeEventListener(EVENT.RESIZE, this.onResize, false);
  401. window.removeEventListener(EVENT.UNLOAD, this.onUnload, false);
  402. window.removeEventListener(EVENT.BLUR, this.onBlur, false);
  403. window.removeEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
  404. window.removeEventListener(EVENT.DROP, this.disableEvent, false);
  405. document.removeEventListener(
  406. EVENT.GESTURE_START,
  407. this.onGestureStart as any,
  408. false,
  409. );
  410. document.removeEventListener(
  411. EVENT.GESTURE_CHANGE,
  412. this.onGestureChange as any,
  413. false,
  414. );
  415. document.removeEventListener(
  416. EVENT.GESTURE_END,
  417. this.onGestureEnd as any,
  418. false,
  419. );
  420. window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
  421. }
  422. private onResize = withBatchedUpdates(() => {
  423. globalSceneState
  424. .getElementsIncludingDeleted()
  425. .forEach((element) => invalidateShapeForElement(element));
  426. this.setState({});
  427. });
  428. private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
  429. if (
  430. this.state.isCollaborating &&
  431. globalSceneState.getElements().length > 0
  432. ) {
  433. event.preventDefault();
  434. // NOTE: modern browsers no longer allow showing a custom message here
  435. event.returnValue = "";
  436. }
  437. });
  438. queueBroadcastAllElements = throttle(() => {
  439. this.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
  440. }, SYNC_FULL_SCENE_INTERVAL_MS);
  441. componentDidUpdate() {
  442. if (this.state.isCollaborating && !this.portal.socket) {
  443. this.initializeSocketClient({ showLoadingState: true });
  444. }
  445. const cursorButton: {
  446. [id: string]: string | undefined;
  447. } = {};
  448. const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
  449. const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
  450. const pointerUsernames: { [id: string]: string } = {};
  451. this.state.collaborators.forEach((user, socketID) => {
  452. if (user.selectedElementIds) {
  453. for (const id of Object.keys(user.selectedElementIds)) {
  454. if (!(id in remoteSelectedElementIds)) {
  455. remoteSelectedElementIds[id] = [];
  456. }
  457. remoteSelectedElementIds[id].push(socketID);
  458. }
  459. }
  460. if (!user.pointer) {
  461. return;
  462. }
  463. if (user.username) {
  464. pointerUsernames[socketID] = user.username;
  465. }
  466. pointerViewportCoords[socketID] = sceneCoordsToViewportCoords(
  467. {
  468. sceneX: user.pointer.x,
  469. sceneY: user.pointer.y,
  470. },
  471. this.state,
  472. this.canvas,
  473. window.devicePixelRatio,
  474. );
  475. cursorButton[socketID] = user.button;
  476. });
  477. const elements = globalSceneState.getElements();
  478. const { atLeastOneVisibleElement, scrollBars } = renderScene(
  479. elements.filter((element) => {
  480. // don't render text element that's being currently edited (it's
  481. // rendered on remote only)
  482. return (
  483. !this.state.editingElement ||
  484. this.state.editingElement.type !== "text" ||
  485. element.id !== this.state.editingElement.id
  486. );
  487. }),
  488. this.state,
  489. this.state.selectionElement,
  490. window.devicePixelRatio,
  491. this.rc!,
  492. this.canvas!,
  493. {
  494. scrollX: this.state.scrollX,
  495. scrollY: this.state.scrollY,
  496. viewBackgroundColor: this.state.viewBackgroundColor,
  497. zoom: this.state.zoom,
  498. remotePointerViewportCoords: pointerViewportCoords,
  499. remotePointerButton: cursorButton,
  500. remoteSelectedElementIds: remoteSelectedElementIds,
  501. remotePointerUsernames: pointerUsernames,
  502. shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
  503. },
  504. {
  505. renderOptimizations: true,
  506. },
  507. );
  508. if (scrollBars) {
  509. currentScrollBars = scrollBars;
  510. }
  511. const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
  512. if (this.state.scrolledOutside !== scrolledOutside) {
  513. this.setState({ scrolledOutside: scrolledOutside });
  514. }
  515. this.saveDebounced();
  516. if (
  517. getDrawingVersion(globalSceneState.getElementsIncludingDeleted()) >
  518. this.lastBroadcastedOrReceivedSceneVersion
  519. ) {
  520. this.broadcastScene(SCENE.UPDATE, /* syncAll */ false);
  521. this.queueBroadcastAllElements();
  522. }
  523. history.record(this.state, globalSceneState.getElementsIncludingDeleted());
  524. }
  525. // Copy/paste
  526. private onCut = withBatchedUpdates((event: ClipboardEvent) => {
  527. if (isWritableElement(event.target)) {
  528. return;
  529. }
  530. this.copyAll();
  531. const { elements: nextElements, appState } = deleteSelectedElements(
  532. globalSceneState.getElementsIncludingDeleted(),
  533. this.state,
  534. );
  535. globalSceneState.replaceAllElements(nextElements);
  536. history.resumeRecording();
  537. this.setState({ ...appState });
  538. event.preventDefault();
  539. });
  540. private onCopy = withBatchedUpdates((event: ClipboardEvent) => {
  541. if (isWritableElement(event.target)) {
  542. return;
  543. }
  544. this.copyAll();
  545. event.preventDefault();
  546. });
  547. private copyAll = () => {
  548. copyToAppClipboard(globalSceneState.getElements(), this.state);
  549. };
  550. private copyToClipboardAsPng = () => {
  551. const elements = globalSceneState.getElements();
  552. const selectedElements = getSelectedElements(elements, this.state);
  553. exportCanvas(
  554. "clipboard",
  555. selectedElements.length ? selectedElements : elements,
  556. this.state,
  557. this.canvas!,
  558. this.state,
  559. );
  560. };
  561. private copyToClipboardAsSvg = () => {
  562. const selectedElements = getSelectedElements(
  563. globalSceneState.getElements(),
  564. this.state,
  565. );
  566. exportCanvas(
  567. "clipboard-svg",
  568. selectedElements.length
  569. ? selectedElements
  570. : globalSceneState.getElements(),
  571. this.state,
  572. this.canvas!,
  573. this.state,
  574. );
  575. };
  576. private resetTapTwice() {
  577. didTapTwice = false;
  578. }
  579. private onTapStart = (event: TouchEvent) => {
  580. if (!didTapTwice) {
  581. didTapTwice = true;
  582. clearTimeout(tappedTwiceTimer);
  583. tappedTwiceTimer = window.setTimeout(
  584. this.resetTapTwice,
  585. TAP_TWICE_TIMEOUT,
  586. );
  587. return;
  588. }
  589. // insert text only if we tapped twice with a single finger
  590. // event.touches.length === 1 will also prevent inserting text when user's zooming
  591. if (didTapTwice && event.touches.length === 1) {
  592. const [touch] = event.touches;
  593. // @ts-ignore
  594. this.handleCanvasDoubleClick({
  595. clientX: touch.clientX,
  596. clientY: touch.clientY,
  597. });
  598. didTapTwice = false;
  599. clearTimeout(tappedTwiceTimer);
  600. }
  601. event.preventDefault();
  602. };
  603. private pasteFromClipboard = withBatchedUpdates(
  604. async (event: ClipboardEvent | null) => {
  605. // #686
  606. const target = document.activeElement;
  607. const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
  608. if (
  609. // if no ClipboardEvent supplied, assume we're pasting via contextMenu
  610. // thus these checks don't make sense
  611. event &&
  612. (!(elementUnderCursor instanceof HTMLCanvasElement) ||
  613. isWritableElement(target))
  614. ) {
  615. return;
  616. }
  617. const data = await getClipboardContent(event);
  618. if (data.elements) {
  619. this.addElementsFromPaste(data.elements);
  620. } else if (data.text) {
  621. this.addTextFromPaste(data.text);
  622. }
  623. this.selectShapeTool("selection");
  624. event?.preventDefault();
  625. },
  626. );
  627. private addElementsFromPaste = (
  628. clipboardElements: readonly ExcalidrawElement[],
  629. ) => {
  630. const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements);
  631. const elementsCenterX = distance(minX, maxX) / 2;
  632. const elementsCenterY = distance(minY, maxY) / 2;
  633. const { x, y } = viewportCoordsToSceneCoords(
  634. { clientX: cursorX, clientY: cursorY },
  635. this.state,
  636. this.canvas,
  637. window.devicePixelRatio,
  638. );
  639. const dx = x - elementsCenterX;
  640. const dy = y - elementsCenterY;
  641. const groupIdMap = new Map();
  642. const newElements = clipboardElements.map((element) =>
  643. duplicateElement(this.state.editingGroupId, groupIdMap, element, {
  644. x: element.x + dx - minX,
  645. y: element.y + dy - minY,
  646. }),
  647. );
  648. globalSceneState.replaceAllElements([
  649. ...globalSceneState.getElementsIncludingDeleted(),
  650. ...newElements,
  651. ]);
  652. history.resumeRecording();
  653. this.setState({
  654. selectedElementIds: newElements.reduce((map, element) => {
  655. map[element.id] = true;
  656. return map;
  657. }, {} as any),
  658. });
  659. };
  660. private addTextFromPaste(text: any) {
  661. const { x, y } = viewportCoordsToSceneCoords(
  662. { clientX: cursorX, clientY: cursorY },
  663. this.state,
  664. this.canvas,
  665. window.devicePixelRatio,
  666. );
  667. const element = newTextElement({
  668. x: x,
  669. y: y,
  670. strokeColor: this.state.currentItemStrokeColor,
  671. backgroundColor: this.state.currentItemBackgroundColor,
  672. fillStyle: this.state.currentItemFillStyle,
  673. strokeWidth: this.state.currentItemStrokeWidth,
  674. strokeStyle: this.state.currentItemStrokeStyle,
  675. roughness: this.state.currentItemRoughness,
  676. opacity: this.state.currentItemOpacity,
  677. text: text,
  678. font: this.state.currentItemFont,
  679. textAlign: this.state.currentItemTextAlign,
  680. });
  681. globalSceneState.replaceAllElements([
  682. ...globalSceneState.getElementsIncludingDeleted(),
  683. element,
  684. ]);
  685. this.setState({ selectedElementIds: { [element.id]: true } });
  686. history.resumeRecording();
  687. }
  688. // Collaboration
  689. setAppState = (obj: any) => {
  690. this.setState(obj);
  691. };
  692. removePointer = (event: React.PointerEvent<HTMLElement>) => {
  693. gesture.pointers.delete(event.pointerId);
  694. };
  695. openPortal = async () => {
  696. window.history.pushState(
  697. {},
  698. "Excalidraw",
  699. await generateCollaborationLink(),
  700. );
  701. this.initializeSocketClient({ showLoadingState: false });
  702. };
  703. closePortal = () => {
  704. window.history.pushState({}, "Excalidraw", window.location.origin);
  705. this.destroySocketClient();
  706. };
  707. toggleLock = () => {
  708. this.setState((prevState) => ({
  709. elementLocked: !prevState.elementLocked,
  710. elementType: prevState.elementLocked
  711. ? "selection"
  712. : prevState.elementType,
  713. }));
  714. };
  715. toggleZenMode = () => {
  716. this.setState({
  717. zenModeEnabled: !this.state.zenModeEnabled,
  718. });
  719. };
  720. private destroySocketClient = () => {
  721. this.setState({
  722. isCollaborating: false,
  723. collaborators: new Map(),
  724. });
  725. this.portal.close();
  726. };
  727. private initializeSocketClient = async (opts: {
  728. showLoadingState: boolean;
  729. }) => {
  730. if (this.portal.socket) {
  731. return;
  732. }
  733. const roomMatch = getCollaborationLinkData(window.location.href);
  734. if (roomMatch) {
  735. const initialize = () => {
  736. this.portal.socketInitialized = true;
  737. clearTimeout(initializationTimer);
  738. if (this.state.isLoading && !this.unmounted) {
  739. this.setState({ isLoading: false });
  740. }
  741. };
  742. // fallback in case you're not alone in the room but still don't receive
  743. // initial SCENE_UPDATE message
  744. const initializationTimer = setTimeout(
  745. initialize,
  746. INITAL_SCENE_UPDATE_TIMEOUT,
  747. );
  748. const updateScene = (
  749. decryptedData: SocketUpdateDataSource[SCENE.INIT | SCENE.UPDATE],
  750. { scrollToContent = false }: { scrollToContent?: boolean } = {},
  751. ) => {
  752. const { elements: remoteElements } = decryptedData.payload;
  753. if (scrollToContent) {
  754. this.setState({
  755. ...this.state,
  756. ...calculateScrollCenter(
  757. remoteElements.filter((element: { isDeleted: boolean }) => {
  758. return !element.isDeleted;
  759. }),
  760. ),
  761. });
  762. }
  763. // Perform reconciliation - in collaboration, if we encounter
  764. // elements with more staler versions than ours, ignore them
  765. // and keep ours.
  766. if (
  767. globalSceneState.getElementsIncludingDeleted() == null ||
  768. globalSceneState.getElementsIncludingDeleted().length === 0
  769. ) {
  770. globalSceneState.replaceAllElements(remoteElements);
  771. } else {
  772. // create a map of ids so we don't have to iterate
  773. // over the array more than once.
  774. const localElementMap = getElementMap(
  775. globalSceneState.getElementsIncludingDeleted(),
  776. );
  777. // Reconcile
  778. const newElements = remoteElements
  779. .reduce((elements, element) => {
  780. // if the remote element references one that's currently
  781. // edited on local, skip it (it'll be added in the next
  782. // step)
  783. if (
  784. element.id === this.state.editingElement?.id ||
  785. element.id === this.state.resizingElement?.id ||
  786. element.id === this.state.draggingElement?.id
  787. ) {
  788. return elements;
  789. }
  790. if (
  791. localElementMap.hasOwnProperty(element.id) &&
  792. localElementMap[element.id].version > element.version
  793. ) {
  794. elements.push(localElementMap[element.id]);
  795. delete localElementMap[element.id];
  796. } else if (
  797. localElementMap.hasOwnProperty(element.id) &&
  798. localElementMap[element.id].version === element.version &&
  799. localElementMap[element.id].versionNonce !==
  800. element.versionNonce
  801. ) {
  802. // resolve conflicting edits deterministically by taking the one with the lowest versionNonce
  803. if (
  804. localElementMap[element.id].versionNonce <
  805. element.versionNonce
  806. ) {
  807. elements.push(localElementMap[element.id]);
  808. } else {
  809. // it should be highly unlikely that the two versionNonces are the same. if we are
  810. // really worried about this, we can replace the versionNonce with the socket id.
  811. elements.push(element);
  812. }
  813. delete localElementMap[element.id];
  814. } else {
  815. elements.push(element);
  816. delete localElementMap[element.id];
  817. }
  818. return elements;
  819. }, [] as Mutable<typeof remoteElements>)
  820. // add local elements that weren't deleted or on remote
  821. .concat(...Object.values(localElementMap));
  822. // Avoid broadcasting to the rest of the collaborators the scene
  823. // we just received!
  824. // Note: this needs to be set before replaceAllElements as it
  825. // syncronously calls render.
  826. this.lastBroadcastedOrReceivedSceneVersion = getDrawingVersion(
  827. newElements,
  828. );
  829. globalSceneState.replaceAllElements(newElements);
  830. }
  831. // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
  832. // when we receive any messages from another peer. This UX can be pretty rough -- if you
  833. // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
  834. // right now we think this is the right tradeoff.
  835. history.clear();
  836. if (this.portal.socketInitialized === false) {
  837. initialize();
  838. }
  839. };
  840. const { default: socketIOClient }: any = await import(
  841. /* webpackChunkName: "socketIoClient" */ "socket.io-client"
  842. );
  843. this.portal.open(
  844. socketIOClient(SOCKET_SERVER),
  845. roomMatch[1],
  846. roomMatch[2],
  847. );
  848. // All socket listeners are moving to Portal
  849. this.portal.socket!.on(
  850. "client-broadcast",
  851. async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
  852. if (!this.portal.roomKey) {
  853. return;
  854. }
  855. const decryptedData = await decryptAESGEM(
  856. encryptedData,
  857. this.portal.roomKey,
  858. iv,
  859. );
  860. switch (decryptedData.type) {
  861. case "INVALID_RESPONSE":
  862. return;
  863. case SCENE.INIT: {
  864. if (!this.portal.socketInitialized) {
  865. updateScene(decryptedData, { scrollToContent: true });
  866. }
  867. break;
  868. }
  869. case SCENE.UPDATE:
  870. updateScene(decryptedData);
  871. break;
  872. case "MOUSE_LOCATION": {
  873. const {
  874. socketID,
  875. pointerCoords,
  876. button,
  877. username,
  878. selectedElementIds,
  879. } = decryptedData.payload;
  880. this.setState((state) => {
  881. if (!state.collaborators.has(socketID)) {
  882. state.collaborators.set(socketID, {});
  883. }
  884. const user = state.collaborators.get(socketID)!;
  885. user.pointer = pointerCoords;
  886. user.button = button;
  887. user.selectedElementIds = selectedElementIds;
  888. user.username = username;
  889. state.collaborators.set(socketID, user);
  890. return state;
  891. });
  892. break;
  893. }
  894. }
  895. },
  896. );
  897. this.portal.socket!.on("first-in-room", () => {
  898. if (this.portal.socket) {
  899. this.portal.socket.off("first-in-room");
  900. }
  901. initialize();
  902. });
  903. this.setState({
  904. isCollaborating: true,
  905. isLoading: opts.showLoadingState ? true : this.state.isLoading,
  906. });
  907. }
  908. };
  909. // Portal-only
  910. setCollaborators(sockets: string[]) {
  911. this.setState((state) => {
  912. const collaborators: typeof state.collaborators = new Map();
  913. for (const socketID of sockets) {
  914. if (state.collaborators.has(socketID)) {
  915. collaborators.set(socketID, state.collaborators.get(socketID)!);
  916. } else {
  917. collaborators.set(socketID, {});
  918. }
  919. }
  920. return {
  921. ...state,
  922. collaborators,
  923. };
  924. });
  925. }
  926. private broadcastMouseLocation = (payload: {
  927. pointerCoords: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointerCoords"];
  928. button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
  929. }) => {
  930. if (this.portal.socket?.id) {
  931. const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
  932. type: "MOUSE_LOCATION",
  933. payload: {
  934. socketID: this.portal.socket.id,
  935. pointerCoords: payload.pointerCoords,
  936. button: payload.button || "up",
  937. selectedElementIds: this.state.selectedElementIds,
  938. username: this.state.username,
  939. },
  940. };
  941. return this.portal._broadcastSocketData(
  942. data as SocketUpdateData,
  943. true, // volatile
  944. );
  945. }
  946. };
  947. // maybe should move to Portal
  948. broadcastScene = (sceneType: SCENE.INIT | SCENE.UPDATE, syncAll: boolean) => {
  949. if (sceneType === SCENE.INIT && !syncAll) {
  950. throw new Error("syncAll must be true when sending SCENE.INIT");
  951. }
  952. let syncableElements = getSyncableElements(
  953. globalSceneState.getElementsIncludingDeleted(),
  954. );
  955. if (!syncAll) {
  956. // sync out only the elements we think we need to to save bandwidth.
  957. // periodically we'll resync the whole thing to make sure no one diverges
  958. // due to a dropped message (server goes down etc).
  959. syncableElements = syncableElements.filter(
  960. (syncableElement) =>
  961. !this.broadcastedElementVersions.has(syncableElement.id) ||
  962. syncableElement.version >
  963. this.broadcastedElementVersions.get(syncableElement.id)!,
  964. );
  965. }
  966. const data: SocketUpdateDataSource[typeof sceneType] = {
  967. type: sceneType,
  968. payload: {
  969. elements: syncableElements,
  970. },
  971. };
  972. this.lastBroadcastedOrReceivedSceneVersion = Math.max(
  973. this.lastBroadcastedOrReceivedSceneVersion,
  974. getDrawingVersion(globalSceneState.getElementsIncludingDeleted()),
  975. );
  976. for (const syncableElement of syncableElements) {
  977. this.broadcastedElementVersions.set(
  978. syncableElement.id,
  979. syncableElement.version,
  980. );
  981. }
  982. return this.portal._broadcastSocketData(data as SocketUpdateData);
  983. };
  984. private onSceneUpdated = () => {
  985. this.setState({});
  986. };
  987. private updateCurrentCursorPosition = withBatchedUpdates(
  988. (event: MouseEvent) => {
  989. cursorX = event.x;
  990. cursorY = event.y;
  991. },
  992. );
  993. restoreUserName() {
  994. const username = restoreUsernameFromLocalStorage();
  995. if (username !== null) {
  996. this.setState({
  997. username,
  998. });
  999. }
  1000. }
  1001. // Input handling
  1002. private onKeyDown = withBatchedUpdates((event: KeyboardEvent) => {
  1003. // ensures we don't prevent devTools select-element feature
  1004. if (event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "C") {
  1005. return;
  1006. }
  1007. if (
  1008. (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) ||
  1009. // case: using arrows to move between buttons
  1010. (isArrowKey(event.key) && isInputLike(event.target))
  1011. ) {
  1012. return;
  1013. }
  1014. if (event.key === KEYS.QUESTION_MARK) {
  1015. this.setState({
  1016. showShortcutsDialog: true,
  1017. });
  1018. }
  1019. if (
  1020. !event[KEYS.CTRL_OR_CMD] &&
  1021. event.altKey &&
  1022. event.keyCode === KEYS.Z_KEY_CODE
  1023. ) {
  1024. this.toggleZenMode();
  1025. }
  1026. if (event.code === "KeyC" && event.altKey && event.shiftKey) {
  1027. this.copyToClipboardAsPng();
  1028. event.preventDefault();
  1029. return;
  1030. }
  1031. if (this.actionManager.handleKeyDown(event)) {
  1032. return;
  1033. }
  1034. const shape = findShapeByKey(event.key);
  1035. if (isArrowKey(event.key)) {
  1036. const step = event.shiftKey
  1037. ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
  1038. : ELEMENT_TRANSLATE_AMOUNT;
  1039. globalSceneState.replaceAllElements(
  1040. globalSceneState.getElementsIncludingDeleted().map((el) => {
  1041. if (this.state.selectedElementIds[el.id]) {
  1042. const update: { x?: number; y?: number } = {};
  1043. if (event.key === KEYS.ARROW_LEFT) {
  1044. update.x = el.x - step;
  1045. } else if (event.key === KEYS.ARROW_RIGHT) {
  1046. update.x = el.x + step;
  1047. } else if (event.key === KEYS.ARROW_UP) {
  1048. update.y = el.y - step;
  1049. } else if (event.key === KEYS.ARROW_DOWN) {
  1050. update.y = el.y + step;
  1051. }
  1052. return newElementWith(el, update);
  1053. }
  1054. return el;
  1055. }),
  1056. );
  1057. event.preventDefault();
  1058. } else if (event.key === KEYS.ENTER) {
  1059. const selectedElements = getSelectedElements(
  1060. globalSceneState.getElements(),
  1061. this.state,
  1062. );
  1063. if (
  1064. selectedElements.length === 1 &&
  1065. !isLinearElement(selectedElements[0])
  1066. ) {
  1067. const selectedElement = selectedElements[0];
  1068. const x = selectedElement.x + selectedElement.width / 2;
  1069. const y = selectedElement.y + selectedElement.height / 2;
  1070. this.startTextEditing({
  1071. x: x,
  1072. y: y,
  1073. });
  1074. event.preventDefault();
  1075. return;
  1076. }
  1077. } else if (
  1078. !event.ctrlKey &&
  1079. !event.altKey &&
  1080. !event.metaKey &&
  1081. this.state.draggingElement === null
  1082. ) {
  1083. if (shapesShortcutKeys.includes(event.key.toLowerCase())) {
  1084. this.selectShapeTool(shape);
  1085. } else if (event.key === "q") {
  1086. this.toggleLock();
  1087. }
  1088. }
  1089. if (event.key === KEYS.SPACE && gesture.pointers.size === 0) {
  1090. isHoldingSpace = true;
  1091. document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
  1092. }
  1093. });
  1094. private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
  1095. if (event.key === KEYS.SPACE) {
  1096. if (this.state.elementType === "selection") {
  1097. resetCursor();
  1098. } else {
  1099. setCursorForShape(this.state.elementType);
  1100. this.setState({
  1101. selectedElementIds: {},
  1102. selectedGroupIds: {},
  1103. editingGroupId: null,
  1104. });
  1105. }
  1106. isHoldingSpace = false;
  1107. }
  1108. });
  1109. private selectShapeTool(elementType: AppState["elementType"]) {
  1110. if (!isHoldingSpace) {
  1111. setCursorForShape(elementType);
  1112. }
  1113. if (isToolIcon(document.activeElement)) {
  1114. document.activeElement.blur();
  1115. }
  1116. if (elementType !== "selection") {
  1117. this.setState({
  1118. elementType,
  1119. selectedElementIds: {},
  1120. selectedGroupIds: {},
  1121. editingGroupId: null,
  1122. });
  1123. } else {
  1124. this.setState({ elementType });
  1125. }
  1126. }
  1127. private onGestureStart = withBatchedUpdates((event: GestureEvent) => {
  1128. event.preventDefault();
  1129. gesture.initialScale = this.state.zoom;
  1130. });
  1131. private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
  1132. event.preventDefault();
  1133. this.setState({
  1134. zoom: getNormalizedZoom(gesture.initialScale! * event.scale),
  1135. });
  1136. });
  1137. private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
  1138. event.preventDefault();
  1139. gesture.initialScale = null;
  1140. });
  1141. private setElements = (elements: readonly ExcalidrawElement[]) => {
  1142. globalSceneState.replaceAllElements(elements);
  1143. };
  1144. private handleTextWysiwyg(
  1145. element: ExcalidrawTextElement,
  1146. {
  1147. x,
  1148. y,
  1149. isExistingElement = false,
  1150. }: { x: number; y: number; isExistingElement?: boolean },
  1151. ) {
  1152. const resetSelection = () => {
  1153. this.setState({
  1154. draggingElement: null,
  1155. editingElement: null,
  1156. });
  1157. };
  1158. const deleteElement = () => {
  1159. globalSceneState.replaceAllElements([
  1160. ...globalSceneState.getElementsIncludingDeleted().map((_element) => {
  1161. if (_element.id === element.id) {
  1162. return newElementWith(_element, { isDeleted: true });
  1163. }
  1164. return _element;
  1165. }),
  1166. ]);
  1167. };
  1168. const updateElement = (text: string) => {
  1169. globalSceneState.replaceAllElements([
  1170. ...globalSceneState.getElementsIncludingDeleted().map((_element) => {
  1171. if (_element.id === element.id) {
  1172. return newTextElement({
  1173. ...(_element as ExcalidrawTextElement),
  1174. x: element.x,
  1175. y: element.y,
  1176. text,
  1177. });
  1178. }
  1179. return _element;
  1180. }),
  1181. ]);
  1182. };
  1183. textWysiwyg({
  1184. id: element.id,
  1185. x,
  1186. y,
  1187. initText: element.text,
  1188. strokeColor: element.strokeColor,
  1189. opacity: element.opacity,
  1190. font: element.font,
  1191. angle: element.angle,
  1192. textAlign: element.textAlign,
  1193. zoom: this.state.zoom,
  1194. onChange: withBatchedUpdates((text) => {
  1195. if (text) {
  1196. updateElement(text);
  1197. } else {
  1198. deleteElement();
  1199. }
  1200. }),
  1201. onSubmit: withBatchedUpdates((text) => {
  1202. updateElement(text);
  1203. this.setState((prevState) => ({
  1204. selectedElementIds: {
  1205. ...prevState.selectedElementIds,
  1206. [element.id]: true,
  1207. },
  1208. }));
  1209. if (this.state.elementLocked) {
  1210. setCursorForShape(this.state.elementType);
  1211. }
  1212. history.resumeRecording();
  1213. resetSelection();
  1214. }),
  1215. onCancel: withBatchedUpdates(() => {
  1216. deleteElement();
  1217. if (isExistingElement) {
  1218. history.resumeRecording();
  1219. }
  1220. resetSelection();
  1221. }),
  1222. });
  1223. // deselect all other elements when inserting text
  1224. this.setState({
  1225. selectedElementIds: {},
  1226. selectedGroupIds: {},
  1227. editingGroupId: null,
  1228. });
  1229. // do an initial update to re-initialize element position since we were
  1230. // modifying element's x/y for sake of editor (case: syncing to remote)
  1231. updateElement(element.text);
  1232. }
  1233. private startTextEditing = ({
  1234. x,
  1235. y,
  1236. clientX,
  1237. clientY,
  1238. centerIfPossible = true,
  1239. }: {
  1240. x: number;
  1241. y: number;
  1242. clientX?: number;
  1243. clientY?: number;
  1244. centerIfPossible?: boolean;
  1245. }) => {
  1246. const elementAtPosition = getElementAtPosition(
  1247. globalSceneState.getElements(),
  1248. this.state,
  1249. x,
  1250. y,
  1251. this.state.zoom,
  1252. );
  1253. const element =
  1254. elementAtPosition && isTextElement(elementAtPosition)
  1255. ? elementAtPosition
  1256. : newTextElement({
  1257. x: x,
  1258. y: y,
  1259. strokeColor: this.state.currentItemStrokeColor,
  1260. backgroundColor: this.state.currentItemBackgroundColor,
  1261. fillStyle: this.state.currentItemFillStyle,
  1262. strokeWidth: this.state.currentItemStrokeWidth,
  1263. strokeStyle: this.state.currentItemStrokeStyle,
  1264. roughness: this.state.currentItemRoughness,
  1265. opacity: this.state.currentItemOpacity,
  1266. text: "",
  1267. font: this.state.currentItemFont,
  1268. textAlign: this.state.currentItemTextAlign,
  1269. });
  1270. this.setState({ editingElement: element });
  1271. let textX = clientX || x;
  1272. let textY = clientY || y;
  1273. let isExistingTextElement = false;
  1274. if (elementAtPosition && isTextElement(elementAtPosition)) {
  1275. isExistingTextElement = true;
  1276. const centerElementX = elementAtPosition.x + elementAtPosition.width / 2;
  1277. const centerElementY = elementAtPosition.y + elementAtPosition.height / 2;
  1278. const {
  1279. x: centerElementXInViewport,
  1280. y: centerElementYInViewport,
  1281. } = sceneCoordsToViewportCoords(
  1282. { sceneX: centerElementX, sceneY: centerElementY },
  1283. this.state,
  1284. this.canvas,
  1285. window.devicePixelRatio,
  1286. );
  1287. textX = centerElementXInViewport;
  1288. textY = centerElementYInViewport;
  1289. // x and y will change after calling newTextElement function
  1290. mutateElement(element, {
  1291. x: centerElementX,
  1292. y: centerElementY,
  1293. });
  1294. } else {
  1295. globalSceneState.replaceAllElements([
  1296. ...globalSceneState.getElementsIncludingDeleted(),
  1297. element,
  1298. ]);
  1299. if (centerIfPossible) {
  1300. const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
  1301. x,
  1302. y,
  1303. this.state,
  1304. this.canvas,
  1305. window.devicePixelRatio,
  1306. );
  1307. if (snappedToCenterPosition) {
  1308. mutateElement(element, {
  1309. x: snappedToCenterPosition.elementCenterX,
  1310. y: snappedToCenterPosition.elementCenterY,
  1311. });
  1312. textX = snappedToCenterPosition.wysiwygX;
  1313. textY = snappedToCenterPosition.wysiwygY;
  1314. }
  1315. }
  1316. }
  1317. this.setState({
  1318. editingElement: element,
  1319. });
  1320. this.handleTextWysiwyg(element, {
  1321. x: textX,
  1322. y: textY,
  1323. isExistingElement: isExistingTextElement,
  1324. });
  1325. };
  1326. private handleCanvasDoubleClick = (
  1327. event: React.MouseEvent<HTMLCanvasElement>,
  1328. ) => {
  1329. // case: double-clicking with arrow/line tool selected would both create
  1330. // text and enter multiElement mode
  1331. if (this.state.multiElement) {
  1332. return;
  1333. }
  1334. const { x, y } = viewportCoordsToSceneCoords(
  1335. event,
  1336. this.state,
  1337. this.canvas,
  1338. window.devicePixelRatio,
  1339. );
  1340. const selectedGroupIds = getSelectedGroupIds(this.state);
  1341. if (selectedGroupIds.length > 0) {
  1342. const elements = globalSceneState.getElements();
  1343. const hitElement = getElementAtPosition(
  1344. elements,
  1345. this.state,
  1346. x,
  1347. y,
  1348. this.state.zoom,
  1349. );
  1350. const selectedGroupId =
  1351. hitElement &&
  1352. getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
  1353. if (selectedGroupId) {
  1354. this.setState((prevState) =>
  1355. selectGroupsForSelectedElements(
  1356. {
  1357. ...prevState,
  1358. editingGroupId: selectedGroupId,
  1359. selectedElementIds: { [hitElement!.id]: true },
  1360. selectedGroupIds: {},
  1361. },
  1362. globalSceneState.getElements(),
  1363. ),
  1364. );
  1365. return;
  1366. }
  1367. }
  1368. resetCursor();
  1369. this.startTextEditing({
  1370. x: x,
  1371. y: y,
  1372. clientX: event.clientX,
  1373. clientY: event.clientY,
  1374. centerIfPossible: !event.altKey,
  1375. });
  1376. };
  1377. private handleCanvasPointerMove = (
  1378. event: React.PointerEvent<HTMLCanvasElement>,
  1379. ) => {
  1380. this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
  1381. if (gesture.pointers.has(event.pointerId)) {
  1382. gesture.pointers.set(event.pointerId, {
  1383. x: event.clientX,
  1384. y: event.clientY,
  1385. });
  1386. }
  1387. if (gesture.pointers.size === 2) {
  1388. const center = getCenter(gesture.pointers);
  1389. const deltaX = center.x - gesture.lastCenter!.x;
  1390. const deltaY = center.y - gesture.lastCenter!.y;
  1391. gesture.lastCenter = center;
  1392. const distance = getDistance(Array.from(gesture.pointers.values()));
  1393. const scaleFactor = distance / gesture.initialDistance!;
  1394. this.setState({
  1395. scrollX: normalizeScroll(this.state.scrollX + deltaX / this.state.zoom),
  1396. scrollY: normalizeScroll(this.state.scrollY + deltaY / this.state.zoom),
  1397. zoom: getNormalizedZoom(gesture.initialScale! * scaleFactor),
  1398. shouldCacheIgnoreZoom: true,
  1399. });
  1400. this.resetShouldCacheIgnoreZoomDebounced();
  1401. } else {
  1402. gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null;
  1403. }
  1404. if (isHoldingSpace || isPanning || isDraggingScrollBar) {
  1405. return;
  1406. }
  1407. const {
  1408. isOverHorizontalScrollBar,
  1409. isOverVerticalScrollBar,
  1410. } = isOverScrollBars(currentScrollBars, event.clientX, event.clientY);
  1411. const isOverScrollBar =
  1412. isOverVerticalScrollBar || isOverHorizontalScrollBar;
  1413. if (!this.state.draggingElement && !this.state.multiElement) {
  1414. if (isOverScrollBar) {
  1415. resetCursor();
  1416. } else {
  1417. setCursorForShape(this.state.elementType);
  1418. }
  1419. }
  1420. const { x, y } = viewportCoordsToSceneCoords(
  1421. event,
  1422. this.state,
  1423. this.canvas,
  1424. window.devicePixelRatio,
  1425. );
  1426. if (this.state.multiElement) {
  1427. const { multiElement } = this.state;
  1428. const { x: rx, y: ry } = multiElement;
  1429. const { points, lastCommittedPoint } = multiElement;
  1430. const lastPoint = points[points.length - 1];
  1431. setCursorForShape(this.state.elementType);
  1432. if (lastPoint === lastCommittedPoint) {
  1433. // if we haven't yet created a temp point and we're beyond commit-zone
  1434. // threshold, add a point
  1435. if (
  1436. distance2d(x - rx, y - ry, lastPoint[0], lastPoint[1]) >=
  1437. LINE_CONFIRM_THRESHOLD
  1438. ) {
  1439. mutateElement(multiElement, {
  1440. points: [...points, [x - rx, y - ry]],
  1441. });
  1442. } else {
  1443. document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
  1444. // in this branch, we're inside the commit zone, and no uncommitted
  1445. // point exists. Thus do nothing (don't add/remove points).
  1446. }
  1447. } else {
  1448. // cursor moved inside commit zone, and there's uncommitted point,
  1449. // thus remove it
  1450. if (
  1451. points.length > 2 &&
  1452. lastCommittedPoint &&
  1453. distance2d(
  1454. x - rx,
  1455. y - ry,
  1456. lastCommittedPoint[0],
  1457. lastCommittedPoint[1],
  1458. ) < LINE_CONFIRM_THRESHOLD
  1459. ) {
  1460. document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
  1461. mutateElement(multiElement, {
  1462. points: points.slice(0, -1),
  1463. });
  1464. } else {
  1465. if (isPathALoop(points)) {
  1466. document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
  1467. }
  1468. // update last uncommitted point
  1469. mutateElement(multiElement, {
  1470. points: [...points.slice(0, -1), [x - rx, y - ry]],
  1471. });
  1472. }
  1473. }
  1474. return;
  1475. }
  1476. const hasDeselectedButton = Boolean(event.buttons);
  1477. if (
  1478. hasDeselectedButton ||
  1479. (this.state.elementType !== "selection" &&
  1480. this.state.elementType !== "text")
  1481. ) {
  1482. return;
  1483. }
  1484. const elements = globalSceneState.getElements();
  1485. const selectedElements = getSelectedElements(elements, this.state);
  1486. if (selectedElements.length === 1 && !isOverScrollBar) {
  1487. const elementWithResizeHandler = getElementWithResizeHandler(
  1488. elements,
  1489. this.state,
  1490. { x, y },
  1491. this.state.zoom,
  1492. event.pointerType,
  1493. );
  1494. if (elementWithResizeHandler && elementWithResizeHandler.resizeHandle) {
  1495. document.documentElement.style.cursor = getCursorForResizingElement(
  1496. elementWithResizeHandler,
  1497. );
  1498. return;
  1499. }
  1500. } else if (selectedElements.length > 1 && !isOverScrollBar) {
  1501. if (canResizeMutlipleElements(selectedElements)) {
  1502. const resizeHandle = getResizeHandlerFromCoords(
  1503. getCommonBounds(selectedElements),
  1504. { x, y },
  1505. this.state.zoom,
  1506. event.pointerType,
  1507. );
  1508. if (resizeHandle) {
  1509. document.documentElement.style.cursor = getCursorForResizingElement({
  1510. resizeHandle,
  1511. });
  1512. return;
  1513. }
  1514. }
  1515. }
  1516. const hitElement = getElementAtPosition(
  1517. elements,
  1518. this.state,
  1519. x,
  1520. y,
  1521. this.state.zoom,
  1522. );
  1523. if (this.state.elementType === "text") {
  1524. document.documentElement.style.cursor = isTextElement(hitElement)
  1525. ? CURSOR_TYPE.TEXT
  1526. : CURSOR_TYPE.CROSSHAIR;
  1527. } else {
  1528. document.documentElement.style.cursor =
  1529. hitElement && !isOverScrollBar ? "move" : "";
  1530. }
  1531. };
  1532. private handleCanvasPointerDown = (
  1533. event: React.PointerEvent<HTMLCanvasElement>,
  1534. ) => {
  1535. event.persist();
  1536. if (lastPointerUp !== null) {
  1537. // Unfortunately, sometimes we don't get a pointerup after a pointerdown,
  1538. // this can happen when a contextual menu or alert is triggered. In order to avoid
  1539. // being in a weird state, we clean up on the next pointerdown
  1540. lastPointerUp(event);
  1541. }
  1542. if (isPanning) {
  1543. return;
  1544. }
  1545. this.setState({
  1546. lastPointerDownWith: event.pointerType,
  1547. cursorButton: "down",
  1548. });
  1549. this.savePointer(event.clientX, event.clientY, "down");
  1550. // pan canvas on wheel button drag or space+drag
  1551. if (
  1552. gesture.pointers.size === 0 &&
  1553. (event.button === POINTER_BUTTON.WHEEL ||
  1554. (event.button === POINTER_BUTTON.MAIN && isHoldingSpace))
  1555. ) {
  1556. isPanning = true;
  1557. let nextPastePrevented = false;
  1558. const isLinux = /Linux/.test(window.navigator.platform);
  1559. document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
  1560. let { clientX: lastX, clientY: lastY } = event;
  1561. const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
  1562. const deltaX = lastX - event.clientX;
  1563. const deltaY = lastY - event.clientY;
  1564. lastX = event.clientX;
  1565. lastY = event.clientY;
  1566. /*
  1567. * Prevent paste event if we move while middle clicking on Linux.
  1568. * See issue #1383.
  1569. */
  1570. if (
  1571. isLinux &&
  1572. !nextPastePrevented &&
  1573. (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1)
  1574. ) {
  1575. nextPastePrevented = true;
  1576. /* Prevent the next paste event */
  1577. const preventNextPaste = (event: ClipboardEvent) => {
  1578. document.body.removeEventListener(EVENT.PASTE, preventNextPaste);
  1579. event.stopPropagation();
  1580. };
  1581. /*
  1582. * Reenable next paste in case of disabled middle click paste for
  1583. * any reason:
  1584. * - rigth click paste
  1585. * - empty clipboard
  1586. */
  1587. const enableNextPaste = () => {
  1588. setTimeout(() => {
  1589. document.body.removeEventListener(EVENT.PASTE, preventNextPaste);
  1590. window.removeEventListener(EVENT.POINTER_UP, enableNextPaste);
  1591. }, 100);
  1592. };
  1593. document.body.addEventListener(EVENT.PASTE, preventNextPaste);
  1594. window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
  1595. }
  1596. this.setState({
  1597. scrollX: normalizeScroll(
  1598. this.state.scrollX - deltaX / this.state.zoom,
  1599. ),
  1600. scrollY: normalizeScroll(
  1601. this.state.scrollY - deltaY / this.state.zoom,
  1602. ),
  1603. });
  1604. });
  1605. const teardown = withBatchedUpdates(
  1606. (lastPointerUp = () => {
  1607. lastPointerUp = null;
  1608. isPanning = false;
  1609. if (!isHoldingSpace) {
  1610. setCursorForShape(this.state.elementType);
  1611. }
  1612. this.setState({
  1613. cursorButton: "up",
  1614. });
  1615. this.savePointer(event.clientX, event.clientY, "up");
  1616. window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
  1617. window.removeEventListener(EVENT.POINTER_UP, teardown);
  1618. window.removeEventListener(EVENT.BLUR, teardown);
  1619. }),
  1620. );
  1621. window.addEventListener(EVENT.BLUR, teardown);
  1622. window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, {
  1623. passive: true,
  1624. });
  1625. window.addEventListener(EVENT.POINTER_UP, teardown);
  1626. return;
  1627. }
  1628. // only handle left mouse button or touch
  1629. if (
  1630. event.button !== POINTER_BUTTON.MAIN &&
  1631. event.button !== POINTER_BUTTON.TOUCH
  1632. ) {
  1633. return;
  1634. }
  1635. gesture.pointers.set(event.pointerId, {
  1636. x: event.clientX,
  1637. y: event.clientY,
  1638. });
  1639. if (gesture.pointers.size === 2) {
  1640. gesture.lastCenter = getCenter(gesture.pointers);
  1641. gesture.initialScale = this.state.zoom;
  1642. gesture.initialDistance = getDistance(
  1643. Array.from(gesture.pointers.values()),
  1644. );
  1645. }
  1646. // fixes pointermove causing selection of UI texts #32
  1647. event.preventDefault();
  1648. // Preventing the event above disables default behavior
  1649. // of defocusing potentially focused element, which is what we
  1650. // want when clicking inside the canvas.
  1651. if (document.activeElement instanceof HTMLElement) {
  1652. document.activeElement.blur();
  1653. }
  1654. // don't select while panning
  1655. if (gesture.pointers.size > 1) {
  1656. return;
  1657. }
  1658. // Handle scrollbars dragging
  1659. const {
  1660. isOverHorizontalScrollBar,
  1661. isOverVerticalScrollBar,
  1662. } = isOverScrollBars(currentScrollBars, event.clientX, event.clientY);
  1663. const { x, y } = viewportCoordsToSceneCoords(
  1664. event,
  1665. this.state,
  1666. this.canvas,
  1667. window.devicePixelRatio,
  1668. );
  1669. let lastX = x;
  1670. let lastY = y;
  1671. if (
  1672. (isOverHorizontalScrollBar || isOverVerticalScrollBar) &&
  1673. !this.state.multiElement
  1674. ) {
  1675. isDraggingScrollBar = true;
  1676. lastX = event.clientX;
  1677. lastY = event.clientY;
  1678. const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
  1679. const target = event.target;
  1680. if (!(target instanceof HTMLElement)) {
  1681. return;
  1682. }
  1683. if (isOverHorizontalScrollBar) {
  1684. const x = event.clientX;
  1685. const dx = x - lastX;
  1686. this.setState({
  1687. scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
  1688. });
  1689. lastX = x;
  1690. return;
  1691. }
  1692. if (isOverVerticalScrollBar) {
  1693. const y = event.clientY;
  1694. const dy = y - lastY;
  1695. this.setState({
  1696. scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
  1697. });
  1698. lastY = y;
  1699. }
  1700. });
  1701. const onPointerUp = withBatchedUpdates(() => {
  1702. isDraggingScrollBar = false;
  1703. setCursorForShape(this.state.elementType);
  1704. lastPointerUp = null;
  1705. this.setState({
  1706. cursorButton: "up",
  1707. });
  1708. this.savePointer(event.clientX, event.clientY, "up");
  1709. window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
  1710. window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
  1711. });
  1712. lastPointerUp = onPointerUp;
  1713. window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
  1714. window.addEventListener(EVENT.POINTER_UP, onPointerUp);
  1715. return;
  1716. }
  1717. const originX = x;
  1718. const originY = y;
  1719. type ResizeTestType = ReturnType<typeof resizeTest>;
  1720. let resizeHandle: ResizeTestType = false;
  1721. const setResizeHandle = (nextResizeHandle: ResizeTestType) => {
  1722. resizeHandle = nextResizeHandle;
  1723. };
  1724. let resizeOffsetXY: [number, number] = [0, 0];
  1725. let resizeArrowDirection: "origin" | "end" = "origin";
  1726. let isResizingElements = false;
  1727. let draggingOccurred = false;
  1728. let hitElement: ExcalidrawElement | null = null;
  1729. let hitElementWasAddedToSelection = false;
  1730. if (this.state.elementType === "selection") {
  1731. const elements = globalSceneState.getElements();
  1732. const selectedElements = getSelectedElements(elements, this.state);
  1733. if (selectedElements.length === 1) {
  1734. const elementWithResizeHandler = getElementWithResizeHandler(
  1735. elements,
  1736. this.state,
  1737. { x, y },
  1738. this.state.zoom,
  1739. event.pointerType,
  1740. );
  1741. if (elementWithResizeHandler) {
  1742. this.setState({
  1743. resizingElement: elementWithResizeHandler
  1744. ? elementWithResizeHandler.element
  1745. : null,
  1746. });
  1747. resizeHandle = elementWithResizeHandler.resizeHandle;
  1748. document.documentElement.style.cursor = getCursorForResizingElement(
  1749. elementWithResizeHandler,
  1750. );
  1751. isResizingElements = true;
  1752. }
  1753. } else if (selectedElements.length > 1) {
  1754. if (canResizeMutlipleElements(selectedElements)) {
  1755. resizeHandle = getResizeHandlerFromCoords(
  1756. getCommonBounds(selectedElements),
  1757. { x, y },
  1758. this.state.zoom,
  1759. event.pointerType,
  1760. );
  1761. if (resizeHandle) {
  1762. document.documentElement.style.cursor = getCursorForResizingElement(
  1763. {
  1764. resizeHandle,
  1765. },
  1766. );
  1767. isResizingElements = true;
  1768. }
  1769. }
  1770. }
  1771. if (isResizingElements) {
  1772. resizeOffsetXY = getResizeOffsetXY(
  1773. resizeHandle,
  1774. selectedElements,
  1775. x,
  1776. y,
  1777. );
  1778. if (
  1779. selectedElements.length === 1 &&
  1780. isLinearElement(selectedElements[0]) &&
  1781. selectedElements[0].points.length === 2
  1782. ) {
  1783. resizeArrowDirection = getResizeArrowDirection(
  1784. resizeHandle,
  1785. selectedElements[0],
  1786. );
  1787. }
  1788. }
  1789. if (!isResizingElements) {
  1790. hitElement = getElementAtPosition(
  1791. elements,
  1792. this.state,
  1793. x,
  1794. y,
  1795. this.state.zoom,
  1796. );
  1797. // clear selection if shift is not clicked
  1798. if (
  1799. !(hitElement && this.state.selectedElementIds[hitElement.id]) &&
  1800. !event.shiftKey
  1801. ) {
  1802. this.setState((prevState) => ({
  1803. selectedElementIds: {},
  1804. selectedGroupIds: {},
  1805. editingGroupId:
  1806. prevState.editingGroupId &&
  1807. hitElement &&
  1808. isElementInGroup(hitElement, prevState.editingGroupId)
  1809. ? prevState.editingGroupId
  1810. : null,
  1811. }));
  1812. }
  1813. // If we click on something
  1814. if (hitElement) {
  1815. // deselect if item is selected
  1816. // if shift is not clicked, this will always return true
  1817. // otherwise, it will trigger selection based on current
  1818. // state of the box
  1819. if (!this.state.selectedElementIds[hitElement.id]) {
  1820. // if we are currently editing a group, treat all selections outside of the group
  1821. // as exiting editing mode.
  1822. if (
  1823. this.state.editingGroupId &&
  1824. !isElementInGroup(hitElement, this.state.editingGroupId)
  1825. ) {
  1826. this.setState({
  1827. selectedElementIds: {},
  1828. selectedGroupIds: {},
  1829. editingGroupId: null,
  1830. });
  1831. return;
  1832. }
  1833. this.setState((prevState) => {
  1834. return selectGroupsForSelectedElements(
  1835. {
  1836. ...prevState,
  1837. selectedElementIds: {
  1838. ...prevState.selectedElementIds,
  1839. [hitElement!.id]: true,
  1840. },
  1841. },
  1842. globalSceneState.getElements(),
  1843. );
  1844. });
  1845. // TODO: this is strange...
  1846. globalSceneState.replaceAllElements(
  1847. globalSceneState.getElementsIncludingDeleted(),
  1848. );
  1849. hitElementWasAddedToSelection = true;
  1850. }
  1851. }
  1852. }
  1853. } else {
  1854. this.setState({
  1855. selectedElementIds: {},
  1856. selectedGroupIds: {},
  1857. editingGroupId: null,
  1858. });
  1859. }
  1860. if (this.state.elementType === "text") {
  1861. // if we're currently still editing text, clicking outside
  1862. // should only finalize it, not create another (irrespective
  1863. // of state.elementLocked)
  1864. if (this.state.editingElement?.type === "text") {
  1865. return;
  1866. }
  1867. const { x, y } = viewportCoordsToSceneCoords(
  1868. event,
  1869. this.state,
  1870. this.canvas,
  1871. window.devicePixelRatio,
  1872. );
  1873. this.startTextEditing({
  1874. x: x,
  1875. y: y,
  1876. clientX: event.clientX,
  1877. clientY: event.clientY,
  1878. centerIfPossible: !event.altKey,
  1879. });
  1880. resetCursor();
  1881. if (!this.state.elementLocked) {
  1882. this.setState({
  1883. elementType: "selection",
  1884. });
  1885. }
  1886. return;
  1887. } else if (
  1888. this.state.elementType === "arrow" ||
  1889. this.state.elementType === "draw" ||
  1890. this.state.elementType === "line"
  1891. ) {
  1892. if (this.state.multiElement) {
  1893. const { multiElement } = this.state;
  1894. // finalize if completing a loop
  1895. if (multiElement.type === "line" && isPathALoop(multiElement.points)) {
  1896. mutateElement(multiElement, {
  1897. lastCommittedPoint:
  1898. multiElement.points[multiElement.points.length - 1],
  1899. });
  1900. this.actionManager.executeAction(actionFinalize);
  1901. return;
  1902. }
  1903. const { x: rx, y: ry, lastCommittedPoint } = multiElement;
  1904. // clicking inside commit zone → finalize arrow
  1905. if (
  1906. multiElement.points.length > 1 &&
  1907. lastCommittedPoint &&
  1908. distance2d(
  1909. x - rx,
  1910. y - ry,
  1911. lastCommittedPoint[0],
  1912. lastCommittedPoint[1],
  1913. ) < LINE_CONFIRM_THRESHOLD
  1914. ) {
  1915. this.actionManager.executeAction(actionFinalize);
  1916. return;
  1917. }
  1918. this.setState((prevState) => ({
  1919. selectedElementIds: {
  1920. ...prevState.selectedElementIds,
  1921. [multiElement.id]: true,
  1922. },
  1923. }));
  1924. // clicking outside commit zone → update reference for last committed
  1925. // point
  1926. mutateElement(multiElement, {
  1927. lastCommittedPoint:
  1928. multiElement.points[multiElement.points.length - 1],
  1929. });
  1930. document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
  1931. } else {
  1932. const element = newLinearElement({
  1933. type: this.state.elementType,
  1934. x: x,
  1935. y: y,
  1936. strokeColor: this.state.currentItemStrokeColor,
  1937. backgroundColor: this.state.currentItemBackgroundColor,
  1938. fillStyle: this.state.currentItemFillStyle,
  1939. strokeWidth: this.state.currentItemStrokeWidth,
  1940. strokeStyle: this.state.currentItemStrokeStyle,
  1941. roughness: this.state.currentItemRoughness,
  1942. opacity: this.state.currentItemOpacity,
  1943. });
  1944. this.setState((prevState) => ({
  1945. selectedElementIds: {
  1946. ...prevState.selectedElementIds,
  1947. [element.id]: false,
  1948. },
  1949. }));
  1950. mutateElement(element, {
  1951. points: [...element.points, [0, 0]],
  1952. });
  1953. globalSceneState.replaceAllElements([
  1954. ...globalSceneState.getElementsIncludingDeleted(),
  1955. element,
  1956. ]);
  1957. this.setState({
  1958. draggingElement: element,
  1959. editingElement: element,
  1960. });
  1961. }
  1962. } else {
  1963. const element = newElement({
  1964. type: this.state.elementType,
  1965. x: x,
  1966. y: y,
  1967. strokeColor: this.state.currentItemStrokeColor,
  1968. backgroundColor: this.state.currentItemBackgroundColor,
  1969. fillStyle: this.state.currentItemFillStyle,
  1970. strokeWidth: this.state.currentItemStrokeWidth,
  1971. strokeStyle: this.state.currentItemStrokeStyle,
  1972. roughness: this.state.currentItemRoughness,
  1973. opacity: this.state.currentItemOpacity,
  1974. });
  1975. if (element.type === "selection") {
  1976. this.setState({
  1977. selectionElement: element,
  1978. draggingElement: element,
  1979. });
  1980. } else {
  1981. globalSceneState.replaceAllElements([
  1982. ...globalSceneState.getElementsIncludingDeleted(),
  1983. element,
  1984. ]);
  1985. this.setState({
  1986. multiElement: null,
  1987. draggingElement: element,
  1988. editingElement: element,
  1989. });
  1990. }
  1991. }
  1992. let selectedElementWasDuplicated = false;
  1993. const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
  1994. const target = event.target;
  1995. if (!(target instanceof HTMLElement)) {
  1996. return;
  1997. }
  1998. if (isOverHorizontalScrollBar) {
  1999. const x = event.clientX;
  2000. const dx = x - lastX;
  2001. this.setState({
  2002. scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
  2003. });
  2004. lastX = x;
  2005. return;
  2006. }
  2007. if (isOverVerticalScrollBar) {
  2008. const y = event.clientY;
  2009. const dy = y - lastY;
  2010. this.setState({
  2011. scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
  2012. });
  2013. lastY = y;
  2014. return;
  2015. }
  2016. const { x, y } = viewportCoordsToSceneCoords(
  2017. event,
  2018. this.state,
  2019. this.canvas,
  2020. window.devicePixelRatio,
  2021. );
  2022. // for arrows/lines, don't start dragging until a given threshold
  2023. // to ensure we don't create a 2-point arrow by mistake when
  2024. // user clicks mouse in a way that it moves a tiny bit (thus
  2025. // triggering pointermove)
  2026. if (
  2027. !draggingOccurred &&
  2028. (this.state.elementType === "arrow" ||
  2029. this.state.elementType === "line")
  2030. ) {
  2031. if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
  2032. return;
  2033. }
  2034. }
  2035. if (isResizingElements) {
  2036. const selectedElements = getSelectedElements(
  2037. globalSceneState.getElements(),
  2038. this.state,
  2039. );
  2040. this.setState({
  2041. isResizing: resizeHandle && resizeHandle !== "rotation",
  2042. isRotating: resizeHandle === "rotation",
  2043. });
  2044. if (
  2045. resizeElements(
  2046. resizeHandle,
  2047. setResizeHandle,
  2048. selectedElements,
  2049. resizeArrowDirection,
  2050. event,
  2051. x - resizeOffsetXY[0],
  2052. y - resizeOffsetXY[1],
  2053. )
  2054. ) {
  2055. return;
  2056. }
  2057. }
  2058. if (hitElement && this.state.selectedElementIds[hitElement.id]) {
  2059. // Marking that click was used for dragging to check
  2060. // if elements should be deselected on pointerup
  2061. draggingOccurred = true;
  2062. const selectedElements = getSelectedElements(
  2063. globalSceneState.getElements(),
  2064. this.state,
  2065. );
  2066. if (selectedElements.length > 0) {
  2067. const { x, y } = viewportCoordsToSceneCoords(
  2068. event,
  2069. this.state,
  2070. this.canvas,
  2071. window.devicePixelRatio,
  2072. );
  2073. selectedElements.forEach((element) => {
  2074. mutateElement(element, {
  2075. x: element.x + x - lastX,
  2076. y: element.y + y - lastY,
  2077. });
  2078. });
  2079. lastX = x;
  2080. lastY = y;
  2081. // We duplicate the selected element if alt is pressed on pointer move
  2082. if (event.altKey && !selectedElementWasDuplicated) {
  2083. // Move the currently selected elements to the top of the z index stack, and
  2084. // put the duplicates where the selected elements used to be.
  2085. // (the origin point where the dragging started)
  2086. selectedElementWasDuplicated = true;
  2087. const nextElements = [];
  2088. const elementsToAppend = [];
  2089. const groupIdMap = new Map();
  2090. for (const element of globalSceneState.getElementsIncludingDeleted()) {
  2091. if (
  2092. this.state.selectedElementIds[element.id] ||
  2093. // case: the state.selectedElementIds might not have been
  2094. // updated yet by the time this mousemove event is fired
  2095. (element.id === hitElement.id && hitElementWasAddedToSelection)
  2096. ) {
  2097. const duplicatedElement = duplicateElement(
  2098. this.state.editingGroupId,
  2099. groupIdMap,
  2100. element,
  2101. );
  2102. mutateElement(duplicatedElement, {
  2103. x: duplicatedElement.x + (originX - lastX),
  2104. y: duplicatedElement.y + (originY - lastY),
  2105. });
  2106. nextElements.push(duplicatedElement);
  2107. elementsToAppend.push(element);
  2108. } else {
  2109. nextElements.push(element);
  2110. }
  2111. }
  2112. globalSceneState.replaceAllElements([
  2113. ...nextElements,
  2114. ...elementsToAppend,
  2115. ]);
  2116. }
  2117. return;
  2118. }
  2119. }
  2120. // It is very important to read this.state within each move event,
  2121. // otherwise we would read a stale one!
  2122. const draggingElement = this.state.draggingElement;
  2123. if (!draggingElement) {
  2124. return;
  2125. }
  2126. let width = distance(originX, x);
  2127. let height = distance(originY, y);
  2128. if (isLinearElement(draggingElement)) {
  2129. draggingOccurred = true;
  2130. const points = draggingElement.points;
  2131. let dx = x - draggingElement.x;
  2132. let dy = y - draggingElement.y;
  2133. if (event.shiftKey && points.length === 2) {
  2134. ({ width: dx, height: dy } = getPerfectElementSize(
  2135. this.state.elementType,
  2136. dx,
  2137. dy,
  2138. ));
  2139. }
  2140. if (points.length === 1) {
  2141. mutateElement(draggingElement, { points: [...points, [dx, dy]] });
  2142. } else if (points.length > 1) {
  2143. if (draggingElement.type === "draw") {
  2144. mutateElement(draggingElement, {
  2145. points: simplify([...(points as Point[]), [dx, dy]], 0.7),
  2146. });
  2147. } else {
  2148. mutateElement(draggingElement, {
  2149. points: [...points.slice(0, -1), [dx, dy]],
  2150. });
  2151. }
  2152. }
  2153. } else {
  2154. if (getResizeWithSidesSameLengthKey(event)) {
  2155. ({ width, height } = getPerfectElementSize(
  2156. this.state.elementType,
  2157. width,
  2158. y < originY ? -height : height,
  2159. ));
  2160. if (height < 0) {
  2161. height = -height;
  2162. }
  2163. }
  2164. let newX = x < originX ? originX - width : originX;
  2165. let newY = y < originY ? originY - height : originY;
  2166. if (getResizeCenterPointKey(event)) {
  2167. width += width;
  2168. height += height;
  2169. newX = originX - width / 2;
  2170. newY = originY - height / 2;
  2171. }
  2172. mutateElement(draggingElement, {
  2173. x: newX,
  2174. y: newY,
  2175. width: width,
  2176. height: height,
  2177. });
  2178. }
  2179. if (this.state.elementType === "selection") {
  2180. const elements = globalSceneState.getElements();
  2181. if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
  2182. this.setState({
  2183. selectedElementIds: {},
  2184. selectedGroupIds: {},
  2185. editingGroupId: null,
  2186. });
  2187. }
  2188. const elementsWithinSelection = getElementsWithinSelection(
  2189. elements,
  2190. draggingElement,
  2191. );
  2192. this.setState((prevState) =>
  2193. selectGroupsForSelectedElements(
  2194. {
  2195. ...prevState,
  2196. selectedElementIds: {
  2197. ...prevState.selectedElementIds,
  2198. ...elementsWithinSelection.reduce((map, element) => {
  2199. map[element.id] = true;
  2200. return map;
  2201. }, {} as any),
  2202. },
  2203. },
  2204. globalSceneState.getElements(),
  2205. ),
  2206. );
  2207. }
  2208. });
  2209. const onPointerUp = withBatchedUpdates((childEvent: PointerEvent) => {
  2210. const {
  2211. draggingElement,
  2212. resizingElement,
  2213. multiElement,
  2214. elementType,
  2215. elementLocked,
  2216. } = this.state;
  2217. this.setState({
  2218. isResizing: false,
  2219. isRotating: false,
  2220. resizingElement: null,
  2221. selectionElement: null,
  2222. cursorButton: "up",
  2223. editingElement: multiElement ? this.state.editingElement : null,
  2224. });
  2225. this.savePointer(childEvent.clientX, childEvent.clientY, "up");
  2226. lastPointerUp = null;
  2227. window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
  2228. window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
  2229. if (draggingElement?.type === "draw") {
  2230. this.actionManager.executeAction(actionFinalize);
  2231. return;
  2232. }
  2233. if (isLinearElement(draggingElement)) {
  2234. if (draggingElement!.points.length > 1) {
  2235. history.resumeRecording();
  2236. }
  2237. if (!draggingOccurred && draggingElement && !multiElement) {
  2238. const { x, y } = viewportCoordsToSceneCoords(
  2239. childEvent,
  2240. this.state,
  2241. this.canvas,
  2242. window.devicePixelRatio,
  2243. );
  2244. mutateElement(draggingElement, {
  2245. points: [
  2246. ...draggingElement.points,
  2247. [x - draggingElement.x, y - draggingElement.y],
  2248. ],
  2249. });
  2250. this.setState({
  2251. multiElement: draggingElement,
  2252. editingElement: this.state.draggingElement,
  2253. });
  2254. } else if (draggingOccurred && !multiElement) {
  2255. if (!elementLocked) {
  2256. resetCursor();
  2257. this.setState((prevState) => ({
  2258. draggingElement: null,
  2259. elementType: "selection",
  2260. selectedElementIds: {
  2261. ...prevState.selectedElementIds,
  2262. [this.state.draggingElement!.id]: true,
  2263. },
  2264. }));
  2265. } else {
  2266. this.setState((prevState) => ({
  2267. draggingElement: null,
  2268. selectedElementIds: {
  2269. ...prevState.selectedElementIds,
  2270. [this.state.draggingElement!.id]: true,
  2271. },
  2272. }));
  2273. }
  2274. }
  2275. return;
  2276. }
  2277. if (
  2278. elementType !== "selection" &&
  2279. draggingElement &&
  2280. isInvisiblySmallElement(draggingElement)
  2281. ) {
  2282. // remove invisible element which was added in onPointerDown
  2283. globalSceneState.replaceAllElements(
  2284. globalSceneState.getElementsIncludingDeleted().slice(0, -1),
  2285. );
  2286. this.setState({
  2287. draggingElement: null,
  2288. });
  2289. return;
  2290. }
  2291. normalizeDimensions(draggingElement);
  2292. if (resizingElement) {
  2293. history.resumeRecording();
  2294. }
  2295. if (resizingElement && isInvisiblySmallElement(resizingElement)) {
  2296. globalSceneState.replaceAllElements(
  2297. globalSceneState
  2298. .getElementsIncludingDeleted()
  2299. .filter((el) => el.id !== resizingElement.id),
  2300. );
  2301. }
  2302. // If click occurred on already selected element
  2303. // it is needed to remove selection from other elements
  2304. // or if SHIFT or META key pressed remove selection
  2305. // from hitted element
  2306. //
  2307. // If click occurred and elements were dragged or some element
  2308. // was added to selection (on pointerdown phase) we need to keep
  2309. // selection unchanged
  2310. if (
  2311. getSelectedGroupIds(this.state).length === 0 &&
  2312. hitElement &&
  2313. !draggingOccurred &&
  2314. !hitElementWasAddedToSelection
  2315. ) {
  2316. if (childEvent.shiftKey) {
  2317. this.setState((prevState) => ({
  2318. selectedElementIds: {
  2319. ...prevState.selectedElementIds,
  2320. [hitElement!.id]: false,
  2321. },
  2322. }));
  2323. } else {
  2324. this.setState((_prevState) => ({
  2325. selectedElementIds: { [hitElement!.id]: true },
  2326. }));
  2327. }
  2328. }
  2329. if (draggingElement === null) {
  2330. // if no element is clicked, clear the selection and redraw
  2331. this.setState({
  2332. selectedElementIds: {},
  2333. selectedGroupIds: {},
  2334. editingGroupId: null,
  2335. });
  2336. return;
  2337. }
  2338. if (!elementLocked) {
  2339. this.setState((prevState) => ({
  2340. selectedElementIds: {
  2341. ...prevState.selectedElementIds,
  2342. [draggingElement.id]: true,
  2343. },
  2344. }));
  2345. }
  2346. if (
  2347. elementType !== "selection" ||
  2348. isSomeElementSelected(globalSceneState.getElements(), this.state)
  2349. ) {
  2350. history.resumeRecording();
  2351. }
  2352. if (!elementLocked) {
  2353. resetCursor();
  2354. this.setState({
  2355. draggingElement: null,
  2356. elementType: "selection",
  2357. });
  2358. } else {
  2359. this.setState({
  2360. draggingElement: null,
  2361. });
  2362. }
  2363. });
  2364. lastPointerUp = onPointerUp;
  2365. window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
  2366. window.addEventListener(EVENT.POINTER_UP, onPointerUp);
  2367. };
  2368. private handleCanvasRef = (canvas: HTMLCanvasElement) => {
  2369. // canvas is null when unmounting
  2370. if (canvas !== null) {
  2371. this.canvas = canvas;
  2372. this.rc = rough.canvas(this.canvas);
  2373. this.canvas.addEventListener(EVENT.WHEEL, this.handleWheel, {
  2374. passive: false,
  2375. });
  2376. this.canvas.addEventListener(EVENT.TOUCH_START, this.onTapStart);
  2377. } else {
  2378. this.canvas?.removeEventListener(EVENT.WHEEL, this.handleWheel);
  2379. this.canvas?.removeEventListener(EVENT.TOUCH_START, this.onTapStart);
  2380. }
  2381. };
  2382. private handleCanvasOnDrop = (event: React.DragEvent<HTMLCanvasElement>) => {
  2383. const file = event.dataTransfer?.files[0];
  2384. if (
  2385. file?.type === "application/json" ||
  2386. file?.name.endsWith(".excalidraw")
  2387. ) {
  2388. this.setState({ isLoading: true });
  2389. loadFromBlob(file)
  2390. .then(({ elements, appState }) =>
  2391. this.syncActionResult({
  2392. elements,
  2393. appState: {
  2394. ...(appState || this.state),
  2395. isLoading: false,
  2396. },
  2397. commitToHistory: false,
  2398. }),
  2399. )
  2400. .catch((error) => {
  2401. this.setState({ isLoading: false, errorMessage: error.message });
  2402. });
  2403. } else {
  2404. this.setState({
  2405. isLoading: false,
  2406. errorMessage: t("alerts.couldNotLoadInvalidFile"),
  2407. });
  2408. }
  2409. };
  2410. private handleCanvasContextMenu = (
  2411. event: React.PointerEvent<HTMLCanvasElement>,
  2412. ) => {
  2413. event.preventDefault();
  2414. const { x, y } = viewportCoordsToSceneCoords(
  2415. event,
  2416. this.state,
  2417. this.canvas,
  2418. window.devicePixelRatio,
  2419. );
  2420. const elements = globalSceneState.getElements();
  2421. const element = getElementAtPosition(
  2422. elements,
  2423. this.state,
  2424. x,
  2425. y,
  2426. this.state.zoom,
  2427. );
  2428. if (!element) {
  2429. ContextMenu.push({
  2430. options: [
  2431. navigator.clipboard && {
  2432. label: t("labels.paste"),
  2433. action: () => this.pasteFromClipboard(null),
  2434. },
  2435. probablySupportsClipboardBlob &&
  2436. elements.length > 0 && {
  2437. label: t("labels.copyAsPng"),
  2438. action: this.copyToClipboardAsPng,
  2439. },
  2440. probablySupportsClipboardWriteText &&
  2441. elements.length > 0 && {
  2442. label: t("labels.copyAsSvg"),
  2443. action: this.copyToClipboardAsSvg,
  2444. },
  2445. ...this.actionManager.getContextMenuItems((action) =>
  2446. this.canvasOnlyActions.includes(action.name),
  2447. ),
  2448. ],
  2449. top: event.clientY,
  2450. left: event.clientX,
  2451. });
  2452. return;
  2453. }
  2454. if (!this.state.selectedElementIds[element.id]) {
  2455. this.setState({ selectedElementIds: { [element.id]: true } });
  2456. }
  2457. ContextMenu.push({
  2458. options: [
  2459. navigator.clipboard && {
  2460. label: t("labels.copy"),
  2461. action: this.copyAll,
  2462. },
  2463. navigator.clipboard && {
  2464. label: t("labels.paste"),
  2465. action: () => this.pasteFromClipboard(null),
  2466. },
  2467. probablySupportsClipboardBlob && {
  2468. label: t("labels.copyAsPng"),
  2469. action: this.copyToClipboardAsPng,
  2470. },
  2471. probablySupportsClipboardWriteText && {
  2472. label: t("labels.copyAsSvg"),
  2473. action: this.copyToClipboardAsSvg,
  2474. },
  2475. ...this.actionManager.getContextMenuItems(
  2476. (action) => !this.canvasOnlyActions.includes(action.name),
  2477. ),
  2478. ],
  2479. top: event.clientY,
  2480. left: event.clientX,
  2481. });
  2482. };
  2483. private handleWheel = withBatchedUpdates((event: WheelEvent) => {
  2484. event.preventDefault();
  2485. const { deltaX, deltaY } = event;
  2486. // note that event.ctrlKey is necessary to handle pinch zooming
  2487. if (event.metaKey || event.ctrlKey) {
  2488. const sign = Math.sign(deltaY);
  2489. const MAX_STEP = 10;
  2490. let delta = Math.abs(deltaY);
  2491. if (delta > MAX_STEP) {
  2492. delta = MAX_STEP;
  2493. }
  2494. delta *= sign;
  2495. this.setState(({ zoom }) => ({
  2496. zoom: getNormalizedZoom(zoom - delta / 100),
  2497. }));
  2498. return;
  2499. }
  2500. // scroll horizontally when shift pressed
  2501. if (event.shiftKey) {
  2502. this.setState(({ zoom, scrollX }) => ({
  2503. // on Mac, shift+wheel tends to result in deltaX
  2504. scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom),
  2505. }));
  2506. return;
  2507. }
  2508. this.setState(({ zoom, scrollX, scrollY }) => ({
  2509. scrollX: normalizeScroll(scrollX - deltaX / zoom),
  2510. scrollY: normalizeScroll(scrollY - deltaY / zoom),
  2511. }));
  2512. });
  2513. private getTextWysiwygSnappedToCenterPosition(
  2514. x: number,
  2515. y: number,
  2516. state: {
  2517. scrollX: FlooredNumber;
  2518. scrollY: FlooredNumber;
  2519. zoom: number;
  2520. },
  2521. canvas: HTMLCanvasElement | null,
  2522. scale: number,
  2523. ) {
  2524. const elementClickedInside = getElementContainingPosition(
  2525. globalSceneState.getElementsIncludingDeleted(),
  2526. x,
  2527. y,
  2528. );
  2529. if (elementClickedInside) {
  2530. const elementCenterX =
  2531. elementClickedInside.x + elementClickedInside.width / 2;
  2532. const elementCenterY =
  2533. elementClickedInside.y + elementClickedInside.height / 2;
  2534. const distanceToCenter = Math.hypot(
  2535. x - elementCenterX,
  2536. y - elementCenterY,
  2537. );
  2538. const isSnappedToCenter =
  2539. distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
  2540. if (isSnappedToCenter) {
  2541. const { x: wysiwygX, y: wysiwygY } = sceneCoordsToViewportCoords(
  2542. { sceneX: elementCenterX, sceneY: elementCenterY },
  2543. state,
  2544. canvas,
  2545. scale,
  2546. );
  2547. return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
  2548. }
  2549. }
  2550. }
  2551. private savePointer = (x: number, y: number, button: "up" | "down") => {
  2552. if (!x || !y) {
  2553. return;
  2554. }
  2555. const pointerCoords = viewportCoordsToSceneCoords(
  2556. { clientX: x, clientY: y },
  2557. this.state,
  2558. this.canvas,
  2559. window.devicePixelRatio,
  2560. );
  2561. if (isNaN(pointerCoords.x) || isNaN(pointerCoords.y)) {
  2562. // sometimes the pointer goes off screen
  2563. return;
  2564. }
  2565. this.portal.socket &&
  2566. // do not broadcast when more than 1 pointer since that shows flickering on the other side
  2567. gesture.pointers.size < 2 &&
  2568. this.broadcastMouseLocation({
  2569. pointerCoords,
  2570. button,
  2571. });
  2572. };
  2573. private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
  2574. this.setState({ shouldCacheIgnoreZoom: false });
  2575. }, 300);
  2576. private saveDebounced = debounce(() => {
  2577. saveToLocalStorage(
  2578. globalSceneState.getElementsIncludingDeleted(),
  2579. this.state,
  2580. );
  2581. }, 300);
  2582. }
  2583. // -----------------------------------------------------------------------------
  2584. // TEST HOOKS
  2585. // -----------------------------------------------------------------------------
  2586. declare global {
  2587. interface Window {
  2588. h: {
  2589. elements: readonly ExcalidrawElement[];
  2590. state: AppState;
  2591. setState: React.Component<any, AppState>["setState"];
  2592. history: SceneHistory;
  2593. app: InstanceType<typeof App>;
  2594. };
  2595. }
  2596. }
  2597. if (
  2598. process.env.NODE_ENV === ENV.TEST ||
  2599. process.env.NODE_ENV === ENV.DEVELOPMENT
  2600. ) {
  2601. window.h = {} as Window["h"];
  2602. Object.defineProperties(window.h, {
  2603. elements: {
  2604. get() {
  2605. return globalSceneState.getElementsIncludingDeleted();
  2606. },
  2607. set(elements: ExcalidrawElement[]) {
  2608. return globalSceneState.replaceAllElements(elements);
  2609. },
  2610. },
  2611. history: {
  2612. get: () => history,
  2613. },
  2614. });
  2615. }
  2616. export default App;