mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge branch 'master' of github.com:excalidraw/excalidraw into arnost/scroll-in-read-only-links
This commit is contained in:
commit
53a88d4c7a
96 changed files with 9129 additions and 4501 deletions
|
@ -423,7 +423,7 @@ export const actionToggleHandTool = register({
|
|||
type: "hand",
|
||||
lastActiveToolBeforeEraser: appState.activeTool,
|
||||
});
|
||||
setCursor(app.canvas, CURSOR_TYPE.GRAB);
|
||||
setCursor(app.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -259,23 +259,25 @@ const duplicateElements = (
|
|||
|
||||
return {
|
||||
elements: finalElements,
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: nextElementsToSelect.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedElementIds: nextElementsToSelect.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -19,7 +19,12 @@ import { AppState } from "../types";
|
|||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
_,
|
||||
{ interactiveCanvas, focusContainer, scene },
|
||||
) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
|
@ -132,7 +137,7 @@ export const actionFinalize = register({
|
|||
appState.activeTool.type !== "freedraw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
resetCursor(interactiveCanvas);
|
||||
}
|
||||
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
|
|
@ -108,7 +108,7 @@ export const actionSetFrameAsActiveTool = register({
|
|||
type: "frame",
|
||||
});
|
||||
|
||||
setCursorForShape(app.canvas, {
|
||||
setCursorForShape(app.interactiveCanvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
|
|
@ -149,11 +149,14 @@ export const actionGroup = register({
|
|||
];
|
||||
|
||||
return {
|
||||
appState: selectGroup(
|
||||
newGroupId,
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroup(
|
||||
newGroupId,
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
},
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
@ -212,7 +215,7 @@ export const actionUngroup = register({
|
|||
});
|
||||
|
||||
const updateAppState = selectGroupsForSelectedElements(
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
appState,
|
||||
getNonDeletedElements(nextElements),
|
||||
appState,
|
||||
null,
|
||||
|
@ -243,7 +246,7 @@ export const actionUngroup = register({
|
|||
);
|
||||
|
||||
return {
|
||||
appState: updateAppState,
|
||||
appState: { ...appState, ...updateAppState },
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
|
|
@ -28,22 +28,24 @@ export const actionSelectAll = register({
|
|||
}, {});
|
||||
|
||||
return {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
selectedLinearElement:
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
: null,
|
||||
editingGroupId: null,
|
||||
selectedElementIds,
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: null,
|
||||
selectedElementIds,
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
selectedLinearElement:
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
|
|
@ -24,6 +24,7 @@ export interface ClipboardData {
|
|||
files?: BinaryFiles;
|
||||
text?: string;
|
||||
errorMessage?: string;
|
||||
programmaticAPI?: boolean;
|
||||
}
|
||||
|
||||
let CLIPBOARD = "";
|
||||
|
@ -48,6 +49,7 @@ const clipboardContainsElements = (
|
|||
[
|
||||
EXPORT_DATA_TYPES.excalidraw,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
|
||||
].includes(contents?.type) &&
|
||||
Array.isArray(contents.elements)
|
||||
) {
|
||||
|
@ -191,6 +193,8 @@ export const parseClipboard = async (
|
|||
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(systemClipboard);
|
||||
const programmaticAPI =
|
||||
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
return {
|
||||
elements: systemClipboardData.elements,
|
||||
|
@ -198,6 +202,7 @@ export const parseClipboard = async (
|
|||
text: isPlainPaste
|
||||
? JSON.stringify(systemClipboardData.elements, null, 2)
|
||||
: undefined,
|
||||
programmaticAPI,
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
|
|
|
@ -213,13 +213,13 @@ export const SelectedShapeActions = ({
|
|||
};
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
activeTool,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
appState,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
activeTool: UIAppState["activeTool"];
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
|
@ -270,7 +270,7 @@ export const ShapesSwitcher = ({
|
|||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, {
|
||||
setCursorForShape(interactiveCanvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
|
|
@ -6,14 +6,14 @@ import { render, queryByTestId } from "../tests/test-utils";
|
|||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
describe("Test <App/>", () => {
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -8,9 +8,9 @@ import { mutateElement } from "../element/mutateElement";
|
|||
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
||||
import { useOutsideClick } from "../hooks/useOutsideClick";
|
||||
import { KEYS } from "../keys";
|
||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import Scene from "../scene/Scene";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
|
||||
|
||||
import "./EyeDropper.scss";
|
||||
|
@ -98,7 +98,7 @@ export const EyeDropper: React.FC<{
|
|||
},
|
||||
false,
|
||||
);
|
||||
invalidateShapeForElement(element);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
Scene.getScene(
|
||||
metaStuffRef.current.selectedElements[0],
|
||||
|
|
|
@ -34,7 +34,7 @@ const JSONExportModal = ({
|
|||
actionManager: ActionManager;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
}) => {
|
||||
const { onExportToBackend } = exportOpts;
|
||||
return (
|
||||
|
@ -100,7 +100,7 @@ export const JSONExportDialog = ({
|
|||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
}) => {
|
||||
const handleClose = React.useCallback(() => {
|
||||
|
|
|
@ -57,7 +57,8 @@ interface LayerUIProps {
|
|||
actionManager: ActionManager;
|
||||
appState: UIAppState;
|
||||
files: BinaryFiles;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onLockToggle: () => void;
|
||||
|
@ -117,6 +118,7 @@ const LayerUI = ({
|
|||
setAppState,
|
||||
elements,
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
|
@ -272,7 +274,7 @@ const LayerUI = ({
|
|||
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
|
@ -413,7 +415,7 @@ const LayerUI = ({
|
|||
onLockToggle={onLockToggle}
|
||||
onHandToolToggle={onHandToolToggle}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
|
@ -464,7 +466,7 @@ const LayerUI = ({
|
|||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
|
@ -507,8 +509,18 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
|
|||
return false;
|
||||
}
|
||||
|
||||
const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
|
||||
const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
|
||||
const {
|
||||
canvas: _pC,
|
||||
interactiveCanvas: _pIC,
|
||||
appState: prevAppState,
|
||||
...prev
|
||||
} = prevProps;
|
||||
const {
|
||||
canvas: _nC,
|
||||
interactiveCanvas: _nIC,
|
||||
appState: nextAppState,
|
||||
...next
|
||||
} = nextProps;
|
||||
|
||||
return (
|
||||
isShallowEqual(
|
||||
|
|
|
@ -36,7 +36,7 @@ type MobileMenuProps = {
|
|||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
|
@ -58,7 +58,7 @@ export const MobileMenu = ({
|
|||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
|
@ -85,7 +85,7 @@ export const MobileMenu = ({
|
|||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
|
@ -202,7 +202,7 @@ export const MobileMenu = ({
|
|||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
|
|
226
src/components/canvases/InteractiveCanvas.tsx
Normal file
226
src/components/canvases/InteractiveCanvas.tsx
Normal file
|
@ -0,0 +1,226 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import { renderInteractiveScene } from "../../renderer/renderScene";
|
||||
import {
|
||||
isRenderThrottlingEnabled,
|
||||
isShallowEqual,
|
||||
sceneCoordsToViewportCoords,
|
||||
} from "../../utils";
|
||||
import { CURSOR_TYPE } from "../../constants";
|
||||
import { t } from "../../i18n";
|
||||
import type { DOMAttributes } from "react";
|
||||
import type { AppState, InteractiveCanvasAppState } from "../../types";
|
||||
import type {
|
||||
InteractiveCanvasRenderConfig,
|
||||
RenderInteractiveSceneCallback,
|
||||
} from "../../scene/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
|
||||
type InteractiveCanvasProps = {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
versionNonce: number | undefined;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: InteractiveCanvasAppState;
|
||||
renderInteractiveSceneCallback: (
|
||||
data: RenderInteractiveSceneCallback,
|
||||
) => void;
|
||||
handleCanvasRef: (canvas: HTMLCanvasElement | null) => void;
|
||||
onContextMenu: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
|
||||
undefined
|
||||
>;
|
||||
onPointerMove: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onPointerMove"],
|
||||
undefined
|
||||
>;
|
||||
onPointerUp: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onPointerUp"],
|
||||
undefined
|
||||
>;
|
||||
onPointerCancel: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onPointerCancel"],
|
||||
undefined
|
||||
>;
|
||||
onTouchMove: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onTouchMove"],
|
||||
undefined
|
||||
>;
|
||||
onPointerDown: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onPointerDown"],
|
||||
undefined
|
||||
>;
|
||||
onDoubleClick: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onDoubleClick"],
|
||||
undefined
|
||||
>;
|
||||
};
|
||||
|
||||
const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
||||
const isComponentMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isComponentMounted.current) {
|
||||
isComponentMounted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorButton: {
|
||||
[id: string]: string | undefined;
|
||||
} = {};
|
||||
const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
||||
{};
|
||||
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
|
||||
{};
|
||||
const pointerUsernames: { [id: string]: string } = {};
|
||||
const pointerUserStates: { [id: string]: string } = {};
|
||||
|
||||
props.appState.collaborators.forEach((user, socketId) => {
|
||||
if (user.selectedElementIds) {
|
||||
for (const id of Object.keys(user.selectedElementIds)) {
|
||||
if (!(id in remoteSelectedElementIds)) {
|
||||
remoteSelectedElementIds[id] = [];
|
||||
}
|
||||
remoteSelectedElementIds[id].push(socketId);
|
||||
}
|
||||
}
|
||||
if (!user.pointer) {
|
||||
return;
|
||||
}
|
||||
if (user.username) {
|
||||
pointerUsernames[socketId] = user.username;
|
||||
}
|
||||
if (user.userState) {
|
||||
pointerUserStates[socketId] = user.userState;
|
||||
}
|
||||
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: user.pointer.x,
|
||||
sceneY: user.pointer.y,
|
||||
},
|
||||
props.appState,
|
||||
);
|
||||
cursorButton[socketId] = user.button;
|
||||
});
|
||||
|
||||
const selectionColor =
|
||||
(props.containerRef?.current &&
|
||||
getComputedStyle(props.containerRef.current).getPropertyValue(
|
||||
"--color-selection",
|
||||
)) ||
|
||||
"#6965db";
|
||||
|
||||
renderInteractiveScene(
|
||||
{
|
||||
canvas: props.canvas,
|
||||
elements: props.elements,
|
||||
visibleElements: props.visibleElements,
|
||||
selectedElements: props.selectedElements,
|
||||
scale: window.devicePixelRatio,
|
||||
appState: props.appState,
|
||||
renderConfig: {
|
||||
remotePointerViewportCoords: pointerViewportCoords,
|
||||
remotePointerButton: cursorButton,
|
||||
remoteSelectedElementIds,
|
||||
remotePointerUsernames: pointerUsernames,
|
||||
remotePointerUserStates: pointerUserStates,
|
||||
selectionColor,
|
||||
renderScrollbars: false,
|
||||
},
|
||||
callback: props.renderInteractiveSceneCallback,
|
||||
},
|
||||
isRenderThrottlingEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<canvas
|
||||
className="excalidraw__canvas interactive"
|
||||
style={{
|
||||
width: props.appState.width,
|
||||
height: props.appState.height,
|
||||
cursor: props.appState.viewModeEnabled
|
||||
? CURSOR_TYPE.GRAB
|
||||
: CURSOR_TYPE.AUTO,
|
||||
}}
|
||||
width={props.appState.width * props.scale}
|
||||
height={props.appState.height * props.scale}
|
||||
ref={props.handleCanvasRef}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onPointerMove={props.onPointerMove}
|
||||
onPointerUp={props.onPointerUp}
|
||||
onPointerCancel={props.onPointerCancel}
|
||||
onTouchMove={props.onTouchMove}
|
||||
onPointerDown={props.onPointerDown}
|
||||
onDoubleClick={
|
||||
props.appState.viewModeEnabled ? undefined : props.onDoubleClick
|
||||
}
|
||||
>
|
||||
{t("labels.drawingCanvas")}
|
||||
</canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const getRelevantAppStateProps = (
|
||||
appState: AppState,
|
||||
): InteractiveCanvasAppState => ({
|
||||
zoom: appState.zoom,
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
viewModeEnabled: appState.viewModeEnabled,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
editingLinearElement: appState.editingLinearElement,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
frameToHighlight: appState.frameToHighlight,
|
||||
offsetLeft: appState.offsetLeft,
|
||||
offsetTop: appState.offsetTop,
|
||||
theme: appState.theme,
|
||||
pendingImageElementId: appState.pendingImageElementId,
|
||||
selectionElement: appState.selectionElement,
|
||||
selectedGroupIds: appState.selectedGroupIds,
|
||||
selectedLinearElement: appState.selectedLinearElement,
|
||||
multiElement: appState.multiElement,
|
||||
isBindingEnabled: appState.isBindingEnabled,
|
||||
suggestedBindings: appState.suggestedBindings,
|
||||
isRotating: appState.isRotating,
|
||||
elementsToHighlight: appState.elementsToHighlight,
|
||||
openSidebar: appState.openSidebar,
|
||||
showHyperlinkPopup: appState.showHyperlinkPopup,
|
||||
collaborators: appState.collaborators, // Necessary for collab. sessions
|
||||
activeEmbeddable: appState.activeEmbeddable,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
prevProps: InteractiveCanvasProps,
|
||||
nextProps: InteractiveCanvasProps,
|
||||
) => {
|
||||
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
|
||||
if (
|
||||
prevProps.selectionNonce !== nextProps.selectionNonce ||
|
||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on element arrays because they may have renewed
|
||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elements !== nextProps.elements ||
|
||||
prevProps.visibleElements !== nextProps.visibleElements ||
|
||||
prevProps.selectedElements !== nextProps.selectedElements
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Comparing the interactive appState for changes in case of some edge cases
|
||||
return isShallowEqual(
|
||||
// asserting AppState because we're being passed the whole AppState
|
||||
// but resolve to only the InteractiveCanvas-relevant props
|
||||
getRelevantAppStateProps(prevProps.appState as AppState),
|
||||
getRelevantAppStateProps(nextProps.appState as AppState),
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(InteractiveCanvas, areEqual);
|
125
src/components/canvases/StaticCanvas.tsx
Normal file
125
src/components/canvases/StaticCanvas.tsx
Normal file
|
@ -0,0 +1,125 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { renderStaticScene } from "../../renderer/renderScene";
|
||||
import { isRenderThrottlingEnabled, isShallowEqual } from "../../utils";
|
||||
import type { AppState, StaticCanvasAppState } from "../../types";
|
||||
import type { StaticCanvasRenderConfig } from "../../scene/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
|
||||
type StaticCanvasProps = {
|
||||
canvas: HTMLCanvasElement;
|
||||
rc: RoughCanvas;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
versionNonce: number | undefined;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: StaticCanvasAppState;
|
||||
renderConfig: StaticCanvasRenderConfig;
|
||||
};
|
||||
|
||||
const StaticCanvas = (props: StaticCanvasProps) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const isComponentMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = props.canvas;
|
||||
|
||||
if (!isComponentMounted.current) {
|
||||
isComponentMounted.current = true;
|
||||
|
||||
wrapper.replaceChildren(canvas);
|
||||
canvas.classList.add("excalidraw__canvas", "static");
|
||||
}
|
||||
|
||||
const widthString = `${props.appState.width}px`;
|
||||
const heightString = `${props.appState.height}px`;
|
||||
if (canvas.style.width !== widthString) {
|
||||
canvas.style.width = widthString;
|
||||
}
|
||||
if (canvas.style.height !== heightString) {
|
||||
canvas.style.height = heightString;
|
||||
}
|
||||
|
||||
const scaledWidth = props.appState.width * props.scale;
|
||||
const scaledHeight = props.appState.height * props.scale;
|
||||
// setting width/height resets the canvas even if dimensions not changed,
|
||||
// which would cause flicker when we skip frame (due to throttling)
|
||||
if (canvas.width !== scaledWidth) {
|
||||
canvas.width = scaledWidth;
|
||||
}
|
||||
if (canvas.height !== scaledHeight) {
|
||||
canvas.height = scaledHeight;
|
||||
}
|
||||
|
||||
renderStaticScene(
|
||||
{
|
||||
canvas,
|
||||
rc: props.rc,
|
||||
scale: props.scale,
|
||||
elements: props.elements,
|
||||
visibleElements: props.visibleElements,
|
||||
appState: props.appState,
|
||||
renderConfig: props.renderConfig,
|
||||
},
|
||||
isRenderThrottlingEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />;
|
||||
};
|
||||
|
||||
const getRelevantAppStateProps = (
|
||||
appState: AppState,
|
||||
): StaticCanvasAppState => ({
|
||||
zoom: appState.zoom,
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
viewModeEnabled: appState.viewModeEnabled,
|
||||
offsetLeft: appState.offsetLeft,
|
||||
offsetTop: appState.offsetTop,
|
||||
theme: appState.theme,
|
||||
pendingImageElementId: appState.pendingImageElementId,
|
||||
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
exportScale: appState.exportScale,
|
||||
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
|
||||
gridSize: appState.gridSize,
|
||||
frameRendering: appState.frameRendering,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
frameToHighlight: appState.frameToHighlight,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
prevProps: StaticCanvasProps,
|
||||
nextProps: StaticCanvasProps,
|
||||
) => {
|
||||
if (
|
||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on element arrays because they may have renewed
|
||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elements !== nextProps.elements ||
|
||||
prevProps.visibleElements !== nextProps.visibleElements
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isShallowEqual(
|
||||
// asserting AppState because we're being passed the whole AppState
|
||||
// but resolve to only the StaticCanvas-relevant props
|
||||
getRelevantAppStateProps(prevProps.appState as AppState),
|
||||
getRelevantAppStateProps(nextProps.appState as AppState),
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(StaticCanvas, areEqual);
|
4
src/components/canvases/index.tsx
Normal file
4
src/components/canvases/index.tsx
Normal file
|
@ -0,0 +1,4 @@
|
|||
import InteractiveCanvas from "./InteractiveCanvas";
|
||||
import StaticCanvas from "./StaticCanvas";
|
||||
|
||||
export { InteractiveCanvas, StaticCanvas };
|
|
@ -117,6 +117,7 @@ export const FRAME_STYLE = {
|
|||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
|
@ -163,6 +164,7 @@ export const EXPORT_DATA_TYPES = {
|
|||
excalidraw: "excalidraw",
|
||||
excalidrawClipboard: "excalidraw/clipboard",
|
||||
excalidrawLibrary: "excalidrawlib",
|
||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE =
|
||||
|
@ -239,6 +241,8 @@ export const VERSIONS = {
|
|||
} as const;
|
||||
|
||||
export const BOUND_TEXT_PADDING = 5;
|
||||
export const ARROW_LABEL_WIDTH_FRACTION = 0.7;
|
||||
export const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11;
|
||||
|
||||
export const VERTICAL_ALIGN = {
|
||||
TOP: "top",
|
||||
|
|
|
@ -3,8 +3,9 @@
|
|||
|
||||
:root {
|
||||
--zIndex-canvas: 1;
|
||||
--zIndex-wysiwyg: 2;
|
||||
--zIndex-layerUI: 3;
|
||||
--zIndex-interactiveCanvas: 2;
|
||||
--zIndex-wysiwyg: 3;
|
||||
--zIndex-layerUI: 4;
|
||||
|
||||
--zIndex-modal: 1000;
|
||||
--zIndex-popup: 1001;
|
||||
|
@ -69,10 +70,19 @@
|
|||
|
||||
z-index: var(--zIndex-canvas);
|
||||
|
||||
&.interactive {
|
||||
z-index: var(--zIndex-interactiveCanvas);
|
||||
}
|
||||
|
||||
// Remove the main canvas from document flow to avoid resizeObserver
|
||||
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
|
||||
}
|
||||
|
||||
&__canvas-wrapper,
|
||||
&__canvas.static {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__canvas {
|
||||
position: absolute;
|
||||
}
|
||||
|
|
2032
src/data/__snapshots__/transform.test.ts.snap
Normal file
2032
src/data/__snapshots__/transform.test.ts.snap
Normal file
File diff suppressed because it is too large
Load diff
|
@ -144,11 +144,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||
fileHandle: fileHandle || blob.handle || null,
|
||||
...cleanAppStateForExport(data.appState || {}),
|
||||
...(localAppState
|
||||
? calculateScrollCenter(
|
||||
data.elements || [],
|
||||
localAppState,
|
||||
null,
|
||||
)
|
||||
? calculateScrollCenter(data.elements || [], localAppState)
|
||||
: {}),
|
||||
},
|
||||
files: data.files,
|
||||
|
|
|
@ -29,6 +29,7 @@ import {
|
|||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
DEFAULT_SIDEBAR,
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
@ -41,7 +42,6 @@ import {
|
|||
getDefaultLineHeight,
|
||||
measureBaseline,
|
||||
} from "../element/textElement";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import { normalizeLink } from "./url";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
|
@ -122,16 +122,18 @@ const restoreElementWithProperties = <
|
|||
versionNonce: element.versionNonce ?? 0,
|
||||
isDeleted: element.isDeleted ?? false,
|
||||
id: element.id || randomId(),
|
||||
fillStyle: element.fillStyle || "hachure",
|
||||
strokeWidth: element.strokeWidth || 1,
|
||||
strokeStyle: element.strokeStyle ?? "solid",
|
||||
roughness: element.roughness ?? 1,
|
||||
opacity: element.opacity == null ? 100 : element.opacity,
|
||||
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
strokeWidth: element.strokeWidth || DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
strokeStyle: element.strokeStyle ?? DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
|
||||
opacity:
|
||||
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
|
||||
angle: element.angle || 0,
|
||||
x: extra.x ?? element.x ?? 0,
|
||||
y: extra.y ?? element.y ?? 0,
|
||||
strokeColor: element.strokeColor || COLOR_PALETTE.black,
|
||||
backgroundColor: element.backgroundColor || COLOR_PALETTE.transparent,
|
||||
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
backgroundColor:
|
||||
element.backgroundColor || DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||
width: element.width || 0,
|
||||
height: element.height || 0,
|
||||
seed: element.seed ?? 1,
|
||||
|
@ -246,7 +248,6 @@ const restoreElement = (
|
|||
startArrowhead = null,
|
||||
endArrowhead = element.type === "arrow" ? "arrow" : null,
|
||||
} = element;
|
||||
|
||||
let x = element.x;
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
|
@ -286,7 +287,7 @@ const restoreElement = (
|
|||
return restoreElementWithProperties(element, {});
|
||||
case "embeddable":
|
||||
return restoreElementWithProperties(element, {
|
||||
validated: undefined,
|
||||
validated: null,
|
||||
});
|
||||
case "frame":
|
||||
return restoreElementWithProperties(element, {
|
||||
|
@ -410,7 +411,6 @@ export const restoreElements = (
|
|||
): ExcalidrawElement[] => {
|
||||
// used to detect duplicate top-level element ids
|
||||
const existingIds = new Set<string>();
|
||||
|
||||
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||
const restoredElements = (elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
|
@ -429,6 +429,7 @@ export const restoreElements = (
|
|||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
}
|
||||
|
|
706
src/data/transform.test.ts
Normal file
706
src/data/transform.test.ts
Normal file
|
@ -0,0 +1,706 @@
|
|||
import { vi } from "vitest";
|
||||
import {
|
||||
ExcalidrawElementSkeleton,
|
||||
convertToExcalidrawElements,
|
||||
} from "./transform";
|
||||
import { ExcalidrawArrowElement } from "../element/types";
|
||||
|
||||
describe("Test Transform", () => {
|
||||
it("should transform regular shapes", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 100,
|
||||
y: 250,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#c0eb75",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 300,
|
||||
y: 250,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#ffc9c9",
|
||||
strokeStyle: "dotted",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 300,
|
||||
y: 400,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#a5d8ff",
|
||||
strokeColor: "#1971c2",
|
||||
strokeStyle: "dashed",
|
||||
fillStyle: "cross-hatch",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
];
|
||||
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform text element", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "text",
|
||||
x: 100,
|
||||
y: 100,
|
||||
text: "HELLO WORLD!",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
x: 100,
|
||||
y: 150,
|
||||
text: "STYLED HELLO WORLD!",
|
||||
fontSize: 20,
|
||||
strokeColor: "#5f3dc4",
|
||||
},
|
||||
];
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform linear elements", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 20,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 450,
|
||||
y: 20,
|
||||
startArrowhead: "dot",
|
||||
endArrowhead: "triangle",
|
||||
strokeColor: "#1971c2",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
x: 100,
|
||||
y: 60,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
x: 450,
|
||||
y: 60,
|
||||
strokeColor: "#2f9e44",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "dotted",
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform to text containers when label provided", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "RECTANGLE TEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 500,
|
||||
y: 100,
|
||||
width: 200,
|
||||
label: {
|
||||
text: "ELLIPSE TEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 150,
|
||||
width: 280,
|
||||
label: {
|
||||
text: "DIAMOND\nTEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 400,
|
||||
width: 300,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "STYLED DIAMOND TEXT CONTAINER",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 500,
|
||||
y: 300,
|
||||
width: 200,
|
||||
strokeColor: "#c2255c",
|
||||
label: {
|
||||
text: "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 500,
|
||||
y: 500,
|
||||
strokeColor: "#f08c00",
|
||||
backgroundColor: "#ffec99",
|
||||
width: 200,
|
||||
label: {
|
||||
text: "STYLED ELLIPSE TEXT CONTAINER",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(12);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform to labelled arrows when label provided for arrows", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "LABELED ARROW",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 200,
|
||||
label: {
|
||||
text: "STYLED LABELED ARROW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 300,
|
||||
strokeColor: "#1098ad",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "ANOTHER STYLED LABELLED ARROW",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 400,
|
||||
strokeColor: "#1098ad",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "ANOTHER STYLED LABELLED ARROW",
|
||||
strokeColor: "#099268",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(8);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test arrow bindings", () => {
|
||||
it("should bind arrows to shapes when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
type: "rectangle",
|
||||
},
|
||||
end: {
|
||||
type: "ellipse",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
const [arrow, text, rectangle, ellipse] = excaldrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: rectangle.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: ellipse.id,
|
||||
focus: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).toMatchObject({
|
||||
x: 340,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
containerId: arrow.id,
|
||||
});
|
||||
|
||||
expect(rectangle).toMatchObject({
|
||||
x: 155,
|
||||
y: 189,
|
||||
type: "rectangle",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ellipse).toMatchObject({
|
||||
x: 555,
|
||||
y: 189,
|
||||
type: "ellipse",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to text when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
},
|
||||
end: {
|
||||
type: "text",
|
||||
text: "WHATS UP ?",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
const [arrow, text1, text2, text3] = excaldrawElements;
|
||||
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [{ id: text1.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: text2.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: text3.id,
|
||||
focus: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(text1).toMatchObject({
|
||||
x: 340,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
containerId: arrow.id,
|
||||
});
|
||||
|
||||
expect(text2).toMatchObject({
|
||||
x: 185,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(text3).toMatchObject({
|
||||
x: 555,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing shapes when start / end provided with ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "ellipse",
|
||||
id: "ellipse-1",
|
||||
strokeColor: "#66a80f",
|
||||
x: 630,
|
||||
y: 316,
|
||||
width: 300,
|
||||
height: 300,
|
||||
backgroundColor: "#d8f5a2",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
id: "diamond-1",
|
||||
strokeColor: "#9c36b5",
|
||||
width: 140,
|
||||
x: 96,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 247,
|
||||
y: 420,
|
||||
width: 395,
|
||||
height: 35,
|
||||
strokeColor: "#1864ab",
|
||||
start: {
|
||||
type: "rectangle",
|
||||
width: 300,
|
||||
height: 300,
|
||||
},
|
||||
end: {
|
||||
id: "ellipse-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 227,
|
||||
y: 450,
|
||||
width: 400,
|
||||
strokeColor: "#e67700",
|
||||
start: {
|
||||
id: "diamond-1",
|
||||
},
|
||||
end: {
|
||||
id: "ellipse-1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(5);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing text elements when start / end provided with ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
x: 100,
|
||||
y: 239,
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
id: "text-1",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
id: "text-2",
|
||||
x: 560,
|
||||
y: 239,
|
||||
text: "Whats up ?",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
id: "text-1",
|
||||
},
|
||||
end: {
|
||||
id: "text-2",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing elements if ids are correct", () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
const elements = [
|
||||
{
|
||||
x: 100,
|
||||
y: 239,
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
id: "text-1",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 560,
|
||||
y: 139,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
backgroundColor: "#bac8ff",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
id: "text-13",
|
||||
},
|
||||
end: {
|
||||
id: "rect-11",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
const [, , arrow] = excaldrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [
|
||||
{
|
||||
id: "id46",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"No element for start binding with id text-13 found",
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"No element for end binding with id rect-11 found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should bind when ids referenced before the element data", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
end: {
|
||||
id: "rect-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 560,
|
||||
y: 139,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
backgroundColor: "#bac8ff",
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
expect(excaldrawElements.length).toBe(2);
|
||||
const [arrow, rect] = excaldrawElements;
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
focus: 0,
|
||||
gap: 5,
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
id: "id47",
|
||||
type: "arrow",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow duplicate ids", () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 100,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
},
|
||||
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 200,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(1);
|
||||
expect(excaldrawElements[0]).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Duplicate id found for rect-1",
|
||||
);
|
||||
});
|
||||
});
|
561
src/data/transform.ts
Normal file
561
src/data/transform.ts
Normal file
|
@ -0,0 +1,561 @@
|
|||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
newElement,
|
||||
newLinearElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { bindLinearElement } from "../element/binding";
|
||||
import {
|
||||
ElementConstructorOpts,
|
||||
newImageElement,
|
||||
newTextElement,
|
||||
} from "../element/newElement";
|
||||
import {
|
||||
getDefaultLineHeight,
|
||||
measureText,
|
||||
normalizeText,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FileId,
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
} from "../element/types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
import { assertNever, getFontString } from "../utils";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
x: number;
|
||||
y: number;
|
||||
label?: {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
end?:
|
||||
| (
|
||||
| (
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
| {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
}
|
||||
)
|
||||
| ((
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type?: "text";
|
||||
id: ExcalidrawTextElement["id"];
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
start?:
|
||||
| (
|
||||
| (
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
| {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
}
|
||||
)
|
||||
| ((
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type?: "text";
|
||||
id: ExcalidrawTextElement["id"];
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & Partial<ExcalidrawLinearElement>;
|
||||
|
||||
export type ValidContainer =
|
||||
| {
|
||||
type: Exclude<ExcalidrawGenericElement["type"], "selection">;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
label?: {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & ElementConstructorOpts;
|
||||
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawFrameElement
|
||||
>
|
||||
| ({
|
||||
type: Extract<ExcalidrawLinearElement["type"], "line">;
|
||||
x: number;
|
||||
y: number;
|
||||
} & Partial<ExcalidrawLinearElement>)
|
||||
| ValidContainer
|
||||
| ValidLinearElement
|
||||
| ({
|
||||
type: "text";
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
id?: ExcalidrawTextElement["id"];
|
||||
} & Partial<ExcalidrawTextElement>)
|
||||
| ({
|
||||
type: Extract<ExcalidrawImageElement["type"], "image">;
|
||||
x: number;
|
||||
y: number;
|
||||
fileId: FileId;
|
||||
} & Partial<ExcalidrawImageElement>);
|
||||
|
||||
const DEFAULT_LINEAR_ELEMENT_PROPS = {
|
||||
width: 300,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_DIMENSION = 100;
|
||||
|
||||
const bindTextToContainer = (
|
||||
container: ExcalidrawElement,
|
||||
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
|
||||
) => {
|
||||
const textElement: ExcalidrawTextElement = newTextElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
...textProps,
|
||||
containerId: container.id,
|
||||
strokeColor: textProps.strokeColor || container.strokeColor,
|
||||
});
|
||||
|
||||
Object.assign(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
return [container, textElement] as const;
|
||||
};
|
||||
|
||||
const bindLinearElementToElement = (
|
||||
linearElement: ExcalidrawArrowElement,
|
||||
start: ValidLinearElement["start"],
|
||||
end: ValidLinearElement["end"],
|
||||
elementStore: ElementStore,
|
||||
): {
|
||||
linearElement: ExcalidrawLinearElement;
|
||||
startBoundElement?: ExcalidrawElement;
|
||||
endBoundElement?: ExcalidrawElement;
|
||||
} => {
|
||||
let startBoundElement;
|
||||
let endBoundElement;
|
||||
|
||||
Object.assign(linearElement, {
|
||||
startBinding: linearElement?.startBinding || null,
|
||||
endBinding: linearElement.endBinding || null,
|
||||
});
|
||||
|
||||
if (start) {
|
||||
const width = start?.width ?? DEFAULT_DIMENSION;
|
||||
const height = start?.height ?? DEFAULT_DIMENSION;
|
||||
|
||||
let existingElement;
|
||||
if (start.id) {
|
||||
existingElement = elementStore.getElement(start.id);
|
||||
if (!existingElement) {
|
||||
console.error(`No element for start binding with id ${start.id} found`);
|
||||
}
|
||||
}
|
||||
|
||||
const startX = start.x || linearElement.x - width;
|
||||
const startY = start.y || linearElement.y - height / 2;
|
||||
const startType = existingElement ? existingElement.type : start.type;
|
||||
|
||||
if (startType) {
|
||||
if (startType === "text") {
|
||||
let text = "";
|
||||
if (existingElement && existingElement.type === "text") {
|
||||
text = existingElement.text;
|
||||
} else if (start.type === "text") {
|
||||
text = start.text;
|
||||
}
|
||||
if (!text) {
|
||||
console.error(
|
||||
`No text found for start binding text element for ${linearElement.id}`,
|
||||
);
|
||||
}
|
||||
startBoundElement = newTextElement({
|
||||
x: startX,
|
||||
y: startY,
|
||||
type: "text",
|
||||
...existingElement,
|
||||
...start,
|
||||
text,
|
||||
});
|
||||
// to position the text correctly when coordinates not provided
|
||||
Object.assign(startBoundElement, {
|
||||
x: start.x || linearElement.x - startBoundElement.width,
|
||||
y: start.y || linearElement.y - startBoundElement.height / 2,
|
||||
});
|
||||
} else {
|
||||
switch (startType) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
startBoundElement = newElement({
|
||||
x: startX,
|
||||
y: startY,
|
||||
width,
|
||||
height,
|
||||
...existingElement,
|
||||
...start,
|
||||
type: startType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
`Unhandled element start type "${start.type}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
startBoundElement as ExcalidrawBindableElement,
|
||||
"start",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (end) {
|
||||
const height = end?.height ?? DEFAULT_DIMENSION;
|
||||
const width = end?.width ?? DEFAULT_DIMENSION;
|
||||
|
||||
let existingElement;
|
||||
if (end.id) {
|
||||
existingElement = elementStore.getElement(end.id);
|
||||
if (!existingElement) {
|
||||
console.error(`No element for end binding with id ${end.id} found`);
|
||||
}
|
||||
}
|
||||
const endX = end.x || linearElement.x + linearElement.width;
|
||||
const endY = end.y || linearElement.y - height / 2;
|
||||
const endType = existingElement ? existingElement.type : end.type;
|
||||
|
||||
if (endType) {
|
||||
if (endType === "text") {
|
||||
let text = "";
|
||||
if (existingElement && existingElement.type === "text") {
|
||||
text = existingElement.text;
|
||||
} else if (end.type === "text") {
|
||||
text = end.text;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
console.error(
|
||||
`No text found for end binding text element for ${linearElement.id}`,
|
||||
);
|
||||
}
|
||||
endBoundElement = newTextElement({
|
||||
x: endX,
|
||||
y: endY,
|
||||
type: "text",
|
||||
...existingElement,
|
||||
...end,
|
||||
text,
|
||||
});
|
||||
// to position the text correctly when coordinates not provided
|
||||
Object.assign(endBoundElement, {
|
||||
y: end.y || linearElement.y - endBoundElement.height / 2,
|
||||
});
|
||||
} else {
|
||||
switch (endType) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
endBoundElement = newElement({
|
||||
x: endX,
|
||||
y: endY,
|
||||
width,
|
||||
height,
|
||||
...existingElement,
|
||||
...end,
|
||||
type: endType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
`Unhandled element end type "${endType}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
endBoundElement as ExcalidrawBindableElement,
|
||||
"end",
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
linearElement,
|
||||
startBoundElement,
|
||||
endBoundElement,
|
||||
};
|
||||
};
|
||||
|
||||
class ElementStore {
|
||||
excalidrawElements = new Map<string, ExcalidrawElement>();
|
||||
|
||||
add = (ele?: ExcalidrawElement) => {
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.excalidrawElements.set(ele.id, ele);
|
||||
};
|
||||
getElements = () => {
|
||||
return Array.from(this.excalidrawElements.values());
|
||||
};
|
||||
|
||||
getElement = (id: string) => {
|
||||
return this.excalidrawElements.get(id);
|
||||
};
|
||||
}
|
||||
|
||||
export const convertToExcalidrawElements = (
|
||||
elements: ExcalidrawElementSkeleton[] | null,
|
||||
) => {
|
||||
if (!elements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const elementStore = new ElementStore();
|
||||
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
|
||||
|
||||
// Create individual elements
|
||||
for (const element of elements) {
|
||||
let excalidrawElement: ExcalidrawElement;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
const width =
|
||||
element?.label?.text && element.width === undefined
|
||||
? 0
|
||||
: element?.width || DEFAULT_DIMENSION;
|
||||
const height =
|
||||
element?.label?.text && element.height === undefined
|
||||
? 0
|
||||
: element?.height || DEFAULT_DIMENSION;
|
||||
excalidrawElement = newElement({
|
||||
...element,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "line": {
|
||||
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
|
||||
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
points: [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
],
|
||||
...element,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "arrow": {
|
||||
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
|
||||
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
endArrowhead: "arrow",
|
||||
points: [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
],
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "text": {
|
||||
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight =
|
||||
element?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const text = element.text ?? "";
|
||||
const normalizedText = normalizeText(text);
|
||||
const metrics = measureText(
|
||||
normalizedText,
|
||||
getFontString({ fontFamily, fontSize }),
|
||||
lineHeight,
|
||||
);
|
||||
|
||||
excalidrawElement = newTextElement({
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
excalidrawElement = newImageElement({
|
||||
width: element?.width || DEFAULT_DIMENSION,
|
||||
height: element?.height || DEFAULT_DIMENSION,
|
||||
...element,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "frame":
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
excalidrawElement = element;
|
||||
assertNever(
|
||||
element,
|
||||
`Unhandled element type "${(element as any).type}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
const existingElement = elementStore.getElement(excalidrawElement.id);
|
||||
if (existingElement) {
|
||||
console.error(`Duplicate id found for ${excalidrawElement.id}`);
|
||||
} else {
|
||||
elementStore.add(excalidrawElement);
|
||||
elementsWithIds.set(excalidrawElement.id, element);
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels and arrow bindings
|
||||
for (const [id, element] of elementsWithIds) {
|
||||
const excalidrawElement = elementStore.getElement(id)!;
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond":
|
||||
case "arrow": {
|
||||
if (element.label?.text) {
|
||||
let [container, text] = bindTextToContainer(
|
||||
excalidrawElement,
|
||||
element?.label,
|
||||
);
|
||||
elementStore.add(container);
|
||||
elementStore.add(text);
|
||||
|
||||
if (container.type === "arrow") {
|
||||
const originalStart =
|
||||
element.type === "arrow" ? element?.start : undefined;
|
||||
const originalEnd =
|
||||
element.type === "arrow" ? element?.end : undefined;
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
container as ExcalidrawArrowElement,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
elementStore,
|
||||
);
|
||||
container = linearElement;
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
}
|
||||
} else {
|
||||
switch (element.type) {
|
||||
case "arrow": {
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
excalidrawElement as ExcalidrawArrowElement,
|
||||
element.start,
|
||||
element.end,
|
||||
elementStore,
|
||||
);
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return elementStore.getElements();
|
||||
};
|
|
@ -25,10 +25,7 @@ import {
|
|||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import {
|
||||
DEFAULT_LINK_SIZE,
|
||||
invalidateShapeForElement,
|
||||
} from "../renderer/renderElement";
|
||||
import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
|
||||
import { rotate } from "../math";
|
||||
import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
|
||||
import { Bounds } from "./bounds";
|
||||
|
@ -42,6 +39,7 @@ import "./Hyperlink.scss";
|
|||
import { trackEvent } from "../analytics";
|
||||
import { useAppProps, useExcalidrawAppState } from "../components/App";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
|
@ -115,7 +113,7 @@ export const Hyperlink = ({
|
|||
validated: false,
|
||||
link,
|
||||
});
|
||||
invalidateShapeForElement(element);
|
||||
ShapeCache.delete(element);
|
||||
} else {
|
||||
const { width, height } = element;
|
||||
const embedLink = getEmbedLink(link);
|
||||
|
@ -147,7 +145,7 @@ export const Hyperlink = ({
|
|||
validated: true,
|
||||
link,
|
||||
});
|
||||
invalidateShapeForElement(element);
|
||||
ShapeCache.delete(element);
|
||||
if (embeddableLinkCache.has(element.id)) {
|
||||
embeddableLinkCache.delete(element.id);
|
||||
}
|
||||
|
@ -393,7 +391,7 @@ export const getContextMenuLabel = (
|
|||
export const getLinkHandleFromCoords = (
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
angle: number,
|
||||
appState: UIAppState,
|
||||
appState: Pick<UIAppState, "zoom">,
|
||||
): [x: number, y: number, width: number, height: number] => {
|
||||
const size = DEFAULT_LINK_SIZE;
|
||||
const linkWidth = size / appState.zoom.value;
|
||||
|
|
|
@ -190,7 +190,7 @@ export const maybeBindLinearElement = (
|
|||
}
|
||||
};
|
||||
|
||||
const bindLinearElement = (
|
||||
export const bindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
|
@ -474,6 +474,7 @@ const maybeCalculateNewGapWhenScaling = (
|
|||
return { elementId, gap: newGap, focus };
|
||||
};
|
||||
|
||||
// TODO: this is a bottleneck, optimise
|
||||
export const getEligibleElementsForBinding = (
|
||||
elements: NonDeleted<ExcalidrawElement>[],
|
||||
): SuggestedBinding[] => {
|
||||
|
|
|
@ -10,10 +10,7 @@ import { distance2d, rotate, rotatePoint } from "../math";
|
|||
import rough from "roughjs/bin/rough";
|
||||
import { Drawable, Op } from "roughjs/bin/core";
|
||||
import { Point } from "../types";
|
||||
import {
|
||||
getShapeForElement,
|
||||
generateRoughOptions,
|
||||
} from "../renderer/renderElement";
|
||||
import { generateRoughOptions } from "../scene/Shape";
|
||||
import {
|
||||
isArrowElement,
|
||||
isFreeDrawElement,
|
||||
|
@ -24,6 +21,7 @@ import { rescalePoints } from "../points";
|
|||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
|
@ -621,7 +619,7 @@ const getLinearElementRotatedBounds = (
|
|||
}
|
||||
|
||||
// first element is always the curve
|
||||
const cachedShape = getShapeForElement(element)?.[0];
|
||||
const cachedShape = ShapeCache.get(element)?.[0];
|
||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = (x: number, y: number) =>
|
||||
|
|
|
@ -39,7 +39,6 @@ import {
|
|||
import { FrameNameBoundsCache, Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isEmbeddableElement,
|
||||
|
@ -50,6 +49,7 @@ import { isTransparent } from "../utils";
|
|||
import { shouldShowBoundingBox } from "./transformHandles";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
|
@ -489,7 +489,7 @@ const hitTestFreeDrawElement = (
|
|||
B = element.points[i + 1];
|
||||
}
|
||||
|
||||
const shape = getShapeForElement(element);
|
||||
const shape = ShapeCache.get(element);
|
||||
|
||||
// for filled freedraw shapes, support
|
||||
// selecting from inside
|
||||
|
@ -502,7 +502,7 @@ const hitTestFreeDrawElement = (
|
|||
|
||||
const hitTestLinear = (args: HitTestArgs): boolean => {
|
||||
const { element, threshold } = args;
|
||||
if (!getShapeForElement(element)) {
|
||||
if (!ShapeCache.get(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -520,7 +520,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
|
|||
}
|
||||
const [relX, relY] = GAPoint.toTuple(point);
|
||||
|
||||
const shape = getShapeForElement(element as ExcalidrawLinearElement);
|
||||
const shape = ShapeCache.get(element as ExcalidrawLinearElement);
|
||||
|
||||
if (!shape) {
|
||||
return false;
|
||||
|
|
|
@ -25,7 +25,12 @@ import {
|
|||
getElementPointsCoords,
|
||||
getMinMaxXYFromCurvePathOps,
|
||||
} from "./bounds";
|
||||
import { Point, AppState, PointerCoords } from "../types";
|
||||
import {
|
||||
Point,
|
||||
AppState,
|
||||
PointerCoords,
|
||||
InteractiveCanvasAppState,
|
||||
} from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import History from "../history";
|
||||
|
||||
|
@ -39,9 +44,9 @@ import { tupleToCoors } from "../utils";
|
|||
import { isBindingElement } from "./typeChecks";
|
||||
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { DRAGGING_THRESHOLD } from "../constants";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
|
@ -264,11 +269,11 @@ export class LinearElementEditor {
|
|||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
|
@ -398,7 +403,7 @@ export class LinearElementEditor {
|
|||
|
||||
static getEditorMidPoints = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
appState: InteractiveCanvasAppState,
|
||||
): typeof editorMidPointsCache["points"] => {
|
||||
const boundText = getBoundTextElement(element);
|
||||
|
||||
|
@ -422,7 +427,7 @@ export class LinearElementEditor {
|
|||
|
||||
static updateEditorMidPointsCache = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||
|
||||
|
@ -1418,7 +1423,7 @@ export class LinearElementEditor {
|
|||
let y1;
|
||||
let x2;
|
||||
let y2;
|
||||
if (element.points.length < 2 || !getShapeForElement(element)) {
|
||||
if (element.points.length < 2 || !ShapeCache.get(element)) {
|
||||
// XXX this is just a poor estimate and not very useful
|
||||
const { minX, minY, maxX, maxY } = element.points.reduce(
|
||||
(limits, [x, y]) => {
|
||||
|
@ -1437,7 +1442,7 @@ export class LinearElementEditor {
|
|||
x2 = maxX + element.x;
|
||||
y2 = maxY + element.y;
|
||||
} else {
|
||||
const shape = getShapeForElement(element)!;
|
||||
const shape = ShapeCache.generateElementShape(element);
|
||||
|
||||
// first element is always the curve
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { ExcalidrawElement } from "./types";
|
||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||
import Scene from "../scene/Scene";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import { Point } from "../types";
|
||||
import { getUpdatedTimestamp } from "../utils";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
|
@ -89,7 +89,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||
typeof fileId != "undefined" ||
|
||||
typeof points !== "undefined"
|
||||
) {
|
||||
invalidateShapeForElement(element);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
|
||||
element.version++;
|
||||
|
|
|
@ -46,7 +46,7 @@ import {
|
|||
} from "../constants";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
| "width"
|
||||
| "height"
|
||||
|
@ -134,7 +134,7 @@ export const newElement = (
|
|||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
validated: boolean | undefined;
|
||||
validated: ExcalidrawEmbeddableElement["validated"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
||||
return {
|
||||
|
@ -187,7 +187,7 @@ export const newTextElement = (
|
|||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
} & ElementConstructorOpts,
|
||||
|
@ -361,8 +361,8 @@ export const newFreeDrawElement = (
|
|||
export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
startArrowhead?: Arrowhead | null;
|
||||
endArrowhead?: Arrowhead | null;
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
|
@ -372,8 +372,8 @@ export const newLinearElement = (
|
|||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead,
|
||||
endArrowhead: opts.endArrowhead,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
endArrowhead: opts.endArrowhead || null,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -477,7 +477,7 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
|
|||
* utility wrapper to generate new id. In test env it reuses the old + postfix
|
||||
* for test assertions.
|
||||
*/
|
||||
const regenerateId = (
|
||||
export const regenerateId = (
|
||||
/** supply null if no previous id exists */
|
||||
previousId: string | null,
|
||||
) => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
import {
|
||||
|
@ -204,8 +204,6 @@ const rescalePointsInElement = (
|
|||
}
|
||||
: {};
|
||||
|
||||
const MIN_FONT_SIZE = 1;
|
||||
|
||||
const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
nextWidth: number,
|
||||
|
@ -589,24 +587,42 @@ export const resizeSingleElement = (
|
|||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isArrowElement(element) &&
|
||||
boundTextElement &&
|
||||
shouldMaintainAspectRatio
|
||||
) {
|
||||
const fontSize =
|
||||
(resizedElement.width / element.width) * boundTextElement.fontSize;
|
||||
if (fontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
boundTextFont.fontSize = fontSize;
|
||||
}
|
||||
|
||||
if (
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.height !== 0 &&
|
||||
Number.isFinite(resizedElement.x) &&
|
||||
Number.isFinite(resizedElement.y)
|
||||
) {
|
||||
mutateElement(element, resizedElement);
|
||||
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
});
|
||||
|
||||
mutateElement(element, resizedElement);
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
baseline: boundTextFont.baseline,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(element, transformHandleDirection);
|
||||
handleBindTextResize(
|
||||
element,
|
||||
transformHandleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -722,12 +738,8 @@ export const resizeMultipleElements = (
|
|||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
baseline?: ExcalidrawTextElement["baseline"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
};
|
||||
boundText: {
|
||||
element: ExcalidrawTextElementWithContainer;
|
||||
fontSize: ExcalidrawTextElement["fontSize"];
|
||||
baseline: ExcalidrawTextElement["baseline"];
|
||||
} | null;
|
||||
}[] = [];
|
||||
|
||||
for (const { orig, latest } of targetElements) {
|
||||
|
@ -798,50 +810,39 @@ export const resizeMultipleElements = (
|
|||
}
|
||||
}
|
||||
|
||||
let boundText: typeof elementsAndUpdates[0]["boundText"] = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(latest);
|
||||
|
||||
if (boundTextElement || isTextElement(orig)) {
|
||||
const updatedElement = {
|
||||
...latest,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const metrics = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (orig as ExcalidrawTextElement),
|
||||
boundTextElement
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
boundTextElement
|
||||
? getBoundTextMaxHeight(updatedElement, boundTextElement)
|
||||
: updatedElement.height,
|
||||
);
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
const metrics = measureFontSizeFromWidth(orig, width, height);
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
boundText = {
|
||||
element: boundTextElement,
|
||||
fontSize: metrics.size,
|
||||
baseline: metrics.baseline,
|
||||
};
|
||||
}
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
elementsAndUpdates.push({ element: latest, update, boundText });
|
||||
const boundTextElement = pointerDownState.originalElements.get(
|
||||
getBoundTextElementId(orig) ?? "",
|
||||
) as ExcalidrawTextElementWithContainer | undefined;
|
||||
|
||||
if (boundTextElement) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
if (newFontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
update.boundTextFontSize = newFontSize;
|
||||
}
|
||||
|
||||
elementsAndUpdates.push({
|
||||
element: latest,
|
||||
update,
|
||||
});
|
||||
}
|
||||
|
||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||
|
||||
for (const { element, update, boundText } of elementsAndUpdates) {
|
||||
for (const {
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
|
||||
mutateElement(element, update, false);
|
||||
|
@ -851,17 +852,17 @@ export const resizeMultipleElements = (
|
|||
newSize: { width, height },
|
||||
});
|
||||
|
||||
if (boundText) {
|
||||
const { element: boundTextElement, ...boundTextUpdates } = boundText;
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
mutateElement(
|
||||
boundTextElement,
|
||||
{
|
||||
...boundTextUpdates,
|
||||
fontSize: boundTextFontSize,
|
||||
angle: isLinearElement(element) ? undefined : angle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
handleBindTextResize(element, transformHandleType);
|
||||
handleBindTextResize(element, transformHandleType, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,9 @@ import { ExcalidrawElement } from "./types";
|
|||
import { mutateElement } from "./mutateElement";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { getElementBounds } from "./bounds";
|
||||
import { viewportCoordsToSceneCoords } from "../utils";
|
||||
|
||||
export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
|
@ -13,6 +15,42 @@ export const isInvisiblySmallElement = (
|
|||
return element.width === 0 && element.height === 0;
|
||||
};
|
||||
|
||||
export const isElementInViewport = (
|
||||
element: ExcalidrawElement,
|
||||
width: number,
|
||||
height: number,
|
||||
viewTransformations: {
|
||||
zoom: Zoom;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
},
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates
|
||||
const topLeftSceneCoords = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: viewTransformations.offsetLeft,
|
||||
clientY: viewTransformations.offsetTop,
|
||||
},
|
||||
viewTransformations,
|
||||
);
|
||||
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: viewTransformations.offsetLeft + width,
|
||||
clientY: viewTransformations.offsetTop + height,
|
||||
},
|
||||
viewTransformations,
|
||||
);
|
||||
|
||||
return (
|
||||
topLeftSceneCoords.x <= x2 &&
|
||||
topLeftSceneCoords.y <= y2 &&
|
||||
bottomRightSceneCoords.x >= x1 &&
|
||||
bottomRightSceneCoords.y >= y1
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a perfect shape or diagonal/horizontal/vertical line
|
||||
*/
|
||||
|
|
|
@ -10,6 +10,8 @@ import {
|
|||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
|
@ -65,7 +67,7 @@ export const redrawTextBoundingBox = (
|
|||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
maxWidth = getBoundTextMaxWidth(container, textElement);
|
||||
boundTextUpdates.text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
|
@ -83,21 +85,27 @@ export const redrawTextBoundingBox = (
|
|||
boundTextUpdates.baseline = metrics.baseline;
|
||||
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
|
||||
let nextHeight = containerDims.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
const nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
if (metrics.width > maxContainerWidth) {
|
||||
const nextWidth = computeContainerDimensionForBoundText(
|
||||
metrics.width,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { width: nextWidth });
|
||||
}
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
|
@ -155,6 +163,7 @@ export const bindTextToShapeAfterDuplication = (
|
|||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
) => {
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
|
@ -175,15 +184,17 @@ export const handleBindTextResize = (
|
|||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxWidth = getBoundTextMaxWidth(container);
|
||||
const maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let containerHeight = containerDims.height;
|
||||
let containerHeight = container.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (
|
||||
shouldMaintainAspectRatio ||
|
||||
(transformHandleType !== "n" && transformHandleType !== "s")
|
||||
) {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
|
@ -207,7 +218,7 @@ export const handleBindTextResize = (
|
|||
container.type,
|
||||
);
|
||||
|
||||
const diff = containerHeight - containerDims.height;
|
||||
const diff = containerHeight - container.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
!isArrowElement(container) &&
|
||||
|
@ -687,16 +698,6 @@ export const getContainerElement = (
|
|||
return null;
|
||||
};
|
||||
|
||||
export const getContainerDims = (element: ExcalidrawElement) => {
|
||||
const MIN_WIDTH = 300;
|
||||
if (isArrowElement(element)) {
|
||||
const width = Math.max(element.width, MIN_WIDTH);
|
||||
const height = element.height;
|
||||
return { width, height };
|
||||
}
|
||||
return { width: element.width, height: element.height };
|
||||
};
|
||||
|
||||
export const getContainerCenter = (
|
||||
container: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
|
@ -865,8 +866,9 @@ const VALID_CONTAINER_TYPES = new Set([
|
|||
"arrow",
|
||||
]);
|
||||
|
||||
export const isValidTextContainer = (element: ExcalidrawElement) =>
|
||||
VALID_CONTAINER_TYPES.has(element.type);
|
||||
export const isValidTextContainer = (element: {
|
||||
type: ExcalidrawElement["type"];
|
||||
}) => VALID_CONTAINER_TYPES.has(element.type);
|
||||
|
||||
export const computeContainerDimensionForBoundText = (
|
||||
dimension: number,
|
||||
|
@ -887,12 +889,19 @@ export const computeContainerDimensionForBoundText = (
|
|||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
export const getBoundTextMaxWidth = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElement | null = getBoundTextElement(
|
||||
container,
|
||||
),
|
||||
) => {
|
||||
const { width } = container;
|
||||
if (isArrowElement(container)) {
|
||||
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
const minWidth =
|
||||
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
|
||||
return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
|
||||
}
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The width of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
||||
|
@ -911,7 +920,7 @@ export const getBoundTextMaxHeight = (
|
|||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const height = getContainerDims(container).height;
|
||||
const { height } = container;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
|
|
|
@ -759,7 +759,7 @@ describe("textWysiwyg", () => {
|
|||
expect(h.elements[1].type).toBe("text");
|
||||
|
||||
API.setSelectedElements([h.elements[0], h.elements[1]]);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
|
@ -903,7 +903,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.clickAt(10, 20);
|
||||
mouse.down();
|
||||
mouse.up();
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
|
@ -1154,7 +1154,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
h.elements = [container, text];
|
||||
API.setSelectedElements([container, text]);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
|
@ -1168,7 +1168,7 @@ describe("textWysiwyg", () => {
|
|||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
|
||||
"Online \nwhitebo\nard \ncollabo\nration \nmade \neasy",
|
||||
);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
|
@ -1406,7 +1406,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
API.setSelectedElements([textElement]);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
|
@ -1509,4 +1509,30 @@ describe("textWysiwyg", () => {
|
|||
expect(text.text).toBe("Excalidraw");
|
||||
});
|
||||
});
|
||||
|
||||
it("should bump the version of labelled arrow when label updated", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
const arrow = UI.createElement("arrow", {
|
||||
width: 300,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
mouse.select(arrow);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
const { version } = arrow;
|
||||
|
||||
mouse.select(arrow);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello\nworld!");
|
||||
editor.blur();
|
||||
|
||||
expect(arrow.version).toEqual(version + 1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -20,10 +20,9 @@ import {
|
|||
ExcalidrawTextContainer,
|
||||
} from "./types";
|
||||
import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { bumpVersion, mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
|
@ -117,7 +116,7 @@ export const textWysiwyg = ({
|
|||
}) => void;
|
||||
getViewportCoords: (x: number, y: number) => [number, number];
|
||||
element: ExcalidrawTextElement;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
excalidrawContainer: HTMLDivElement | null;
|
||||
app: App;
|
||||
}) => {
|
||||
|
@ -177,20 +176,19 @@ export const textWysiwyg = ({
|
|||
updatedTextElement,
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
containerDims.height,
|
||||
container.height,
|
||||
);
|
||||
} else {
|
||||
originalContainerData = originalContainerCache[container.id];
|
||||
if (!originalContainerData) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
containerDims.height,
|
||||
container.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -214,7 +212,7 @@ export const textWysiwyg = ({
|
|||
// autoshrink container height until original container height
|
||||
// is reached when text is removed
|
||||
!isArrowElement(container) &&
|
||||
containerDims.height > originalContainerData.height &&
|
||||
container.height > originalContainerData.height &&
|
||||
textElementHeight < maxHeight
|
||||
) {
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
|
@ -543,6 +541,9 @@ export const textWysiwyg = ({
|
|||
id: element.id,
|
||||
}),
|
||||
});
|
||||
} else if (isArrowElement(container)) {
|
||||
// updating an arrow label may change bounds, prevent stale cache:
|
||||
bumpVersion(container);
|
||||
}
|
||||
} else {
|
||||
mutateElement(container, {
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { InteractiveCanvasAppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
import { isFrameElement, isLinearElement } from "./typeChecks";
|
||||
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||
|
@ -276,8 +276,8 @@ export const getTransformHandles = (
|
|||
};
|
||||
|
||||
export const shouldShowBoundingBox = (
|
||||
elements: NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
|
|
|
@ -86,15 +86,15 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
|||
|
||||
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "embeddable";
|
||||
/**
|
||||
* indicates whether the embeddable src (url) has been validated for rendering.
|
||||
* nullish value indicates that the validation is pending. We reset the
|
||||
* null value indicates that the validation is pending. We reset the
|
||||
* value on each restore (or url change) so that we can guarantee
|
||||
* the validation came from a trusted source (the editor). Also because we
|
||||
* may not have access to host-app supplied url validator during restore.
|
||||
*/
|
||||
validated?: boolean;
|
||||
type: "embeddable";
|
||||
validated: boolean | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
|
@ -123,7 +123,6 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
|||
export type ExcalidrawGenericElement =
|
||||
| ExcalidrawSelectionElement
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
|
@ -138,7 +137,8 @@ export type ExcalidrawElement =
|
|||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement;
|
||||
| ExcalidrawFrameElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
|
|
|
@ -16,14 +16,24 @@ export const isBrowserStorageStateNewer = (type: BrowserStateTypes) => {
|
|||
|
||||
export const updateBrowserStateVersion = (type: BrowserStateTypes) => {
|
||||
const timestamp = Date.now();
|
||||
localStorage.setItem(type, JSON.stringify(timestamp));
|
||||
LOCAL_STATE_VERSIONS[type] = timestamp;
|
||||
try {
|
||||
localStorage.setItem(type, JSON.stringify(timestamp));
|
||||
LOCAL_STATE_VERSIONS[type] = timestamp;
|
||||
} catch (error) {
|
||||
console.error("error while updating browser state verison", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const resetBrowserStateVersions = () => {
|
||||
for (const key of Object.keys(LOCAL_STATE_VERSIONS) as BrowserStateTypes[]) {
|
||||
const timestamp = -1;
|
||||
localStorage.setItem(key, JSON.stringify(timestamp));
|
||||
LOCAL_STATE_VERSIONS[key] = timestamp;
|
||||
try {
|
||||
for (const key of Object.keys(
|
||||
LOCAL_STATE_VERSIONS,
|
||||
) as BrowserStateTypes[]) {
|
||||
const timestamp = -1;
|
||||
localStorage.setItem(key, JSON.stringify(timestamp));
|
||||
LOCAL_STATE_VERSIONS[key] = timestamp;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error while resetting browser state verison", error);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -598,7 +598,7 @@ const ExcalidrawWrapper = () => {
|
|||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
canvas: HTMLCanvasElement,
|
||||
) => {
|
||||
if (exportedElements.length === 0) {
|
||||
return window.alert(t("alerts.cannotExportEmptyCanvas"));
|
||||
|
|
128
src/frame.test.tsx
Normal file
128
src/frame.test.tsx
Normal file
|
@ -0,0 +1,128 @@
|
|||
import {
|
||||
convertToExcalidrawElements,
|
||||
Excalidraw,
|
||||
} from "./packages/excalidraw/index";
|
||||
import { API } from "./tests/helpers/api";
|
||||
import { Pointer } from "./tests/helpers/ui";
|
||||
import { render } from "./tests/test-utils";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("adding elements to frames", () => {
|
||||
type ElementType = string;
|
||||
const assertOrder = (
|
||||
els: readonly { type: ElementType }[],
|
||||
order: ElementType[],
|
||||
) => {
|
||||
expect(els.map((el) => el.type)).toEqual(order);
|
||||
};
|
||||
|
||||
const reorderElements = <T extends { type: ElementType }>(
|
||||
els: readonly T[],
|
||||
order: ElementType[],
|
||||
) => {
|
||||
return order.reduce((acc: T[], el) => {
|
||||
acc.push(els.find((e) => e.type === el)!);
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
describe("resizing frame over elements", () => {
|
||||
const testElements = async (
|
||||
containerType: "arrow" | "rectangle",
|
||||
initialOrder: ElementType[],
|
||||
expectedOrder: ElementType[],
|
||||
) => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
const frame = API.createElement({ type: "frame", x: 0, y: 0 });
|
||||
|
||||
h.elements = reorderElements(
|
||||
[
|
||||
frame,
|
||||
...convertToExcalidrawElements([
|
||||
{
|
||||
type: containerType,
|
||||
x: 100,
|
||||
y: 100,
|
||||
height: 10,
|
||||
label: { text: "xx" },
|
||||
},
|
||||
]),
|
||||
],
|
||||
initialOrder,
|
||||
);
|
||||
|
||||
assertOrder(h.elements, initialOrder);
|
||||
|
||||
expect(h.elements[1].frameId).toBe(null);
|
||||
expect(h.elements[2].frameId).toBe(null);
|
||||
|
||||
const container = h.elements[1];
|
||||
|
||||
mouse.clickAt(0, 0);
|
||||
mouse.downAt(frame.x + frame.width, frame.y + frame.height);
|
||||
mouse.moveTo(
|
||||
container.x + container.width + 100,
|
||||
container.y + container.height + 100,
|
||||
);
|
||||
mouse.up();
|
||||
assertOrder(h.elements, expectedOrder);
|
||||
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
};
|
||||
|
||||
it("resizing over text containers / labelled arrows", async () => {
|
||||
await testElements(
|
||||
"rectangle",
|
||||
["frame", "rectangle", "text"],
|
||||
["rectangle", "text", "frame"],
|
||||
);
|
||||
await testElements(
|
||||
"rectangle",
|
||||
["frame", "text", "rectangle"],
|
||||
["rectangle", "text", "frame"],
|
||||
);
|
||||
await testElements(
|
||||
"rectangle",
|
||||
["rectangle", "text", "frame"],
|
||||
["rectangle", "text", "frame"],
|
||||
);
|
||||
await testElements(
|
||||
"rectangle",
|
||||
["text", "rectangle", "frame"],
|
||||
["text", "rectangle", "frame"],
|
||||
);
|
||||
|
||||
await testElements(
|
||||
"arrow",
|
||||
["frame", "arrow", "text"],
|
||||
["arrow", "text", "frame"],
|
||||
);
|
||||
await testElements(
|
||||
"arrow",
|
||||
["text", "arrow", "frame"],
|
||||
["text", "arrow", "frame"],
|
||||
);
|
||||
|
||||
// FIXME failing in tests (it fails to add elements to frame for some
|
||||
// reason) but works in browser. (╯°□°)╯︵ ┻━┻
|
||||
//
|
||||
// Looks like the `getElementsCompletelyInFrame()` doesn't work
|
||||
// in these cases.
|
||||
//
|
||||
// await testElements(
|
||||
// "arrow",
|
||||
// ["arrow", "text", "frame"],
|
||||
// ["arrow", "text", "frame"],
|
||||
// );
|
||||
// await testElements(
|
||||
// "arrow",
|
||||
// ["frame", "text", "arrow"],
|
||||
// ["text", "arrow", "frame"],
|
||||
// );
|
||||
});
|
||||
});
|
||||
});
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "./element/textElement";
|
||||
import { arrayToMap, findIndex } from "./utils";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { AppClassProperties, AppState } from "./types";
|
||||
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
|
||||
import { getElementsWithinSelection, getSelectedElements } from "./scene";
|
||||
import { isFrameElement } from "./element";
|
||||
import { moveOneRight } from "./zindex";
|
||||
|
@ -471,7 +471,6 @@ export const addElementsToFrame = (
|
|||
let nextElements = allElements.slice();
|
||||
|
||||
const frameBoundary = findIndex(nextElements, (e) => e.frameId === frame.id);
|
||||
|
||||
for (const element of omitGroupsContainingFrames(
|
||||
allElements,
|
||||
_elementsToAdd,
|
||||
|
@ -648,7 +647,7 @@ export const omitGroupsContainingFrames = (
|
|||
*/
|
||||
export const getTargetFrame = (
|
||||
element: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
const _element = isTextElement(element)
|
||||
? getContainerElement(element) || element
|
||||
|
@ -660,11 +659,12 @@ export const getTargetFrame = (
|
|||
: getContainingFrame(_element);
|
||||
};
|
||||
|
||||
// TODO: this a huge bottleneck for large scenes, optimise
|
||||
// given an element, return if the element is in some frame
|
||||
export const isElementInFrame = (
|
||||
element: ExcalidrawElement,
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
appState: AppState,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
const frame = getTargetFrame(element, appState);
|
||||
const _element = isTextElement(element)
|
||||
|
|
253
src/groups.ts
253
src/groups.ts
|
@ -4,27 +4,41 @@ import {
|
|||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { AppClassProperties, AppState } from "./types";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
InteractiveCanvasAppState,
|
||||
} from "./types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { getBoundTextElement } from "./element/textElement";
|
||||
import { makeNextSelectedElementIds } from "./scene/selection";
|
||||
import { Mutable } from "./utility-types";
|
||||
|
||||
export const selectGroup = (
|
||||
groupId: GroupId,
|
||||
appState: AppState,
|
||||
appState: InteractiveCanvasAppState,
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
): AppState => {
|
||||
const elementsInGroup = elements.filter((element) =>
|
||||
element.groupIds.includes(groupId),
|
||||
): Pick<
|
||||
InteractiveCanvasAppState,
|
||||
"selectedGroupIds" | "selectedElementIds" | "editingGroupId"
|
||||
> => {
|
||||
const elementsInGroup = elements.reduce(
|
||||
(acc: Record<string, true>, element) => {
|
||||
if (element.groupIds.includes(groupId)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
if (elementsInGroup.length < 2) {
|
||||
if (Object.keys(elementsInGroup).length < 2) {
|
||||
if (
|
||||
appState.selectedGroupIds[groupId] ||
|
||||
appState.editingGroupId === groupId
|
||||
) {
|
||||
return {
|
||||
...appState,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: false },
|
||||
editingGroupId: null,
|
||||
};
|
||||
|
@ -33,104 +47,190 @@ export const selectGroup = (
|
|||
}
|
||||
|
||||
return {
|
||||
...appState,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: true },
|
||||
selectedElementIds: {
|
||||
...appState.selectedElementIds,
|
||||
...Object.fromEntries(
|
||||
elementsInGroup.map((element) => [element.id, true]),
|
||||
),
|
||||
...elementsInGroup,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const selectGroupsForSelectedElements = (function () {
|
||||
type SelectGroupsReturnType = Pick<
|
||||
InteractiveCanvasAppState,
|
||||
"selectedGroupIds" | "editingGroupId" | "selectedElementIds"
|
||||
>;
|
||||
|
||||
let lastSelectedElements: readonly NonDeleted<ExcalidrawElement>[] | null =
|
||||
null;
|
||||
let lastElements: readonly NonDeleted<ExcalidrawElement>[] | null = null;
|
||||
let lastReturnValue: SelectGroupsReturnType | null = null;
|
||||
|
||||
const _selectGroups = (
|
||||
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
|
||||
prevAppState: InteractiveCanvasAppState,
|
||||
): SelectGroupsReturnType => {
|
||||
if (
|
||||
lastReturnValue !== undefined &&
|
||||
elements === lastElements &&
|
||||
selectedElements === lastSelectedElements &&
|
||||
appState.editingGroupId === lastReturnValue?.editingGroupId
|
||||
) {
|
||||
return lastReturnValue;
|
||||
}
|
||||
|
||||
const selectedGroupIds: Record<GroupId, boolean> = {};
|
||||
// Gather all the groups withing selected elements
|
||||
for (const selectedElement of selectedElements) {
|
||||
let groupIds = selectedElement.groupIds;
|
||||
if (appState.editingGroupId) {
|
||||
// handle the case where a group is nested within a group
|
||||
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
|
||||
if (indexOfEditingGroup > -1) {
|
||||
groupIds = groupIds.slice(0, indexOfEditingGroup);
|
||||
}
|
||||
}
|
||||
if (groupIds.length > 0) {
|
||||
const lastSelectedGroup = groupIds[groupIds.length - 1];
|
||||
selectedGroupIds[lastSelectedGroup] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Gather all the elements within selected groups
|
||||
const groupElementsIndex: Record<GroupId, string[]> = {};
|
||||
const selectedElementIdsInGroups = elements.reduce(
|
||||
(acc: Record<string, true>, element) => {
|
||||
const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
|
||||
|
||||
if (groupId) {
|
||||
acc[element.id] = true;
|
||||
|
||||
// Populate the index
|
||||
if (!Array.isArray(groupElementsIndex[groupId])) {
|
||||
groupElementsIndex[groupId] = [element.id];
|
||||
} else {
|
||||
groupElementsIndex[groupId].push(element.id);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
for (const groupId of Object.keys(groupElementsIndex)) {
|
||||
// If there is one element in the group, and the group is selected or it's being edited, it's not a group
|
||||
if (groupElementsIndex[groupId].length < 2) {
|
||||
if (selectedGroupIds[groupId]) {
|
||||
selectedGroupIds[groupId] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastElements = elements;
|
||||
lastSelectedElements = selectedElements;
|
||||
|
||||
lastReturnValue = {
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedGroupIds,
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
...appState.selectedElementIds,
|
||||
...selectedElementIdsInGroups,
|
||||
},
|
||||
prevAppState,
|
||||
),
|
||||
};
|
||||
|
||||
return lastReturnValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* When you select an element, you often want to actually select the whole group it's in, unless
|
||||
* you're currently editing that group.
|
||||
*/
|
||||
const selectGroupsForSelectedElements = (
|
||||
appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
prevAppState: InteractiveCanvasAppState,
|
||||
/**
|
||||
* supply null in cases where you don't have access to App instance and
|
||||
* you don't care about optimizing selectElements retrieval
|
||||
*/
|
||||
app: AppClassProperties | null,
|
||||
): Mutable<
|
||||
Pick<
|
||||
InteractiveCanvasAppState,
|
||||
"selectedGroupIds" | "editingGroupId" | "selectedElementIds"
|
||||
>
|
||||
> => {
|
||||
const selectedElements = app
|
||||
? app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
// supplying elements explicitly in case we're passed non-state elements
|
||||
elements,
|
||||
})
|
||||
: getSelectedElements(elements, appState);
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return {
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
appState.selectedElementIds,
|
||||
prevAppState,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return _selectGroups(selectedElements, elements, appState, prevAppState);
|
||||
};
|
||||
|
||||
selectGroupsForSelectedElements.clearCache = () => {
|
||||
lastElements = null;
|
||||
lastSelectedElements = null;
|
||||
lastReturnValue = null;
|
||||
};
|
||||
|
||||
return selectGroupsForSelectedElements;
|
||||
})();
|
||||
|
||||
/**
|
||||
* If the element's group is selected, don't render an individual
|
||||
* selection border around it.
|
||||
*/
|
||||
export const isSelectedViaGroup = (
|
||||
appState: AppState,
|
||||
appState: InteractiveCanvasAppState,
|
||||
element: ExcalidrawElement,
|
||||
) => getSelectedGroupForElement(appState, element) != null;
|
||||
|
||||
export const getSelectedGroupForElement = (
|
||||
appState: AppState,
|
||||
appState: InteractiveCanvasAppState,
|
||||
element: ExcalidrawElement,
|
||||
) =>
|
||||
element.groupIds
|
||||
.filter((groupId) => groupId !== appState.editingGroupId)
|
||||
.find((groupId) => appState.selectedGroupIds[groupId]);
|
||||
|
||||
export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
|
||||
export const getSelectedGroupIds = (
|
||||
appState: InteractiveCanvasAppState,
|
||||
): GroupId[] =>
|
||||
Object.entries(appState.selectedGroupIds)
|
||||
.filter(([groupId, isSelected]) => isSelected)
|
||||
.map(([groupId, isSelected]) => groupId);
|
||||
|
||||
/**
|
||||
* When you select an element, you often want to actually select the whole group it's in, unless
|
||||
* you're currently editing that group.
|
||||
*/
|
||||
export const selectGroupsForSelectedElements = (
|
||||
appState: AppState,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
prevAppState: AppState,
|
||||
/**
|
||||
* supply null in cases where you don't have access to App instance and
|
||||
* you don't care about optimizing selectElements retrieval
|
||||
*/
|
||||
app: AppClassProperties | null,
|
||||
): AppState => {
|
||||
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
|
||||
|
||||
const selectedElements = app
|
||||
? app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
// supplying elements explicitly in case we're passed non-state elements
|
||||
elements,
|
||||
})
|
||||
: getSelectedElements(elements, appState);
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return {
|
||||
...nextAppState,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
nextAppState.selectedElementIds,
|
||||
prevAppState,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
for (const selectedElement of selectedElements) {
|
||||
let groupIds = selectedElement.groupIds;
|
||||
if (appState.editingGroupId) {
|
||||
// handle the case where a group is nested within a group
|
||||
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
|
||||
if (indexOfEditingGroup > -1) {
|
||||
groupIds = groupIds.slice(0, indexOfEditingGroup);
|
||||
}
|
||||
}
|
||||
if (groupIds.length > 0) {
|
||||
const groupId = groupIds[groupIds.length - 1];
|
||||
nextAppState = selectGroup(groupId, nextAppState, elements);
|
||||
}
|
||||
}
|
||||
|
||||
nextAppState.selectedElementIds = makeNextSelectedElementIds(
|
||||
nextAppState.selectedElementIds,
|
||||
prevAppState,
|
||||
);
|
||||
|
||||
return nextAppState;
|
||||
};
|
||||
|
||||
// given a list of elements, return the the actual group ids that should be selected
|
||||
// or used to update the elements
|
||||
export const selectGroupsFromGivenElements = (
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
appState: AppState,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
|
||||
let nextAppState: InteractiveCanvasAppState = {
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
};
|
||||
|
||||
for (const element of elements) {
|
||||
let groupIds = element.groupIds;
|
||||
|
@ -142,7 +242,10 @@ export const selectGroupsFromGivenElements = (
|
|||
}
|
||||
if (groupIds.length > 0) {
|
||||
const groupId = groupIds[groupIds.length - 1];
|
||||
nextAppState = selectGroup(groupId, nextAppState, elements);
|
||||
nextAppState = {
|
||||
...nextAppState,
|
||||
...selectGroup(groupId, nextAppState, elements),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,9 +10,9 @@ import {
|
|||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
} from "./element/types";
|
||||
import { getShapeForElement } from "./renderer/renderElement";
|
||||
import { getCurvePathOps } from "./element/bounds";
|
||||
import { Mutable } from "./utility-types";
|
||||
import { ShapeCache } from "./scene/ShapeCache";
|
||||
|
||||
export const rotate = (
|
||||
x1: number,
|
||||
|
@ -303,7 +303,7 @@ export const getControlPointsForBezierCurve = (
|
|||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
endPoint: Point,
|
||||
) => {
|
||||
const shape = getShapeForElement(element as ExcalidrawLinearElement);
|
||||
const shape = ShapeCache.generateElementShape(element);
|
||||
if (!shape) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[
|
||||
{
|
||||
"path": "dist/excalidraw.production.min.js",
|
||||
"limit": "290 kB"
|
||||
"limit": "291 kB"
|
||||
},
|
||||
{
|
||||
"path": "dist/excalidraw-assets/locales",
|
||||
|
|
|
@ -75,6 +75,7 @@ const {
|
|||
WelcomeScreen,
|
||||
MainMenu,
|
||||
LiveCollaborationTrigger,
|
||||
convertToExcalidrawElements,
|
||||
} = window.ExcalidrawLib;
|
||||
|
||||
const COMMENT_ICON_DIMENSION = 32;
|
||||
|
@ -140,7 +141,10 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||
];
|
||||
|
||||
//@ts-ignore
|
||||
initialStatePromiseRef.current.promise.resolve(initialData);
|
||||
initialStatePromiseRef.current.promise.resolve({
|
||||
...initialData,
|
||||
elements: convertToExcalidrawElements(initialData.elements),
|
||||
});
|
||||
excalidrawAPI.addFiles(imagesArray);
|
||||
};
|
||||
};
|
||||
|
@ -184,38 +188,40 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||
const updateScene = () => {
|
||||
const sceneData = {
|
||||
elements: restoreElements(
|
||||
[
|
||||
convertToExcalidrawElements([
|
||||
{
|
||||
type: "rectangle",
|
||||
version: 141,
|
||||
versionNonce: 361174001,
|
||||
isDeleted: false,
|
||||
id: "oDVXy8D6rom3H1-LLH2-f",
|
||||
id: "rect-1",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
angle: 0,
|
||||
x: 100.50390625,
|
||||
y: 93.67578125,
|
||||
strokeColor: "#c92a2a",
|
||||
backgroundColor: "transparent",
|
||||
width: 186.47265625,
|
||||
height: 141.9765625,
|
||||
seed: 1968410350,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
boundElements: null,
|
||||
locked: false,
|
||||
link: null,
|
||||
updated: 1,
|
||||
roundness: {
|
||||
type: ROUNDNESS.ADAPTIVE_RADIUS,
|
||||
value: 32,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
type: "arrow",
|
||||
x: 300,
|
||||
y: 150,
|
||||
start: { id: "rect-1" },
|
||||
end: { type: "ellipse" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
x: 300,
|
||||
y: 100,
|
||||
text: "HELLO WORLD!",
|
||||
},
|
||||
]),
|
||||
null,
|
||||
),
|
||||
appState: {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -255,3 +255,4 @@ export { LiveCollaborationTrigger };
|
|||
export { DefaultSidebar } from "../../components/DefaultSidebar";
|
||||
|
||||
export { normalizeLink } from "../../data/url";
|
||||
export { convertToExcalidrawElements } from "../../data/transform";
|
||||
|
|
|
@ -52,7 +52,7 @@
|
|||
"@babel/preset-env": "7.18.6",
|
||||
"@babel/preset-react": "7.18.6",
|
||||
"@babel/preset-typescript": "7.18.6",
|
||||
"@size-limit/preset-big-lib": "8.2.6",
|
||||
"@size-limit/preset-big-lib": "9.0.0",
|
||||
"autoprefixer": "10.4.7",
|
||||
"babel-loader": "8.2.5",
|
||||
"babel-plugin-transform-class-properties": "6.24.1",
|
||||
|
@ -63,7 +63,7 @@
|
|||
"mini-css-extract-plugin": "2.6.1",
|
||||
"postcss-loader": "7.0.1",
|
||||
"sass-loader": "13.0.2",
|
||||
"size-limit": "8.2.4",
|
||||
"size-limit": "9.0.0",
|
||||
"style-loader": "3.3.3",
|
||||
"terser-webpack-plugin": "5.3.3",
|
||||
"ts-loader": "9.3.1",
|
||||
|
|
|
@ -2,8 +2,8 @@ const path = require("path");
|
|||
const webpack = require("webpack");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const { parseEnvVariables } = require("./env");
|
||||
|
||||
const outputDir = process.env.EXAMPLE === "true" ? "example/public" : "dist";
|
||||
|
||||
module.exports = {
|
||||
mode: "development",
|
||||
devtool: false,
|
||||
|
@ -17,7 +17,6 @@ module.exports = {
|
|||
filename: "[name].js",
|
||||
chunkFilename: "excalidraw-assets-dev/[name]-[contenthash].js",
|
||||
assetModuleFilename: "excalidraw-assets-dev/[name][ext]",
|
||||
|
||||
publicPath: "",
|
||||
},
|
||||
resolve: {
|
||||
|
@ -45,7 +44,7 @@ module.exports = {
|
|||
{
|
||||
test: /\.(ts|tsx|js|jsx|mjs)$/,
|
||||
exclude:
|
||||
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
/node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
use: [
|
||||
{
|
||||
loader: "import-meta-loader",
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const { parseEnvVariables } = require("./env");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const BundleAnalyzerPlugin =
|
||||
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const webpack = require("webpack");
|
||||
const { parseEnvVariables } = require("./env");
|
||||
|
||||
module.exports = {
|
||||
mode: "production",
|
||||
|
@ -47,8 +47,7 @@ module.exports = {
|
|||
{
|
||||
test: /\.(ts|tsx|js|jsx|mjs)$/,
|
||||
exclude:
|
||||
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
|
||||
/node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
use: [
|
||||
{
|
||||
loader: "import-meta-loader",
|
||||
|
|
|
@ -1112,38 +1112,37 @@
|
|||
dependencies:
|
||||
debug "^4.1.1"
|
||||
|
||||
"@size-limit/file@8.2.6":
|
||||
version "8.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-8.2.6.tgz#0e17045a0fa8009fc787c85e3c09f611316f908c"
|
||||
integrity sha512-B7ayjxiJsbtXdIIWazJkB5gezi5WBMecdHTFPMDhI3NwEML1RVvUjAkrb1mPAAkIpt2LVHPnhdCUHjqDdjugwg==
|
||||
"@size-limit/file@9.0.0":
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-9.0.0.tgz#eed5415f5bcc8407979e47ffa49ffaf12d2d2378"
|
||||
integrity sha512-oM2UaH2FRq4q22k+R+P6xCpzET10T94LFdSjb9svVu/vOD7NaB9LGcG6se8TW1BExXiyXO4GEhLsBt3uMKM3qA==
|
||||
dependencies:
|
||||
semver "7.5.3"
|
||||
semver "7.5.4"
|
||||
|
||||
"@size-limit/preset-big-lib@8.2.6":
|
||||
version "8.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@size-limit/preset-big-lib/-/preset-big-lib-8.2.6.tgz#fbff51e7a03fc36b6b3d9103cbe5b3909e35a83e"
|
||||
integrity sha512-63a+yos0QNMVCfx1OWnxBrdQVTlBVGzW5fDXwpWq/hKfP3B89XXHYGeL2Z2f8IXSVeGkAHXnDcTZyIPRaXffVg==
|
||||
"@size-limit/preset-big-lib@9.0.0":
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@size-limit/preset-big-lib/-/preset-big-lib-9.0.0.tgz#ddcf30e7646b66ecc0f8a1a6498a5eda6d82876d"
|
||||
integrity sha512-wc+VNLXjn0z11s1IWevo8+utP7uZGPVDNNe5cNyMFYHv7/pwJtgsd8w2onEkbK1h8x1oJfWlcqFNKAnvD1Bylw==
|
||||
dependencies:
|
||||
"@size-limit/file" "8.2.6"
|
||||
"@size-limit/time" "8.2.6"
|
||||
"@size-limit/webpack" "8.2.6"
|
||||
size-limit "8.2.6"
|
||||
"@size-limit/file" "9.0.0"
|
||||
"@size-limit/time" "9.0.0"
|
||||
"@size-limit/webpack" "9.0.0"
|
||||
size-limit "9.0.0"
|
||||
|
||||
"@size-limit/time@8.2.6":
|
||||
version "8.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@size-limit/time/-/time-8.2.6.tgz#5d1912bcfc6437f6f59804737ad0538b25c207ed"
|
||||
integrity sha512-fUEPvz7Uq6+oUQxSYbNlJt3tTgQBl1VY21USi/B7ebdnVKLnUx1JyPI9v7imN6XEkB2VpJtnYgjFeLgNrirzMA==
|
||||
"@size-limit/time@9.0.0":
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@size-limit/time/-/time-9.0.0.tgz#44ba75b3cba30736b133dbb3fd740f894a642c87"
|
||||
integrity sha512-//Yba5fRkYqpBZ6MFtjDTSjCpQonDMqkwofpe0G1hMd/5l/3PZXVLDCAU2BW3nQFqTkpeyytFG6Y3jxUqSddiw==
|
||||
dependencies:
|
||||
estimo "^2.3.6"
|
||||
react "^17.0.2"
|
||||
|
||||
"@size-limit/webpack@8.2.6":
|
||||
version "8.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@size-limit/webpack/-/webpack-8.2.6.tgz#3a3c98293b80f7c5fb6e8499199ae6f94f05b463"
|
||||
integrity sha512-y2sB66m5sJxIjZ8SEAzpWbiw3/+bnQHDHfk9cSbV5ChKklq02AlYg8BS5KxGWmMpdyUo4TzpjSCP9oEudY+hxQ==
|
||||
"@size-limit/webpack@9.0.0":
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@size-limit/webpack/-/webpack-9.0.0.tgz#4514851d3607490e228bf22bc95286643f64a490"
|
||||
integrity sha512-0YwdvmBj9rS4bXE/PY9vSdc5lCiQXmT0794EsG7yvlDMWyrWa/dsgcRok/w0MoZstfuLaS6lv03VI5UJRFU/lg==
|
||||
dependencies:
|
||||
nanoid "^3.3.6"
|
||||
webpack "^5.88.0"
|
||||
webpack "^5.88.2"
|
||||
|
||||
"@types/body-parser@*":
|
||||
version "1.19.2"
|
||||
|
@ -1694,9 +1693,9 @@ ansi-styles@^4.1.0:
|
|||
color-convert "^2.0.1"
|
||||
|
||||
anymatch@~3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
|
||||
integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
|
||||
integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
|
||||
dependencies:
|
||||
normalize-path "^3.0.0"
|
||||
picomatch "^2.0.4"
|
||||
|
@ -2553,9 +2552,9 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
|||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
|
||||
fast-glob@^3.2.9:
|
||||
version "3.2.12"
|
||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
|
||||
integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4"
|
||||
integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
|
||||
dependencies:
|
||||
"@nodelib/fs.stat" "^2.0.2"
|
||||
"@nodelib/fs.walk" "^1.2.3"
|
||||
|
@ -2672,9 +2671,9 @@ fs.realpath@^1.0.0:
|
|||
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
|
||||
|
||||
fsevents@~2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
||||
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||
|
||||
function-bind@^1.1.1:
|
||||
version "1.1.1"
|
||||
|
@ -2993,7 +2992,7 @@ is-docker@^2.0.0, is-docker@^2.1.1:
|
|||
is-extglob@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
|
||||
integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
|
||||
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
|
||||
|
||||
is-glob@^4.0.1, is-glob@~4.0.1:
|
||||
version "4.0.3"
|
||||
|
@ -3105,7 +3104,7 @@ klona@^2.0.4, klona@^2.0.5:
|
|||
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.5.tgz#d166574d90076395d9963aa7a928fabb8d76afbc"
|
||||
integrity sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==
|
||||
|
||||
lilconfig@^2.0.6, lilconfig@^2.1.0:
|
||||
lilconfig@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
|
||||
integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
|
||||
|
@ -3146,7 +3145,7 @@ lodash@^4.17.20, lodash@^4.17.4:
|
|||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
|
||||
loose-envify@^1.0.0, loose-envify@^1.1.0:
|
||||
loose-envify@^1.0.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||
|
@ -3360,11 +3359,6 @@ npm-run-path@^4.0.1:
|
|||
dependencies:
|
||||
path-key "^3.0.0"
|
||||
|
||||
object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
||||
|
||||
object-inspect@^1.9.0:
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0"
|
||||
|
@ -3519,16 +3513,16 @@ picocolors@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
|
||||
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
|
||||
|
||||
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
|
||||
integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
|
||||
|
||||
picomatch@^2.3.1:
|
||||
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
|
||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||
|
||||
picomatch@^2.2.3:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
|
||||
integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
|
||||
|
||||
pkg-dir@4.2.0, pkg-dir@^4.1.0, pkg-dir@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
|
||||
|
@ -3685,14 +3679,6 @@ raw-body@2.5.1:
|
|||
iconv-lite "0.4.24"
|
||||
unpipe "1.0.0"
|
||||
|
||||
react@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
readable-stream@^2.0.1:
|
||||
version "2.3.7"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
|
||||
|
@ -3927,24 +3913,17 @@ semver@7.0.0:
|
|||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
|
||||
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
|
||||
|
||||
semver@7.5.3:
|
||||
version "7.5.3"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.3.tgz#161ce8c2c6b4b3bdca6caadc9fa3317a4c4fe88e"
|
||||
integrity sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==
|
||||
semver@7.5.4, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
|
||||
version "7.5.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
|
||||
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
|
||||
version "6.3.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
|
||||
|
||||
semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
|
||||
version "7.3.7"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
|
||||
integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
version "6.3.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
send@0.18.0:
|
||||
version "0.18.0"
|
||||
|
@ -4054,22 +4033,10 @@ sirv@^1.0.7:
|
|||
mime "^2.3.1"
|
||||
totalist "^1.0.0"
|
||||
|
||||
size-limit@8.2.4:
|
||||
version "8.2.4"
|
||||
resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-8.2.4.tgz#0ab0df7cbc89007d544a50b451f5fb4d110694ca"
|
||||
integrity sha512-Un16nSreD1v2CYwSorattiJcHuAWqXvg4TsGgzpjnoByqQwsSfCIEQHuaD14HNStzredR8cdsO9oGH91ibypTA==
|
||||
dependencies:
|
||||
bytes-iec "^3.1.1"
|
||||
chokidar "^3.5.3"
|
||||
globby "^11.1.0"
|
||||
lilconfig "^2.0.6"
|
||||
nanospinner "^1.1.0"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
size-limit@8.2.6:
|
||||
version "8.2.6"
|
||||
resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-8.2.6.tgz#e41dbc74a4d7fc13be72551b6ef31ea50007d18d"
|
||||
integrity sha512-zpznim/tX/NegjoQuRKgWTF4XiB0cn2qt90uJzxYNTFAqexk4b94DOAkBD3TwhC6c3kw2r0KcnA5upziVMZqDg==
|
||||
size-limit@9.0.0:
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-9.0.0.tgz#203c47303462a8351976eb26175acea5f4e80447"
|
||||
integrity sha512-DrA7o2DeRN3s+vwCA9nn7Ck9Y4pn9t0GNUwQRpKqBtBmNkl6LA2s/NlNCdtKHrEkRTeYA1ZQ65mnYveo9rUqgA==
|
||||
dependencies:
|
||||
bytes-iec "^3.1.1"
|
||||
chokidar "^3.5.3"
|
||||
|
@ -4556,7 +4523,7 @@ webpack@5.76.0:
|
|||
watchpack "^2.4.0"
|
||||
webpack-sources "^3.2.3"
|
||||
|
||||
webpack@^5.88.0:
|
||||
webpack@^5.88.2:
|
||||
version "5.88.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.88.2.tgz#f62b4b842f1c6ff580f3fcb2ed4f0b579f4c210e"
|
||||
integrity sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==
|
||||
|
|
|
@ -2259,14 +2259,14 @@ semver@7.0.0:
|
|||
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
|
||||
|
||||
semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
|
||||
version "6.3.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
|
||||
version "6.3.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^7.3.4, semver@^7.3.5:
|
||||
version "7.3.5"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
|
||||
integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
|
||||
version "7.5.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
|
||||
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
Arrowhead,
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
|
@ -16,27 +14,22 @@ import {
|
|||
isArrowElement,
|
||||
hasBoundTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
getDiamondPoints,
|
||||
getElementAbsoluteCoords,
|
||||
getArrowheadPoints,
|
||||
} from "../element/bounds";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { Drawable, Options } from "roughjs/bin/core";
|
||||
import { RoughSVG } from "roughjs/bin/svg";
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
import { getElementAbsoluteCoords } from "../element/bounds";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import type { Drawable } from "roughjs/bin/core";
|
||||
import type { RoughSVG } from "roughjs/bin/svg";
|
||||
|
||||
import { RenderConfig } from "../scene/types";
|
||||
import {
|
||||
distance,
|
||||
getFontString,
|
||||
getFontFamilyString,
|
||||
isRTL,
|
||||
isTransparent,
|
||||
} from "../utils";
|
||||
import { StaticCanvasRenderConfig } from "../scene/types";
|
||||
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
|
||||
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { AppState, BinaryFiles, Zoom } from "../types";
|
||||
import {
|
||||
AppState,
|
||||
StaticCanvasAppState,
|
||||
BinaryFiles,
|
||||
Zoom,
|
||||
InteractiveCanvasAppState,
|
||||
} from "../types";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
|
@ -61,6 +54,7 @@ import {
|
|||
} from "../element/embeddable";
|
||||
import { getContainingFrame } from "../frame";
|
||||
import { normalizeLink, toValidURL } from "../data/url";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
|
@ -72,36 +66,33 @@ const defaultAppState = getDefaultAppState();
|
|||
|
||||
const isPendingImageElement = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: RenderConfig,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
) =>
|
||||
isInitializedImageElement(element) &&
|
||||
!renderConfig.imageCache.has(element.fileId);
|
||||
|
||||
const shouldResetImageFilter = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: RenderConfig,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
return (
|
||||
renderConfig.theme === "dark" &&
|
||||
appState.theme === "dark" &&
|
||||
isInitializedImageElement(element) &&
|
||||
!isPendingImageElement(element, renderConfig) &&
|
||||
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
|
||||
);
|
||||
};
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||
|
||||
const getCanvasPadding = (element: ExcalidrawElement) =>
|
||||
element.type === "freedraw" ? element.strokeWidth * 12 : 20;
|
||||
|
||||
export interface ExcalidrawElementWithCanvas {
|
||||
element: ExcalidrawElement | ExcalidrawTextElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
theme: RenderConfig["theme"];
|
||||
theme: AppState["theme"];
|
||||
scale: number;
|
||||
zoomValue: RenderConfig["zoom"]["value"];
|
||||
zoomValue: AppState["zoom"]["value"];
|
||||
canvasOffsetX: number;
|
||||
canvasOffsetY: number;
|
||||
boundTextElementVersion: number | null;
|
||||
|
@ -165,7 +156,8 @@ const cappedElementCanvasSize = (
|
|||
const generateElementCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
zoom: Zoom,
|
||||
renderConfig: RenderConfig,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
): ExcalidrawElementWithCanvas => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
@ -205,17 +197,17 @@ const generateElementCanvas = (
|
|||
const rc = rough.canvas(canvas);
|
||||
|
||||
// in dark theme, revert the image color filter
|
||||
if (shouldResetImageFilter(element, renderConfig)) {
|
||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
||||
context.filter = IMAGE_INVERT_FILTER;
|
||||
}
|
||||
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
context.restore();
|
||||
|
||||
return {
|
||||
element,
|
||||
canvas,
|
||||
theme: renderConfig.theme,
|
||||
theme: appState.theme,
|
||||
scale,
|
||||
zoomValue: zoom.value,
|
||||
canvasOffsetX,
|
||||
|
@ -262,11 +254,13 @@ const drawImagePlaceholder = (
|
|||
size,
|
||||
);
|
||||
};
|
||||
|
||||
const drawElementOnCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: RenderConfig,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
context.globalAlpha =
|
||||
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
||||
|
@ -277,7 +271,7 @@ const drawElementOnCanvas = (
|
|||
case "ellipse": {
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
rc.draw(getShapeForElement(element)!);
|
||||
rc.draw(ShapeCache.get(element)!);
|
||||
break;
|
||||
}
|
||||
case "arrow":
|
||||
|
@ -285,7 +279,7 @@ const drawElementOnCanvas = (
|
|||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
|
||||
getShapeForElement(element)!.forEach((shape) => {
|
||||
ShapeCache.get(element)!.forEach((shape) => {
|
||||
rc.draw(shape);
|
||||
});
|
||||
break;
|
||||
|
@ -296,7 +290,7 @@ const drawElementOnCanvas = (
|
|||
context.fillStyle = element.strokeColor;
|
||||
|
||||
const path = getFreeDrawPath2D(element) as Path2D;
|
||||
const fillShape = getShapeForElement(element);
|
||||
const fillShape = ShapeCache.get(element);
|
||||
|
||||
if (fillShape) {
|
||||
rc.draw(fillShape);
|
||||
|
@ -321,7 +315,7 @@ const drawElementOnCanvas = (
|
|||
element.height,
|
||||
);
|
||||
} else {
|
||||
drawImagePlaceholder(element, context, renderConfig.zoom.value);
|
||||
drawImagePlaceholder(element, context, appState.zoom.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -373,405 +367,29 @@ const drawElementOnCanvas = (
|
|||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
const elementWithCanvasCache = new WeakMap<
|
||||
export const elementWithCanvasCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementWithCanvas
|
||||
>();
|
||||
|
||||
const shapeCache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||
|
||||
type ElementShape = Drawable | Drawable[] | null;
|
||||
|
||||
type ElementShapes = {
|
||||
freedraw: Drawable | null;
|
||||
arrow: Drawable[];
|
||||
line: Drawable[];
|
||||
text: null;
|
||||
image: null;
|
||||
};
|
||||
|
||||
export const getShapeForElement = <T extends ExcalidrawElement>(element: T) =>
|
||||
shapeCache.get(element) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: Drawable | null | undefined;
|
||||
|
||||
export const setShapeForElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable,
|
||||
) => shapeCache.set(element, shape);
|
||||
|
||||
export const invalidateShapeForElement = (element: ExcalidrawElement) =>
|
||||
shapeCache.delete(element);
|
||||
|
||||
export const generateRoughOptions = (
|
||||
element: ExcalidrawElement,
|
||||
continuousPath = false,
|
||||
): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
strokeLineDash:
|
||||
element.strokeStyle === "dashed"
|
||||
? getDashArrayDashed(element.strokeWidth)
|
||||
: element.strokeStyle === "dotted"
|
||||
? getDashArrayDotted(element.strokeWidth)
|
||||
: undefined,
|
||||
// for non-solid strokes, disable multiStroke because it tends to make
|
||||
// dashes/dots overlay each other
|
||||
disableMultiStroke: element.strokeStyle !== "solid",
|
||||
// for non-solid strokes, increase the width a bit to make it visually
|
||||
// similar to solid strokes, because we're also disabling multiStroke
|
||||
strokeWidth:
|
||||
element.strokeStyle !== "solid"
|
||||
? element.strokeWidth + 0.5
|
||||
: element.strokeWidth,
|
||||
// when increasing strokeWidth, we must explicitly set fillWeight and
|
||||
// hachureGap because if not specified, roughjs uses strokeWidth to
|
||||
// calculate them (and we don't want the fills to be modified)
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: element.roughness,
|
||||
stroke: element.strokeColor,
|
||||
preserveVertices: continuousPath,
|
||||
};
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "embeddable":
|
||||
case "diamond":
|
||||
case "ellipse": {
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill = isTransparent(element.backgroundColor)
|
||||
? undefined
|
||||
: element.backgroundColor;
|
||||
if (element.type === "ellipse") {
|
||||
options.curveFitting = 1;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
case "line":
|
||||
case "freedraw": {
|
||||
if (isPathALoop(element.points)) {
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill =
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
case "arrow":
|
||||
return options;
|
||||
default: {
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const modifyEmbeddableForRoughOptions = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
isExporting: boolean,
|
||||
) => {
|
||||
if (
|
||||
element.type === "embeddable" &&
|
||||
(isExporting || !element.validated) &&
|
||||
isTransparent(element.backgroundColor) &&
|
||||
isTransparent(element.strokeColor)
|
||||
) {
|
||||
return {
|
||||
...element,
|
||||
roughness: 0,
|
||||
backgroundColor: "#d3d3d3",
|
||||
fillStyle: "solid",
|
||||
} as const;
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the element's shape and puts it into the cache.
|
||||
* @param element
|
||||
* @param generator
|
||||
*/
|
||||
const generateElementShape = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
generator: RoughGenerator,
|
||||
isExporting: boolean = false,
|
||||
) => {
|
||||
let shape = isExporting ? undefined : shapeCache.get(element);
|
||||
|
||||
// `null` indicates no rc shape applicable for this element type
|
||||
// (= do not generate anything)
|
||||
if (shape === undefined) {
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "embeddable": {
|
||||
// this is for rendering the stroke/bg of the embeddable, especially
|
||||
// when the src url is not set
|
||||
|
||||
if (element.roundness) {
|
||||
const w = element.width;
|
||||
const h = element.height;
|
||||
const r = getCornerRadius(Math.min(w, h), element);
|
||||
shape = generator.path(
|
||||
`M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
|
||||
h - r
|
||||
} Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
|
||||
h - r
|
||||
} L 0 ${r} Q 0 0, ${r} 0`,
|
||||
generateRoughOptions(
|
||||
modifyEmbeddableForRoughOptions(element, isExporting),
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
shape = generator.rectangle(
|
||||
0,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(
|
||||
modifyEmbeddableForRoughOptions(element, isExporting),
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
setShapeForElement(element, shape);
|
||||
|
||||
break;
|
||||
}
|
||||
case "diamond": {
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
if (element.roundness) {
|
||||
const verticalRadius = getCornerRadius(
|
||||
Math.abs(topX - leftX),
|
||||
element,
|
||||
);
|
||||
|
||||
const horizontalRadius = getCornerRadius(
|
||||
Math.abs(rightY - topY),
|
||||
element,
|
||||
);
|
||||
|
||||
shape = generator.path(
|
||||
`M ${topX + verticalRadius} ${topY + horizontalRadius} L ${
|
||||
rightX - verticalRadius
|
||||
} ${rightY - horizontalRadius}
|
||||
C ${rightX} ${rightY}, ${rightX} ${rightY}, ${
|
||||
rightX - verticalRadius
|
||||
} ${rightY + horizontalRadius}
|
||||
L ${bottomX + verticalRadius} ${bottomY - horizontalRadius}
|
||||
C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${
|
||||
bottomX - verticalRadius
|
||||
} ${bottomY - horizontalRadius}
|
||||
L ${leftX + verticalRadius} ${leftY + horizontalRadius}
|
||||
C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${
|
||||
leftY - horizontalRadius
|
||||
}
|
||||
L ${topX - verticalRadius} ${topY + horizontalRadius}
|
||||
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
|
||||
topY + horizontalRadius
|
||||
}`,
|
||||
generateRoughOptions(element, true),
|
||||
);
|
||||
} else {
|
||||
shape = generator.polygon(
|
||||
[
|
||||
[topX, topY],
|
||||
[rightX, rightY],
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
generateRoughOptions(element),
|
||||
);
|
||||
}
|
||||
setShapeForElement(element, shape);
|
||||
|
||||
break;
|
||||
}
|
||||
case "ellipse":
|
||||
shape = generator.ellipse(
|
||||
element.width / 2,
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(element),
|
||||
);
|
||||
setShapeForElement(element, shape);
|
||||
|
||||
break;
|
||||
case "line":
|
||||
case "arrow": {
|
||||
const options = generateRoughOptions(element);
|
||||
|
||||
// points array can be empty in the beginning, so it is important to add
|
||||
// initial position to it
|
||||
const points = element.points.length ? element.points : [[0, 0]];
|
||||
|
||||
// curve is always the first element
|
||||
// this simplifies finding the curve for an element
|
||||
if (!element.roundness) {
|
||||
if (options.fill) {
|
||||
shape = [generator.polygon(points as [number, number][], options)];
|
||||
} else {
|
||||
shape = [
|
||||
generator.linearPath(points as [number, number][], options),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
shape = [generator.curve(points as [number, number][], options)];
|
||||
}
|
||||
|
||||
// add lines only in arrow
|
||||
if (element.type === "arrow") {
|
||||
const { startArrowhead = null, endArrowhead = "arrow" } = element;
|
||||
|
||||
const getArrowheadShapes = (
|
||||
element: ExcalidrawLinearElement,
|
||||
shape: Drawable[],
|
||||
position: "start" | "end",
|
||||
arrowhead: Arrowhead,
|
||||
) => {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Other arrowheads here...
|
||||
if (arrowhead === "dot") {
|
||||
const [x, y, r] = arrowheadPoints;
|
||||
|
||||
return [
|
||||
generator.circle(x, y, r, {
|
||||
...options,
|
||||
fill: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
stroke: "none",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (arrowhead === "triangle") {
|
||||
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
|
||||
|
||||
// always use solid stroke for triangle arrowhead
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
[
|
||||
[x, y],
|
||||
[x2, y2],
|
||||
[x3, y3],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Arrow arrowheads
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
options.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete options.strokeLineDash;
|
||||
}
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
];
|
||||
};
|
||||
|
||||
if (startArrowhead !== null) {
|
||||
const shapes = getArrowheadShapes(
|
||||
element,
|
||||
shape,
|
||||
"start",
|
||||
startArrowhead,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
|
||||
if (endArrowhead !== null) {
|
||||
if (endArrowhead === undefined) {
|
||||
// Hey, we have an old arrow here!
|
||||
}
|
||||
|
||||
const shapes = getArrowheadShapes(
|
||||
element,
|
||||
shape,
|
||||
"end",
|
||||
endArrowhead,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
}
|
||||
|
||||
setShapeForElement(element, shape);
|
||||
|
||||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
generateFreeDrawShape(element);
|
||||
|
||||
if (isPathALoop(element.points)) {
|
||||
// generate rough polygon to fill freedraw shape
|
||||
shape = generator.polygon(element.points as [number, number][], {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
||||
});
|
||||
} else {
|
||||
shape = null;
|
||||
}
|
||||
setShapeForElement(element, shape);
|
||||
break;
|
||||
}
|
||||
case "text":
|
||||
case "image": {
|
||||
// just to ensure we don't regenerate element.canvas on rerenders
|
||||
setShapeForElement(element, null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const generateElementWithCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
renderConfig: RenderConfig,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
const zoom: Zoom = renderConfig ? renderConfig.zoom : defaultAppState.zoom;
|
||||
const zoom: Zoom = renderConfig ? appState.zoom : defaultAppState.zoom;
|
||||
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
||||
const shouldRegenerateBecauseZoom =
|
||||
prevElementWithCanvas &&
|
||||
prevElementWithCanvas.zoomValue !== zoom.value &&
|
||||
!renderConfig?.shouldCacheIgnoreZoom;
|
||||
!appState?.shouldCacheIgnoreZoom;
|
||||
const boundTextElementVersion = getBoundTextElement(element)?.version || null;
|
||||
const containingFrameOpacity = getContainingFrame(element)?.opacity || 100;
|
||||
|
||||
if (
|
||||
!prevElementWithCanvas ||
|
||||
shouldRegenerateBecauseZoom ||
|
||||
prevElementWithCanvas.theme !== renderConfig.theme ||
|
||||
prevElementWithCanvas.theme !== appState.theme ||
|
||||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
|
||||
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
|
||||
) {
|
||||
|
@ -779,6 +397,7 @@ const generateElementWithCanvas = (
|
|||
element,
|
||||
zoom,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
|
||||
elementWithCanvasCache.set(element, elementWithCanvas);
|
||||
|
@ -790,9 +409,9 @@ const generateElementWithCanvas = (
|
|||
|
||||
const drawElementFromCanvas = (
|
||||
elementWithCanvas: ExcalidrawElementWithCanvas,
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: RenderConfig,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
const element = elementWithCanvas.element;
|
||||
const padding = getCanvasPadding(element);
|
||||
|
@ -807,8 +426,8 @@ const drawElementFromCanvas = (
|
|||
y2 = Math.ceil(y2);
|
||||
}
|
||||
|
||||
const cx = ((x1 + x2) / 2 + renderConfig.scrollX) * window.devicePixelRatio;
|
||||
const cy = ((y1 + y2) / 2 + renderConfig.scrollY) * window.devicePixelRatio;
|
||||
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
|
||||
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
|
||||
|
||||
context.save();
|
||||
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
|
||||
|
@ -906,9 +525,9 @@ const drawElementFromCanvas = (
|
|||
|
||||
context.drawImage(
|
||||
elementWithCanvas.canvas!,
|
||||
(x1 + renderConfig.scrollX) * window.devicePixelRatio -
|
||||
(x1 + appState.scrollX) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||
(y1 + renderConfig.scrollY) * window.devicePixelRatio -
|
||||
(y1 + appState.scrollY) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
|
||||
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
||||
|
@ -926,8 +545,8 @@ const drawElementFromCanvas = (
|
|||
context.strokeStyle = "#c92a2a";
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(
|
||||
(coords.x + renderConfig.scrollX) * window.devicePixelRatio,
|
||||
(coords.y + renderConfig.scrollY) * window.devicePixelRatio,
|
||||
(coords.x + appState.scrollX) * window.devicePixelRatio,
|
||||
(coords.y + appState.scrollY) * window.devicePixelRatio,
|
||||
getBoundTextMaxWidth(element) * window.devicePixelRatio,
|
||||
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
|
||||
);
|
||||
|
@ -938,40 +557,37 @@ const drawElementFromCanvas = (
|
|||
// Clear the nested element we appended to the DOM
|
||||
};
|
||||
|
||||
export const renderSelectionElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
context.save();
|
||||
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
// render from 0.5px offset to get 1px wide line
|
||||
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
|
||||
// TODO can be be improved by offseting to the negative when user selects
|
||||
// from right to left
|
||||
const offset = 0.5 / appState.zoom.value;
|
||||
|
||||
context.fillRect(offset, offset, element.width, element.height);
|
||||
context.lineWidth = 1 / appState.zoom.value;
|
||||
context.strokeStyle = " rgb(105, 101, 219)";
|
||||
context.strokeRect(offset, offset, element.width, element.height);
|
||||
|
||||
context.restore();
|
||||
};
|
||||
|
||||
export const renderElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: RenderConfig,
|
||||
appState: AppState,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
const generator = rc.generator;
|
||||
switch (element.type) {
|
||||
case "selection": {
|
||||
// do not render selection when exporting
|
||||
if (!renderConfig.isExporting) {
|
||||
context.save();
|
||||
context.translate(
|
||||
element.x + renderConfig.scrollX,
|
||||
element.y + renderConfig.scrollY,
|
||||
);
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
// render from 0.5px offset to get 1px wide line
|
||||
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
|
||||
// TODO can be be improved by offseting to the negative when user selects
|
||||
// from right to left
|
||||
const offset = 0.5 / renderConfig.zoom.value;
|
||||
|
||||
context.fillRect(offset, offset, element.width, element.height);
|
||||
context.lineWidth = 1 / renderConfig.zoom.value;
|
||||
context.strokeStyle = " rgb(105, 101, 219)";
|
||||
context.strokeRect(offset, offset, element.width, element.height);
|
||||
|
||||
context.restore();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "frame": {
|
||||
if (
|
||||
!renderConfig.isExporting &&
|
||||
|
@ -980,12 +596,12 @@ export const renderElement = (
|
|||
) {
|
||||
context.save();
|
||||
context.translate(
|
||||
element.x + renderConfig.scrollX,
|
||||
element.y + renderConfig.scrollY,
|
||||
element.x + appState.scrollX,
|
||||
element.y + appState.scrollY,
|
||||
);
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
context.lineWidth = 2 / renderConfig.zoom.value;
|
||||
context.lineWidth = 2 / appState.zoom.value;
|
||||
context.strokeStyle = FRAME_STYLE.strokeColor;
|
||||
|
||||
if (FRAME_STYLE.radius && context.roundRect) {
|
||||
|
@ -995,7 +611,7 @@ export const renderElement = (
|
|||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
FRAME_STYLE.radius / renderConfig.zoom.value,
|
||||
FRAME_STYLE.radius / appState.zoom.value,
|
||||
);
|
||||
context.stroke();
|
||||
context.closePath();
|
||||
|
@ -1008,26 +624,35 @@ export const renderElement = (
|
|||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
generateElementShape(element, generator);
|
||||
// TODO investigate if we can do this in situ. Right now we need to call
|
||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||
// rely on existing shapes
|
||||
ShapeCache.generateElementShape(element);
|
||||
|
||||
if (renderConfig.isExporting) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2 + renderConfig.scrollX;
|
||||
const cy = (y1 + y2) / 2 + renderConfig.scrollY;
|
||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||
const cy = (y1 + y2) / 2 + appState.scrollY;
|
||||
const shiftX = (x2 - x1) / 2 - (element.x - x1);
|
||||
const shiftY = (y2 - y1) / 2 - (element.y - y1);
|
||||
context.save();
|
||||
context.translate(cx, cy);
|
||||
context.rotate(element.angle);
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
context.restore();
|
||||
} else {
|
||||
const elementWithCanvas = generateElementWithCanvas(
|
||||
element,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
drawElementFromCanvas(
|
||||
elementWithCanvas,
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
|
||||
}
|
||||
|
||||
break;
|
||||
|
@ -1040,11 +665,14 @@ export const renderElement = (
|
|||
case "image":
|
||||
case "text":
|
||||
case "embeddable": {
|
||||
generateElementShape(element, generator, renderConfig.isExporting);
|
||||
// TODO investigate if we can do this in situ. Right now we need to call
|
||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||
// rely on existing shapes
|
||||
ShapeCache.generateElementShape(element, renderConfig.isExporting);
|
||||
if (renderConfig.isExporting) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2 + renderConfig.scrollX;
|
||||
const cy = (y1 + y2) / 2 + renderConfig.scrollY;
|
||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||
const cy = (y1 + y2) / 2 + appState.scrollY;
|
||||
let shiftX = (x2 - x1) / 2 - (element.x - x1);
|
||||
let shiftY = (y2 - y1) / 2 - (element.y - y1);
|
||||
if (isTextElement(element)) {
|
||||
|
@ -1062,7 +690,7 @@ export const renderElement = (
|
|||
context.save();
|
||||
context.translate(cx, cy);
|
||||
|
||||
if (shouldResetImageFilter(element, renderConfig)) {
|
||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
||||
context.filter = "none";
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
@ -1096,7 +724,13 @@ export const renderElement = (
|
|||
|
||||
tempCanvasContext.translate(-shiftX, -shiftY);
|
||||
|
||||
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
|
||||
drawElementOnCanvas(
|
||||
element,
|
||||
tempRc,
|
||||
tempCanvasContext,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
|
||||
tempCanvasContext.translate(shiftX, shiftY);
|
||||
|
||||
|
@ -1133,7 +767,7 @@ export const renderElement = (
|
|||
}
|
||||
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
|
@ -1143,6 +777,7 @@ export const renderElement = (
|
|||
const elementWithCanvas = generateElementWithCanvas(
|
||||
element,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
|
||||
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
|
||||
|
@ -1150,7 +785,7 @@ export const renderElement = (
|
|||
if (
|
||||
// do not disable smoothing during zoom as blurry shapes look better
|
||||
// on low resolution (while still zooming in) than sharp ones
|
||||
!renderConfig?.shouldCacheIgnoreZoom &&
|
||||
!appState?.shouldCacheIgnoreZoom &&
|
||||
// angle is 0 -> always disable smoothing
|
||||
(!element.angle ||
|
||||
// or check if angle is a right angle in which case we can still
|
||||
|
@ -1167,7 +802,12 @@ export const renderElement = (
|
|||
context.imageSmoothingEnabled = false;
|
||||
}
|
||||
|
||||
drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
|
||||
drawElementFromCanvas(
|
||||
elementWithCanvas,
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
|
||||
// reset
|
||||
context.imageSmoothingEnabled = currentImageSmoothingStatus;
|
||||
|
@ -1245,7 +885,6 @@ export const renderElementToSvg = (
|
|||
}
|
||||
}
|
||||
const degree = (180 * element.angle) / Math.PI;
|
||||
const generator = rsvg.generator;
|
||||
|
||||
// element to append node to, most of the time svgRoot
|
||||
let root = svgRoot;
|
||||
|
@ -1270,10 +909,10 @@ export const renderElementToSvg = (
|
|||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse": {
|
||||
generateElementShape(element, generator);
|
||||
const shape = ShapeCache.generateElementShape(element);
|
||||
const node = roughSVGDrawWithPrecision(
|
||||
rsvg,
|
||||
getShapeForElement(element)!,
|
||||
shape,
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
);
|
||||
if (opacity !== 1) {
|
||||
|
@ -1300,10 +939,10 @@ export const renderElementToSvg = (
|
|||
}
|
||||
case "embeddable": {
|
||||
// render placeholder rectangle
|
||||
generateElementShape(element, generator, true);
|
||||
const shape = ShapeCache.generateElementShape(element, true);
|
||||
const node = roughSVGDrawWithPrecision(
|
||||
rsvg,
|
||||
getShapeForElement(element)!,
|
||||
shape,
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
);
|
||||
const opacity = element.opacity / 100;
|
||||
|
@ -1337,7 +976,7 @@ export const renderElementToSvg = (
|
|||
// render embeddable element + iframe
|
||||
const embeddableNode = roughSVGDrawWithPrecision(
|
||||
rsvg,
|
||||
getShapeForElement(element)!,
|
||||
shape,
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
);
|
||||
embeddableNode.setAttribute("stroke-linecap", "round");
|
||||
|
@ -1443,14 +1082,14 @@ export const renderElementToSvg = (
|
|||
maskRectInvisible.setAttribute("opacity", "1");
|
||||
maskPath.appendChild(maskRectInvisible);
|
||||
}
|
||||
generateElementShape(element, generator);
|
||||
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||
if (boundText) {
|
||||
group.setAttribute("mask", `url(#mask-${element.id})`);
|
||||
}
|
||||
group.setAttribute("stroke-linecap", "round");
|
||||
|
||||
getShapeForElement(element)!.forEach((shape) => {
|
||||
const shapes = ShapeCache.generateElementShape(element);
|
||||
shapes.forEach((shape) => {
|
||||
const node = roughSVGDrawWithPrecision(
|
||||
rsvg,
|
||||
shape,
|
||||
|
@ -1491,11 +1130,13 @@ export const renderElementToSvg = (
|
|||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
generateElementShape(element, generator);
|
||||
generateFreeDrawShape(element);
|
||||
const shape = getShapeForElement(element);
|
||||
const node = shape
|
||||
? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
|
||||
const backgroundFillShape = ShapeCache.generateElementShape(element);
|
||||
const node = backgroundFillShape
|
||||
? roughSVGDrawWithPrecision(
|
||||
rsvg,
|
||||
backgroundFillShape,
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
)
|
||||
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||
if (opacity !== 1) {
|
||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,9 +2,9 @@ import { isTextElement, refreshTextDimensions } from "../element";
|
|||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||
import { getFontString } from "../utils";
|
||||
import type Scene from "./Scene";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
export class Fonts {
|
||||
private scene: Scene;
|
||||
|
@ -54,7 +54,7 @@ export class Fonts {
|
|||
|
||||
this.scene.mapElements((element) => {
|
||||
if (isTextElement(element) && !isBoundToContainer(element)) {
|
||||
invalidateShapeForElement(element);
|
||||
ShapeCache.delete(element);
|
||||
didUpdate = true;
|
||||
return newElementWith(element, {
|
||||
...refreshTextDimensions(element),
|
||||
|
|
131
src/scene/Renderer.ts
Normal file
131
src/scene/Renderer.ts
Normal file
|
@ -0,0 +1,131 @@
|
|||
import { isElementInViewport } from "../element/sizeHelpers";
|
||||
import { isImageElement } from "../element/typeChecks";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { cancelRender } from "../renderer/renderScene";
|
||||
import { AppState } from "../types";
|
||||
import { memoize } from "../utils";
|
||||
import Scene from "./Scene";
|
||||
|
||||
export class Renderer {
|
||||
private scene: Scene;
|
||||
|
||||
constructor(scene: Scene) {
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
public getRenderableElements = (() => {
|
||||
const getVisibleCanvasElements = ({
|
||||
elements,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
}): readonly NonDeletedExcalidrawElement[] => {
|
||||
return elements.filter((element) =>
|
||||
isElementInViewport(element, width, height, {
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const getCanvasElements = ({
|
||||
editingElement,
|
||||
elements,
|
||||
pendingImageElementId,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
editingElement: AppState["editingElement"];
|
||||
pendingImageElementId: AppState["pendingImageElementId"];
|
||||
}) => {
|
||||
return elements.filter((element) => {
|
||||
if (isImageElement(element)) {
|
||||
if (
|
||||
// => not placed on canvas yet (but in elements array)
|
||||
pendingImageElementId === element.id
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// we don't want to render text element that's being currently edited
|
||||
// (it's rendered on remote only)
|
||||
return (
|
||||
!editingElement ||
|
||||
editingElement.type !== "text" ||
|
||||
element.id !== editingElement.id
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return memoize(
|
||||
({
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
editingElement,
|
||||
pendingImageElementId,
|
||||
// unused but serves we cache on it to invalidate elements if they
|
||||
// get mutated
|
||||
versionNonce: _versionNonce,
|
||||
}: {
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
editingElement: AppState["editingElement"];
|
||||
pendingImageElementId: AppState["pendingImageElementId"];
|
||||
versionNonce: ReturnType<InstanceType<typeof Scene>["getVersionNonce"]>;
|
||||
}) => {
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
|
||||
const canvasElements = getCanvasElements({
|
||||
elements,
|
||||
editingElement,
|
||||
pendingImageElementId,
|
||||
});
|
||||
|
||||
const visibleElements = getVisibleCanvasElements({
|
||||
elements: canvasElements,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
});
|
||||
|
||||
return { canvasElements, visibleElements };
|
||||
},
|
||||
);
|
||||
})();
|
||||
|
||||
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
|
||||
// safe to break TS contract here (for upstream cases)
|
||||
public destroy() {
|
||||
cancelRender();
|
||||
this.getRenderableElements.clear();
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@ import { isFrameElement } from "../element/typeChecks";
|
|||
import { getSelectedElements } from "./selection";
|
||||
import { AppState } from "../types";
|
||||
import { Assert, SameType } from "../utility-types";
|
||||
import { randomInteger } from "../random";
|
||||
|
||||
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
||||
type ElementKey = ExcalidrawElement | ElementIdKey;
|
||||
|
@ -105,6 +106,7 @@ class Scene {
|
|||
elements: null,
|
||||
cache: new Map(),
|
||||
};
|
||||
private versionNonce: number | undefined;
|
||||
|
||||
getElementsIncludingDeleted() {
|
||||
return this.elements;
|
||||
|
@ -172,6 +174,10 @@ class Scene {
|
|||
return (this.elementsMap.get(id) as T | undefined) || null;
|
||||
}
|
||||
|
||||
getVersionNonce() {
|
||||
return this.versionNonce;
|
||||
}
|
||||
|
||||
getNonDeletedElement(
|
||||
id: ExcalidrawElement["id"],
|
||||
): NonDeleted<ExcalidrawElement> | null {
|
||||
|
@ -230,6 +236,8 @@ class Scene {
|
|||
}
|
||||
|
||||
informMutation() {
|
||||
this.versionNonce = randomInteger();
|
||||
|
||||
for (const callback of Array.from(this.callbacks)) {
|
||||
callback();
|
||||
}
|
||||
|
|
362
src/scene/Shape.ts
Normal file
362
src/scene/Shape.ts
Normal file
|
@ -0,0 +1,362 @@
|
|||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
import type { RoughGenerator } from "roughjs/bin/generator";
|
||||
import { getDiamondPoints, getArrowheadPoints } from "../element";
|
||||
import type { ElementShapes } from "./types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
} from "../element/types";
|
||||
import { isPathALoop, getCornerRadius } from "../math";
|
||||
import { generateFreeDrawShape } from "../renderer/renderElement";
|
||||
import { isTransparent, assertNever } from "../utils";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||
|
||||
export const generateRoughOptions = (
|
||||
element: ExcalidrawElement,
|
||||
continuousPath = false,
|
||||
): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
strokeLineDash:
|
||||
element.strokeStyle === "dashed"
|
||||
? getDashArrayDashed(element.strokeWidth)
|
||||
: element.strokeStyle === "dotted"
|
||||
? getDashArrayDotted(element.strokeWidth)
|
||||
: undefined,
|
||||
// for non-solid strokes, disable multiStroke because it tends to make
|
||||
// dashes/dots overlay each other
|
||||
disableMultiStroke: element.strokeStyle !== "solid",
|
||||
// for non-solid strokes, increase the width a bit to make it visually
|
||||
// similar to solid strokes, because we're also disabling multiStroke
|
||||
strokeWidth:
|
||||
element.strokeStyle !== "solid"
|
||||
? element.strokeWidth + 0.5
|
||||
: element.strokeWidth,
|
||||
// when increasing strokeWidth, we must explicitly set fillWeight and
|
||||
// hachureGap because if not specified, roughjs uses strokeWidth to
|
||||
// calculate them (and we don't want the fills to be modified)
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: element.roughness,
|
||||
stroke: element.strokeColor,
|
||||
preserveVertices: continuousPath,
|
||||
};
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "embeddable":
|
||||
case "diamond":
|
||||
case "ellipse": {
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill = isTransparent(element.backgroundColor)
|
||||
? undefined
|
||||
: element.backgroundColor;
|
||||
if (element.type === "ellipse") {
|
||||
options.curveFitting = 1;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
case "line":
|
||||
case "freedraw": {
|
||||
if (isPathALoop(element.points)) {
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill =
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
case "arrow":
|
||||
return options;
|
||||
default: {
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const modifyEmbeddableForRoughOptions = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
isExporting: boolean,
|
||||
) => {
|
||||
if (
|
||||
element.type === "embeddable" &&
|
||||
(isExporting || !element.validated) &&
|
||||
isTransparent(element.backgroundColor) &&
|
||||
isTransparent(element.strokeColor)
|
||||
) {
|
||||
return {
|
||||
...element,
|
||||
roughness: 0,
|
||||
backgroundColor: "#d3d3d3",
|
||||
fillStyle: "solid",
|
||||
} as const;
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the roughjs shape for given element.
|
||||
*
|
||||
* Low-level. Use `ShapeCache.generateElementShape` instead.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export const _generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
isExporting: boolean = false,
|
||||
): Drawable | Drawable[] | null => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "embeddable": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
// this is for rendering the stroke/bg of the embeddable, especially
|
||||
// when the src url is not set
|
||||
|
||||
if (element.roundness) {
|
||||
const w = element.width;
|
||||
const h = element.height;
|
||||
const r = getCornerRadius(Math.min(w, h), element);
|
||||
shape = generator.path(
|
||||
`M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
|
||||
h - r
|
||||
} Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
|
||||
h - r
|
||||
} L 0 ${r} Q 0 0, ${r} 0`,
|
||||
generateRoughOptions(
|
||||
modifyEmbeddableForRoughOptions(element, isExporting),
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
shape = generator.rectangle(
|
||||
0,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(
|
||||
modifyEmbeddableForRoughOptions(element, isExporting),
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
case "diamond": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
if (element.roundness) {
|
||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
||||
|
||||
const horizontalRadius = getCornerRadius(
|
||||
Math.abs(rightY - topY),
|
||||
element,
|
||||
);
|
||||
|
||||
shape = generator.path(
|
||||
`M ${topX + verticalRadius} ${topY + horizontalRadius} L ${
|
||||
rightX - verticalRadius
|
||||
} ${rightY - horizontalRadius}
|
||||
C ${rightX} ${rightY}, ${rightX} ${rightY}, ${
|
||||
rightX - verticalRadius
|
||||
} ${rightY + horizontalRadius}
|
||||
L ${bottomX + verticalRadius} ${bottomY - horizontalRadius}
|
||||
C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${
|
||||
bottomX - verticalRadius
|
||||
} ${bottomY - horizontalRadius}
|
||||
L ${leftX + verticalRadius} ${leftY + horizontalRadius}
|
||||
C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${
|
||||
leftY - horizontalRadius
|
||||
}
|
||||
L ${topX - verticalRadius} ${topY + horizontalRadius}
|
||||
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
|
||||
topY + horizontalRadius
|
||||
}`,
|
||||
generateRoughOptions(element, true),
|
||||
);
|
||||
} else {
|
||||
shape = generator.polygon(
|
||||
[
|
||||
[topX, topY],
|
||||
[rightX, rightY],
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
generateRoughOptions(element),
|
||||
);
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
case "ellipse": {
|
||||
const shape: ElementShapes[typeof element.type] = generator.ellipse(
|
||||
element.width / 2,
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(element),
|
||||
);
|
||||
return shape;
|
||||
}
|
||||
case "line":
|
||||
case "arrow": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
const options = generateRoughOptions(element);
|
||||
|
||||
// points array can be empty in the beginning, so it is important to add
|
||||
// initial position to it
|
||||
const points = element.points.length ? element.points : [[0, 0]];
|
||||
|
||||
// curve is always the first element
|
||||
// this simplifies finding the curve for an element
|
||||
if (!element.roundness) {
|
||||
if (options.fill) {
|
||||
shape = [generator.polygon(points as [number, number][], options)];
|
||||
} else {
|
||||
shape = [generator.linearPath(points as [number, number][], options)];
|
||||
}
|
||||
} else {
|
||||
shape = [generator.curve(points as [number, number][], options)];
|
||||
}
|
||||
|
||||
// add lines only in arrow
|
||||
if (element.type === "arrow") {
|
||||
const { startArrowhead = null, endArrowhead = "arrow" } = element;
|
||||
|
||||
const getArrowheadShapes = (
|
||||
element: ExcalidrawLinearElement,
|
||||
shape: Drawable[],
|
||||
position: "start" | "end",
|
||||
arrowhead: Arrowhead,
|
||||
) => {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Other arrowheads here...
|
||||
if (arrowhead === "dot") {
|
||||
const [x, y, r] = arrowheadPoints;
|
||||
|
||||
return [
|
||||
generator.circle(x, y, r, {
|
||||
...options,
|
||||
fill: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
stroke: "none",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (arrowhead === "triangle") {
|
||||
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
|
||||
|
||||
// always use solid stroke for triangle arrowhead
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
[
|
||||
[x, y],
|
||||
[x2, y2],
|
||||
[x3, y3],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Arrow arrowheads
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
options.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete options.strokeLineDash;
|
||||
}
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
];
|
||||
};
|
||||
|
||||
if (startArrowhead !== null) {
|
||||
const shapes = getArrowheadShapes(
|
||||
element,
|
||||
shape,
|
||||
"start",
|
||||
startArrowhead,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
|
||||
if (endArrowhead !== null) {
|
||||
if (endArrowhead === undefined) {
|
||||
// Hey, we have an old arrow here!
|
||||
}
|
||||
|
||||
const shapes = getArrowheadShapes(
|
||||
element,
|
||||
shape,
|
||||
"end",
|
||||
endArrowhead,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
case "freedraw": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
generateFreeDrawShape(element);
|
||||
|
||||
if (isPathALoop(element.points)) {
|
||||
// generate rough polygon to fill freedraw shape
|
||||
shape = generator.polygon(element.points as [number, number][], {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
||||
});
|
||||
} else {
|
||||
shape = null;
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
case "frame":
|
||||
case "text":
|
||||
case "image": {
|
||||
const shape: ElementShapes[typeof element.type] = null;
|
||||
// we return (and cache) `null` to make sure we don't regenerate
|
||||
// `element.canvas` on rerenders
|
||||
return shape;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
element,
|
||||
`generateElementShape(): Unimplemented type ${(element as any)?.type}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
74
src/scene/ShapeCache.ts
Normal file
74
src/scene/ShapeCache.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { Drawable } from "roughjs/bin/core";
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
} from "../element/types";
|
||||
import { elementWithCanvasCache } from "../renderer/renderElement";
|
||||
import { _generateElementShape } from "./Shape";
|
||||
import { ElementShape, ElementShapes } from "./types";
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||
|
||||
/**
|
||||
* Retrieves shape from cache if available. Use this only if shape
|
||||
* is optional and you have a fallback in case it's not cached.
|
||||
*/
|
||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||
return ShapeCache.cache.get(
|
||||
element,
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
};
|
||||
|
||||
public static set = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable,
|
||||
) => ShapeCache.cache.set(element, shape);
|
||||
|
||||
public static delete = (element: ExcalidrawElement) =>
|
||||
ShapeCache.cache.delete(element);
|
||||
|
||||
public static destroy = () => {
|
||||
ShapeCache.cache = new WeakMap();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates & caches shape for element if not already cached, otherwise
|
||||
* returns cached shape.
|
||||
*/
|
||||
public static generateElementShape = <
|
||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
>(
|
||||
element: T,
|
||||
isExporting = false,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
const cachedShape = isExporting ? undefined : ShapeCache.get(element);
|
||||
|
||||
// `null` indicates no rc shape applicable for this element type,
|
||||
// but it's considered a valid cache value (= do not regenerate)
|
||||
if (cachedShape !== undefined) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
const shape = _generateElementShape(
|
||||
element,
|
||||
ShapeCache.rg,
|
||||
isExporting,
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable | null;
|
||||
|
||||
ShapeCache.cache.set(element, shape);
|
||||
|
||||
return shape;
|
||||
};
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import rough from "roughjs/bin/rough";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
|
||||
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
||||
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
|
||||
import { distance, isOnlyExportingSingleFrame } from "../utils";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
|
||||
|
@ -54,26 +54,23 @@ export const exportToCanvas = async (
|
|||
|
||||
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
||||
|
||||
renderScene({
|
||||
elements,
|
||||
appState,
|
||||
scale,
|
||||
rc: rough.canvas(canvas),
|
||||
renderStaticScene({
|
||||
canvas,
|
||||
renderConfig: {
|
||||
rc: rough.canvas(canvas),
|
||||
elements,
|
||||
visibleElements: elements,
|
||||
scale,
|
||||
appState: {
|
||||
...appState,
|
||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
|
||||
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
|
||||
zoom: defaultAppState.zoom,
|
||||
remotePointerViewportCoords: {},
|
||||
remoteSelectedElementIds: {},
|
||||
shouldCacheIgnoreZoom: false,
|
||||
remotePointerUsernames: {},
|
||||
remotePointerUserStates: {},
|
||||
theme: appState.exportWithDarkMode ? "dark" : "light",
|
||||
},
|
||||
renderConfig: {
|
||||
imageCache,
|
||||
renderScrollbars: false,
|
||||
renderSelection: false,
|
||||
renderGrid: false,
|
||||
isExporting: true,
|
||||
},
|
||||
|
|
|
@ -11,11 +11,7 @@ import {
|
|||
viewportCoordsToSceneCoords,
|
||||
} from "../utils";
|
||||
|
||||
const isOutsideViewPort = (
|
||||
appState: AppState,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
cords: Array<number>,
|
||||
) => {
|
||||
const isOutsideViewPort = (appState: AppState, cords: Array<number>) => {
|
||||
const [x1, y1, x2, y2] = cords;
|
||||
const { x: viewportX1, y: viewportY1 } = sceneCoordsToViewportCoords(
|
||||
{ sceneX: x1, sceneY: y1 },
|
||||
|
@ -49,7 +45,6 @@ export const centerScrollOn = ({
|
|||
export const calculateScrollCenter = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
): { scrollX: number; scrollY: number } => {
|
||||
elements = getVisibleElements(elements);
|
||||
|
||||
|
@ -61,7 +56,7 @@ export const calculateScrollCenter = (
|
|||
}
|
||||
let [x1, y1, x2, y2] = getCommonBounds(elements);
|
||||
|
||||
if (isOutsideViewPort(appState, canvas, [x1, y1, x2, y2])) {
|
||||
if (isOutsideViewPort(appState, [x1, y1, x2, y2])) {
|
||||
[x1, y1, x2, y2] = getClosestElementBounds(
|
||||
elements,
|
||||
viewportCoordsToSceneCoords(
|
||||
|
|
497
src/scene/scrollConstraints.ts
Normal file
497
src/scene/scrollConstraints.ts
Normal file
|
@ -0,0 +1,497 @@
|
|||
import { AppState, ScrollConstraints } from "../types";
|
||||
import { easeToValuesRAF, isShallowEqual } from "../utils";
|
||||
import { getNormalizedZoom } from "./zoom";
|
||||
|
||||
/**
|
||||
* Calculates the scroll center coordinates and the optimal zoom level to fit the constrained scrollable area within the viewport.
|
||||
*
|
||||
* This method first calculates the necessary zoom level to fit the entire constrained scrollable area within the viewport.
|
||||
* Then it calculates the constraints for the viewport given the new zoom level and the current scrollable area dimensions.
|
||||
* The function returns an object containing the optimal scroll positions and zoom level.
|
||||
*
|
||||
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
|
||||
* @param appState - An object containing the current horizontal and vertical scroll positions.
|
||||
* @returns An object containing the calculated optimal horizontal and vertical scroll positions and zoom level.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const { scrollX, scrollY, zoom } = this.calculateConstrainedScrollCenter(scrollConstraints, { scrollX, scrollY });
|
||||
*/
|
||||
export const calculateConstrainedScrollCenter = (
|
||||
state: AppState,
|
||||
{ scrollX, scrollY }: Pick<AppState, "scrollX" | "scrollY">,
|
||||
): {
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
zoom: AppState["zoom"];
|
||||
} => {
|
||||
const { width, height, zoom, scrollConstraints } = state;
|
||||
|
||||
if (!scrollConstraints) {
|
||||
return { scrollX, scrollY, zoom };
|
||||
}
|
||||
|
||||
const { zoomLevelX, zoomLevelY, initialZoomLevel } = calculateZoomLevel(
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
// The zoom level to contain the whole constrained area in view
|
||||
const _zoom = {
|
||||
value: getNormalizedZoom(
|
||||
initialZoomLevel ?? Math.min(zoomLevelX, zoomLevelY),
|
||||
),
|
||||
};
|
||||
|
||||
const constraints = calculateConstraints({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom: _zoom,
|
||||
cursorButton: "up",
|
||||
});
|
||||
|
||||
return {
|
||||
scrollX: constraints.minScrollX,
|
||||
scrollY: constraints.minScrollY,
|
||||
zoom: constraints.constrainedZoom,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the zoom levels necessary to fit the constrained scrollable area within the viewport on the X and Y axes.
|
||||
*
|
||||
* The function considers the dimensions of the scrollable area, the dimensions of the viewport, the viewport zoom factor,
|
||||
* and whether the zoom should be locked. It then calculates the necessary zoom levels for the X and Y axes separately.
|
||||
* If the zoom should be locked, it calculates the maximum zoom level that fits the scrollable area within the viewport,
|
||||
* factoring in the viewport zoom factor. If the zoom should not be locked, the maximum zoom level is set to null.
|
||||
*
|
||||
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
|
||||
* @param width - The width of the viewport.
|
||||
* @param height - The height of the viewport.
|
||||
* @returns An object containing the calculated zoom levels for the X and Y axes, and the maximum zoom level if applicable.
|
||||
*/
|
||||
const calculateZoomLevel = (
|
||||
scrollConstraints: ScrollConstraints,
|
||||
width: AppState["width"],
|
||||
height: AppState["height"],
|
||||
) => {
|
||||
const DEFAULT_VIEWPORT_ZOOM_FACTOR = 0.7;
|
||||
|
||||
const viewportZoomFactor = scrollConstraints.viewportZoomFactor
|
||||
? Math.min(1, Math.max(scrollConstraints.viewportZoomFactor, 0.1))
|
||||
: DEFAULT_VIEWPORT_ZOOM_FACTOR;
|
||||
|
||||
const scrollableWidth = scrollConstraints.width;
|
||||
const scrollableHeight = scrollConstraints.height;
|
||||
const zoomLevelX = width / scrollableWidth;
|
||||
const zoomLevelY = height / scrollableHeight;
|
||||
const initialZoomLevel = getNormalizedZoom(
|
||||
Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor,
|
||||
);
|
||||
return { zoomLevelX, zoomLevelY, initialZoomLevel };
|
||||
};
|
||||
|
||||
const calculateConstraints = ({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
cursorButton,
|
||||
}: {
|
||||
scrollConstraints: ScrollConstraints;
|
||||
width: AppState["width"];
|
||||
height: AppState["height"];
|
||||
zoom: AppState["zoom"];
|
||||
cursorButton: AppState["cursorButton"];
|
||||
}) => {
|
||||
// Set the overscroll allowance percentage
|
||||
const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2;
|
||||
|
||||
/**
|
||||
* Calculates the center position of the constrained scroll area.
|
||||
* @returns The X and Y coordinates of the center position.
|
||||
*/
|
||||
const calculateConstrainedScrollCenter = (zoom: number) => {
|
||||
const constrainedScrollCenterX =
|
||||
scrollConstraints.x + (scrollConstraints.width - width / zoom) / -2;
|
||||
const constrainedScrollCenterY =
|
||||
scrollConstraints.y + (scrollConstraints.height - height / zoom) / -2;
|
||||
return { constrainedScrollCenterX, constrainedScrollCenterY };
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the overscroll allowance values for the constrained area.
|
||||
* @returns The overscroll allowance values for the X and Y axes.
|
||||
*/
|
||||
const calculateOverscrollAllowance = () => {
|
||||
const overscrollAllowanceX =
|
||||
OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollConstraints.width;
|
||||
const overscrollAllowanceY =
|
||||
OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollConstraints.height;
|
||||
|
||||
return Math.min(overscrollAllowanceX, overscrollAllowanceY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the minimum and maximum scroll values based on the current state.
|
||||
* @param shouldAdjustForCenteredViewX - Whether the view should be adjusted for centered view on X axis - when constrained area width fits the viewport.
|
||||
* @param shouldAdjustForCenteredViewY - Whether the view should be adjusted for centered view on Y axis - when constrained area height fits the viewport.
|
||||
* @param overscrollAllowanceX - The overscroll allowance value for the X axis.
|
||||
* @param overscrollAllowanceY - The overscroll allowance value for the Y axis.
|
||||
* @param constrainedScrollCenterX - The X coordinate of the constrained scroll area center.
|
||||
* @param constrainedScrollCenterY - The Y coordinate of the constrained scroll area center.
|
||||
* @returns The minimum and maximum scroll values for the X and Y axes.
|
||||
*/
|
||||
const calculateMinMaxScrollValues = (
|
||||
shouldAdjustForCenteredViewX: boolean,
|
||||
shouldAdjustForCenteredViewY: boolean,
|
||||
overscrollAllowance: number,
|
||||
constrainedScrollCenterX: number,
|
||||
constrainedScrollCenterY: number,
|
||||
zoom: number,
|
||||
) => {
|
||||
let maxScrollX;
|
||||
let minScrollX;
|
||||
let maxScrollY;
|
||||
let minScrollY;
|
||||
|
||||
// Handling the X-axis
|
||||
if (cursorButton === "down") {
|
||||
if (shouldAdjustForCenteredViewX) {
|
||||
maxScrollX = constrainedScrollCenterX + overscrollAllowance;
|
||||
minScrollX = constrainedScrollCenterX - overscrollAllowance;
|
||||
} else {
|
||||
maxScrollX = scrollConstraints.x + overscrollAllowance;
|
||||
minScrollX =
|
||||
scrollConstraints.x -
|
||||
scrollConstraints.width +
|
||||
width / zoom -
|
||||
overscrollAllowance;
|
||||
}
|
||||
} else if (shouldAdjustForCenteredViewX) {
|
||||
maxScrollX = constrainedScrollCenterX;
|
||||
minScrollX = constrainedScrollCenterX;
|
||||
} else {
|
||||
maxScrollX = scrollConstraints.x;
|
||||
minScrollX = scrollConstraints.x - scrollConstraints.width + width / zoom;
|
||||
}
|
||||
|
||||
// Handling the Y-axis
|
||||
if (cursorButton === "down") {
|
||||
if (shouldAdjustForCenteredViewY) {
|
||||
maxScrollY = constrainedScrollCenterY + overscrollAllowance;
|
||||
minScrollY = constrainedScrollCenterY - overscrollAllowance;
|
||||
} else {
|
||||
maxScrollY = scrollConstraints.y + overscrollAllowance;
|
||||
minScrollY =
|
||||
scrollConstraints.y -
|
||||
scrollConstraints.height +
|
||||
height / zoom -
|
||||
overscrollAllowance;
|
||||
}
|
||||
} else if (shouldAdjustForCenteredViewY) {
|
||||
maxScrollY = constrainedScrollCenterY;
|
||||
minScrollY = constrainedScrollCenterY;
|
||||
} else {
|
||||
maxScrollY = scrollConstraints.y;
|
||||
minScrollY =
|
||||
scrollConstraints.y - scrollConstraints.height + height / zoom;
|
||||
}
|
||||
|
||||
return { maxScrollX, minScrollX, maxScrollY, minScrollY };
|
||||
};
|
||||
|
||||
const { zoomLevelX, zoomLevelY, initialZoomLevel } = calculateZoomLevel(
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
const constrainedZoom = getNormalizedZoom(
|
||||
scrollConstraints.lockZoom
|
||||
? Math.max(initialZoomLevel, zoom.value)
|
||||
: zoom.value,
|
||||
);
|
||||
const { constrainedScrollCenterX, constrainedScrollCenterY } =
|
||||
calculateConstrainedScrollCenter(constrainedZoom);
|
||||
const overscrollAllowance = calculateOverscrollAllowance();
|
||||
const shouldAdjustForCenteredViewX = constrainedZoom <= zoomLevelX;
|
||||
const shouldAdjustForCenteredViewY = constrainedZoom <= zoomLevelY;
|
||||
const { maxScrollX, minScrollX, maxScrollY, minScrollY } =
|
||||
calculateMinMaxScrollValues(
|
||||
shouldAdjustForCenteredViewX,
|
||||
shouldAdjustForCenteredViewY,
|
||||
overscrollAllowance,
|
||||
constrainedScrollCenterX,
|
||||
constrainedScrollCenterY,
|
||||
constrainedZoom,
|
||||
);
|
||||
|
||||
return {
|
||||
maxScrollX,
|
||||
minScrollX,
|
||||
maxScrollY,
|
||||
minScrollY,
|
||||
constrainedZoom: {
|
||||
value: constrainedZoom,
|
||||
},
|
||||
initialZoomLevel,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Constrains the scroll values within the constrained area.
|
||||
* @param maxScrollX - The maximum scroll value for the X axis.
|
||||
* @param minScrollX - The minimum scroll value for the X axis.
|
||||
* @param maxScrollY - The maximum scroll value for the Y axis.
|
||||
* @param minScrollY - The minimum scroll value for the Y axis.
|
||||
* @returns The constrained scroll values for the X and Y axes.
|
||||
*/
|
||||
const constrainScrollValues = ({
|
||||
scrollX,
|
||||
scrollY,
|
||||
maxScrollX,
|
||||
minScrollX,
|
||||
maxScrollY,
|
||||
minScrollY,
|
||||
constrainedZoom,
|
||||
}: {
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
maxScrollX: number;
|
||||
minScrollX: number;
|
||||
maxScrollY: number;
|
||||
minScrollY: number;
|
||||
constrainedZoom: AppState["zoom"];
|
||||
}) => {
|
||||
const constrainedScrollX = Math.min(
|
||||
maxScrollX,
|
||||
Math.max(scrollX, minScrollX),
|
||||
);
|
||||
const constrainedScrollY = Math.min(
|
||||
maxScrollY,
|
||||
Math.max(scrollY, minScrollY),
|
||||
);
|
||||
return { constrainedScrollX, constrainedScrollY, constrainedZoom };
|
||||
};
|
||||
|
||||
/**
|
||||
* Animate the scroll values to the constrained area
|
||||
*/
|
||||
const animateConstrainedScroll = ({
|
||||
state,
|
||||
constrainedScrollX,
|
||||
constrainedScrollY,
|
||||
opts,
|
||||
}: {
|
||||
state: AppState;
|
||||
constrainedScrollX: number;
|
||||
constrainedScrollY: number;
|
||||
opts?: {
|
||||
onStartCallback?: () => void;
|
||||
onEndCallback?: () => void;
|
||||
};
|
||||
}) => {
|
||||
const { scrollX, scrollY, scrollConstraints } = state;
|
||||
|
||||
const { onStartCallback, onEndCallback } = opts || {};
|
||||
|
||||
if (!scrollConstraints) {
|
||||
return null;
|
||||
}
|
||||
|
||||
easeToValuesRAF({
|
||||
fromValues: { scrollX, scrollY },
|
||||
toValues: {
|
||||
scrollX: constrainedScrollX,
|
||||
scrollY: constrainedScrollY,
|
||||
},
|
||||
onStep: ({ scrollX, scrollY }) => {
|
||||
// TODO: this.setState({ scrollX, scrollY });
|
||||
},
|
||||
onStart: () => {
|
||||
// TODO: this.setState({
|
||||
// scrollConstraints: { ...scrollConstraints, isAnimating: true },
|
||||
// });
|
||||
onStartCallback && onStartCallback();
|
||||
},
|
||||
onEnd: () => {
|
||||
// TODO: this.setState({
|
||||
// scrollConstraints: { ...scrollConstraints, isAnimating: false },
|
||||
// });
|
||||
onEndCallback && onEndCallback();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isViewportOutsideOfConstrainedArea = ({
|
||||
scrollX,
|
||||
scrollY,
|
||||
width,
|
||||
height,
|
||||
scrollConstraints,
|
||||
}: {
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
width: AppState["width"];
|
||||
height: AppState["height"];
|
||||
scrollConstraints: AppState["scrollConstraints"];
|
||||
}) => {
|
||||
if (!scrollConstraints) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
scrollX < scrollConstraints.x ||
|
||||
scrollX + width > scrollConstraints.x + scrollConstraints.width ||
|
||||
scrollY < scrollConstraints.y ||
|
||||
scrollY + height > scrollConstraints.y + scrollConstraints.height
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the state change based on the constrained scroll values.
|
||||
* Also handles the animation to the constrained area when the viewport is outside of constrained area.
|
||||
* @param constrainedScrollX - The constrained scroll value for the X axis.
|
||||
* @param constrainedScrollY - The constrained scroll value for the Y axis.
|
||||
* @returns The constrained state if the state has changed, when needs to be passed into render function, otherwise null.
|
||||
*/
|
||||
const handleConstrainedScrollStateChange = ({
|
||||
state,
|
||||
constrainedScrollX,
|
||||
constrainedScrollY,
|
||||
constrainedZoom,
|
||||
shouldAnimate,
|
||||
}: {
|
||||
constrainedScrollX: number;
|
||||
constrainedScrollY: number;
|
||||
constrainedZoom: AppState["zoom"];
|
||||
shouldAnimate?: boolean;
|
||||
state: AppState;
|
||||
}) => {
|
||||
const { scrollX, scrollY } = state;
|
||||
const isStateChanged =
|
||||
constrainedScrollX !== scrollX || constrainedScrollY !== scrollY;
|
||||
|
||||
if (isStateChanged) {
|
||||
if (shouldAnimate) {
|
||||
animateConstrainedScroll({
|
||||
state,
|
||||
constrainedScrollX,
|
||||
constrainedScrollY,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
const constrainedState = {
|
||||
scrollX: constrainedScrollX,
|
||||
scrollY: constrainedScrollY,
|
||||
zoom: constrainedZoom,
|
||||
};
|
||||
|
||||
// TODO: this.setState(constrainedState);
|
||||
return constrainedState;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const setScrollConstraints = (
|
||||
scrollConstraints: ScrollConstraints,
|
||||
state: AppState,
|
||||
onAnimteEndCallback?: () => void,
|
||||
) => {
|
||||
const { scrollX, scrollY, width, height, zoom, cursorButton } = state;
|
||||
const constrainedScrollValues = constrainScrollValues({
|
||||
...calculateConstraints({
|
||||
scrollConstraints,
|
||||
zoom,
|
||||
cursorButton,
|
||||
width,
|
||||
height,
|
||||
}),
|
||||
scrollX,
|
||||
scrollY,
|
||||
});
|
||||
animateConstrainedScroll({
|
||||
state,
|
||||
...constrainedScrollValues,
|
||||
opts: {
|
||||
onEndCallback: () => {
|
||||
onAnimteEndCallback && onAnimteEndCallback();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
let memoizedScrollConstraints: ReturnType<typeof calculateConstraints> | null =
|
||||
null;
|
||||
|
||||
export const constrainScrollState = (state: AppState, prevState: AppState) => {
|
||||
if (!state.scrollConstraints || state.scrollConstraints.isAnimating) {
|
||||
return state;
|
||||
}
|
||||
const {
|
||||
scrollX,
|
||||
scrollY,
|
||||
width,
|
||||
height,
|
||||
scrollConstraints,
|
||||
zoom,
|
||||
cursorButton,
|
||||
} = state;
|
||||
|
||||
const canUseMemoizedConstraints =
|
||||
isShallowEqual(scrollConstraints, prevState.scrollConstraints ?? {}) &&
|
||||
isShallowEqual(
|
||||
{ width, height, zoom: zoom.value, cursorButton },
|
||||
{
|
||||
width: prevState.width,
|
||||
height: prevState.height,
|
||||
zoom: prevState.zoom.value,
|
||||
cursorButton: prevState.cursorButton,
|
||||
} ?? {},
|
||||
);
|
||||
|
||||
const calculatedConstraints =
|
||||
canUseMemoizedConstraints && !!memoizedScrollConstraints
|
||||
? memoizedScrollConstraints
|
||||
: calculateConstraints({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
cursorButton,
|
||||
});
|
||||
|
||||
memoizedScrollConstraints = calculatedConstraints;
|
||||
|
||||
const constrainedScrollValues = constrainScrollValues({
|
||||
...calculatedConstraints,
|
||||
scrollX,
|
||||
scrollY,
|
||||
});
|
||||
|
||||
const viewportOutsideOfConstrainedArea = isViewportOutsideOfConstrainedArea({
|
||||
scrollX,
|
||||
scrollY,
|
||||
width,
|
||||
height,
|
||||
scrollConstraints,
|
||||
});
|
||||
|
||||
const shouldAnimate =
|
||||
viewportOutsideOfConstrainedArea &&
|
||||
state.cursorButton !== "down" &&
|
||||
prevState.cursorButton === "down" &&
|
||||
prevState.zoom.value === state.zoom.value &&
|
||||
!state.isLoading; // Do not animate when app is initialized but scene is empty - it would cause flickering
|
||||
|
||||
return handleConstrainedScrollStateChange({
|
||||
state,
|
||||
...constrainedScrollValues,
|
||||
shouldAnimate,
|
||||
});
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getCommonBounds } from "../element";
|
||||
import { Zoom } from "../types";
|
||||
import { InteractiveCanvasAppState } from "../types";
|
||||
import { ScrollBars } from "./types";
|
||||
import { getGlobalCSSVariable } from "../utils";
|
||||
import { getLanguage } from "../i18n";
|
||||
|
@ -13,15 +13,7 @@ export const getScrollBars = (
|
|||
elements: readonly ExcalidrawElement[],
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
{
|
||||
scrollX,
|
||||
scrollY,
|
||||
zoom,
|
||||
}: {
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
zoom: Zoom;
|
||||
},
|
||||
appState: InteractiveCanvasAppState,
|
||||
): ScrollBars => {
|
||||
if (elements.length === 0) {
|
||||
return {
|
||||
|
@ -34,8 +26,8 @@ export const getScrollBars = (
|
|||
getCommonBounds(elements);
|
||||
|
||||
// Apply zoom
|
||||
const viewportWidthWithZoom = viewportWidth / zoom.value;
|
||||
const viewportHeightWithZoom = viewportHeight / zoom.value;
|
||||
const viewportWidthWithZoom = viewportWidth / appState.zoom.value;
|
||||
const viewportHeightWithZoom = viewportHeight / appState.zoom.value;
|
||||
|
||||
const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
|
||||
const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
|
||||
|
@ -50,8 +42,10 @@ export const getScrollBars = (
|
|||
const isRTL = getLanguage().rtl;
|
||||
|
||||
// The viewport is the rectangle currently visible for the user
|
||||
const viewportMinX = -scrollX + viewportWidthDiff / 2 + safeArea.left;
|
||||
const viewportMinY = -scrollY + viewportHeightDiff / 2 + safeArea.top;
|
||||
const viewportMinX =
|
||||
-appState.scrollX + viewportWidthDiff / 2 + safeArea.left;
|
||||
const viewportMinY =
|
||||
-appState.scrollY + viewportHeightDiff / 2 + safeArea.top;
|
||||
const viewportMaxX = viewportMinX + viewportWidthWithZoom - safeArea.right;
|
||||
const viewportMaxY = viewportMinY + viewportHeightWithZoom - safeArea.bottom;
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import {
|
|||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "../element";
|
||||
import { AppState } from "../types";
|
||||
import { AppState, InteractiveCanvasAppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
|
@ -146,7 +146,7 @@ export const getCommonAttributeOfSelectedElements = <T>(
|
|||
|
||||
export const getSelectedElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Pick<AppState, "selectedElementIds">,
|
||||
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
||||
opts?: {
|
||||
includeBoundTextElement?: boolean;
|
||||
includeElementsInFrames?: boolean;
|
||||
|
|
|
@ -1,33 +1,65 @@
|
|||
import { ExcalidrawTextElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import {
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
InteractiveCanvasAppState,
|
||||
StaticCanvasAppState,
|
||||
} from "../types";
|
||||
|
||||
export type RenderConfig = {
|
||||
// AppState values
|
||||
// ---------------------------------------------------------------------------
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
/** null indicates transparent bg */
|
||||
viewBackgroundColor: AppState["viewBackgroundColor"] | null;
|
||||
zoom: AppState["zoom"];
|
||||
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
|
||||
theme: AppState["theme"];
|
||||
// collab-related state
|
||||
// ---------------------------------------------------------------------------
|
||||
remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
|
||||
remotePointerButton?: { [id: string]: string | undefined };
|
||||
remoteSelectedElementIds: { [elementId: string]: string[] };
|
||||
remotePointerUsernames: { [id: string]: string };
|
||||
remotePointerUserStates: { [id: string]: string };
|
||||
export type StaticCanvasRenderConfig = {
|
||||
// extra options passed to the renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
imageCache: AppClassProperties["imageCache"];
|
||||
renderScrollbars?: boolean;
|
||||
renderSelection?: boolean;
|
||||
renderGrid?: boolean;
|
||||
renderGrid: boolean;
|
||||
/** when exporting the behavior is slightly different (e.g. we can't use
|
||||
CSS filters), and we disable render optimizations for best output */
|
||||
CSS filters), and we disable render optimizations for best output */
|
||||
isExporting: boolean;
|
||||
};
|
||||
|
||||
export type InteractiveCanvasRenderConfig = {
|
||||
// collab-related state
|
||||
// ---------------------------------------------------------------------------
|
||||
remoteSelectedElementIds: { [elementId: string]: string[] };
|
||||
remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
|
||||
remotePointerUserStates: { [id: string]: string };
|
||||
remotePointerUsernames: { [id: string]: string };
|
||||
remotePointerButton?: { [id: string]: string | undefined };
|
||||
selectionColor?: string;
|
||||
// extra options passed to the renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
renderScrollbars?: boolean;
|
||||
};
|
||||
|
||||
export type RenderInteractiveSceneCallback = {
|
||||
atLeastOneVisibleElement: boolean;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
scrollBars?: ScrollBars;
|
||||
};
|
||||
|
||||
export type StaticSceneRenderConfig = {
|
||||
canvas: HTMLCanvasElement;
|
||||
rc: RoughCanvas;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
scale: number;
|
||||
appState: StaticCanvasAppState;
|
||||
renderConfig: StaticCanvasRenderConfig;
|
||||
};
|
||||
|
||||
export type InteractiveSceneRenderConfig = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
scale: number;
|
||||
appState: InteractiveCanvasAppState;
|
||||
renderConfig: InteractiveCanvasRenderConfig;
|
||||
callback: (data: RenderInteractiveSceneCallback) => void;
|
||||
};
|
||||
|
||||
export type SceneScroll = {
|
||||
|
@ -65,3 +97,18 @@ export type ConstrainedScrollValues = Pick<
|
|||
AppState,
|
||||
"scrollX" | "scrollY" | "zoom"
|
||||
> | null;
|
||||
|
||||
export type ElementShape = Drawable | Drawable[] | null;
|
||||
|
||||
export type ElementShapes = {
|
||||
rectangle: Drawable;
|
||||
ellipse: Drawable;
|
||||
diamond: Drawable;
|
||||
embeddable: Drawable;
|
||||
freedraw: Drawable | null;
|
||||
arrow: Drawable[];
|
||||
line: Drawable[];
|
||||
text: null;
|
||||
image: null;
|
||||
frame: null;
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -33,7 +33,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
|
@ -42,7 +42,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
|||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 449462985,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
|
@ -69,14 +69,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 1278240551,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
|
@ -103,14 +103,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 1278240551,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
|
@ -148,7 +148,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
|
@ -157,7 +157,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
|||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 449462985,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
|
@ -184,14 +184,14 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
|||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 1278240551,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
|
|
|
@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
|||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
|
|
|
@ -18,14 +18,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 1`] = `
|
|||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 401146281,
|
||||
"seed": 1014066025,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 2019559783,
|
||||
"versionNonce": 238820263,
|
||||
"width": 30,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
|
@ -50,14 +50,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 2`] = `
|
|||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 1150084233,
|
||||
"versionNonce": 1604849351,
|
||||
"width": 30,
|
||||
"x": -10,
|
||||
"y": 60,
|
||||
|
@ -82,14 +82,14 @@ exports[`move element > rectangle 1`] = `
|
|||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 453191,
|
||||
"versionNonce": 1150084233,
|
||||
"width": 30,
|
||||
"x": 0,
|
||||
"y": 40,
|
||||
|
@ -119,14 +119,14 @@ exports[`move element > rectangles with binding arrow 1`] = `
|
|||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 1014066025,
|
||||
"versionNonce": 81784553,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
|
@ -156,14 +156,14 @@ exports[`move element > rectangles with binding arrow 2`] = `
|
|||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 449462985,
|
||||
"seed": 2019559783,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"versionNonce": 1723083209,
|
||||
"versionNonce": 927333447,
|
||||
"width": 300,
|
||||
"x": 201,
|
||||
"y": 2,
|
||||
|
@ -205,7 +205,7 @@ exports[`move element > rectangles with binding arrow 3`] = `
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 401146281,
|
||||
"seed": 238820263,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id0",
|
||||
|
@ -218,7 +218,7 @@ exports[`move element > rectangles with binding arrow 3`] = `
|
|||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"versionNonce": 1006504105,
|
||||
"versionNonce": 1051383431,
|
||||
"width": 81,
|
||||
"x": 110,
|
||||
"y": 49.981789081137734,
|
||||
|
|
|
@ -38,7 +38,7 @@ exports[`multi point mode in linear elements > arrow 1`] = `
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
|
@ -47,7 +47,7 @@ exports[`multi point mode in linear elements > arrow 1`] = `
|
|||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 1150084233,
|
||||
"versionNonce": 1505387817,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
|
@ -92,7 +92,7 @@ exports[`multi point mode in linear elements > line 1`] = `
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
|
@ -101,7 +101,7 @@ exports[`multi point mode in linear elements > line 1`] = `
|
|||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 1150084233,
|
||||
"versionNonce": 1505387817,
|
||||
"width": 70,
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -31,7 +31,7 @@ exports[`select single element on the scene > arrow 1`] = `
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
|
@ -40,7 +40,7 @@ exports[`select single element on the scene > arrow 1`] = `
|
|||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 449462985,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
|
@ -78,7 +78,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"startArrowhead": null,
|
||||
"startBinding": null,
|
||||
"strokeColor": "#1e1e1e",
|
||||
|
@ -87,7 +87,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
|
|||
"type": "line",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 449462985,
|
||||
"versionNonce": 401146281,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
|
@ -112,14 +112,14 @@ exports[`select single element on the scene > diamond 1`] = `
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 1278240551,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
|
@ -144,14 +144,14 @@ exports[`select single element on the scene > ellipse 1`] = `
|
|||
"roundness": {
|
||||
"type": 2,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 1278240551,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
|
@ -176,14 +176,14 @@ exports[`select single element on the scene > rectangle 1`] = `
|
|||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 337897,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 1278240551,
|
||||
"versionNonce": 453191,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
|
|
|
@ -24,7 +24,7 @@ import { LibraryItem } from "../types";
|
|||
import { vi } from "vitest";
|
||||
|
||||
const checkpoint = (name: string) => {
|
||||
expect(renderScene.mock.calls.length).toMatchSnapshot(
|
||||
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
|
||||
`[${name}] number of renders`,
|
||||
);
|
||||
expect(h.state).toMatchSnapshot(`[${name}] appState`);
|
||||
|
@ -40,10 +40,10 @@ const mouse = new Pointer("mouse");
|
|||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
|
@ -52,7 +52,7 @@ const { h } = window;
|
|||
describe("contextMenu element", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
|
@ -75,7 +75,7 @@ describe("contextMenu element", () => {
|
|||
});
|
||||
|
||||
it("shows context menu for canvas", () => {
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -105,7 +105,7 @@ describe("contextMenu element", () => {
|
|||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -159,7 +159,7 @@ describe("contextMenu element", () => {
|
|||
API.setSelectedElements([rect1]);
|
||||
|
||||
// lower z-index
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
|
@ -169,7 +169,7 @@ describe("contextMenu element", () => {
|
|||
|
||||
// higher z-index
|
||||
API.setSelectedElements([rect2]);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
|
@ -193,7 +193,7 @@ describe("contextMenu element", () => {
|
|||
mouse.click(20, 0);
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -246,7 +246,7 @@ describe("contextMenu element", () => {
|
|||
Keyboard.keyPress(KEYS.G);
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -285,7 +285,7 @@ describe("contextMenu element", () => {
|
|||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -333,7 +333,7 @@ describe("contextMenu element", () => {
|
|||
mouse.reset();
|
||||
|
||||
// Copy styles of second rectangle
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
|
@ -346,7 +346,7 @@ describe("contextMenu element", () => {
|
|||
|
||||
mouse.reset();
|
||||
// Paste styles to first rectangle
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
|
@ -370,7 +370,7 @@ describe("contextMenu element", () => {
|
|||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -386,7 +386,7 @@ describe("contextMenu element", () => {
|
|||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -407,7 +407,7 @@ describe("contextMenu element", () => {
|
|||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -430,7 +430,7 @@ describe("contextMenu element", () => {
|
|||
mouse.up(20, 20);
|
||||
|
||||
mouse.reset();
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
|
@ -452,7 +452,7 @@ describe("contextMenu element", () => {
|
|||
mouse.up(20, 20);
|
||||
|
||||
mouse.reset();
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
|
@ -474,7 +474,7 @@ describe("contextMenu element", () => {
|
|||
mouse.up(20, 20);
|
||||
|
||||
mouse.reset();
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
|
@ -495,7 +495,7 @@ describe("contextMenu element", () => {
|
|||
mouse.up(20, 20);
|
||||
|
||||
mouse.reset();
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
|
@ -520,7 +520,7 @@ describe("contextMenu element", () => {
|
|||
mouse.click(10, 10);
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -550,7 +550,7 @@ describe("contextMenu element", () => {
|
|||
Keyboard.keyPress(KEYS.G);
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
|
|
@ -140,9 +140,8 @@ describe("restoreElements", () => {
|
|||
expect(restoredArrow).toMatchSnapshot({ seed: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("when arrow element has defined endArrowHead", () => {
|
||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
|
||||
const arrowElement = API.createElement({ type: "arrow" });
|
||||
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
|
||||
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
|
||||
|
@ -150,7 +149,7 @@ describe("restoreElements", () => {
|
|||
expect(arrowElement.endArrowhead).toBe(restoredArrow.endArrowhead);
|
||||
});
|
||||
|
||||
it("when arrow element has undefined endArrowHead", () => {
|
||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is undefined', () => {
|
||||
const arrowElement = API.createElement({ type: "arrow" });
|
||||
Object.defineProperty(arrowElement, "endArrowhead", {
|
||||
get: vi.fn(() => undefined),
|
||||
|
|
|
@ -15,10 +15,13 @@ import { vi } from "vitest";
|
|||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
|
@ -32,7 +35,7 @@ describe("Test dragCreate", () => {
|
|||
const tool = getByToolName("rectangle");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
@ -43,7 +46,8 @@ describe("Test dragCreate", () => {
|
|||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
@ -63,7 +67,7 @@ describe("Test dragCreate", () => {
|
|||
const tool = getByToolName("ellipse");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
@ -74,7 +78,9 @@ describe("Test dragCreate", () => {
|
|||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
@ -94,7 +100,7 @@ describe("Test dragCreate", () => {
|
|||
const tool = getByToolName("diamond");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
@ -105,7 +111,8 @@ describe("Test dragCreate", () => {
|
|||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
@ -125,7 +132,7 @@ describe("Test dragCreate", () => {
|
|||
const tool = getByToolName("arrow");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
@ -136,7 +143,8 @@ describe("Test dragCreate", () => {
|
|||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
@ -160,7 +168,7 @@ describe("Test dragCreate", () => {
|
|||
const tool = getByToolName("line");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
@ -171,7 +179,8 @@ describe("Test dragCreate", () => {
|
|||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
@ -203,7 +212,7 @@ describe("Test dragCreate", () => {
|
|||
const tool = getByToolName("rectangle");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
@ -211,7 +220,8 @@ describe("Test dragCreate", () => {
|
|||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(7);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
@ -222,7 +232,7 @@ describe("Test dragCreate", () => {
|
|||
const tool = getByToolName("ellipse");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
@ -230,7 +240,8 @@ describe("Test dragCreate", () => {
|
|||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(7);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
@ -241,7 +252,7 @@ describe("Test dragCreate", () => {
|
|||
const tool = getByToolName("diamond");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
@ -249,7 +260,8 @@ describe("Test dragCreate", () => {
|
|||
// finish (position does not matter)
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(7);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
@ -260,7 +272,7 @@ describe("Test dragCreate", () => {
|
|||
const tool = getByToolName("arrow");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
@ -273,7 +285,8 @@ describe("Test dragCreate", () => {
|
|||
key: KEYS.ENTER,
|
||||
});
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(8);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
@ -284,7 +297,7 @@ describe("Test dragCreate", () => {
|
|||
const tool = getByToolName("line");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
// start from (30, 20)
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
|
@ -297,7 +310,8 @@ describe("Test dragCreate", () => {
|
|||
key: KEYS.ENTER,
|
||||
});
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(8);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
|
1
src/tests/fixtures/elementFixture.ts
vendored
1
src/tests/fixtures/elementFixture.ts
vendored
|
@ -34,6 +34,7 @@ export const rectangleFixture: ExcalidrawElement = {
|
|||
export const embeddableFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "embeddable",
|
||||
validated: null,
|
||||
};
|
||||
export const ellipseFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
|
|
|
@ -15,11 +15,17 @@ import fs from "fs";
|
|||
import util from "util";
|
||||
import path from "path";
|
||||
import { getMimeType } from "../../data/blob";
|
||||
import { newFreeDrawElement, newImageElement } from "../../element/newElement";
|
||||
import {
|
||||
newEmbeddableElement,
|
||||
newFrameElement,
|
||||
newFreeDrawElement,
|
||||
newImageElement,
|
||||
} from "../../element/newElement";
|
||||
import { Point } from "../../types";
|
||||
import { getSelectedElements } from "../../scene/selection";
|
||||
import { isLinearElementType } from "../../element/typeChecks";
|
||||
import { Mutable } from "../../utility-types";
|
||||
import { assertNever } from "../../utils";
|
||||
|
||||
const readFile = util.promisify(fs.readFile);
|
||||
|
||||
|
@ -178,14 +184,20 @@ export class API {
|
|||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "embeddable":
|
||||
element = newElement({
|
||||
type: type as "rectangle" | "diamond" | "ellipse" | "embeddable",
|
||||
type: type as "rectangle" | "diamond" | "ellipse",
|
||||
width,
|
||||
height,
|
||||
...base,
|
||||
});
|
||||
break;
|
||||
case "embeddable":
|
||||
element = newEmbeddableElement({
|
||||
type: "embeddable",
|
||||
...base,
|
||||
validated: null,
|
||||
});
|
||||
break;
|
||||
case "text":
|
||||
const fontSize = rest.fontSize ?? appState.currentItemFontSize;
|
||||
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
|
||||
|
@ -234,6 +246,15 @@ export class API {
|
|||
scale: rest.scale || [1, 1],
|
||||
});
|
||||
break;
|
||||
case "frame":
|
||||
element = newFrameElement({ ...base, width, height });
|
||||
break;
|
||||
default:
|
||||
assertNever(
|
||||
type,
|
||||
`API.createElement: unimplemented element type ${type}}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (element.type === "arrow") {
|
||||
element.startBinding = rest.startBinding ?? null;
|
||||
|
@ -269,7 +290,7 @@ export class API {
|
|||
};
|
||||
|
||||
static drop = async (blob: Blob) => {
|
||||
const fileDropEvent = createEvent.drop(GlobalTestState.canvas);
|
||||
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
|
||||
const text = await new Promise<string>((resolve, reject) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
|
@ -296,6 +317,6 @@ export class API {
|
|||
},
|
||||
},
|
||||
});
|
||||
fireEvent(GlobalTestState.canvas, fileDropEvent);
|
||||
fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -107,7 +107,7 @@ export class Pointer {
|
|||
restorePosition(x = 0, y = 0) {
|
||||
this.clientX = x;
|
||||
this.clientY = y;
|
||||
fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent());
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
private getEvent() {
|
||||
|
@ -129,18 +129,18 @@ export class Pointer {
|
|||
if (dx !== 0 || dy !== 0) {
|
||||
this.clientX += dx;
|
||||
this.clientY += dy;
|
||||
fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent());
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
}
|
||||
|
||||
down(dx = 0, dy = 0) {
|
||||
this.move(dx, dy);
|
||||
fireEvent.pointerDown(GlobalTestState.canvas, this.getEvent());
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
up(dx = 0, dy = 0) {
|
||||
this.move(dx, dy);
|
||||
fireEvent.pointerUp(GlobalTestState.canvas, this.getEvent());
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
click(dx = 0, dy = 0) {
|
||||
|
@ -150,7 +150,7 @@ export class Pointer {
|
|||
|
||||
doubleClick(dx = 0, dy = 0) {
|
||||
this.move(dx, dy);
|
||||
fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent());
|
||||
fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
// absolute coords
|
||||
|
@ -159,19 +159,19 @@ export class Pointer {
|
|||
moveTo(x: number = this.clientX, y: number = this.clientY) {
|
||||
this.clientX = x;
|
||||
this.clientY = y;
|
||||
fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent());
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
downAt(x = this.clientX, y = this.clientY) {
|
||||
this.clientX = x;
|
||||
this.clientY = y;
|
||||
fireEvent.pointerDown(GlobalTestState.canvas, this.getEvent());
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
upAt(x = this.clientX, y = this.clientY) {
|
||||
this.clientX = x;
|
||||
this.clientY = y;
|
||||
fireEvent.pointerUp(GlobalTestState.canvas, this.getEvent());
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
clickAt(x: number, y: number) {
|
||||
|
@ -180,7 +180,7 @@ export class Pointer {
|
|||
}
|
||||
|
||||
rightClickAt(x: number, y: number) {
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
|
@ -189,7 +189,7 @@ export class Pointer {
|
|||
|
||||
doubleClickAt(x: number, y: number) {
|
||||
this.moveTo(x, y);
|
||||
fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent());
|
||||
fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -327,6 +327,13 @@ export class UI {
|
|||
});
|
||||
}
|
||||
|
||||
static ungroup(elements: ExcalidrawElement[]) {
|
||||
mouse.select(elements);
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
Keyboard.keyPress(KEYS.G);
|
||||
});
|
||||
}
|
||||
|
||||
static queryContextMenu = () => {
|
||||
return GlobalTestState.renderResult.container.querySelector(
|
||||
".context-menu",
|
||||
|
|
|
@ -26,26 +26,28 @@ import * as textElementUtils from "../element/textElement";
|
|||
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
const { h } = window;
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
|
||||
describe("Test Linear Elements", () => {
|
||||
let container: HTMLElement;
|
||||
let canvas: HTMLCanvasElement;
|
||||
let interactiveCanvas: HTMLCanvasElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
const comp = await render(<ExcalidrawApp />);
|
||||
h.state.width = 1000;
|
||||
h.state.height = 1000;
|
||||
container = comp.container;
|
||||
canvas = container.querySelector("canvas")!;
|
||||
canvas.width = 1000;
|
||||
canvas.height = 1000;
|
||||
interactiveCanvas = container.querySelector("canvas.interactive")!;
|
||||
});
|
||||
|
||||
const p1: Point = [20, 20];
|
||||
|
@ -120,26 +122,26 @@ describe("Test Linear Elements", () => {
|
|||
};
|
||||
|
||||
const drag = (startPoint: Point, endPoint: Point) => {
|
||||
fireEvent.pointerDown(canvas, {
|
||||
fireEvent.pointerDown(interactiveCanvas, {
|
||||
clientX: startPoint[0],
|
||||
clientY: startPoint[1],
|
||||
});
|
||||
fireEvent.pointerMove(canvas, {
|
||||
fireEvent.pointerMove(interactiveCanvas, {
|
||||
clientX: endPoint[0],
|
||||
clientY: endPoint[1],
|
||||
});
|
||||
fireEvent.pointerUp(canvas, {
|
||||
fireEvent.pointerUp(interactiveCanvas, {
|
||||
clientX: endPoint[0],
|
||||
clientY: endPoint[1],
|
||||
});
|
||||
};
|
||||
|
||||
const deletePoint = (point: Point) => {
|
||||
fireEvent.pointerDown(canvas, {
|
||||
fireEvent.pointerDown(interactiveCanvas, {
|
||||
clientX: point[0],
|
||||
clientY: point[1],
|
||||
});
|
||||
fireEvent.pointerUp(canvas, {
|
||||
fireEvent.pointerUp(interactiveCanvas, {
|
||||
clientX: point[0],
|
||||
clientY: point[1],
|
||||
});
|
||||
|
@ -172,12 +174,14 @@ describe("Test Linear Elements", () => {
|
|||
createTwoPointerLinearElement("line");
|
||||
const line = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(7);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(5);
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
|
||||
|
||||
// drag line from midpoint
|
||||
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
|
||||
expect(renderScene).toHaveBeenCalledTimes(11);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(line.points).toMatchInlineSnapshot(`
|
||||
[
|
||||
|
@ -199,14 +203,14 @@ describe("Test Linear Elements", () => {
|
|||
|
||||
it("should allow entering and exiting line editor via context menu", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: midpoint[0],
|
||||
clientY: midpoint[1],
|
||||
});
|
||||
// Enter line editor
|
||||
let contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: midpoint[0],
|
||||
clientY: midpoint[1],
|
||||
|
@ -216,13 +220,13 @@ describe("Test Linear Elements", () => {
|
|||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
|
||||
// Exiting line editor
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: midpoint[0],
|
||||
clientY: midpoint[1],
|
||||
});
|
||||
contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: midpoint[0],
|
||||
clientY: midpoint[1],
|
||||
|
@ -270,7 +274,8 @@ describe("Test Linear Elements", () => {
|
|||
|
||||
// drag line from midpoint
|
||||
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
|
||||
expect(renderScene).toHaveBeenCalledTimes(15);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(line.points).toMatchInlineSnapshot(`
|
||||
|
@ -307,7 +312,9 @@ describe("Test Linear Elements", () => {
|
|||
// update roundness
|
||||
fireEvent.click(screen.getByTitle("Round"));
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(12);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
|
||||
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
|
||||
h.elements[0] as ExcalidrawLinearElement,
|
||||
h.state,
|
||||
|
@ -351,7 +358,9 @@ describe("Test Linear Elements", () => {
|
|||
// Move the element
|
||||
drag(startPoint, endPoint);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(16);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
|
||||
expect([line.x, line.y]).toEqual([
|
||||
points[0][0] + deltaX,
|
||||
points[0][1] + deltaY,
|
||||
|
@ -408,7 +417,9 @@ describe("Test Linear Elements", () => {
|
|||
lastSegmentMidpoint[1] + delta,
|
||||
]);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
||||
|
@ -447,7 +458,8 @@ describe("Test Linear Elements", () => {
|
|||
// Drag from first point
|
||||
drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(16);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
|
||||
expect([newPoints[0][0], newPoints[0][1]]).toEqual([
|
||||
|
@ -473,7 +485,8 @@ describe("Test Linear Elements", () => {
|
|||
// Drag from first point
|
||||
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(16);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
|
||||
expect([newPoints[0][0], newPoints[0][1]]).toEqual([
|
||||
|
@ -507,7 +520,8 @@ describe("Test Linear Elements", () => {
|
|||
// delete 3rd point
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderScene).toHaveBeenCalledTimes(22);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
|
||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||
line,
|
||||
|
@ -553,8 +567,8 @@ describe("Test Linear Elements", () => {
|
|||
lastSegmentMidpoint[0] + delta,
|
||||
lastSegmentMidpoint[1] + delta,
|
||||
]);
|
||||
expect(renderScene).toHaveBeenCalledTimes(21);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
||||
|
@ -629,7 +643,8 @@ describe("Test Linear Elements", () => {
|
|||
// Drag from first point
|
||||
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(16);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
|
||||
expect([newPoints[0][0], newPoints[0][1]]).toEqual([
|
||||
|
@ -870,10 +885,10 @@ describe("Test Linear Elements", () => {
|
|||
]);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
.toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
|
||||
|
@ -904,10 +919,10 @@ describe("Test Linear Elements", () => {
|
|||
]);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
.toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not bind text to line when double clicked", async () => {
|
||||
|
@ -1046,9 +1061,9 @@ describe("Test Linear Elements", () => {
|
|||
`);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
.toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made easy"
|
||||
`);
|
||||
"Online whiteboard
|
||||
collaboration made easy"
|
||||
`);
|
||||
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
|
||||
.toMatchInlineSnapshot(`
|
||||
[
|
||||
|
@ -1128,7 +1143,7 @@ describe("Test Linear Elements", () => {
|
|||
height: 500,
|
||||
});
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
x: -10,
|
||||
y: 250,
|
||||
width: 400,
|
||||
height: 1,
|
||||
|
@ -1152,8 +1167,8 @@ describe("Test Linear Elements", () => {
|
|||
expect(
|
||||
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard collaboration
|
||||
made easy"
|
||||
"Online whiteboard
|
||||
collaboration made easy"
|
||||
`);
|
||||
const handleBindTextResizeSpy = vi.spyOn(
|
||||
textElementUtils,
|
||||
|
@ -1165,7 +1180,7 @@ describe("Test Linear Elements", () => {
|
|||
mouse.moveTo(200, 0);
|
||||
mouse.upAt(200, 0);
|
||||
|
||||
expect(arrow.width).toBe(170);
|
||||
expect(arrow.width).toBe(200);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
|
@ -1206,7 +1221,7 @@ describe("Test Linear Elements", () => {
|
|||
|
||||
const container = h.elements[0];
|
||||
API.setSelectedElements([container, text]);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
|
@ -1231,7 +1246,7 @@ describe("Test Linear Elements", () => {
|
|||
mouse.up();
|
||||
API.setSelectedElements([h.elements[0], h.elements[1]]);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
|
|
|
@ -17,10 +17,13 @@ import { vi } from "vitest";
|
|||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
|
@ -29,7 +32,7 @@ const { h } = window;
|
|||
describe("move element", () => {
|
||||
it("rectangle", async () => {
|
||||
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
{
|
||||
// create element
|
||||
|
@ -39,20 +42,23 @@ describe("move element", () => {
|
|||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
|
||||
|
||||
renderScene.mockClear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
}
|
||||
|
||||
fireEvent.pointerDown(canvas, { clientX: 50, clientY: 20 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 20, clientY: 40 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(3);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(2);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect([h.elements[0].x, h.elements[0].y]).toEqual([0, 40]);
|
||||
|
@ -78,7 +84,8 @@ describe("move element", () => {
|
|||
// select the second rectangles
|
||||
new Pointer("mouse").clickOn(rectB);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(23);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(20);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(3);
|
||||
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
|
||||
|
@ -87,7 +94,8 @@ describe("move element", () => {
|
|||
expect([line.x, line.y]).toEqual([110, 50]);
|
||||
expect([line.width, line.height]).toEqual([80, 80]);
|
||||
|
||||
renderScene.mockClear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
|
||||
// Move selected rectangle
|
||||
Keyboard.keyDown(KEYS.ARROW_RIGHT);
|
||||
|
@ -95,7 +103,8 @@ describe("move element", () => {
|
|||
Keyboard.keyDown(KEYS.ARROW_DOWN);
|
||||
|
||||
// Check that the arrow size has been changed according to moving the rectangle
|
||||
expect(renderScene).toHaveBeenCalledTimes(3);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(3);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(3);
|
||||
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
|
||||
|
@ -111,7 +120,7 @@ describe("move element", () => {
|
|||
describe("duplicate element on move when ALT is clicked", () => {
|
||||
it("rectangle", async () => {
|
||||
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
|
||||
{
|
||||
// create element
|
||||
|
@ -121,13 +130,15 @@ describe("duplicate element on move when ALT is clicked", () => {
|
|||
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]);
|
||||
|
||||
renderScene.mockClear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
}
|
||||
|
||||
fireEvent.pointerDown(canvas, { clientX: 50, clientY: 20 });
|
||||
|
@ -141,7 +152,8 @@ describe("duplicate element on move when ALT is clicked", () => {
|
|||
|
||||
// TODO: This used to be 4, but binding made it go up to 5. Do we need
|
||||
// that additional render?
|
||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(3);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(2);
|
||||
|
||||
|
|
|
@ -15,10 +15,13 @@ import { vi } from "vitest";
|
|||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
|
@ -39,11 +42,12 @@ describe("remove shape in non linear elements", () => {
|
|||
const tool = getByToolName("rectangle");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(7);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
||||
|
@ -53,11 +57,12 @@ describe("remove shape in non linear elements", () => {
|
|||
const tool = getByToolName("ellipse");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(7);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
|
||||
|
@ -67,11 +72,12 @@ describe("remove shape in non linear elements", () => {
|
|||
const tool = getByToolName("diamond");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(7);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
@ -83,7 +89,7 @@ describe("multi point mode in linear elements", () => {
|
|||
const tool = getByToolName("arrow");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
// first point is added on pointer down
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 30 });
|
||||
|
||||
|
@ -103,7 +109,8 @@ describe("multi point mode in linear elements", () => {
|
|||
key: KEYS.ENTER,
|
||||
});
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(15);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(11);
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
@ -126,7 +133,7 @@ describe("multi point mode in linear elements", () => {
|
|||
const tool = getByToolName("line");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
// first point is added on pointer down
|
||||
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 30 });
|
||||
|
||||
|
@ -146,7 +153,8 @@ describe("multi point mode in linear elements", () => {
|
|||
key: KEYS.ENTER,
|
||||
});
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(15);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(11);
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
const element = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { fireEvent, GlobalTestState, toggleMenu, render } from "../test-utils";
|
||||
import { Excalidraw, Footer, MainMenu } from "../../packages/excalidraw/index";
|
||||
import { queryByText, queryByTestId, screen } from "@testing-library/react";
|
||||
import { queryByText, queryByTestId } from "@testing-library/react";
|
||||
import { GRID_SIZE, THEME } from "../../constants";
|
||||
import { t } from "../../i18n";
|
||||
import { useMemo } from "react";
|
||||
|
@ -23,7 +23,7 @@ describe("<Excalidraw/>", () => {
|
|||
).toBe(0);
|
||||
expect(h.state.zenModeEnabled).toBe(false);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -42,8 +42,8 @@ describe("<Excalidraw/>", () => {
|
|||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
expect(h.state.zenModeEnabled).toBe(true);
|
||||
screen.debug();
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -95,7 +95,7 @@ describe("<Excalidraw/>", () => {
|
|||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
@ -114,7 +114,7 @@ describe("<Excalidraw/>", () => {
|
|||
expect(
|
||||
container.getElementsByClassName("disable-zen-mode--visible").length,
|
||||
).toBe(0);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
|
|
|
@ -21,7 +21,7 @@ import { vi } from "vitest";
|
|||
|
||||
const { h } = window;
|
||||
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
const finger1 = new Pointer("touch", 1);
|
||||
|
@ -33,7 +33,7 @@ const finger2 = new Pointer("touch", 2);
|
|||
* to debug where a test failure came from.
|
||||
*/
|
||||
const checkpoint = (name: string) => {
|
||||
expect(renderScene.mock.calls.length).toMatchSnapshot(
|
||||
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
|
||||
`[${name}] number of renders`,
|
||||
);
|
||||
expect(h.state).toMatchSnapshot(`[${name}] appState`);
|
||||
|
@ -48,7 +48,7 @@ beforeEach(async () => {
|
|||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
|
@ -1056,6 +1056,28 @@ describe("regression tests", () => {
|
|||
expect(API.getSelectedElements()).toEqual(selectedElements_prev);
|
||||
});
|
||||
|
||||
it("deleting last but one element in editing group should unselect the group", () => {
|
||||
const rect1 = UI.createElement("rectangle", { x: 10 });
|
||||
const rect2 = UI.createElement("rectangle", { x: 50 });
|
||||
|
||||
UI.group([rect1, rect2]);
|
||||
|
||||
mouse.doubleClickOn(rect1);
|
||||
Keyboard.keyDown(KEYS.DELETE);
|
||||
|
||||
// Clicking on the deleted element, hence in the empty space
|
||||
mouse.clickOn(rect1);
|
||||
|
||||
expect(h.state.selectedGroupIds).toEqual({});
|
||||
expect(API.getSelectedElements()).toEqual([]);
|
||||
|
||||
// Clicking back in and expecting no group selection
|
||||
mouse.clickOn(rect2);
|
||||
|
||||
expect(h.state.selectedGroupIds).toEqual({ [rect2.groupIds[0]]: false });
|
||||
expect(API.getSelectedElements()).toEqual([rect2.get()]);
|
||||
});
|
||||
|
||||
it("Cmd/Ctrl-click exclusively select element under pointer", () => {
|
||||
const rect1 = UI.createElement("rectangle", { x: 0 });
|
||||
const rect2 = UI.createElement("rectangle", { x: 30 });
|
||||
|
|
|
@ -14,10 +14,11 @@ import { vi } from "vitest";
|
|||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
|
|
|
@ -18,10 +18,13 @@ import { vi } from "vitest";
|
|||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
|
@ -201,7 +204,7 @@ describe("inner box-selection", () => {
|
|||
});
|
||||
h.elements = [rect1, rect2, rect3];
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.downAt(rect2.x - 20, rect2.x - 20);
|
||||
mouse.downAt(rect2.x - 20, rect2.y - 20);
|
||||
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
|
||||
assertSelectedElements([rect2.id, rect3.id]);
|
||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||
|
@ -220,10 +223,11 @@ describe("selection element", () => {
|
|||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(3);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(3);
|
||||
const selectionElement = h.state.selectionElement!;
|
||||
expect(selectionElement).not.toBeNull();
|
||||
expect(selectionElement.type).toEqual("selection");
|
||||
|
@ -240,11 +244,12 @@ describe("selection element", () => {
|
|||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(4);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(3);
|
||||
const selectionElement = h.state.selectionElement!;
|
||||
expect(selectionElement).not.toBeNull();
|
||||
expect(selectionElement.type).toEqual("selection");
|
||||
|
@ -261,12 +266,13 @@ describe("selection element", () => {
|
|||
const tool = getByToolName("selection");
|
||||
fireEvent.click(tool);
|
||||
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
|
||||
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(7);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(3);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
});
|
||||
});
|
||||
|
@ -282,7 +288,7 @@ describe("select single element on the scene", () => {
|
|||
|
||||
it("rectangle", async () => {
|
||||
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("rectangle");
|
||||
|
@ -301,7 +307,8 @@ describe("select single element on the scene", () => {
|
|||
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(11);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
|
@ -311,7 +318,7 @@ describe("select single element on the scene", () => {
|
|||
|
||||
it("diamond", async () => {
|
||||
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("diamond");
|
||||
|
@ -330,7 +337,8 @@ describe("select single element on the scene", () => {
|
|||
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(11);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
|
@ -340,7 +348,7 @@ describe("select single element on the scene", () => {
|
|||
|
||||
it("ellipse", async () => {
|
||||
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("ellipse");
|
||||
|
@ -359,7 +367,8 @@ describe("select single element on the scene", () => {
|
|||
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(11);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
|
@ -369,7 +378,7 @@ describe("select single element on the scene", () => {
|
|||
|
||||
it("arrow", async () => {
|
||||
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("arrow");
|
||||
|
@ -401,7 +410,8 @@ describe("select single element on the scene", () => {
|
|||
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(11);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
|
@ -410,7 +420,7 @@ describe("select single element on the scene", () => {
|
|||
|
||||
it("arrow escape", async () => {
|
||||
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
const canvas = container.querySelector("canvas.interactive")!;
|
||||
{
|
||||
// create element
|
||||
const tool = getByToolName("line");
|
||||
|
@ -442,7 +452,8 @@ describe("select single element on the scene", () => {
|
|||
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderScene).toHaveBeenCalledTimes(11);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
|
@ -466,3 +477,46 @@ describe("tool locking & selection", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectedElementIds stability", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
});
|
||||
|
||||
it("box-selection should be stable when not changing selection", () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
});
|
||||
|
||||
h.elements = [rectangle];
|
||||
|
||||
const selectedElementIds_1 = h.state.selectedElementIds;
|
||||
|
||||
mouse.downAt(-100, -100);
|
||||
mouse.moveTo(-50, -50);
|
||||
mouse.up();
|
||||
|
||||
expect(h.state.selectedElementIds).toBe(selectedElementIds_1);
|
||||
|
||||
mouse.downAt(-50, -50);
|
||||
mouse.moveTo(50, 50);
|
||||
|
||||
const selectedElementIds_2 = h.state.selectedElementIds;
|
||||
|
||||
expect(selectedElementIds_2).toEqual({ [rectangle.id]: true });
|
||||
|
||||
mouse.moveTo(60, 60);
|
||||
|
||||
// box-selecting further without changing selection should keep
|
||||
// selectedElementIds stable (the same object)
|
||||
expect(h.state.selectedElementIds).toBe(selectedElementIds_2);
|
||||
|
||||
mouse.up();
|
||||
|
||||
expect(h.state.selectedElementIds).toBe(selectedElementIds_2);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -49,15 +49,30 @@ const renderApp: TestRenderFn = async (ui, options) => {
|
|||
// child App component isn't likely mounted yet (and thus canvas not
|
||||
// present in DOM)
|
||||
get() {
|
||||
return renderResult.container.querySelector("canvas")!;
|
||||
return renderResult.container.querySelector("canvas.static")!;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(GlobalTestState, "interactiveCanvas", {
|
||||
// must be a getter because at the time of ExcalidrawApp render the
|
||||
// child App component isn't likely mounted yet (and thus canvas not
|
||||
// present in DOM)
|
||||
get() {
|
||||
return renderResult.container.querySelector("canvas.interactive")!;
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = renderResult.container.querySelector("canvas");
|
||||
const canvas = renderResult.container.querySelector("canvas.static");
|
||||
if (!canvas) {
|
||||
throw new Error("not initialized yet");
|
||||
}
|
||||
|
||||
const interactiveCanvas =
|
||||
renderResult.container.querySelector("canvas.interactive");
|
||||
if (!interactiveCanvas) {
|
||||
throw new Error("not initialized yet");
|
||||
}
|
||||
});
|
||||
|
||||
return renderResult;
|
||||
|
@ -81,11 +96,17 @@ export class GlobalTestState {
|
|||
*/
|
||||
static renderResult: RenderResult<typeof customQueries> = null!;
|
||||
/**
|
||||
* retrieves canvas for currently rendered app instance
|
||||
* retrieves static canvas for currently rendered app instance
|
||||
*/
|
||||
static get canvas(): HTMLCanvasElement {
|
||||
return null!;
|
||||
}
|
||||
/**
|
||||
* retrieves interactive canvas for currently rendered app instance
|
||||
*/
|
||||
static get interactiveCanvas(): HTMLCanvasElement {
|
||||
return null!;
|
||||
}
|
||||
}
|
||||
|
||||
const initLocalStorage = (data: ImportedDataState) => {
|
||||
|
|
|
@ -17,7 +17,9 @@ describe("view mode", () => {
|
|||
|
||||
it("after switching to view mode – cursor type should be pointer", async () => {
|
||||
h.setState({ viewModeEnabled: true });
|
||||
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.GRAB,
|
||||
);
|
||||
});
|
||||
|
||||
it("after switching to view mode, moving, clicking, and pressing space key – cursor type should be pointer", async () => {
|
||||
|
@ -29,7 +31,9 @@ describe("view mode", () => {
|
|||
pointer.move(100, 100);
|
||||
pointer.click();
|
||||
Keyboard.keyPress(KEYS.SPACE);
|
||||
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.GRAB,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -45,13 +49,19 @@ describe("view mode", () => {
|
|||
pointer.moveTo(50, 50);
|
||||
// eslint-disable-next-line dot-notation
|
||||
if (pointerType["pointerType"] === "mouse") {
|
||||
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.MOVE);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.MOVE,
|
||||
);
|
||||
} else {
|
||||
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.GRAB,
|
||||
);
|
||||
}
|
||||
|
||||
h.setState({ viewModeEnabled: true });
|
||||
expect(GlobalTestState.canvas.style.cursor).toBe(CURSOR_TYPE.GRAB);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.GRAB,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -94,7 +94,7 @@ const populateElements = (
|
|||
),
|
||||
...appState,
|
||||
selectedElementIds,
|
||||
});
|
||||
} as AppState);
|
||||
|
||||
return selectedElementIds;
|
||||
};
|
||||
|
|
56
src/types.ts
56
src/types.ts
|
@ -104,6 +104,55 @@ export type LastActiveTool =
|
|||
export type SidebarName = string;
|
||||
export type SidebarTabName = string;
|
||||
|
||||
type _CommonCanvasAppState = {
|
||||
zoom: AppState["zoom"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
width: AppState["width"];
|
||||
height: AppState["height"];
|
||||
viewModeEnabled: AppState["viewModeEnabled"];
|
||||
editingGroupId: AppState["editingGroupId"]; // TODO: move to interactive canvas if possible
|
||||
selectedElementIds: AppState["selectedElementIds"]; // TODO: move to interactive canvas if possible
|
||||
frameToHighlight: AppState["frameToHighlight"]; // TODO: move to interactive canvas if possible
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
theme: AppState["theme"];
|
||||
pendingImageElementId: AppState["pendingImageElementId"];
|
||||
};
|
||||
|
||||
export type StaticCanvasAppState = Readonly<
|
||||
_CommonCanvasAppState & {
|
||||
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
|
||||
/** null indicates transparent bg */
|
||||
viewBackgroundColor: AppState["viewBackgroundColor"] | null;
|
||||
exportScale: AppState["exportScale"];
|
||||
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
|
||||
gridSize: AppState["gridSize"];
|
||||
frameRendering: AppState["frameRendering"];
|
||||
}
|
||||
>;
|
||||
|
||||
export type InteractiveCanvasAppState = Readonly<
|
||||
_CommonCanvasAppState & {
|
||||
// renderInteractiveScene
|
||||
activeEmbeddable: AppState["activeEmbeddable"];
|
||||
editingLinearElement: AppState["editingLinearElement"];
|
||||
selectionElement: AppState["selectionElement"];
|
||||
selectedGroupIds: AppState["selectedGroupIds"];
|
||||
selectedLinearElement: AppState["selectedLinearElement"];
|
||||
multiElement: AppState["multiElement"];
|
||||
isBindingEnabled: AppState["isBindingEnabled"];
|
||||
suggestedBindings: AppState["suggestedBindings"];
|
||||
isRotating: AppState["isRotating"];
|
||||
elementsToHighlight: AppState["elementsToHighlight"];
|
||||
// App
|
||||
openSidebar: AppState["openSidebar"];
|
||||
showHyperlinkPopup: AppState["showHyperlinkPopup"];
|
||||
// Collaborators
|
||||
collaborators: AppState["collaborators"];
|
||||
}
|
||||
>;
|
||||
|
||||
export type AppState = {
|
||||
contextMenu: {
|
||||
items: ContextMenuItems;
|
||||
|
@ -409,13 +458,13 @@ export type ExportOpts = {
|
|||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: UIAppState,
|
||||
files: BinaryFiles,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
canvas: HTMLCanvasElement,
|
||||
) => void;
|
||||
renderCustomUI?: (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: UIAppState,
|
||||
files: BinaryFiles,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
canvas: HTMLCanvasElement,
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
|
@ -460,7 +509,8 @@ export type AppProps = Merge<
|
|||
* in the app, eg Manager. Factored out into a separate type to keep DRY. */
|
||||
export type AppClassProperties = {
|
||||
props: AppProps;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
canvas: HTMLCanvasElement;
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
focusContainer(): void;
|
||||
library: Library;
|
||||
imageCache: Map<
|
||||
|
|
116
src/utils.ts
116
src/utils.ts
|
@ -20,6 +20,7 @@ import { unstable_batchedUpdates } from "react-dom";
|
|||
import { SHAPES } from "./shapes";
|
||||
import { isEraserActive, isHandToolActive } from "./appState";
|
||||
import { ResolutionType } from "./utility-types";
|
||||
import React from "react";
|
||||
|
||||
let mockDateTime: string | null = null;
|
||||
|
||||
|
@ -399,22 +400,25 @@ export const updateActiveTool = (
|
|||
};
|
||||
};
|
||||
|
||||
export const resetCursor = (canvas: HTMLCanvasElement | null) => {
|
||||
if (canvas) {
|
||||
canvas.style.cursor = "";
|
||||
export const resetCursor = (interactiveCanvas: HTMLCanvasElement | null) => {
|
||||
if (interactiveCanvas) {
|
||||
interactiveCanvas.style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
export const setCursor = (canvas: HTMLCanvasElement | null, cursor: string) => {
|
||||
if (canvas) {
|
||||
canvas.style.cursor = cursor;
|
||||
export const setCursor = (
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
cursor: string,
|
||||
) => {
|
||||
if (interactiveCanvas) {
|
||||
interactiveCanvas.style.cursor = cursor;
|
||||
}
|
||||
};
|
||||
|
||||
let eraserCanvasCache: any;
|
||||
let previewDataURL: string;
|
||||
export const setEraserCursor = (
|
||||
canvas: HTMLCanvasElement | null,
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
theme: AppState["theme"],
|
||||
) => {
|
||||
const cursorImageSizePx = 20;
|
||||
|
@ -446,7 +450,7 @@ export const setEraserCursor = (
|
|||
}
|
||||
|
||||
setCursor(
|
||||
canvas,
|
||||
interactiveCanvas,
|
||||
`url(${previewDataURL}) ${cursorImageSizePx / 2} ${
|
||||
cursorImageSizePx / 2
|
||||
}, auto`,
|
||||
|
@ -454,23 +458,23 @@ export const setEraserCursor = (
|
|||
};
|
||||
|
||||
export const setCursorForShape = (
|
||||
canvas: HTMLCanvasElement | null,
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
appState: Pick<AppState, "activeTool" | "theme">,
|
||||
) => {
|
||||
if (!canvas) {
|
||||
if (!interactiveCanvas) {
|
||||
return;
|
||||
}
|
||||
if (appState.activeTool.type === "selection") {
|
||||
resetCursor(canvas);
|
||||
resetCursor(interactiveCanvas);
|
||||
} else if (isHandToolActive(appState)) {
|
||||
canvas.style.cursor = CURSOR_TYPE.GRAB;
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.GRAB;
|
||||
} else if (isEraserActive(appState)) {
|
||||
setEraserCursor(canvas, appState.theme);
|
||||
setEraserCursor(interactiveCanvas, appState.theme);
|
||||
// do nothing if image tool is selected which suggests there's
|
||||
// a image-preview set as the cursor
|
||||
// Ignore custom type as well and let host decide
|
||||
} else if (!["image", "custom"].includes(appState.activeTool.type)) {
|
||||
canvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -914,3 +918,87 @@ export const isOnlyExportingSingleFrame = (
|
|||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const assertNever = (
|
||||
value: never,
|
||||
message: string,
|
||||
softAssert?: boolean,
|
||||
): never => {
|
||||
if (softAssert) {
|
||||
console.error(message);
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Memoizes on values of `opts` object (strict equality).
|
||||
*/
|
||||
export const memoize = <T extends Record<string, any>, R extends any>(
|
||||
func: (opts: T) => R,
|
||||
) => {
|
||||
let lastArgs: Map<string, any> | undefined;
|
||||
let lastResult: R | undefined;
|
||||
|
||||
const ret = function (opts: T) {
|
||||
const currentArgs = Object.entries(opts);
|
||||
|
||||
if (lastArgs) {
|
||||
let argsAreEqual = true;
|
||||
for (const [key, value] of currentArgs) {
|
||||
if (lastArgs.get(key) !== value) {
|
||||
argsAreEqual = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (argsAreEqual) {
|
||||
return lastResult;
|
||||
}
|
||||
}
|
||||
|
||||
const result = func(opts);
|
||||
|
||||
lastArgs = new Map(currentArgs);
|
||||
lastResult = result;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
ret.clear = () => {
|
||||
lastArgs = undefined;
|
||||
lastResult = undefined;
|
||||
};
|
||||
|
||||
return ret as typeof func & { clear: () => void };
|
||||
};
|
||||
|
||||
export const isRenderThrottlingEnabled = (() => {
|
||||
// we don't want to throttle in react < 18 because of #5439 and it was
|
||||
// getting more complex to maintain the fix
|
||||
let IS_REACT_18_AND_UP: boolean;
|
||||
try {
|
||||
const version = React.version.split(".");
|
||||
IS_REACT_18_AND_UP = Number(version[0]) > 17;
|
||||
} catch {
|
||||
IS_REACT_18_AND_UP = false;
|
||||
}
|
||||
|
||||
let hasWarned = false;
|
||||
|
||||
return () => {
|
||||
if (window.EXCALIDRAW_THROTTLE_RENDER === true) {
|
||||
if (!IS_REACT_18_AND_UP) {
|
||||
if (!hasWarned) {
|
||||
hasWarned = true;
|
||||
console.warn(
|
||||
"Excalidraw: render throttling is disabled on React versions < 18.",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
})();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue