Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 555bf6338f | |||
| 0bef3945f6 | |||
| dc97dc30bf | |||
| a0ecfed4cd | |||
| e201e79cd0 | |||
| e1c5c706c6 | |||
| bdc56090d7 | |||
| 58accc9310 | |||
| b91158198e | |||
| 938ce241ff | |||
| 0228646507 | |||
| 25ea97d0f9 |
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
*
|
*
|
||||||
!.env
|
!.env.development
|
||||||
|
!.env.production
|
||||||
!.eslintrc.json
|
!.eslintrc.json
|
||||||
!.npmrc
|
!.npmrc
|
||||||
!.prettierrc
|
!.prettierrc
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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")]}
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ const LayerUI = ({
|
|||||||
})}
|
})}
|
||||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="menu-button"
|
||||||
>
|
>
|
||||||
{HamburgerMenuIcon}
|
{HamburgerMenuIcon}
|
||||||
</button>
|
</button>
|
||||||
@@ -220,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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
."
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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]},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">;
|
||||||
|
|||||||
Reference in New Issue
Block a user