1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056 |
- import React from "react";
- import ReactDOM from "react-dom";
- import rough from "roughjs/bin/rough";
- import { RoughCanvas } from "roughjs/bin/canvas";
- import {
- newElement,
- newTextElement,
- duplicateElement,
- resizeTest,
- normalizeResizeHandle,
- isInvisiblySmallElement,
- isTextElement,
- textWysiwyg,
- getCommonBounds,
- getCursorForResizingElement,
- getPerfectElementSize,
- normalizeDimensions,
- } from "./element";
- import {
- clearSelection,
- deleteSelectedElements,
- getElementsWithinSelection,
- isOverScrollBars,
- restoreFromLocalStorage,
- saveToLocalStorage,
- getElementAtPosition,
- createScene,
- getElementContainingPosition,
- hasBackground,
- hasStroke,
- hasText,
- exportCanvas,
- importFromBackend,
- addToLoadedScenes,
- loadedScenes,
- calculateScrollCenter,
- loadFromBlob,
- } from "./scene";
- import { renderScene } from "./renderer";
- import { AppState } from "./types";
- import { ExcalidrawElement } from "./element/types";
- import {
- isWritableElement,
- isInputLike,
- debounce,
- capitalizeString,
- distance,
- distance2d,
- } from "./utils";
- import { KEYS, isArrowKey } from "./keys";
- import { findShapeByKey, shapesShortcutKeys, SHAPES } from "./shapes";
- import { createHistory } from "./history";
- import ContextMenu from "./components/ContextMenu";
- import "./styles.scss";
- import { getElementWithResizeHandler } from "./element/resizeTest";
- import {
- ActionManager,
- actionDeleteSelected,
- actionSendBackward,
- actionBringForward,
- actionSendToBack,
- actionBringToFront,
- actionSelectAll,
- actionChangeStrokeColor,
- actionChangeBackgroundColor,
- actionChangeOpacity,
- actionChangeStrokeWidth,
- actionChangeFillStyle,
- actionChangeSloppiness,
- actionChangeFontSize,
- actionChangeFontFamily,
- actionChangeViewBackgroundColor,
- actionClearCanvas,
- actionChangeProjectName,
- actionChangeExportBackground,
- actionLoadScene,
- actionSaveScene,
- actionCopyStyles,
- actionPasteStyles,
- actionFinalize,
- } from "./actions";
- import { Action, ActionResult } from "./actions/types";
- import { getDefaultAppState } from "./appState";
- import { Island } from "./components/Island";
- import Stack from "./components/Stack";
- import { FixedSideContainer } from "./components/FixedSideContainer";
- import { ToolButton } from "./components/ToolButton";
- import { LockIcon } from "./components/LockIcon";
- import { ExportDialog } from "./components/ExportDialog";
- import { LanguageList } from "./components/LanguageList";
- import { Point } from "roughjs/bin/geometry";
- import { t, languages, setLanguage, getLanguage } from "./i18n";
- import { StoredScenesList } from "./components/StoredScenesList";
- import { HintViewer } from "./components/HintViewer";
- import {
- getAppClipboard,
- copyToAppClipboard,
- parseClipboardEvent,
- } from "./clipboard";
- let { elements } = createScene();
- const { history } = createHistory();
- const CANVAS_WINDOW_OFFSET_LEFT = 0;
- const CANVAS_WINDOW_OFFSET_TOP = 0;
- function resetCursor() {
- document.documentElement.style.cursor = "";
- }
- function setCursorForShape(shape: string) {
- if (shape === "selection") {
- resetCursor();
- } else {
- document.documentElement.style.cursor =
- shape === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
- }
- }
- const DRAGGING_THRESHOLD = 10; // 10px
- const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
- const ELEMENT_TRANSLATE_AMOUNT = 1;
- const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
- const CURSOR_TYPE = {
- TEXT: "text",
- CROSSHAIR: "crosshair",
- GRABBING: "grabbing",
- };
- const MOUSE_BUTTON = {
- MAIN: 0,
- WHEEL: 1,
- SECONDARY: 2,
- };
- let lastCanvasWidth = -1;
- let lastCanvasHeight = -1;
- let lastMouseUp: ((e: any) => void) | null = null;
- export function viewportCoordsToSceneCoords(
- { clientX, clientY }: { clientX: number; clientY: number },
- { scrollX, scrollY }: { scrollX: number; scrollY: number },
- ) {
- const x = clientX - CANVAS_WINDOW_OFFSET_LEFT - scrollX;
- const y = clientY - CANVAS_WINDOW_OFFSET_TOP - scrollY;
- return { x, y };
- }
- let cursorX = 0;
- let cursorY = 0;
- let isHoldingSpace: boolean = false;
- let isPanning: boolean = false;
- let isHoldingMouseButton: boolean = false;
- interface LayerUIProps {
- actionManager: ActionManager;
- appState: AppState;
- canvas: HTMLCanvasElement | null;
- setAppState: any;
- elements: readonly ExcalidrawElement[];
- setElements: (elements: readonly ExcalidrawElement[]) => void;
- }
- const LayerUI = React.memo(
- ({
- actionManager,
- appState,
- setAppState,
- canvas,
- elements,
- setElements,
- }: LayerUIProps) => {
- function renderCanvasActions() {
- return (
- <Stack.Col gap={4}>
- <Stack.Row justifyContent={"space-between"}>
- {actionManager.renderAction("loadScene")}
- {actionManager.renderAction("saveScene")}
- <ExportDialog
- elements={elements}
- appState={appState}
- actionManager={actionManager}
- onExportToPng={(exportedElements, scale) => {
- if (canvas) {
- exportCanvas("png", exportedElements, canvas, {
- exportBackground: appState.exportBackground,
- name: appState.name,
- viewBackgroundColor: appState.viewBackgroundColor,
- scale,
- });
- }
- }}
- onExportToSvg={(exportedElements, scale) => {
- if (canvas) {
- exportCanvas("svg", exportedElements, canvas, {
- exportBackground: appState.exportBackground,
- name: appState.name,
- viewBackgroundColor: appState.viewBackgroundColor,
- scale,
- });
- }
- }}
- onExportToClipboard={(exportedElements, scale) => {
- if (canvas) {
- exportCanvas("clipboard", exportedElements, canvas, {
- exportBackground: appState.exportBackground,
- name: appState.name,
- viewBackgroundColor: appState.viewBackgroundColor,
- scale,
- });
- }
- }}
- onExportToBackend={exportedElements => {
- if (canvas) {
- exportCanvas(
- "backend",
- exportedElements.map(element => ({
- ...element,
- isSelected: false,
- })),
- canvas,
- appState,
- );
- }
- }}
- />
- {actionManager.renderAction("clearCanvas")}
- </Stack.Row>
- {actionManager.renderAction("changeViewBackgroundColor")}
- </Stack.Col>
- );
- }
- function renderSelectedShapeActions(
- elements: readonly ExcalidrawElement[],
- ) {
- const { elementType, editingElement } = appState;
- const targetElements = editingElement
- ? [editingElement]
- : elements.filter(el => el.isSelected);
- if (!targetElements.length && elementType === "selection") {
- return null;
- }
- return (
- <Island padding={4}>
- <div className="panelColumn">
- {actionManager.renderAction("changeStrokeColor")}
- {(hasBackground(elementType) ||
- targetElements.some(element => hasBackground(element.type))) && (
- <>
- {actionManager.renderAction("changeBackgroundColor")}
- {actionManager.renderAction("changeFillStyle")}
- </>
- )}
- {(hasStroke(elementType) ||
- targetElements.some(element => hasStroke(element.type))) && (
- <>
- {actionManager.renderAction("changeStrokeWidth")}
- {actionManager.renderAction("changeSloppiness")}
- </>
- )}
- {(hasText(elementType) ||
- targetElements.some(element => hasText(element.type))) && (
- <>
- {actionManager.renderAction("changeFontSize")}
- {actionManager.renderAction("changeFontFamily")}
- </>
- )}
- {actionManager.renderAction("changeOpacity")}
- {actionManager.renderAction("deleteSelectedElements")}
- </div>
- </Island>
- );
- }
- function renderShapesSwitcher() {
- return (
- <>
- {SHAPES.map(({ value, icon }, index) => {
- const label = t(`toolBar.${value}`);
- return (
- <ToolButton
- key={value}
- type="radio"
- icon={icon}
- checked={appState.elementType === value}
- name="editor-current-shape"
- title={`${capitalizeString(label)} — ${
- capitalizeString(value)[0]
- }, ${index + 1}`}
- keyBindingLabel={`${index + 1}`}
- aria-label={capitalizeString(label)}
- aria-keyshortcuts={`${label[0]} ${index + 1}`}
- onChange={() => {
- setAppState({ elementType: value, multiElement: null });
- setElements(clearSelection(elements));
- document.documentElement.style.cursor =
- value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
- setAppState({});
- }}
- ></ToolButton>
- );
- })}
- </>
- );
- }
- return (
- <FixedSideContainer side="top">
- <div className="App-menu App-menu_top">
- <Stack.Col gap={4} align="end">
- <section
- className="App-right-menu"
- aria-labelledby="canvas-actions-title"
- >
- <h2 className="visually-hidden" id="canvas-actions-title">
- {t("headings.canvasActions")}
- </h2>
- <Island padding={4}>{renderCanvasActions()}</Island>
- </section>
- <section
- className="App-right-menu"
- aria-labelledby="selected-shape-title"
- >
- <h2 className="visually-hidden" id="selected-shape-title">
- {t("headings.selectedShapeActions")}
- </h2>
- {renderSelectedShapeActions(elements)}
- </section>
- </Stack.Col>
- <section aria-labelledby="shapes-title">
- <Stack.Col gap={4} align="start">
- <Stack.Row gap={1}>
- <Island padding={1}>
- <h2 className="visually-hidden" id="shapes-title">
- {t("headings.shapes")}
- </h2>
- <Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
- </Island>
- <LockIcon
- checked={appState.elementLocked}
- onChange={() => {
- setAppState({
- elementLocked: !appState.elementLocked,
- elementType: appState.elementLocked
- ? "selection"
- : appState.elementType,
- });
- }}
- title={t("toolBar.lock")}
- />
- </Stack.Row>
- </Stack.Col>
- </section>
- <div />
- </div>
- </FixedSideContainer>
- );
- },
- (prev, next) => {
- const getNecessaryObj = (appState: AppState): Partial<AppState> => {
- const {
- draggingElement,
- resizingElement,
- multiElement,
- editingElement,
- isResizing,
- cursorX,
- cursorY,
- ...ret
- } = appState;
- return ret;
- };
- const prevAppState = getNecessaryObj(prev.appState);
- const nextAppState = getNecessaryObj(next.appState);
- const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
- return (
- prev.elements === next.elements &&
- keys.every(k => prevAppState[k] === nextAppState[k])
- );
- },
- );
- export class App extends React.Component<any, AppState> {
- canvas: HTMLCanvasElement | null = null;
- rc: RoughCanvas | null = null;
- actionManager: ActionManager;
- canvasOnlyActions: Array<Action>;
- constructor(props: any) {
- super(props);
- this.actionManager = new ActionManager(
- this.syncActionResult,
- () => {
- history.resumeRecording();
- },
- () => this.state,
- () => elements,
- );
- this.actionManager.registerAction(actionFinalize);
- this.actionManager.registerAction(actionDeleteSelected);
- this.actionManager.registerAction(actionSendToBack);
- this.actionManager.registerAction(actionBringToFront);
- this.actionManager.registerAction(actionSendBackward);
- this.actionManager.registerAction(actionBringForward);
- this.actionManager.registerAction(actionSelectAll);
- this.actionManager.registerAction(actionChangeStrokeColor);
- this.actionManager.registerAction(actionChangeBackgroundColor);
- this.actionManager.registerAction(actionChangeFillStyle);
- this.actionManager.registerAction(actionChangeStrokeWidth);
- this.actionManager.registerAction(actionChangeOpacity);
- this.actionManager.registerAction(actionChangeSloppiness);
- this.actionManager.registerAction(actionChangeFontSize);
- this.actionManager.registerAction(actionChangeFontFamily);
- this.actionManager.registerAction(actionChangeViewBackgroundColor);
- this.actionManager.registerAction(actionClearCanvas);
- this.actionManager.registerAction(actionChangeProjectName);
- this.actionManager.registerAction(actionChangeExportBackground);
- this.actionManager.registerAction(actionSaveScene);
- this.actionManager.registerAction(actionLoadScene);
- this.actionManager.registerAction(actionCopyStyles);
- this.actionManager.registerAction(actionPasteStyles);
- this.canvasOnlyActions = [actionSelectAll];
- }
- private syncActionResult = (res: ActionResult) => {
- if (res.elements !== undefined) {
- elements = res.elements;
- this.setState({});
- }
- if (res.appState !== undefined) {
- this.setState({ ...res.appState });
- }
- };
- private onCut = (e: ClipboardEvent) => {
- if (isWritableElement(e.target)) {
- return;
- }
- copyToAppClipboard(elements);
- elements = deleteSelectedElements(elements);
- history.resumeRecording();
- this.setState({});
- e.preventDefault();
- };
- private onCopy = (e: ClipboardEvent) => {
- if (isWritableElement(e.target)) {
- return;
- }
- copyToAppClipboard(elements);
- e.preventDefault();
- };
- private onPaste = (e: ClipboardEvent) => {
- // #686
- const target = document.activeElement;
- const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
- if (
- elementUnderCursor instanceof HTMLCanvasElement &&
- !isWritableElement(target)
- ) {
- const data = parseClipboardEvent(e);
- if (data.elements) {
- this.addElementsFromPaste(data.elements);
- } else if (data.text) {
- const { x, y } = viewportCoordsToSceneCoords(
- { clientX: cursorX, clientY: cursorY },
- this.state,
- );
- const element = newTextElement(
- newElement(
- "text",
- x,
- y,
- this.state.currentItemStrokeColor,
- this.state.currentItemBackgroundColor,
- this.state.currentItemFillStyle,
- this.state.currentItemStrokeWidth,
- this.state.currentItemRoughness,
- this.state.currentItemOpacity,
- ),
- data.text,
- this.state.currentItemFont,
- );
- element.isSelected = true;
- elements = [...clearSelection(elements), element];
- history.resumeRecording();
- this.setState({});
- }
- e.preventDefault();
- }
- };
- private onUnload = () => {
- isHoldingSpace = false;
- this.saveDebounced();
- this.saveDebounced.flush();
- };
- private async loadScene(id: string | null, k: string | undefined) {
- let data;
- let selectedId;
- if (id != null) {
- // k is the private key used to decrypt the content from the server, take
- // extra care not to leak it
- data = await importFromBackend(id, k);
- addToLoadedScenes(id, k);
- selectedId = id;
- window.history.replaceState({}, "Excalidraw", window.location.origin);
- } else {
- data = restoreFromLocalStorage();
- }
- if (data.elements) {
- elements = data.elements;
- }
- if (data.appState) {
- history.resumeRecording();
- this.setState({ ...data.appState, selectedId });
- } else {
- this.setState({});
- }
- }
- public async componentDidMount() {
- document.addEventListener("copy", this.onCopy);
- document.addEventListener("paste", this.onPaste);
- document.addEventListener("cut", this.onCut);
- document.addEventListener("keydown", this.onKeyDown, false);
- document.addEventListener("keyup", this.onKeyUp, { passive: true });
- document.addEventListener("mousemove", this.updateCurrentCursorPosition);
- window.addEventListener("resize", this.onResize, false);
- window.addEventListener("unload", this.onUnload, false);
- window.addEventListener("blur", this.onUnload, false);
- window.addEventListener("dragover", e => e.preventDefault(), false);
- window.addEventListener("drop", e => e.preventDefault(), false);
- const searchParams = new URLSearchParams(window.location.search);
- const id = searchParams.get("id");
- if (id) {
- // Backwards compatibility with legacy url format
- this.loadScene(id, undefined);
- } else {
- const match = window.location.hash.match(
- /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
- );
- if (match) {
- this.loadScene(match[1], match[2]);
- } else {
- this.loadScene(null, undefined);
- }
- }
- }
- public componentWillUnmount() {
- document.removeEventListener("copy", this.onCopy);
- document.removeEventListener("paste", this.onPaste);
- document.removeEventListener("cut", this.onCut);
- document.removeEventListener("keydown", this.onKeyDown, false);
- document.removeEventListener(
- "mousemove",
- this.updateCurrentCursorPosition,
- false,
- );
- window.removeEventListener("resize", this.onResize, false);
- window.removeEventListener("unload", this.onUnload, false);
- window.removeEventListener("blur", this.onUnload, false);
- }
- public state: AppState = getDefaultAppState();
- private onResize = () => {
- this.setState({});
- };
- private updateCurrentCursorPosition = (e: MouseEvent) => {
- cursorX = e.x;
- cursorY = e.y;
- };
- private onKeyDown = (event: KeyboardEvent) => {
- if (
- (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) ||
- // case: using arrows to move between buttons
- (isArrowKey(event.key) && isInputLike(event.target))
- ) {
- return;
- }
- const actionResult = this.actionManager.handleKeyDown(event);
- if (actionResult) {
- this.syncActionResult(actionResult);
- if (actionResult) {
- return;
- }
- }
- const shape = findShapeByKey(event.key);
- if (isArrowKey(event.key)) {
- const step = event.shiftKey
- ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
- : ELEMENT_TRANSLATE_AMOUNT;
- elements = elements.map(el => {
- if (el.isSelected) {
- const element = { ...el };
- if (event.key === KEYS.ARROW_LEFT) {
- element.x -= step;
- } else if (event.key === KEYS.ARROW_RIGHT) {
- element.x += step;
- } else if (event.key === KEYS.ARROW_UP) {
- element.y -= step;
- } else if (event.key === KEYS.ARROW_DOWN) {
- element.y += step;
- }
- return element;
- }
- return el;
- });
- this.setState({});
- event.preventDefault();
- } else if (
- shapesShortcutKeys.includes(event.key.toLowerCase()) &&
- !event.ctrlKey &&
- !event.altKey &&
- !event.metaKey &&
- this.state.draggingElement === null
- ) {
- if (!isHoldingSpace) {
- setCursorForShape(shape);
- }
- if (document.activeElement instanceof HTMLElement) {
- document.activeElement.blur();
- }
- elements = clearSelection(elements);
- this.setState({ elementType: shape });
- // Undo action
- } else if (event[KEYS.META] && /z/i.test(event.key)) {
- event.preventDefault();
- if (
- this.state.multiElement ||
- this.state.resizingElement ||
- this.state.editingElement ||
- this.state.draggingElement
- ) {
- return;
- }
- if (event.shiftKey) {
- // Redo action
- const data = history.redoOnce();
- if (data !== null) {
- elements = data.elements;
- this.setState({ ...data.appState });
- }
- } else {
- // undo action
- const data = history.undoOnce();
- if (data !== null) {
- elements = data.elements;
- this.setState({ ...data.appState });
- }
- }
- } else if (event.key === KEYS.SPACE && !isHoldingMouseButton) {
- isHoldingSpace = true;
- document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
- }
- };
- private onKeyUp = (event: KeyboardEvent) => {
- if (event.key === KEYS.SPACE) {
- if (this.state.elementType === "selection") {
- resetCursor();
- } else {
- elements = clearSelection(elements);
- document.documentElement.style.cursor =
- this.state.elementType === "text"
- ? CURSOR_TYPE.TEXT
- : CURSOR_TYPE.CROSSHAIR;
- this.setState({});
- }
- isHoldingSpace = false;
- }
- };
- private removeWheelEventListener: (() => void) | undefined;
- private copyToAppClipboard = () => {
- copyToAppClipboard(elements);
- };
- private pasteFromClipboard = () => {
- const data = getAppClipboard();
- if (data.elements) {
- this.addElementsFromPaste(data.elements);
- }
- };
- setAppState = (obj: any) => {
- this.setState(obj);
- };
- setElements = (elements_: readonly ExcalidrawElement[]) => {
- elements = elements_;
- this.setState({});
- };
- public render() {
- const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
- const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
- return (
- <div className="container">
- <LayerUI
- canvas={this.canvas}
- appState={this.state}
- setAppState={this.setAppState}
- actionManager={this.actionManager}
- elements={elements}
- setElements={this.setElements}
- />
- <main>
- <canvas
- id="canvas"
- style={{
- width: canvasWidth,
- height: canvasHeight,
- }}
- width={canvasWidth * window.devicePixelRatio}
- height={canvasHeight * window.devicePixelRatio}
- ref={canvas => {
- if (this.canvas === null) {
- this.canvas = canvas;
- this.rc = rough.canvas(this.canvas!);
- }
- if (this.removeWheelEventListener) {
- this.removeWheelEventListener();
- this.removeWheelEventListener = undefined;
- }
- if (canvas) {
- canvas.addEventListener("wheel", this.handleWheel, {
- passive: false,
- });
- this.removeWheelEventListener = () =>
- canvas.removeEventListener("wheel", this.handleWheel);
- // Whenever React sets the width/height of the canvas element,
- // the context loses the scale transform. We need to re-apply it
- if (
- canvasWidth !== lastCanvasWidth ||
- canvasHeight !== lastCanvasHeight
- ) {
- lastCanvasWidth = canvasWidth;
- lastCanvasHeight = canvasHeight;
- canvas
- .getContext("2d")!
- .scale(window.devicePixelRatio, window.devicePixelRatio);
- }
- }
- }}
- onContextMenu={e => {
- e.preventDefault();
- const { x, y } = viewportCoordsToSceneCoords(e, this.state);
- const element = getElementAtPosition(elements, x, y);
- if (!element) {
- ContextMenu.push({
- options: [
- navigator.clipboard && {
- label: t("labels.paste"),
- action: () => this.pasteFromClipboard(),
- },
- ...this.actionManager.getContextMenuItems(action =>
- this.canvasOnlyActions.includes(action),
- ),
- ],
- top: e.clientY,
- left: e.clientX,
- });
- return;
- }
- if (!element.isSelected) {
- elements = clearSelection(elements);
- element.isSelected = true;
- this.setState({});
- }
- ContextMenu.push({
- options: [
- navigator.clipboard && {
- label: t("labels.copy"),
- action: this.copyToAppClipboard,
- },
- navigator.clipboard && {
- label: t("labels.paste"),
- action: () => this.pasteFromClipboard(),
- },
- ...this.actionManager.getContextMenuItems(
- action => !this.canvasOnlyActions.includes(action),
- ),
- ],
- top: e.clientY,
- left: e.clientX,
- });
- }}
- onMouseDown={e => {
- if (lastMouseUp !== null) {
- // Unfortunately, sometimes we don't get a mouseup after a mousedown,
- // this can happen when a contextual menu or alert is triggered. In order to avoid
- // being in a weird state, we clean up on the next mousedown
- lastMouseUp(e);
- }
- if (isPanning) {
- return;
- }
- // pan canvas on wheel button drag or space+drag
- if (
- !isHoldingMouseButton &&
- (e.button === MOUSE_BUTTON.WHEEL ||
- (e.button === MOUSE_BUTTON.MAIN && isHoldingSpace))
- ) {
- isHoldingMouseButton = true;
- isPanning = true;
- document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
- let { clientX: lastX, clientY: lastY } = e;
- const onMouseMove = (e: MouseEvent) => {
- const deltaX = lastX - e.clientX;
- const deltaY = lastY - e.clientY;
- lastX = e.clientX;
- lastY = e.clientY;
- this.setState({
- scrollX: this.state.scrollX - deltaX,
- scrollY: this.state.scrollY - deltaY,
- });
- };
- const teardown = (lastMouseUp = () => {
- lastMouseUp = null;
- isPanning = false;
- isHoldingMouseButton = false;
- if (!isHoldingSpace) {
- setCursorForShape(this.state.elementType);
- }
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", teardown);
- window.removeEventListener("blur", teardown);
- });
- window.addEventListener("blur", teardown);
- window.addEventListener("mousemove", onMouseMove, {
- passive: true,
- });
- window.addEventListener("mouseup", teardown);
- return;
- }
- // only handle left mouse button
- if (e.button !== MOUSE_BUTTON.MAIN) {
- return;
- }
- // fixes mousemove causing selection of UI texts #32
- e.preventDefault();
- // Preventing the event above disables default behavior
- // of defocusing potentially focused element, which is what we
- // want when clicking inside the canvas.
- if (document.activeElement instanceof HTMLElement) {
- document.activeElement.blur();
- }
- // Handle scrollbars dragging
- const {
- isOverHorizontalScrollBar,
- isOverVerticalScrollBar,
- } = isOverScrollBars(
- elements,
- e.clientX - CANVAS_WINDOW_OFFSET_LEFT,
- e.clientY - CANVAS_WINDOW_OFFSET_TOP,
- canvasWidth,
- canvasHeight,
- this.state.scrollX,
- this.state.scrollY,
- );
- const { x, y } = viewportCoordsToSceneCoords(e, this.state);
- const originX = x;
- const originY = y;
- let element = newElement(
- this.state.elementType,
- x,
- y,
- this.state.currentItemStrokeColor,
- this.state.currentItemBackgroundColor,
- this.state.currentItemFillStyle,
- this.state.currentItemStrokeWidth,
- this.state.currentItemRoughness,
- this.state.currentItemOpacity,
- );
- if (isTextElement(element)) {
- element = newTextElement(
- element,
- "",
- this.state.currentItemFont,
- );
- }
- type ResizeTestType = ReturnType<typeof resizeTest>;
- let resizeHandle: ResizeTestType = false;
- let isResizingElements = false;
- let draggingOccurred = false;
- let hitElement: ExcalidrawElement | null = null;
- let elementIsAddedToSelection = false;
- if (this.state.elementType === "selection") {
- const resizeElement = getElementWithResizeHandler(
- elements,
- { x, y },
- this.state,
- );
- this.setState({
- resizingElement: resizeElement ? resizeElement.element : null,
- });
- if (resizeElement) {
- resizeHandle = resizeElement.resizeHandle;
- document.documentElement.style.cursor = getCursorForResizingElement(
- resizeElement,
- );
- isResizingElements = true;
- } else {
- hitElement = getElementAtPosition(elements, x, y);
- // clear selection if shift is not clicked
- if (!hitElement?.isSelected && !e.shiftKey) {
- elements = clearSelection(elements);
- }
- // If we click on something
- if (hitElement) {
- // deselect if item is selected
- // if shift is not clicked, this will always return true
- // otherwise, it will trigger selection based on current
- // state of the box
- if (!hitElement.isSelected) {
- hitElement.isSelected = true;
- elements = elements.slice();
- elementIsAddedToSelection = true;
- }
- // We duplicate the selected element if alt is pressed on Mouse down
- if (e.altKey) {
- elements = [
- ...elements.map(element => ({
- ...element,
- isSelected: false,
- })),
- ...elements
- .filter(element => element.isSelected)
- .map(element => {
- const newElement = duplicateElement(element);
- newElement.isSelected = true;
- return newElement;
- }),
- ];
- }
- }
- }
- } else {
- elements = clearSelection(elements);
- }
- if (isTextElement(element)) {
- let textX = e.clientX;
- let textY = e.clientY;
- if (!e.altKey) {
- const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
- x,
- y,
- );
- if (snappedToCenterPosition) {
- element.x = snappedToCenterPosition.elementCenterX;
- element.y = snappedToCenterPosition.elementCenterY;
- textX = snappedToCenterPosition.wysiwygX;
- textY = snappedToCenterPosition.wysiwygY;
- }
- }
- const resetSelection = () => {
- this.setState({
- draggingElement: null,
- editingElement: null,
- elementType: "selection",
- });
- };
- textWysiwyg({
- initText: "",
- x: textX,
- y: textY,
- strokeColor: this.state.currentItemStrokeColor,
- opacity: this.state.currentItemOpacity,
- font: this.state.currentItemFont,
- onSubmit: text => {
- if (text) {
- elements = [
- ...elements,
- {
- ...newTextElement(
- element,
- text,
- this.state.currentItemFont,
- ),
- isSelected: true,
- },
- ];
- }
- history.resumeRecording();
- resetSelection();
- },
- onCancel: () => {
- resetSelection();
- },
- });
- this.setState({
- elementType: "selection",
- editingElement: element,
- });
- return;
- } else if (
- this.state.elementType === "arrow" ||
- this.state.elementType === "line"
- ) {
- if (this.state.multiElement) {
- const { multiElement } = this.state;
- const { x: rx, y: ry } = multiElement;
- multiElement.isSelected = true;
- multiElement.points.push([x - rx, y - ry]);
- multiElement.shape = null;
- } else {
- element.isSelected = false;
- element.points.push([0, 0]);
- element.shape = null;
- elements = [...elements, element];
- this.setState({
- draggingElement: element,
- });
- }
- } else if (element.type === "selection") {
- this.setState({
- selectionElement: element,
- draggingElement: element,
- });
- } else {
- elements = [...elements, element];
- this.setState({ multiElement: null, draggingElement: element });
- }
- let lastX = x;
- let lastY = y;
- if (isOverHorizontalScrollBar || isOverVerticalScrollBar) {
- lastX = e.clientX - CANVAS_WINDOW_OFFSET_LEFT;
- lastY = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
- }
- let resizeArrowFn:
- | ((
- element: ExcalidrawElement,
- p1: Point,
- deltaX: number,
- deltaY: number,
- mouseX: number,
- mouseY: number,
- perfect: boolean,
- ) => void)
- | null = null;
- const arrowResizeOrigin = (
- element: ExcalidrawElement,
- p1: Point,
- deltaX: number,
- deltaY: number,
- mouseX: number,
- mouseY: number,
- perfect: boolean,
- ) => {
- if (perfect) {
- const absPx = p1[0] + element.x;
- const absPy = p1[1] + element.y;
- const { width, height } = getPerfectElementSize(
- element.type,
- mouseX - element.x - p1[0],
- mouseY - element.y - p1[1],
- );
- const dx = element.x + width + p1[0];
- const dy = element.y + height + p1[1];
- element.x = dx;
- element.y = dy;
- p1[0] = absPx - element.x;
- p1[1] = absPy - element.y;
- } else {
- element.x += deltaX;
- element.y += deltaY;
- p1[0] -= deltaX;
- p1[1] -= deltaY;
- }
- };
- const arrowResizeEnd = (
- element: ExcalidrawElement,
- p1: Point,
- deltaX: number,
- deltaY: number,
- mouseX: number,
- mouseY: number,
- perfect: boolean,
- ) => {
- if (perfect) {
- const { width, height } = getPerfectElementSize(
- element.type,
- mouseX - element.x,
- mouseY - element.y,
- );
- p1[0] = width;
- p1[1] = height;
- } else {
- p1[0] += deltaX;
- p1[1] += deltaY;
- }
- };
- const onMouseMove = (e: MouseEvent) => {
- const target = e.target;
- if (!(target instanceof HTMLElement)) {
- return;
- }
- if (isOverHorizontalScrollBar) {
- const x = e.clientX - CANVAS_WINDOW_OFFSET_LEFT;
- const dx = x - lastX;
- this.setState({ scrollX: this.state.scrollX - dx });
- lastX = x;
- return;
- }
- if (isOverVerticalScrollBar) {
- const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
- const dy = y - lastY;
- this.setState({ scrollY: this.state.scrollY - dy });
- lastY = y;
- return;
- }
- // for arrows, don't start dragging until a given threshold
- // to ensure we don't create a 2-point arrow by mistake when
- // user clicks mouse in a way that it moves a tiny bit (thus
- // triggering mousemove)
- if (
- !draggingOccurred &&
- (this.state.elementType === "arrow" ||
- this.state.elementType === "line")
- ) {
- const { x, y } = viewportCoordsToSceneCoords(e, this.state);
- if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
- return;
- }
- }
- if (isResizingElements && this.state.resizingElement) {
- this.setState({ isResizing: true });
- const el = this.state.resizingElement;
- const selectedElements = elements.filter(el => el.isSelected);
- if (selectedElements.length === 1) {
- const { x, y } = viewportCoordsToSceneCoords(e, this.state);
- const deltaX = x - lastX;
- const deltaY = y - lastY;
- const element = selectedElements[0];
- const isLinear =
- element.type === "line" || element.type === "arrow";
- switch (resizeHandle) {
- case "nw":
- if (isLinear && element.points.length === 2) {
- const [, p1] = element.points;
- if (!resizeArrowFn) {
- if (p1[0] < 0 || p1[1] < 0) {
- resizeArrowFn = arrowResizeEnd;
- } else {
- resizeArrowFn = arrowResizeOrigin;
- }
- }
- resizeArrowFn(
- element,
- p1,
- deltaX,
- deltaY,
- x,
- y,
- e.shiftKey,
- );
- } else {
- element.width -= deltaX;
- element.x += deltaX;
- if (e.shiftKey) {
- element.y += element.height - element.width;
- element.height = element.width;
- } else {
- element.height -= deltaY;
- element.y += deltaY;
- }
- }
- break;
- case "ne":
- if (isLinear && element.points.length === 2) {
- const [, p1] = element.points;
- if (!resizeArrowFn) {
- if (p1[0] >= 0) {
- resizeArrowFn = arrowResizeEnd;
- } else {
- resizeArrowFn = arrowResizeOrigin;
- }
- }
- resizeArrowFn(
- element,
- p1,
- deltaX,
- deltaY,
- x,
- y,
- e.shiftKey,
- );
- } else {
- element.width += deltaX;
- if (e.shiftKey) {
- element.y += element.height - element.width;
- element.height = element.width;
- } else {
- element.height -= deltaY;
- element.y += deltaY;
- }
- }
- break;
- case "sw":
- if (isLinear && element.points.length === 2) {
- const [, p1] = element.points;
- if (!resizeArrowFn) {
- if (p1[0] <= 0) {
- resizeArrowFn = arrowResizeEnd;
- } else {
- resizeArrowFn = arrowResizeOrigin;
- }
- }
- resizeArrowFn(
- element,
- p1,
- deltaX,
- deltaY,
- x,
- y,
- e.shiftKey,
- );
- } else {
- element.width -= deltaX;
- element.x += deltaX;
- if (e.shiftKey) {
- element.height = element.width;
- } else {
- element.height += deltaY;
- }
- }
- break;
- case "se":
- if (isLinear && element.points.length === 2) {
- const [, p1] = element.points;
- if (!resizeArrowFn) {
- if (p1[0] > 0 || p1[1] > 0) {
- resizeArrowFn = arrowResizeEnd;
- } else {
- resizeArrowFn = arrowResizeOrigin;
- }
- }
- resizeArrowFn(
- element,
- p1,
- deltaX,
- deltaY,
- x,
- y,
- e.shiftKey,
- );
- } else {
- if (e.shiftKey) {
- element.width += deltaX;
- element.height = element.width;
- } else {
- element.width += deltaX;
- element.height += deltaY;
- }
- }
- break;
- case "n": {
- element.height -= deltaY;
- element.y += deltaY;
- if (element.points.length > 0) {
- const len = element.points.length;
- const points = [...element.points].sort(
- (a, b) => a[1] - b[1],
- );
- for (let i = 1; i < points.length; ++i) {
- const pnt = points[i];
- pnt[1] -= deltaY / (len - i);
- }
- }
- break;
- }
- case "w": {
- element.width -= deltaX;
- element.x += deltaX;
- if (element.points.length > 0) {
- const len = element.points.length;
- const points = [...element.points].sort(
- (a, b) => a[0] - b[0],
- );
- for (let i = 0; i < points.length; ++i) {
- const pnt = points[i];
- pnt[0] -= deltaX / (len - i);
- }
- }
- break;
- }
- case "s": {
- element.height += deltaY;
- if (element.points.length > 0) {
- const len = element.points.length;
- const points = [...element.points].sort(
- (a, b) => a[1] - b[1],
- );
- for (let i = 1; i < points.length; ++i) {
- const pnt = points[i];
- pnt[1] += deltaY / (len - i);
- }
- }
- break;
- }
- case "e": {
- element.width += deltaX;
- if (element.points.length > 0) {
- const len = element.points.length;
- const points = [...element.points].sort(
- (a, b) => a[0] - b[0],
- );
- for (let i = 1; i < points.length; ++i) {
- const pnt = points[i];
- pnt[0] += deltaX / (len - i);
- }
- }
- break;
- }
- }
- if (resizeHandle) {
- resizeHandle = normalizeResizeHandle(
- element,
- resizeHandle,
- );
- }
- normalizeDimensions(element);
- document.documentElement.style.cursor = getCursorForResizingElement(
- { element, resizeHandle },
- );
- el.x = element.x;
- el.y = element.y;
- el.shape = null;
- lastX = x;
- lastY = y;
- this.setState({});
- return;
- }
- }
- if (hitElement?.isSelected) {
- // Marking that click was used for dragging to check
- // if elements should be deselected on mouseup
- draggingOccurred = true;
- const selectedElements = elements.filter(el => el.isSelected);
- if (selectedElements.length) {
- const { x, y } = viewportCoordsToSceneCoords(e, this.state);
- selectedElements.forEach(element => {
- element.x += x - lastX;
- element.y += y - lastY;
- });
- lastX = x;
- lastY = y;
- this.setState({});
- return;
- }
- }
- // It is very important to read this.state within each move event,
- // otherwise we would read a stale one!
- const draggingElement = this.state.draggingElement;
- if (!draggingElement) {
- return;
- }
- const { x, y } = viewportCoordsToSceneCoords(e, this.state);
- let width = distance(originX, x);
- let height = distance(originY, y);
- const isLinear =
- this.state.elementType === "line" ||
- this.state.elementType === "arrow";
- if (isLinear) {
- draggingOccurred = true;
- const points = draggingElement.points;
- let dx = x - draggingElement.x;
- let dy = y - draggingElement.y;
- if (e.shiftKey && points.length === 2) {
- ({ width: dx, height: dy } = getPerfectElementSize(
- this.state.elementType,
- dx,
- dy,
- ));
- }
- if (points.length === 1) {
- points.push([dx, dy]);
- } else if (points.length > 1) {
- const pnt = points[points.length - 1];
- pnt[0] = dx;
- pnt[1] = dy;
- }
- } else {
- if (e.shiftKey) {
- ({ width, height } = getPerfectElementSize(
- this.state.elementType,
- width,
- y < originY ? -height : height,
- ));
- if (height < 0) {
- height = -height;
- }
- }
- draggingElement.x = x < originX ? originX - width : originX;
- draggingElement.y = y < originY ? originY - height : originY;
- draggingElement.width = width;
- draggingElement.height = height;
- }
- draggingElement.shape = null;
- if (this.state.elementType === "selection") {
- if (!e.shiftKey && elements.some(el => el.isSelected)) {
- elements = clearSelection(elements);
- }
- const elementsWithinSelection = getElementsWithinSelection(
- elements,
- draggingElement,
- );
- elementsWithinSelection.forEach(element => {
- element.isSelected = true;
- });
- }
- this.setState({});
- };
- const onMouseUp = (e: MouseEvent) => {
- const {
- draggingElement,
- resizingElement,
- multiElement,
- elementType,
- elementLocked,
- } = this.state;
- this.setState({
- isResizing: false,
- resizingElement: null,
- selectionElement: null,
- });
- resizeArrowFn = null;
- lastMouseUp = null;
- isHoldingMouseButton = false;
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- if (elementType === "arrow" || elementType === "line") {
- if (draggingElement!.points.length > 1) {
- history.resumeRecording();
- this.setState({});
- }
- if (!draggingOccurred && draggingElement && !multiElement) {
- const { x, y } = viewportCoordsToSceneCoords(e, this.state);
- draggingElement.points.push([
- x - draggingElement.x,
- y - draggingElement.y,
- ]);
- draggingElement.shape = null;
- this.setState({ multiElement: this.state.draggingElement });
- } else if (draggingOccurred && !multiElement) {
- this.state.draggingElement!.isSelected = true;
- this.setState({
- draggingElement: null,
- elementType: "selection",
- });
- }
- return;
- }
- if (
- elementType !== "selection" &&
- draggingElement &&
- isInvisiblySmallElement(draggingElement)
- ) {
- // remove invisible element which was added in onMouseDown
- elements = elements.slice(0, -1);
- this.setState({
- draggingElement: null,
- });
- return;
- }
- if (normalizeDimensions(draggingElement)) {
- this.setState({});
- }
- if (resizingElement) {
- history.resumeRecording();
- this.setState({});
- }
- if (
- resizingElement &&
- isInvisiblySmallElement(resizingElement)
- ) {
- elements = elements.filter(
- el => el.id !== resizingElement.id,
- );
- }
- // If click occurred on already selected element
- // it is needed to remove selection from other elements
- // or if SHIFT or META key pressed remove selection
- // from hitted element
- //
- // If click occurred and elements were dragged or some element
- // was added to selection (on mousedown phase) we need to keep
- // selection unchanged
- if (
- hitElement &&
- !draggingOccurred &&
- !elementIsAddedToSelection
- ) {
- if (e.shiftKey) {
- hitElement.isSelected = false;
- } else {
- elements = clearSelection(elements);
- hitElement.isSelected = true;
- }
- }
- if (draggingElement === null) {
- // if no element is clicked, clear the selection and redraw
- elements = clearSelection(elements);
- this.setState({});
- return;
- }
- if (!elementLocked) {
- draggingElement.isSelected = true;
- }
- if (
- elementType !== "selection" ||
- elements.some(el => el.isSelected)
- ) {
- history.resumeRecording();
- }
- if (!elementLocked) {
- resetCursor();
- this.setState({
- draggingElement: null,
- elementType: "selection",
- });
- } else {
- this.setState({
- draggingElement: null,
- });
- }
- };
- lastMouseUp = onMouseUp;
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
- }}
- onDoubleClick={e => {
- const { x, y } = viewportCoordsToSceneCoords(e, this.state);
- const elementAtPosition = getElementAtPosition(elements, x, y);
- const element =
- elementAtPosition && isTextElement(elementAtPosition)
- ? elementAtPosition
- : newTextElement(
- newElement(
- "text",
- x,
- y,
- this.state.currentItemStrokeColor,
- this.state.currentItemBackgroundColor,
- this.state.currentItemFillStyle,
- this.state.currentItemStrokeWidth,
- this.state.currentItemRoughness,
- this.state.currentItemOpacity,
- ),
- "", // default text
- this.state.currentItemFont, // default font
- );
- this.setState({ editingElement: element });
- let textX = e.clientX;
- let textY = e.clientY;
- if (elementAtPosition && isTextElement(elementAtPosition)) {
- elements = elements.filter(
- element => element.id !== elementAtPosition.id,
- );
- this.setState({});
- textX =
- this.state.scrollX +
- elementAtPosition.x +
- CANVAS_WINDOW_OFFSET_LEFT +
- elementAtPosition.width / 2;
- textY =
- this.state.scrollY +
- elementAtPosition.y +
- CANVAS_WINDOW_OFFSET_TOP +
- elementAtPosition.height / 2;
- // x and y will change after calling newTextElement function
- element.x = elementAtPosition.x + elementAtPosition.width / 2;
- element.y = elementAtPosition.y + elementAtPosition.height / 2;
- } else if (!e.altKey) {
- const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
- x,
- y,
- );
- if (snappedToCenterPosition) {
- element.x = snappedToCenterPosition.elementCenterX;
- element.y = snappedToCenterPosition.elementCenterY;
- textX = snappedToCenterPosition.wysiwygX;
- textY = snappedToCenterPosition.wysiwygY;
- }
- }
- const resetSelection = () => {
- this.setState({
- draggingElement: null,
- editingElement: null,
- elementType: "selection",
- });
- };
- textWysiwyg({
- initText: element.text,
- x: textX,
- y: textY,
- strokeColor: element.strokeColor,
- font: element.font,
- opacity: this.state.currentItemOpacity,
- onSubmit: text => {
- if (text) {
- elements = [
- ...elements,
- {
- // we need to recreate the element to update dimensions &
- // position
- ...newTextElement(element, text, element.font),
- isSelected: true,
- },
- ];
- }
- history.resumeRecording();
- resetSelection();
- },
- onCancel: () => {
- resetSelection();
- },
- });
- }}
- onMouseMove={e => {
- if (isHoldingSpace || isPanning) {
- return;
- }
- const hasDeselectedButton = Boolean(e.buttons);
- const { x, y } = viewportCoordsToSceneCoords(e, this.state);
- if (this.state.multiElement) {
- const { multiElement } = this.state;
- const originX = multiElement.x;
- const originY = multiElement.y;
- const points = multiElement.points;
- const pnt = points[points.length - 1];
- pnt[0] = x - originX;
- pnt[1] = y - originY;
- multiElement.shape = null;
- this.setState({});
- return;
- }
- if (
- hasDeselectedButton ||
- this.state.elementType !== "selection"
- ) {
- return;
- }
- const selectedElements = elements.filter(e => e.isSelected)
- .length;
- if (selectedElements === 1) {
- const resizeElement = getElementWithResizeHandler(
- elements,
- { x, y },
- this.state,
- );
- if (resizeElement && resizeElement.resizeHandle) {
- document.documentElement.style.cursor = getCursorForResizingElement(
- resizeElement,
- );
- return;
- }
- }
- const hitElement = getElementAtPosition(elements, x, y);
- document.documentElement.style.cursor = hitElement ? "move" : "";
- }}
- onDrop={e => {
- const file = e.dataTransfer.files[0];
- if (file?.type === "application/json") {
- loadFromBlob(file)
- .then(({ elements, appState }) =>
- this.syncActionResult({
- elements,
- appState,
- } as ActionResult),
- )
- .catch(err => console.error(err));
- }
- }}
- >
- {t("labels.drawingCanvas")}
- </canvas>
- </main>
- <footer role="contentinfo">
- <HintViewer
- elementType={this.state.elementType}
- multiMode={this.state.multiElement !== null}
- isResizing={this.state.isResizing}
- elements={elements}
- />
- <LanguageList
- onChange={lng => {
- setLanguage(lng);
- this.setState({});
- }}
- languages={languages}
- currentLanguage={getLanguage()}
- />
- {this.renderIdsDropdown()}
- {this.state.scrolledOutside && (
- <button
- className="scroll-back-to-content"
- onClick={() => {
- this.setState({ ...calculateScrollCenter(elements) });
- }}
- >
- {t("buttons.scrollBackToContent")}
- </button>
- )}
- </footer>
- </div>
- );
- }
- private renderIdsDropdown() {
- const scenes = loadedScenes();
- if (scenes.length === 0) {
- return;
- }
- return (
- <StoredScenesList
- scenes={scenes}
- currentId={this.state.selectedId}
- onChange={(id, k) => this.loadScene(id, k)}
- />
- );
- }
- private handleWheel = (e: WheelEvent) => {
- e.preventDefault();
- const { deltaX, deltaY } = e;
- this.setState({
- scrollX: this.state.scrollX - deltaX,
- scrollY: this.state.scrollY - deltaY,
- });
- };
- private addElementsFromPaste = (
- clipboardElements: readonly ExcalidrawElement[],
- ) => {
- elements = clearSelection(elements);
- const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements);
- const elementsCenterX = distance(minX, maxX) / 2;
- const elementsCenterY = distance(minY, maxY) / 2;
- const dx =
- cursorX -
- this.state.scrollX -
- CANVAS_WINDOW_OFFSET_LEFT -
- elementsCenterX;
- const dy =
- cursorY - this.state.scrollY - CANVAS_WINDOW_OFFSET_TOP - elementsCenterY;
- elements = [
- ...elements,
- ...clipboardElements.map(clipboardElements => {
- const duplicate = duplicateElement(clipboardElements);
- duplicate.x += dx - minX;
- duplicate.y += dy - minY;
- return duplicate;
- }),
- ];
- history.resumeRecording();
- this.setState({});
- };
- private getTextWysiwygSnappedToCenterPosition(x: number, y: number) {
- const elementClickedInside = getElementContainingPosition(elements, x, y);
- if (elementClickedInside) {
- const elementCenterX =
- elementClickedInside.x + elementClickedInside.width / 2;
- const elementCenterY =
- elementClickedInside.y + elementClickedInside.height / 2;
- const distanceToCenter = Math.hypot(
- x - elementCenterX,
- y - elementCenterY,
- );
- const isSnappedToCenter =
- distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
- if (isSnappedToCenter) {
- const wysiwygX =
- this.state.scrollX +
- elementClickedInside.x +
- CANVAS_WINDOW_OFFSET_LEFT +
- elementClickedInside.width / 2;
- const wysiwygY =
- this.state.scrollY +
- elementClickedInside.y +
- CANVAS_WINDOW_OFFSET_TOP +
- elementClickedInside.height / 2;
- return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
- }
- }
- }
- private saveDebounced = debounce(() => {
- saveToLocalStorage(
- elements.filter(x => x.type !== "selection"),
- this.state,
- );
- }, 300);
- componentDidUpdate() {
- const atLeastOneVisibleElement = renderScene(
- elements,
- this.state.selectionElement,
- this.rc!,
- this.canvas!,
- {
- scrollX: this.state.scrollX,
- scrollY: this.state.scrollY,
- viewBackgroundColor: this.state.viewBackgroundColor,
- },
- );
- const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
- if (this.state.scrolledOutside !== scrolledOutside) {
- this.setState({ scrolledOutside: scrolledOutside });
- }
- this.saveDebounced();
- if (history.isRecording()) {
- history.pushEntry(this.state, elements);
- history.skipRecording();
- }
- }
- }
- const rootElement = document.getElementById("root");
- class TopErrorBoundary extends React.Component {
- state = { hasError: false, stack: "", localStorage: "" };
- static getDerivedStateFromError(error: any) {
- console.error(error);
- return {
- hasError: true,
- localStorage: JSON.stringify({ ...localStorage }),
- stack: error.stack,
- };
- }
- private selectTextArea(event: React.MouseEvent<HTMLTextAreaElement>) {
- (event.target as HTMLTextAreaElement).select();
- }
- private async createGithubIssue() {
- let body = "";
- try {
- const templateStr = (await import("./bug-issue-template")).default;
- if (typeof templateStr === "string") {
- body = encodeURIComponent(templateStr);
- }
- } catch {}
- window.open(
- `https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
- );
- }
- render() {
- if (this.state.hasError) {
- return (
- <div className="ErrorSplash">
- <div className="ErrorSplash-messageContainer">
- <div className="ErrorSplash-paragraph bigger">
- Encountered an error. Please{" "}
- <button onClick={() => window.location.reload()}>
- reload the page
- </button>
- .
- </div>
- <div className="ErrorSplash-paragraph">
- If reloading doesn't work. Try{" "}
- <button
- onClick={() => {
- localStorage.clear();
- window.location.reload();
- }}
- >
- clearing the canvas
- </button>
- .<br />
- <div className="smaller">
- (This will unfortunately result in loss of work.)
- </div>
- </div>
- <div>
- <div className="ErrorSplash-paragraph">
- Before doing so, we'd appreciate if you opened an issue on our{" "}
- <button onClick={this.createGithubIssue}>bug tracker</button>.
- Please include the following error stack trace & localStorage
- content (provided it's not private):
- </div>
- <div className="ErrorSplash-paragraph">
- <div className="ErrorSplash-details">
- <label>Error stack trace:</label>
- <textarea
- rows={10}
- onClick={this.selectTextArea}
- defaultValue={this.state.stack}
- />
- <label>LocalStorage content:</label>
- <textarea
- rows={5}
- onClick={this.selectTextArea}
- defaultValue={this.state.localStorage}
- />
- </div>
- </div>
- </div>
- </div>
- </div>
- );
- }
- return this.props.children;
- }
- }
- ReactDOM.render(
- <TopErrorBoundary>
- <App />
- </TopErrorBoundary>,
- rootElement,
- );
|