Compare commits

..

12 Commits

Author SHA1 Message Date
Aakansha Doshi 555bf6338f fix test 2022-11-09 22:55:27 +05:30
Daniel J. Geiger 0bef3945f6 Fix #5855. 2022-11-09 07:54:49 -06:00
Aakansha Doshi dc97dc30bf fix: disable FAST_REFRESH to fix live reload (#5852) 2022-11-09 17:13:20 +05:30
DanielJGeiger a0ecfed4cd fix: Paste clipboard contents into unbound text elements (#5849)
* Fix #5848.

* Add test.

* some tweaks

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-11-09 11:30:22 +05:30
Aakansha Doshi e201e79cd0 fix: compute dimensions of container correctly when text pasted on container (#5845)
* fix: compute dimensions of container correctly when text pasted on container

* add test

* remove only
2022-11-08 19:50:41 +05:30
Pritam Sangani e1c5c706c6 build: stops ignoring .env files from docker context so env variables get set during react app build. (#5809)
build: stops ignoring .env.development and .env.production files from docker context so env variables get set during react app build.
* this fixes the issue where Browse Libraries button link was broken in
  docker/self-hosted versions of excalidraw
2022-11-07 16:48:38 +05:30
David Luzar bdc56090d7 feat: reintroduce x shortcut for freedraw (#5840) 2022-11-06 23:07:15 +01:00
David Luzar 58accc9310 feat: tweak toolbar shortcuts & remove library shortcut (#5832) 2022-11-06 20:14:53 +01:00
David Luzar b91158198e feat: clean unused images only after 24hrs (local-only) (#5839)
* feat: clean unused images only after 24hrs (local-only)

* fix test

* make optional for now
2022-11-06 19:41:14 +01:00
David Luzar 938ce241ff feat: refetch errored/pending images on collab room init load (#5833) 2022-11-05 15:55:14 +01:00
David Luzar 0228646507 fix: line editor points rendering below elements (#5781)
* fix: line editor points rendering below elements

* add test
2022-11-05 11:35:53 +01:00
Aakansha Doshi 25ea97d0f9 test: fix failing tests and API (#5823)
* tests: fix failing tests

* fix selection.test.tsx

* fix excalidraw.test.tsx and don't show image export when SaveAsImage is false in UIOptions.canvasActions

* more fixes

* require fake index db in setUp test to fix the tests

* fix regression
2022-11-04 18:22:21 +05:30
42 changed files with 2354 additions and 781 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
* *
!.env !.env.development
!.env.production
!.eslintrc.json !.eslintrc.json
!.npmrc !.npmrc
!.prettierrc !.prettierrc
+2
View File
@@ -20,3 +20,5 @@ REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when # whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers. # debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD= REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false
-22
View File
@@ -89,28 +89,6 @@ export const actionChangeExportScale = register({
}, },
}); });
export const actionChangeExportPadding = register({
name: "changeExportPadding",
trackEvent: { category: "export", action: "togglePadding" },
perform: (_elements, appState, value) => {
return {
appState: {
...appState,
exportPadding: value ? DEFAULT_EXPORT_PADDING : 0,
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
checked={!!appState.exportPadding}
onChange={(checked) => updateData(checked)}
>
{"Padding"}
</CheckboxItem>
),
});
export const actionChangeExportBackground = register({ export const actionChangeExportBackground = register({
name: "changeExportBackground", name: "changeExportBackground",
trackEvent: { category: "export", action: "toggleBackground" }, trackEvent: { category: "export", action: "toggleBackground" },
-1
View File
@@ -68,7 +68,6 @@ export type ActionName =
| "finalize" | "finalize"
| "changeProjectName" | "changeProjectName"
| "changeExportBackground" | "changeExportBackground"
| "changeExportPadding"
| "changeExportEmbedScene" | "changeExportEmbedScene"
| "changeExportScale" | "changeExportScale"
| "saveToActiveFile" | "saveToActiveFile"
-3
View File
@@ -1,6 +1,5 @@
import oc from "open-color"; import oc from "open-color";
import { import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
@@ -56,7 +55,6 @@ export const getDefaultAppState = (): Omit<
exportScale: defaultExportScale, exportScale: defaultExportScale,
exportEmbedScene: false, exportEmbedScene: false,
exportWithDarkMode: false, exportWithDarkMode: false,
exportPadding: DEFAULT_EXPORT_PADDING,
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: null,
isBindingEnabled: true, isBindingEnabled: true,
@@ -147,7 +145,6 @@ const APP_STATE_STORAGE_CONF = (<
exportBackground: { browser: true, export: false, server: false }, exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false }, exportEmbedScene: { browser: true, export: false, server: false },
exportScale: { browser: true, export: false, server: false }, exportScale: { browser: true, export: false, server: false },
exportPadding: { browser: true, export: false, server: false },
exportWithDarkMode: { browser: true, export: false, server: false }, exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false }, fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true }, gridSize: { browser: true, export: true, server: true },
+4 -5
View File
@@ -218,13 +218,12 @@ export const ShapesSwitcher = ({
appState: AppState; appState: AppState;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key, fillable }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const numberKey = value === "eraser" ? 0 : index + 1;
const label = t(`toolBar.${value}`); const label = t(`toolBar.${value}`);
const letter = key && (typeof key === "string" ? key : key[0]); const letter = key && (typeof key === "string" ? key : key[0]);
const shortcut = letter const shortcut = letter
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numberKey}` ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}`
: `${numberKey}`; : `${numericKey}`;
return ( return (
<ToolButton <ToolButton
className={clsx("Shape", { fillable })} className={clsx("Shape", { fillable })}
@@ -234,7 +233,7 @@ export const ShapesSwitcher = ({
checked={activeTool.type === value} checked={activeTool.type === value}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={`${numberKey}`} keyBindingLabel={numericKey}
aria-label={capitalizeString(label)} aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`} data-testid={`toolbar-${value}`}
+1 -12
View File
@@ -1912,18 +1912,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ isBindingEnabled: false }); this.setState({ isBindingEnabled: false });
} }
if (event.code === CODES.ZERO) {
const nextState = this.toggleMenu("library");
// track only openings
if (nextState) {
trackEvent(
"library",
"toggleLibrary (open)",
`keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
);
}
}
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
const step = const step =
(this.state.gridSize && (this.state.gridSize &&
@@ -5243,6 +5231,7 @@ class App extends React.Component<AppProps, AppState> {
id: fileId, id: fileId,
dataURL, dataURL,
created: Date.now(), created: Date.now(),
lastRetrieved: Date.now(),
}, },
}; };
const cachedImageData = this.imageCache.get(fileId); const cachedImageData = this.imageCache.get(fileId);
+33 -13
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { isDarwin, isWindows } from "../keys"; import { isDarwin, isWindows, KEYS } from "../keys";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import "./HelpDialog.scss"; import "./HelpDialog.scss";
@@ -118,22 +118,42 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
className="HelpDialog__island--tools" className="HelpDialog__island--tools"
caption={t("helpDialog.tools")} caption={t("helpDialog.tools")}
> >
<Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} /> <Shortcut
<Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} /> label={t("toolBar.selection")}
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} /> shortcuts={[KEYS.V, KEYS["1"]]}
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} /> />
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} /> <Shortcut
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} /> label={t("toolBar.rectangle")}
shortcuts={[KEYS.R, KEYS["2"]]}
/>
<Shortcut
label={t("toolBar.diamond")}
shortcuts={[KEYS.D, KEYS["3"]]}
/>
<Shortcut
label={t("toolBar.ellipse")}
shortcuts={[KEYS.O, KEYS["4"]]}
/>
<Shortcut
label={t("toolBar.arrow")}
shortcuts={[KEYS.A, KEYS["5"]]}
/>
<Shortcut
label={t("toolBar.line")}
shortcuts={[KEYS.P, KEYS["6"]]}
/>
<Shortcut <Shortcut
label={t("toolBar.freedraw")} label={t("toolBar.freedraw")}
shortcuts={["Shift + P", "X", "7"]} shortcuts={["Shift + P", KEYS["7"]]}
/> />
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> <Shortcut
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} /> label={t("toolBar.text")}
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} /> shortcuts={[KEYS.T, KEYS["8"]]}
/>
<Shortcut label={t("toolBar.image")} shortcuts={[KEYS["9"]]} />
<Shortcut <Shortcut
label={t("toolBar.eraser")} label={t("toolBar.eraser")}
shortcuts={[getShortcutKey("E")]} shortcuts={[KEYS.E, KEYS["0"]]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.editSelectedShape")} label={t("helpDialog.editSelectedShape")}
@@ -173,7 +193,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
]} ]}
isOr={false} isOr={false}
/> />
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} /> <Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut <Shortcut
label={t("helpDialog.preventBinding")} label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]} shortcuts={[getShortcutKey("CtrlOrCmd")]}
+7 -3
View File
@@ -79,6 +79,7 @@ const ImageExportModal = ({
elements, elements,
appState, appState,
files, files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager, actionManager,
onExportToPng, onExportToPng,
onExportToSvg, onExportToSvg,
@@ -87,6 +88,7 @@ const ImageExportModal = ({
appState: AppState; appState: AppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number;
actionManager: ActionManager; actionManager: ActionManager;
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
@@ -114,7 +116,7 @@ const ImageExportModal = ({
exportToCanvas(exportedElements, appState, files, { exportToCanvas(exportedElements, appState, files, {
exportBackground, exportBackground,
viewBackgroundColor, viewBackgroundColor,
exportPadding: appState.exportPadding, exportPadding,
}) })
.then((canvas) => { .then((canvas) => {
// if converting to blob fails, there's some problem that will // if converting to blob fails, there's some problem that will
@@ -132,6 +134,7 @@ const ImageExportModal = ({
files, files,
exportedElements, exportedElements,
exportBackground, exportBackground,
exportPadding,
viewBackgroundColor, viewBackgroundColor,
]); ]);
@@ -148,10 +151,8 @@ const ImageExportModal = ({
// dunno why this is needed, but when the items wrap it creates // dunno why this is needed, but when the items wrap it creates
// an overflow // an overflow
overflow: "hidden", overflow: "hidden",
gap: ".6rem",
}} }}
> >
{actionManager.renderAction("changeExportPadding")}
{actionManager.renderAction("changeExportBackground")} {actionManager.renderAction("changeExportBackground")}
{someElementIsSelected && ( {someElementIsSelected && (
<CheckboxItem <CheckboxItem
@@ -220,6 +221,7 @@ export const ImageExportDialog = ({
appState, appState,
setAppState, setAppState,
files, files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager, actionManager,
onExportToPng, onExportToPng,
onExportToSvg, onExportToSvg,
@@ -229,6 +231,7 @@ export const ImageExportDialog = ({
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number;
actionManager: ActionManager; actionManager: ActionManager;
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
@@ -246,6 +249,7 @@ export const ImageExportDialog = ({
elements={elements} elements={elements}
appState={appState} appState={appState}
files={files} files={files}
exportPadding={exportPadding}
actionManager={actionManager} actionManager={actionManager}
onExportToPng={onExportToPng} onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg} onExportToSvg={onExportToSvg}
+10 -8
View File
@@ -144,7 +144,6 @@ const LayerUI = ({
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
name: appState.name, name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
exportPadding: appState.exportPadding,
}, },
) )
.catch(muteFSAbortError) .catch(muteFSAbortError)
@@ -197,6 +196,7 @@ const LayerUI = ({
})} })}
onClick={() => setIsMenuOpen(!isMenuOpen)} onClick={() => setIsMenuOpen(!isMenuOpen)}
type="button" type="button"
data-testid="menu-button"
> >
{HamburgerMenuIcon} {HamburgerMenuIcon}
</button> </button>
@@ -221,13 +221,15 @@ const LayerUI = ({
{appState.fileHandle && {appState.fileHandle &&
actionManager.renderAction("saveToActiveFile")} actionManager.renderAction("saveToActiveFile")}
{renderJSONExportDialog()} {renderJSONExportDialog()}
<MenuItem {UIOptions.canvasActions.saveAsImage && (
label={t("buttons.exportImage")} <MenuItem
icon={ExportImageIcon} label={t("buttons.exportImage")}
dataTestId="image-export-button" icon={ExportImageIcon}
onClick={() => setAppState({ openDialog: "imageExport" })} dataTestId="image-export-button"
shortcut={getShortcutFromShortcutName("imageExport")} onClick={() => setAppState({ openDialog: "imageExport" })}
/> shortcut={getShortcutFromShortcutName("imageExport")}
/>
)}
{onCollabButtonClick && ( {onCollabButtonClick && (
<CollabButton <CollabButton
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
+1 -1
View File
@@ -22,7 +22,7 @@ export const LibraryButton: React.FC<{
} }
return ( return (
<label title={`${capitalizeString(t("toolBar.library"))} — 0`}> <label title={`${capitalizeString(t("toolBar.library"))}`}>
<input <input
className="ToolIcon_type_checkbox" className="ToolIcon_type_checkbox"
type="checkbox" type="checkbox"
+2 -2
View File
@@ -90,10 +90,10 @@ describe("Sidebar", () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar"); const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null); expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close"); const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
expect(closeButton).not.toBe(null); expect(closeButton).not.toBe(null);
fireEvent.click(closeButton!.querySelector("button")!); fireEvent.click(closeButton);
await waitFor(() => { await waitFor(() => {
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null); expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
+1 -1
View File
@@ -492,7 +492,7 @@ export const getElementBounds = (
export const getCommonBounds = ( export const getCommonBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): [minX: number, minY: number, maxX: number, maxY: number] => { ): [number, number, number, number] => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }
+1 -3
View File
@@ -169,8 +169,7 @@ const getAdjustedDimensions = (
let maxWidth = null; let maxWidth = null;
const container = getContainerElement(element); const container = getContainerElement(element);
if (container) { if (container) {
const containerDims = getContainerDims(container); maxWidth = getMaxContainerWidth(container);
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
} }
const { const {
width: nextWidth, width: nextWidth,
@@ -258,7 +257,6 @@ export const refreshTextDimensions = (
) => { ) => {
const container = getContainerElement(textElement); const container = getContainerElement(textElement);
if (container) { if (container) {
// text = wrapText(text, getFontString(textElement), container.width);
text = wrapText( text = wrapText(
text, text,
getFontString(textElement), getFontString(textElement),
+2 -4
View File
@@ -19,13 +19,12 @@ export const redrawTextBoundingBox = (
) => { ) => {
let maxWidth = undefined; let maxWidth = undefined;
let text = textElement.text; let text = textElement.text;
if (container) { if (container) {
maxWidth = getMaxContainerWidth(container); maxWidth = getMaxContainerWidth(container);
text = wrapText( text = wrapText(
textElement.originalText, textElement.originalText,
getFontString(textElement), getFontString(textElement),
getMaxContainerWidth(container), maxWidth,
); );
} }
const metrics = measureText( const metrics = measureText(
@@ -230,10 +229,9 @@ export const measureText = (
const baseline = span.offsetTop + span.offsetHeight; const baseline = span.offsetTop + span.offsetHeight;
// Since span adds 1px extra width to the container // Since span adds 1px extra width to the container
const width = container.offsetWidth + 1; const width = container.offsetWidth + 1;
const height = container.offsetHeight; const height = container.offsetHeight;
document.body.removeChild(container);
document.body.removeChild(container);
return { width, height, baseline }; return { width, height, baseline };
}; };
+84
View File
@@ -10,11 +10,13 @@ import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { import {
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontString,
} from "./types"; } from "./types";
import * as textElementUtils from "./textElement"; import * as textElementUtils from "./textElement";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils"; import { resize } from "../tests/utils";
import { getMaxContainerWidth } from "./newElement";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -433,6 +435,25 @@ describe("textWysiwyg", () => {
); );
expect(h.state.zoom.value).toBe(1); expect(h.state.zoom.value).toBe(1);
}); });
it("should paste text correctly", async () => {
Keyboard.keyPress(KEYS.ENTER);
await new Promise((r) => setTimeout(r, 0));
const text = "A quick brown fox jumps over the lazy dog.";
//@ts-ignore
textarea.onpaste({
preventDefault: () => {},
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
await new Promise((cb) => setTimeout(cb, 0));
textarea.blur();
expect(textElement.text).toBe(text);
});
}); });
describe("Test container-bound text", () => { describe("Test container-bound text", () => {
@@ -876,5 +897,68 @@ describe("textWysiwyg", () => {
] ]
`); `);
}); });
it("should compute the dimensions correctly when text pasted", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
const wrappedText = textElementUtils.wrapText(
"Wikipedia is hosted by the Wikimedia Foundation, a non-profit organization that also hosts a range of other projects.",
font,
getMaxContainerWidth(rectangle),
);
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation((text, font, maxWidth) => {
if (text === wrappedText) {
return { width: rectangle.width, height: 200, baseline: 30 };
}
return { width: 0, height: 0, baseline: 0 };
});
//@ts-ignore
editor.onpaste({
preventDefault: () => {},
//@ts-ignore
clipboardData: {
getData: () =>
"Wikipedia is hosted by the Wikimedia Foundation, a non-profit organization that also hosts a range of other projects.",
},
});
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
expect(rectangle.width).toBe(100);
expect(rectangle.height).toBe(210);
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(textElement.text).toMatchInlineSnapshot(
`
"Wikipedi
a is
hosted
by the
Wikimedi
a
Foundati
on, a
non-prof
it
organiza
tion
that
also
hosts a
range of
other
projects
."
`,
);
});
}); });
}); });
+31
View File
@@ -20,6 +20,7 @@ import {
getBoundTextElementId, getBoundTextElementId,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
measureText,
wrapText, wrapText,
} from "./textElement"; } from "./textElement";
import { import {
@@ -29,6 +30,7 @@ import {
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App"; import App from "../components/App";
import { getMaxContainerWidth } from "./newElement"; import { getMaxContainerWidth } from "./newElement";
import { parseClipboard } from "../clipboard";
const normalizeText = (text: string) => { const normalizeText = (text: string) => {
return ( return (
@@ -275,6 +277,35 @@ export const textWysiwyg = ({
updateWysiwygStyle(); updateWysiwygStyle();
if (onChange) { if (onChange) {
editable.onpaste = async (event) => {
event.preventDefault();
const clipboardData = await parseClipboard(event);
if (!clipboardData.text) {
return;
}
const data = normalizeText(clipboardData.text);
const container = getContainerElement(element);
const font = getFontString({
fontSize: app.state.currentItemFontSize,
fontFamily: app.state.currentItemFontFamily,
});
if (data) {
const text = editable.value;
const start = Math.min(editable.selectionStart, editable.selectionEnd);
const end = Math.max(editable.selectionStart, editable.selectionEnd);
const newText = `${text.substring(0, start)}${data}${text.substring(
end,
)}`;
const wrappedText = container
? wrapText(newText, font, getMaxContainerWidth(container!))
: newText;
const dimensions = measureText(wrappedText, font);
editable.style.height = `${dimensions.height}px`;
onChange(newText);
}
};
editable.oninput = () => { editable.oninput = () => {
const updatedTextElement = Scene.getScene(element)?.getElement( const updatedTextElement = Scene.getScene(element)?.getElement(
id, id,
+14 -3
View File
@@ -310,16 +310,27 @@ class Collab extends PureComponent<Props, CollabState> {
} }
}; };
private fetchImageFilesFromFirebase = async (scene: { private fetchImageFilesFromFirebase = async (opts: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
/**
* Indicates whether to fetch files that are errored or pending and older
* than 10 seconds.
*
* Use this as a machanism to fetch files which may be ok but for some
* reason their status was not updated correctly.
*/
forceFetchFiles?: boolean;
}) => { }) => {
const unfetchedImages = scene.elements const unfetchedImages = opts.elements
.filter((element) => { .filter((element) => {
return ( return (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
!this.fileManager.isFileHandled(element.fileId) && !this.fileManager.isFileHandled(element.fileId) &&
!element.isDeleted && !element.isDeleted &&
element.status === "saved" (opts.forceFetchFiles
? element.status !== "pending" ||
Date.now() - element.updated > 10000
: element.status === "saved")
); );
}) })
.map((element) => (element as InitializedExcalidrawImageElement).fileId); .map((element) => (element as InitializedExcalidrawImageElement).fileId);
+1
View File
@@ -195,6 +195,7 @@ export const encodeFilesForUpload = async ({
id, id,
mimeType: fileData.mimeType, mimeType: fileData.mimeType,
created: Date.now(), created: Date.now(),
lastRetrieved: Date.now(),
}, },
}); });
+32 -8
View File
@@ -10,7 +10,7 @@
* (localStorage, indexedDB). * (localStorage, indexedDB).
*/ */
import { createStore, keys, del, getMany, set } from "idb-keyval"; import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../appState"; import { clearAppStateForLocalStorage } from "../../appState";
import { clearElementsForLocalStorage } from "../../element"; import { clearElementsForLocalStorage } from "../../element";
import { ExcalidrawElement, FileId } from "../../element/types"; import { ExcalidrawElement, FileId } from "../../element/types";
@@ -25,12 +25,21 @@ const filesStore = createStore("files-db", "files-store");
class LocalFileManager extends FileManager { class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => { clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
const allIds = await keys(filesStore); await entries(filesStore).then((entries) => {
for (const id of allIds) { for (const [id, imageData] of entries as [FileId, BinaryFileData][]) {
if (!opts.currentFileIds.includes(id as FileId)) { // if image is unused (not on canvas) & is older than 1 day, delete it
del(id, filesStore); // from storage. We check `lastRetrieved` we care about the last time
// the image was used (loaded on canvas), not when it was initially
// created.
if (
(!imageData.lastRetrieved ||
Date.now() - imageData.lastRetrieved > 24 * 3600 * 1000) &&
!opts.currentFileIds.includes(id as FileId)
) {
del(id, filesStore);
}
} }
} });
}; };
} }
@@ -111,18 +120,33 @@ export class LocalData {
static fileStorage = new LocalFileManager({ static fileStorage = new LocalFileManager({
getFiles(ids) { getFiles(ids) {
return getMany(ids, filesStore).then( return getMany(ids, filesStore).then(
(filesData: (BinaryFileData | undefined)[]) => { async (filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = []; const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>(); const erroredFiles = new Map<FileId, true>();
const filesToSave: [FileId, BinaryFileData][] = [];
filesData.forEach((data, index) => { filesData.forEach((data, index) => {
const id = ids[index]; const id = ids[index];
if (data) { if (data) {
loadedFiles.push(data); const _data: BinaryFileData = {
...data,
lastRetrieved: Date.now(),
};
filesToSave.push([id, _data]);
loadedFiles.push(_data);
} else { } else {
erroredFiles.set(id, true); erroredFiles.set(id, true);
} }
}); });
try {
// save loaded files back to storage with updated `lastRetrieved`
setMany(filesToSave, filesStore);
} catch (error) {
console.warn(error);
}
return { loadedFiles, erroredFiles }; return { loadedFiles, erroredFiles };
}, },
); );
+1
View File
@@ -330,6 +330,7 @@ export const loadFilesFromFirebase = async (
id, id,
dataURL, dataURL,
created: metadata?.created || Date.now(), created: metadata?.created || Date.now(),
lastRetrieved: metadata?.created || Date.now(),
}); });
} else { } else {
erroredFiles.set(id, true); erroredFiles.set(id, true);
+1
View File
@@ -285,6 +285,7 @@ const ExcalidrawWrapper = () => {
collabAPI collabAPI
.fetchImageFilesFromFirebase({ .fetchImageFilesFromFirebase({
elements: data.scene.elements, elements: data.scene.elements,
forceFetchFiles: true,
}) })
.then(({ loadedFiles, erroredFiles }) => { .then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles); excalidrawAPI.addFiles(loadedFiles);
+11
View File
@@ -63,6 +63,17 @@ export const KEYS = {
Y: "y", Y: "y",
Z: "z", Z: "z",
K: "k", K: "k",
0: "0",
1: "1",
2: "2",
3: "3",
4: "4",
5: "5",
6: "6",
7: "7",
8: "8",
9: "9",
} as const; } as const;
export type Key = keyof typeof KEYS; export type Key = keyof typeof KEYS;
+1
View File
@@ -148,6 +148,7 @@ export default function App() {
dataURL: reader.result as BinaryFileData["dataURL"], dataURL: reader.result as BinaryFileData["dataURL"],
mimeType: MIME_TYPES.jpg, mimeType: MIME_TYPES.jpg,
created: 1644915140367, created: 1644915140367,
lastRetrieved: 1644915140367,
}, },
]; ];
+13 -7
View File
@@ -406,6 +406,8 @@ export const _renderScene = ({
}), }),
); );
let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
undefined;
visibleElements.forEach((element) => { visibleElements.forEach((element) => {
try { try {
renderElement(element, rc, context, renderConfig); renderElement(element, rc, context, renderConfig);
@@ -414,15 +416,10 @@ export const _renderScene = ({
// correct element from visible elements // correct element from visible elements
if (appState.editingLinearElement?.elementId === element.id) { if (appState.editingLinearElement?.elementId === element.id) {
if (element) { if (element) {
renderLinearPointHandles( editingLinearElement =
context, element as NonDeleted<ExcalidrawLinearElement>;
appState,
renderConfig,
element as NonDeleted<ExcalidrawLinearElement>,
);
} }
} }
if (!isExporting) { if (!isExporting) {
renderLinkIcon(element, context, appState); renderLinkIcon(element, context, appState);
} }
@@ -431,6 +428,15 @@ export const _renderScene = ({
} }
}); });
if (editingLinearElement) {
renderLinearPointHandles(
context,
appState,
renderConfig,
editingLinearElement,
);
}
// Paint selection element // Paint selection element
if (appState.selectionElement) { if (appState.selectionElement) {
try { try {
+11 -157
View File
@@ -14,102 +14,6 @@ import {
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
const getExactBoundingBox = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: {
exportBackground: boolean;
exportPadding?: number;
exportScale?: number;
viewBackgroundColor: string;
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
},
files: BinaryFiles,
): Promise<
[offsetLeft: number, offsetTop: number, width: number, height: number]
> => {
const padding = DEFAULT_EXPORT_PADDING;
// const padding = 0;
const [minX, minY, width, height] = getApproximateCanvasSize(
elements,
padding,
);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const { imageCache } = await updateImageCache({
imageCache: new Map(),
fileIds: getInitializedImageElements(elements).map(
(element) => element.fileId,
),
files,
});
const defaultAppState = getDefaultAppState();
renderScene({
elements,
// @ts-ignore
appState,
scale: 1,
rc: rough.canvas(canvas),
canvas,
renderConfig: {
viewBackgroundColor: null,
scrollX: -minX + padding,
scrollY: -minY + padding,
zoom: defaultAppState.zoom,
remotePointerViewportCoords: {},
remoteSelectedElementIds: {},
shouldCacheIgnoreZoom: false,
remotePointerUsernames: {},
remotePointerUserStates: {},
theme: "light",
imageCache,
renderScrollbars: false,
renderSelection: false,
renderGrid: false,
isExporting: true,
},
});
const ctx = canvas.getContext("2d")!;
const { data } = ctx.getImageData(0, 0, width, height);
let _minX = Infinity;
let _minY = Infinity;
let _maxX = -Infinity;
let _maxY = -Infinity;
const rows = [];
let row: number[][] = [];
for (let i = 0; i < data.length - 1; i = i + 4) {
if (i && i % (width * 4) === 0) {
rows.push(row);
row = [];
}
const pixel = [data[i], data[i + 1], data[i + 2], data[i + 3]];
row.push(pixel);
}
for (const [y, row] of rows.entries()) {
for (const [x, pixel] of row.entries()) {
if (pixel[3] > 0) {
_minX = Math.min(_minX, x);
_minY = Math.min(_minY, y);
_maxX = Math.max(_maxX, x);
_maxY = Math.max(_maxY, y);
}
}
}
const offsetLeft = padding - _minX;
const offsetTop = padding - _minY;
return [offsetLeft, offsetTop, _maxX - _minX, _maxY - _minY];
};
export const exportToCanvas = async ( export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
@@ -133,12 +37,7 @@ export const exportToCanvas = async (
return { canvas, scale: appState.exportScale }; return { canvas, scale: appState.exportScale };
}, },
) => { ) => {
const [scrollX, scrollY, width, height] = await getCanvasSize( const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
elements,
appState,
files,
exportPadding,
);
const { canvas, scale = 1 } = createCanvas(width, height); const { canvas, scale = 1 } = createCanvas(width, height);
@@ -160,8 +59,8 @@ export const exportToCanvas = async (
canvas, canvas,
renderConfig: { renderConfig: {
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX, scrollX: -minX + exportPadding,
scrollY, scrollY: -minY + exportPadding,
zoom: defaultAppState.zoom, zoom: defaultAppState.zoom,
remotePointerViewportCoords: {}, remotePointerViewportCoords: {},
remoteSelectedElementIds: {}, remoteSelectedElementIds: {},
@@ -210,12 +109,7 @@ export const exportToSvg = async (
console.error(error); console.error(error);
} }
} }
const [minX, minY, width, height] = await getCanvasSize( const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
elements,
appState,
files || {},
exportPadding,
);
// initialize SVG root // initialize SVG root
const svgRoot = document.createElementNS(SVG_NS, "svg"); const svgRoot = document.createElementNS(SVG_NS, "svg");
@@ -278,66 +172,26 @@ export const exportToSvg = async (
return svgRoot; return svgRoot;
}; };
const getApproximateCanvasSize = ( // calculate smallest area to fit the contents in
const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
): [number, number, number, number] => { ): [number, number, number, number] => {
const bounds = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const minX = Math.floor(bounds[0]);
const minY = Math.floor(bounds[1]);
const maxX = Math.ceil(bounds[2]);
const maxY = Math.ceil(bounds[3]);
const width = distance(minX, maxX) + exportPadding * 2; const width = distance(minX, maxX) + exportPadding * 2;
const height = const height = distance(minY, maxY) + exportPadding + exportPadding;
Math.ceil(distance(minY, maxY)) + exportPadding + exportPadding;
return [minX, minY, width, height]; return [minX, minY, width, height];
}; };
// calculate smallest area to fit the contents in
const getCanvasSize = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: {
exportBackground: boolean;
exportPadding?: number;
exportScale?: number;
viewBackgroundColor: string;
exportWithDarkMode?: boolean;
exportEmbedScene?: boolean;
},
files: BinaryFiles,
exportPadding: number,
): Promise<[number, number, number, number]> => {
if (exportPadding) {
const [minX, minY, width, height] = getApproximateCanvasSize(
elements,
exportPadding,
);
return [-minX + exportPadding, -minY + exportPadding, width, height];
} else {
const [minX, minY] = getApproximateCanvasSize(elements, exportPadding);
const [offsetLeft, offsetRight, width, height] = await getExactBoundingBox(
elements,
appState,
files,
);
return [-minX + offsetLeft, -minY + offsetRight, width, height];
}
};
export const getExportSize = ( export const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
scale: number, scale: number,
): [number, number] => { ): [number, number] => {
const [, , width, height] = getApproximateCanvasSize( const [, , width, height] = getCanvasSize(elements, exportPadding).map(
elements, (dimension) => Math.trunc(dimension * scale),
exportPadding, );
).map((dimension) => Math.trunc(dimension * scale));
return [width, height]; return [width, height];
}; };
+2
View File
@@ -3,6 +3,8 @@ import "jest-canvas-mock";
import dotenv from "dotenv"; import dotenv from "dotenv";
import polyfill from "./polyfill"; import polyfill from "./polyfill";
require("fake-indexeddb/auto");
polyfill(); polyfill();
// jest doesn't know of .env.development so we need to init it ourselves // jest doesn't know of .env.development so we need to init it ourselves
dotenv.config({ dotenv.config({
+13 -3
View File
@@ -17,60 +17,70 @@ export const SHAPES = [
icon: SelectionIcon, icon: SelectionIcon,
value: "selection", value: "selection",
key: KEYS.V, key: KEYS.V,
numericKey: KEYS["1"],
fillable: true, fillable: true,
}, },
{ {
icon: RectangleIcon, icon: RectangleIcon,
value: "rectangle", value: "rectangle",
key: KEYS.R, key: KEYS.R,
numericKey: KEYS["2"],
fillable: true, fillable: true,
}, },
{ {
icon: DiamondIcon, icon: DiamondIcon,
value: "diamond", value: "diamond",
key: KEYS.D, key: KEYS.D,
numericKey: KEYS["3"],
fillable: true, fillable: true,
}, },
{ {
icon: EllipseIcon, icon: EllipseIcon,
value: "ellipse", value: "ellipse",
key: KEYS.O, key: KEYS.O,
numericKey: KEYS["4"],
fillable: true, fillable: true,
}, },
{ {
icon: ArrowIcon, icon: ArrowIcon,
value: "arrow", value: "arrow",
key: KEYS.A, key: KEYS.A,
numericKey: KEYS["5"],
fillable: true, fillable: true,
}, },
{ {
icon: LineIcon, icon: LineIcon,
value: "line", value: "line",
key: [KEYS.P, KEYS.L], key: KEYS.L,
numericKey: KEYS["6"],
fillable: true, fillable: true,
}, },
{ {
icon: FreedrawIcon, icon: FreedrawIcon,
value: "freedraw", value: "freedraw",
key: [KEYS.X, KEYS.P.toUpperCase()], key: [KEYS.P, KEYS.X],
numericKey: KEYS["7"],
fillable: false, fillable: false,
}, },
{ {
icon: TextIcon, icon: TextIcon,
value: "text", value: "text",
key: KEYS.T, key: KEYS.T,
numericKey: KEYS["8"],
fillable: false, fillable: false,
}, },
{ {
icon: ImageIcon, icon: ImageIcon,
value: "image", value: "image",
key: null, key: null,
numericKey: KEYS["9"],
fillable: false, fillable: false,
}, },
{ {
icon: EraserIcon, icon: EraserIcon,
value: "eraser", value: "eraser",
key: KEYS.E, key: KEYS.E,
numericKey: KEYS["0"],
fillable: false, fillable: false,
}, },
] as const; ] as const;
@@ -78,7 +88,7 @@ export const SHAPES = [
export const findShapeByKey = (key: string) => { export const findShapeByKey = (key: string) => {
const shape = SHAPES.find((shape, index) => { const shape = SHAPES.find((shape, index) => {
return ( return (
key === (shape.value === "eraser" ? 0 : index + 1).toString() || (shape.numericKey != null && key === shape.numericKey.toString()) ||
(shape.key && (shape.key &&
(typeof shape.key === "string" (typeof shape.key === "string"
? shape.key === key ? shape.key === key
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -42,7 +42,7 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8); expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@@ -73,7 +73,7 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8); expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@@ -104,7 +104,7 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8); expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@@ -135,7 +135,7 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8); expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@@ -170,7 +170,7 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8); expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@@ -210,7 +210,7 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(6); expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -229,7 +229,7 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(6); expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -248,7 +248,7 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(6); expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -272,7 +272,7 @@ describe("Test dragCreate", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderScene).toHaveBeenCalledTimes(7); expect(renderScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -296,7 +296,7 @@ describe("Test dragCreate", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderScene).toHaveBeenCalledTimes(7); expect(renderScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
+1
View File
@@ -158,6 +158,7 @@ describe("export", () => {
dataURL: await getDataURL(await API.loadFile("./fixtures/deer.png")), dataURL: await getDataURL(await API.loadFile("./fixtures/deer.png")),
mimeType: "image/png", mimeType: "image/png",
created: Date.now(), created: Date.now(),
lastRetrieved: Date.now(),
}, },
} as const; } as const;
+5 -7
View File
@@ -1,4 +1,6 @@
import { fireEvent, render, waitFor } from "./test-utils"; import { fireEvent, render, waitFor } from "./test-utils";
import { queryByTestId } from "@testing-library/react";
import ExcalidrawApp from "../excalidraw-app"; import ExcalidrawApp from "../excalidraw-app";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
@@ -93,15 +95,11 @@ describe("library menu", () => {
const latestLibrary = await h.app.library.getLatestLibrary(); const latestLibrary = await h.app.library.getLatestLibrary();
expect(latestLibrary.length).toBe(0); expect(latestLibrary.length).toBe(0);
const libraryButton = container.querySelector(".ToolIcon__library"); const libraryButton = container.querySelector(".library-button");
fireEvent.click(libraryButton!); fireEvent.click(libraryButton!);
fireEvent.click(container.querySelector(".Sidebar__dropdown-btn")!);
const loadLibraryButton = container.querySelector( queryByTestId(container, "lib-dropdown--load")!.click();
".library-actions .library-actions--load",
);
fireEvent.click(loadLibraryButton!);
const libraryItems = parseLibraryJSON(await libraryJSONPromise); const libraryItems = parseLibraryJSON(await libraryJSONPromise);
+48 -3
View File
@@ -16,7 +16,7 @@ const renderScene = jest.spyOn(Renderer, "renderScene");
const { h } = window; const { h } = window;
describe(" Test Linear Elements", () => { describe("Test Linear Elements", () => {
let container: HTMLElement; let container: HTMLElement;
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
@@ -89,8 +89,15 @@ describe(" Test Linear Elements", () => {
mouse.clickAt(p1[0], p1[1]); mouse.clickAt(p1[0], p1[1]);
}; };
const enterLineEditingMode = (line: ExcalidrawLinearElement) => { const enterLineEditingMode = (
mouse.clickAt(p1[0], p1[1]); line: ExcalidrawLinearElement,
selectProgrammatically = false,
) => {
if (selectProgrammatically) {
API.setSelectedElements([line]);
} else {
mouse.clickAt(p1[0], p1[1]);
}
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingLinearElement?.elementId).toEqual(line.id); expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
}; };
@@ -604,5 +611,43 @@ describe(" Test Linear Elements", () => {
`); `);
}); });
}); });
it("in-editor dragging a line point covered by another element", () => {
createTwoPointerLinearElement("line");
const line = h.elements[0] as ExcalidrawLinearElement;
h.elements = [
line,
API.createElement({
type: "rectangle",
x: line.x - 50,
y: line.y - 50,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
}),
];
const origPoints = line.points.map((point) => [...point]);
const dragEndPositionOffset = [100, 100] as const;
API.setSelectedElements([line]);
enterLineEditingMode(line, true);
drag(
[line.points[0][0] + line.x, line.points[0][1] + line.y],
[dragEndPositionOffset[0] + line.x, dragEndPositionOffset[1] + line.y],
);
expect(line.points).toMatchInlineSnapshot(`
Array [
Array [
0,
0,
],
Array [
${origPoints[1][0] - dragEndPositionOffset[0]},
${origPoints[1][1] - dragEndPositionOffset[1]},
],
]
`);
});
}); });
}); });
+3 -3
View File
@@ -38,7 +38,7 @@ describe("move element", () => {
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8); expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -77,7 +77,7 @@ describe("move element", () => {
// select the second rectangles // select the second rectangles
new Pointer("mouse").clickOn(rectB); new Pointer("mouse").clickOn(rectB);
expect(renderScene).toHaveBeenCalledTimes(22); expect(renderScene).toHaveBeenCalledTimes(23);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3); expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@@ -120,7 +120,7 @@ describe("duplicate element on move when ALT is clicked", () => {
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8); expect(renderScene).toHaveBeenCalledTimes(9);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
+5 -5
View File
@@ -42,7 +42,7 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 }); fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(6); expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -56,7 +56,7 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 }); fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(6); expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -70,7 +70,7 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 }); fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(6); expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
}); });
@@ -102,7 +102,7 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderScene).toHaveBeenCalledTimes(14); expect(renderScene).toHaveBeenCalledTimes(15);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;
@@ -145,7 +145,7 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderScene).toHaveBeenCalledTimes(14); expect(renderScene).toHaveBeenCalledTimes(15);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;
File diff suppressed because it is too large Load Diff
+3
View File
@@ -90,7 +90,10 @@ describe("<Excalidraw/>", () => {
describe("Test theme prop", () => { describe("Test theme prop", () => {
it("should show the theme toggle by default", async () => { it("should show the theme toggle by default", async () => {
const { container } = await render(<Excalidraw />); const { container } = await render(<Excalidraw />);
expect(h.state.theme).toBe(THEME.LIGHT); expect(h.state.theme).toBe(THEME.LIGHT);
queryByTestId(container, "menu-button")!.click();
const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBeTruthy(); expect(darkModeToggle).toBeTruthy();
}); });
+1
View File
@@ -10,6 +10,7 @@ const toolMap = {
line: "line", line: "line",
freedraw: "freedraw", freedraw: "freedraw",
text: "text", text: "text",
eraser: "eraser",
}; };
export type ToolName = keyof typeof toolMap; export type ToolName = keyof typeof toolMap;
+10 -7
View File
@@ -138,7 +138,7 @@ describe("regression tests", () => {
[`4${KEYS.O}`, "ellipse", true], [`4${KEYS.O}`, "ellipse", true],
[`5${KEYS.A}`, "arrow", true], [`5${KEYS.A}`, "arrow", true],
[`6${KEYS.L}`, "line", true], [`6${KEYS.L}`, "line", true],
[`7${KEYS.X}`, "freedraw", false], [`7${KEYS.P}`, "freedraw", false],
] as [string, ExcalidrawElement["type"], boolean][]) { ] as [string, ExcalidrawElement["type"], boolean][]) {
for (const key of keys) { for (const key of keys) {
it(`key ${key} selects ${shape} tool`, () => { it(`key ${key} selects ${shape} tool`, () => {
@@ -174,7 +174,7 @@ describe("regression tests", () => {
mouse.up(10, 10); mouse.up(10, 10);
const { x: prevX, y: prevY } = API.getSelectedElement(); const { x: prevX, y: prevY } = API.getSelectedElement();
mouse.down(-10, -10); mouse.down(-8, -8);
mouse.up(10, 10); mouse.up(10, 10);
const { x: nextX, y: nextY } = API.getSelectedElement(); const { x: nextX, y: nextY } = API.getSelectedElement();
@@ -201,7 +201,7 @@ describe("regression tests", () => {
).toBe(1); ).toBe(1);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(-10, -10); mouse.down(-8, -8);
mouse.up(10, 10); mouse.up(10, 10);
}); });
@@ -446,6 +446,8 @@ describe("regression tests", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
// english lang should display `thin` label // english lang should display `thin` label
expect(screen.queryByTitle(/thin/i)).not.toBeNull(); expect(screen.queryByTitle(/thin/i)).not.toBeNull();
fireEvent.click(document.querySelector(".menu-button")!);
fireEvent.change(document.querySelector(".dropdown-select__language")!, { fireEvent.change(document.querySelector(".dropdown-select__language")!, {
target: { value: "de-DE" }, target: { value: "de-DE" },
}); });
@@ -672,9 +674,10 @@ describe("regression tests", () => {
mouse.down(); mouse.down();
mouse.up(100, 100); mouse.up(100, 100);
// hits bounding box without hitting element
mouse.down();
expect(API.getSelectedElements().length).toBe(1); expect(API.getSelectedElements().length).toBe(1);
// hits bounding box without hitting element
mouse.down(98, 98);
mouse.up(); mouse.up();
expect(API.getSelectedElements().length).toBe(0); expect(API.getSelectedElements().length).toBe(0);
}); });
@@ -744,7 +747,7 @@ describe("regression tests", () => {
// drag element from point on bounding box that doesn't hit element // drag element from point on bounding box that doesn't hit element
mouse.reset(); mouse.reset();
mouse.down(); mouse.down(8, 8);
mouse.up(25, 25); mouse.up(25, 25);
expect(API.getSelectedElement().x).toEqual(prevX + 25); expect(API.getSelectedElement().x).toEqual(prevX + 25);
@@ -1020,7 +1023,7 @@ describe("regression tests", () => {
// Rectangle is already selected since creating // Rectangle is already selected since creating
// it was our last action // it was our last action
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.down(); mouse.down(-8, -8);
}); });
expect(API.getSelectedElements().length).toBe(1); expect(API.getSelectedElements().length).toBe(1);
+8 -8
View File
@@ -154,7 +154,7 @@ describe("selection element", () => {
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 }); fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
expect(renderScene).toHaveBeenCalledTimes(4); expect(renderScene).toHaveBeenCalledTimes(5);
const selectionElement = h.state.selectionElement!; const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull(); expect(selectionElement).not.toBeNull();
expect(selectionElement.type).toEqual("selection"); expect(selectionElement.type).toEqual("selection");
@@ -175,7 +175,7 @@ describe("selection element", () => {
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 }); fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 }); fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
expect(renderScene).toHaveBeenCalledTimes(5); expect(renderScene).toHaveBeenCalledTimes(6);
const selectionElement = h.state.selectionElement!; const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull(); expect(selectionElement).not.toBeNull();
expect(selectionElement.type).toEqual("selection"); expect(selectionElement.type).toEqual("selection");
@@ -197,7 +197,7 @@ describe("selection element", () => {
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 }); fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(6); expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
}); });
}); });
@@ -232,7 +232,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(10); expect(renderScene).toHaveBeenCalledTimes(11);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -261,7 +261,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(10); expect(renderScene).toHaveBeenCalledTimes(11);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -290,7 +290,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(10); expect(renderScene).toHaveBeenCalledTimes(11);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -332,7 +332,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(10); expect(renderScene).toHaveBeenCalledTimes(11);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -373,7 +373,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(10); expect(renderScene).toHaveBeenCalledTimes(11);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
-2
View File
@@ -16,8 +16,6 @@ import { SceneData } from "../types";
import { getSelectedElements } from "../scene/selection"; import { getSelectedElements } from "../scene/selection";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
require("fake-indexeddb/auto");
const customQueries = { const customQueries = {
...queries, ...queries,
...toolQueries, ...toolQueries,
+11 -1
View File
@@ -61,7 +61,18 @@ export type BinaryFileData = {
| typeof MIME_TYPES.binary; | typeof MIME_TYPES.binary;
id: FileId; id: FileId;
dataURL: DataURL; dataURL: DataURL;
/**
* Epoch timestamp in milliseconds
*/
created: number; created: number;
/**
* Indicates when the file was last retrieved from storage to be loaded
* onto the scene. We use this flag to determine whether to delete unused
* files from storage.
*
* Epoch timestamp in milliseconds.
*/
lastRetrieved?: number;
}; };
export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">; export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
@@ -113,7 +124,6 @@ export type AppState = {
exportEmbedScene: boolean; exportEmbedScene: boolean;
exportWithDarkMode: boolean; exportWithDarkMode: boolean;
exportScale: number; exportScale: number;
exportPadding: number;
currentItemStrokeColor: string; currentItemStrokeColor: string;
currentItemBackgroundColor: string; currentItemBackgroundColor: string;
currentItemFillStyle: ExcalidrawElement["fillStyle"]; currentItemFillStyle: ExcalidrawElement["fillStyle"];