App.tsx 84 KB

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