Introducing interactive canvas

Separation of Appstate and RenderConfig for InteractiveCanvas

Sepration of static canvas

Fixing test type-errors, removing original RenderConfig

Deduplication of canvases AppState and RenderConfig

Added mutation hook for shared computation between canvases

Moved interaction handlers to interactive canvas and closed some fixes

Added CanvasWrapper and first render optimisations

Optimising selection + frame selection bottlenecks with cache/improved algo

Static canvas rendering bottlenecks WIP

Cursors regression moved to interactive canvas

Regression, adding back render interactive scene callback, adding back throttleRAF to both canvases

Fix for scroll back to content & scrollbars

Separating renderInteractiveScene and renderScene

Common canvas context bootstrap

Groups cache fix, mutation elements fix and other smaller fixes

Remove getSelectedElements cache

Fixing broken tests

Updated tests with expected # of renderStaticScene calls, adding group selection edge-case test, other smaller fixes
This commit is contained in:
Marcel Mraz 2023-07-05 23:09:01 +02:00
parent e57dc405fa
commit 903c94d2ca
56 changed files with 3679 additions and 2520 deletions

View File

@ -422,7 +422,7 @@ export const actionToggleHandTool = register({
type: "hand",
lastActiveToolBeforeEraser: appState.activeTool,
});
setCursor(app.canvas, CURSOR_TYPE.GRAB);
setCursor(app.interactiveCanvas, CURSOR_TYPE.GRAB);
}
return {

View File

@ -276,6 +276,6 @@ const duplicateElements = (
getNonDeletedElements(finalElements),
appState,
null,
),
) as AppState,
};
};

View File

@ -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"];

View File

@ -108,7 +108,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame",
});
setCursorForShape(app.canvas, {
setCursorForShape(app.interactiveCanvas, {
...appState,
activeTool: nextActiveTool,
});

View File

@ -153,7 +153,7 @@ export const actionGroup = register({
newGroupId,
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
) as AppState,
elements: nextElements,
commitToHistory: true,
};
@ -216,7 +216,7 @@ export const actionUngroup = register({
getNonDeletedElements(nextElements),
appState,
null,
);
) as AppState;
frames.forEach((frame) => {
if (frame) {

View File

@ -6,6 +6,7 @@ import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { AppState } from "../types";
export const actionSelectAll = register({
name: "selectAll",
@ -43,7 +44,7 @@ export const actionSelectAll = register({
getNonDeletedElements(elements),
appState,
app,
),
) as AppState,
commitToHistory: true,
};
},

View File

@ -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;
@ -269,7 +269,7 @@ export const ShapesSwitcher = ({
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, {
setCursorForShape(interactiveCanvas, {
...appState,
activeTool: nextActiveTool,
});

View File

@ -5,14 +5,14 @@ import { render, queryByTestId } from "../tests/test-utils";
import ExcalidrawApp from "../excalidraw-app";
const renderScene = jest.spyOn(Renderer, "renderScene");
const renderStaticScene = jest.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);
});

View File

@ -173,6 +173,7 @@ import {
getSelectedGroupIds,
isElementInGroup,
isSelectedViaGroup,
selectGroups,
selectGroupsForSelectedElements,
} from "../groups";
import History from "../history";
@ -186,7 +187,7 @@ import {
KEYS,
} from "../keys";
import { distance2d, getGridPoint, isPathALoop } from "../math";
import { isVisibleElement, renderScene } from "../renderer/renderScene";
import { isVisibleElement } from "../renderer/renderScene";
import { invalidateShapeForElement } from "../renderer/renderElement";
import {
calculateScrollCenter,
@ -199,7 +200,7 @@ import {
isSomeElementSelected,
} from "../scene";
import Scene from "../scene/Scene";
import { RenderConfig, ScrollBars } from "../scene/types";
import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey, SHAPES } from "../shapes";
import {
@ -331,6 +332,11 @@ import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { activeEyeDropperAtom } from "./EyeDropper";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas, CanvasesWrapper } from "./canvases";
// remove this hack when we can sync render & resizeObserver (state update)
// to rAF. See #5439
window.EXCALIDRAW_THROTTLE_NEXT_RENDER = true;
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -400,10 +406,6 @@ let currentScrollBars: ScrollBars = { horizontal: null, vertical: null };
let touchTimeout = 0;
let invalidateContextMenu = false;
// remove this hack when we can sync render & resizeObserver (state update)
// to rAF. See #5439
let THROTTLE_NEXT_RENDER = true;
let IS_PLAIN_PASTE = false;
let IS_PLAIN_PASTE_TIMER = 0;
let PLAIN_PASTE_TOAST_SHOWN = false;
@ -418,6 +420,7 @@ const gesture: Gesture = {
class App extends React.Component<AppProps, AppState> {
canvas: AppClassProperties["canvas"] = null;
interactiveCanvas: AppClassProperties["canvas"] = null;
rc: RoughCanvas | null = null;
unmounted: boolean = false;
actionManager: ActionManager;
@ -538,65 +541,6 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAction(createRedoAction(this.history));
}
private renderCanvas() {
const canvasScale = window.devicePixelRatio;
const {
width: canvasDOMWidth,
height: canvasDOMHeight,
viewModeEnabled,
} = this.state;
const canvasWidth = canvasDOMWidth * canvasScale;
const canvasHeight = canvasDOMHeight * canvasScale;
if (viewModeEnabled) {
return (
<canvas
className="excalidraw__canvas"
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
cursor: CURSOR_TYPE.GRAB,
}}
width={canvasWidth}
height={canvasHeight}
ref={this.handleCanvasRef}
onContextMenu={(event: React.PointerEvent<HTMLCanvasElement>) =>
this.handleCanvasContextMenu(event)
}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onPointerDown={this.handleCanvasPointerDown}
>
{t("labels.drawingCanvas")}
</canvas>
);
}
return (
<canvas
className="excalidraw__canvas"
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
}}
width={canvasWidth}
height={canvasHeight}
ref={this.handleCanvasRef}
onContextMenu={(event: React.PointerEvent<HTMLCanvasElement>) =>
this.handleCanvasContextMenu(event)
}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
>
{t("labels.drawingCanvas")}
</canvas>
);
}
private getFrameNameDOMId = (frameElement: ExcalidrawElement) => {
return `${this.id}-frame-name-${frameElement.id}`;
};
@ -782,9 +726,11 @@ class App extends React.Component<AppProps, AppState> {
}}
onPointerDown={(event) => this.handleCanvasPointerDown(event)}
onWheel={(event) => this.handleWheel(event)}
onContextMenu={(event: React.PointerEvent<HTMLDivElement>) => {
this.handleCanvasContextMenu(event);
}}
onContextMenu={
this.handleCanvasContextMenu as (
event: React.PointerEvent<HTMLDivElement>,
) => void
}
onDoubleClick={() => {
this.setState({
editingFrame: f.id,
@ -830,6 +776,7 @@ class App extends React.Component<AppProps, AppState> {
>
<LayerUI
canvas={this.canvas}
interactiveCanvas={this.interactiveCanvas}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
@ -888,7 +835,61 @@ class App extends React.Component<AppProps, AppState> {
actionManager={this.actionManager}
/>
)}
<main>{this.renderCanvas()}</main>
<CanvasesWrapper
appState={this.state}
scene={this.scene}
>
{(elements, versionNonce) => (
<>
<StaticCanvas
canvas={this.canvas}
rc={this.rc}
elements={elements}
versionNonce={versionNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
appState={this.state}
renderConfig={{
imageCache: this.imageCache,
isExporting: false,
renderGrid: true,
}}
handleCanvasRef={this.handleCanvasRef}
/>
<InteractiveCanvas
canvas={this.interactiveCanvas}
elements={elements}
versionNonce={versionNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
appState={this.state}
renderInteractiveSceneCallback={
this.renderInteractiveSceneCallback
}
handleCanvasRef={
this.handleInteractiveCanvasRef
}
onContextMenu={
this.handleCanvasContextMenu as (
event: React.PointerEvent<HTMLCanvasElement>,
) => void
}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={
this.state.viewModeEnabled
? undefined
: this.handleCanvasDoubleClick
}
/>
</>
)}
</CanvasesWrapper>
{this.renderFrameNames()}
</ExcalidrawActionManagerContext.Provider>
</ExcalidrawElementsContext.Provider>
@ -1191,17 +1192,13 @@ class App extends React.Component<AppProps, AppState> {
if (initialData?.scrollToContent) {
scene.appState = {
...scene.appState,
...calculateScrollCenter(
scene.elements,
{
...calculateScrollCenter(scene.elements, {
...scene.appState,
width: this.state.width,
height: this.state.height,
offsetTop: this.state.offsetTop,
offsetLeft: this.state.offsetLeft,
},
null,
),
}),
};
}
// FontFaceSet loadingdone event we listen on may not always fire
@ -1286,7 +1283,7 @@ class App extends React.Component<AppProps, AppState> {
if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
this.resizeObserver = new ResizeObserver(() => {
THROTTLE_NEXT_RENDER = false;
window.EXCALIDRAW_THROTTLE_NEXT_RENDER = false;
// recompute device dimensions state
// ---------------------------------------------------------------------
this.refreshDeviceState(this.excalidrawContainerRef.current!);
@ -1351,6 +1348,7 @@ class App extends React.Component<AppProps, AppState> {
this.library.destroy();
clearTimeout(touchTimeout);
isSomeElementSelected.clearCache();
selectGroups.clearCache();
touchTimeout = 0;
}
@ -1519,7 +1517,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type === "eraser" &&
prevState.theme !== this.state.theme
) {
setEraserCursor(this.canvas, this.state.theme);
setEraserCursor(this.interactiveCanvas, this.state.theme);
}
// Hide hyperlink popup if shown when element type is not selection
if (
@ -1615,7 +1613,6 @@ class App extends React.Component<AppProps, AppState> {
),
);
}
this.renderScene();
this.history.record(this.state, this.scene.getElementsIncludingDeleted());
// Do not notify consumers if we're still loading the scene. Among other
@ -1631,93 +1628,11 @@ class App extends React.Component<AppProps, AppState> {
}
}
private renderScene = () => {
const cursorButton: {
[id: string]: string | undefined;
} = {};
const pointerViewportCoords: RenderConfig["remotePointerViewportCoords"] =
{};
const remoteSelectedElementIds: RenderConfig["remoteSelectedElementIds"] =
{};
const pointerUsernames: { [id: string]: string } = {};
const pointerUserStates: { [id: string]: string } = {};
this.state.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,
},
this.state,
);
cursorButton[socketId] = user.button;
});
const renderingElements = this.scene
.getNonDeletedElements()
.filter((element) => {
if (isImageElement(element)) {
if (
// not placed on canvas yet (but in elements array)
this.state.pendingImageElementId === element.id
) {
return false;
}
}
// don't render text element that's being currently edited (it's
// rendered on remote only)
return (
!this.state.editingElement ||
this.state.editingElement.type !== "text" ||
element.id !== this.state.editingElement.id
);
});
const selectionColor = getComputedStyle(
document.querySelector(".excalidraw")!,
).getPropertyValue("--color-selection");
renderScene(
{
elements: renderingElements,
appState: this.state,
scale: window.devicePixelRatio,
rc: this.rc!,
canvas: this.canvas!,
renderConfig: {
selectionColor,
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor,
zoom: this.state.zoom,
remotePointerViewportCoords: pointerViewportCoords,
remotePointerButton: cursorButton,
remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames,
remotePointerUserStates: pointerUserStates,
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
theme: this.state.theme,
imageCache: this.imageCache,
isExporting: false,
renderScrollbars: false,
},
callback: ({ atLeastOneVisibleElement, scrollBars }) => {
private renderInteractiveSceneCallback = ({
atLeastOneVisibleElement,
scrollBars,
elements,
}: RenderInteractiveSceneCallback) => {
if (scrollBars) {
currentScrollBars = scrollBars;
}
@ -1725,20 +1640,12 @@ class App extends React.Component<AppProps, AppState> {
// hide when editing text
isTextElement(this.state.editingElement)
? false
: !atLeastOneVisibleElement && renderingElements.length > 0;
: !atLeastOneVisibleElement && elements.length > 0;
if (this.state.scrolledOutside !== scrolledOutside) {
this.setState({ scrolledOutside });
}
this.scheduleImageRefresh();
},
},
THROTTLE_NEXT_RENDER && window.EXCALIDRAW_THROTTLE_RENDER === true,
);
if (!THROTTLE_NEXT_RENDER) {
THROTTLE_NEXT_RENDER = true;
}
};
private onScroll = debounce(() => {
@ -2301,11 +2208,7 @@ class App extends React.Component<AppProps, AppState> {
scrollY = appState.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(
targetElements,
this.state,
this.canvas,
);
const scroll = calculateScrollCenter(targetElements, this.state);
scrollX = scroll.scrollX;
scrollY = scroll.scrollY;
}
@ -2694,7 +2597,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (event.key === KEYS.SPACE && gesture.pointers.size === 0) {
isHoldingSpace = true;
setCursor(this.canvas, CURSOR_TYPE.GRAB);
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
event.preventDefault();
}
@ -2758,11 +2661,11 @@ class App extends React.Component<AppProps, AppState> {
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
if (event.key === KEYS.SPACE) {
if (this.state.viewModeEnabled) {
setCursor(this.canvas, CURSOR_TYPE.GRAB);
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.activeTool.type === "selection") {
resetCursor(this.canvas);
resetCursor(this.interactiveCanvas);
} else {
setCursorForShape(this.canvas, this.state);
setCursorForShape(this.interactiveCanvas, this.state);
this.setState({
selectedElementIds: makeNextSelectedElementIds({}, this.state),
selectedGroupIds: {},
@ -2790,9 +2693,9 @@ class App extends React.Component<AppProps, AppState> {
) => {
const nextActiveTool = updateActiveTool(this.state, tool);
if (nextActiveTool.type === "hand") {
setCursor(this.canvas, CURSOR_TYPE.GRAB);
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (!isHoldingSpace) {
setCursorForShape(this.canvas, this.state);
setCursorForShape(this.interactiveCanvas, this.state);
}
if (isToolIcon(document.activeElement)) {
this.focusContainer();
@ -2816,11 +2719,11 @@ class App extends React.Component<AppProps, AppState> {
};
private setCursor = (cursor: string) => {
setCursor(this.canvas, cursor);
setCursor(this.interactiveCanvas, cursor);
};
private resetCursor = () => {
resetCursor(this.canvas);
resetCursor(this.interactiveCanvas);
};
/**
* returns whether user is making a gesture with >= 2 fingers (points)
@ -2979,7 +2882,7 @@ class App extends React.Component<AppProps, AppState> {
editingElement: null,
});
if (this.state.activeTool.locked) {
setCursorForShape(this.canvas, this.state);
setCursorForShape(this.interactiveCanvas, this.state);
}
this.focusContainer();
@ -3279,7 +3182,7 @@ class App extends React.Component<AppProps, AppState> {
}
}
resetCursor(this.canvas);
resetCursor(this.interactiveCanvas);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
@ -3313,7 +3216,7 @@ class App extends React.Component<AppProps, AppState> {
}
}
resetCursor(this.canvas);
resetCursor(this.interactiveCanvas);
if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
const container = getTextBindableContainerAtPosition(
this.scene.getNonDeletedElements(),
@ -3528,9 +3431,9 @@ class App extends React.Component<AppProps, AppState> {
const isOverScrollBar = isPointerOverScrollBars.isOverEither;
if (!this.state.draggingElement && !this.state.multiElement) {
if (isOverScrollBar) {
resetCursor(this.canvas);
resetCursor(this.interactiveCanvas);
} else {
setCursorForShape(this.canvas, this.state);
setCursorForShape(this.interactiveCanvas, this.state);
}
}
@ -3552,14 +3455,9 @@ class App extends React.Component<AppProps, AppState> {
editingLinearElement &&
editingLinearElement !== this.state.editingLinearElement
) {
// Since we are reading from previous state which is not possible with
// automatic batching in React 18 hence using flush sync to synchronously
// update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
flushSync(() => {
this.setState({
editingLinearElement,
});
});
}
if (editingLinearElement?.lastUncommittedPoint != null) {
this.maybeSuggestBindingAtCursor(scenePointer);
@ -3593,7 +3491,7 @@ class App extends React.Component<AppProps, AppState> {
const { points, lastCommittedPoint } = multiElement;
const lastPoint = points[points.length - 1];
setCursorForShape(this.canvas, this.state);
setCursorForShape(this.interactiveCanvas, this.state);
if (lastPoint === lastCommittedPoint) {
// if we haven't yet created a temp point and we're beyond commit-zone
@ -3610,7 +3508,7 @@ class App extends React.Component<AppProps, AppState> {
points: [...points, [scenePointerX - rx, scenePointerY - ry]],
});
} else {
setCursor(this.canvas, CURSOR_TYPE.POINTER);
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
// in this branch, we're inside the commit zone, and no uncommitted
// point exists. Thus do nothing (don't add/remove points).
}
@ -3624,7 +3522,7 @@ class App extends React.Component<AppProps, AppState> {
lastCommittedPoint[1],
) < LINE_CONFIRM_THRESHOLD
) {
setCursor(this.canvas, CURSOR_TYPE.POINTER);
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
mutateElement(multiElement, {
points: points.slice(0, -1),
});
@ -3654,7 +3552,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.canvas, CURSOR_TYPE.POINTER);
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
// update last uncommitted point
mutateElement(multiElement, {
@ -3702,7 +3600,7 @@ class App extends React.Component<AppProps, AppState> {
elementWithTransformHandleType.transformHandleType
) {
setCursor(
this.canvas,
this.interactiveCanvas,
getCursorForResizingElement(elementWithTransformHandleType),
);
return;
@ -3717,7 +3615,7 @@ class App extends React.Component<AppProps, AppState> {
);
if (transformHandleType) {
setCursor(
this.canvas,
this.interactiveCanvas,
getCursorForResizingElement({
transformHandleType,
}),
@ -3741,7 +3639,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id]
) {
setCursor(this.canvas, CURSOR_TYPE.POINTER);
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
showHyperlinkTooltip(this.hitLinkElement, this.state);
} else {
hideHyperlinkToolip();
@ -3755,13 +3653,13 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ showHyperlinkPopup: "info" });
} else if (this.state.activeTool.type === "text") {
setCursor(
this.canvas,
this.interactiveCanvas,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
);
} else if (this.state.viewModeEnabled) {
setCursor(this.canvas, CURSOR_TYPE.GRAB);
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (isOverScrollBar) {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if (this.state.selectedLinearElement) {
this.handleHoverSelectedLinearElement(
this.state.selectedLinearElement,
@ -3780,10 +3678,10 @@ class App extends React.Component<AppProps, AppState> {
)) &&
!hitElement?.locked
) {
setCursor(this.canvas, CURSOR_TYPE.MOVE);
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
} else {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
}
}
};
@ -3921,9 +3819,9 @@ class App extends React.Component<AppProps, AppState> {
);
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
setCursor(this.canvas, CURSOR_TYPE.POINTER);
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} else {
setCursor(this.canvas, CURSOR_TYPE.MOVE);
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
} else if (
shouldShowBoundingBox([element], this.state) &&
@ -3935,7 +3833,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointerY,
)
) {
setCursor(this.canvas, CURSOR_TYPE.MOVE);
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} else if (
boundTextElement &&
hitTest(
@ -3946,7 +3844,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointerY,
)
) {
setCursor(this.canvas, CURSOR_TYPE.MOVE);
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
if (
@ -3974,7 +3872,7 @@ class App extends React.Component<AppProps, AppState> {
});
}
} else {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
}
}
private handleCanvasPointerDown = (
@ -4128,7 +4026,7 @@ class App extends React.Component<AppProps, AppState> {
);
} else if (this.state.activeTool.type === "image") {
// reset image preview on pointerdown
setCursor(this.canvas, CURSOR_TYPE.CROSSHAIR);
setCursor(this.interactiveCanvas, CURSOR_TYPE.CROSSHAIR);
// retrieve the latest element as the state may be stale
const pendingImageElement =
@ -4158,7 +4056,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState,
);
} else if (this.state.activeTool.type === "custom") {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if (this.state.activeTool.type === "frame") {
this.createFrameElementOnPointerDown(pointerDownState);
} else if (
@ -4289,7 +4187,7 @@ class App extends React.Component<AppProps, AppState> {
let nextPastePrevented = false;
const isLinux = /Linux/.test(window.navigator.platform);
setCursor(this.canvas, CURSOR_TYPE.GRABBING);
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRABBING);
let { clientX: lastX, clientY: lastY } = event;
const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => {
const deltaX = lastX - event.clientX;
@ -4342,9 +4240,9 @@ class App extends React.Component<AppProps, AppState> {
isPanning = false;
if (!isHoldingSpace) {
if (this.state.viewModeEnabled) {
setCursor(this.canvas, CURSOR_TYPE.GRAB);
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else {
setCursorForShape(this.canvas, this.state);
setCursorForShape(this.interactiveCanvas, this.state);
}
}
this.setState({
@ -4467,7 +4365,7 @@ class App extends React.Component<AppProps, AppState> {
const onPointerUp = withBatchedUpdates(() => {
isDraggingScrollBar = false;
setCursorForShape(this.canvas, this.state);
setCursorForShape(this.interactiveCanvas, this.state);
lastPointerUp = null;
this.setState({
cursorButton: "up",
@ -4822,7 +4720,7 @@ class App extends React.Component<AppProps, AppState> {
container,
});
resetCursor(this.canvas);
resetCursor(this.interactiveCanvas);
if (!this.state.activeTool.locked) {
this.setState({
activeTool: updateActiveTool(this.state, { type: "selection" }),
@ -4984,7 +4882,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(multiElement, {
lastCommittedPoint: multiElement.points[multiElement.points.length - 1],
});
setCursor(this.canvas, CURSOR_TYPE.POINTER);
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} else {
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
@ -5876,7 +5774,7 @@ class App extends React.Component<AppProps, AppState> {
}
this.setState({ suggestedBindings: [], startBoundElement: null });
if (!activeTool.locked) {
resetCursor(this.canvas);
resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({
draggingElement: null,
activeTool: updateActiveTool(this.state, {
@ -6371,7 +6269,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (!activeTool.locked && activeTool.type !== "freedraw") {
resetCursor(this.canvas);
resetCursor(this.interactiveCanvas);
this.setState({
draggingElement: null,
suggestedBindings: [],
@ -6463,7 +6361,7 @@ class App extends React.Component<AppProps, AppState> {
}
const mimeType = imageFile.type;
setCursor(this.canvas, "wait");
setCursor(this.interactiveCanvas, "wait");
if (mimeType === MIME_TYPES.svg) {
try {
@ -6563,7 +6461,7 @@ class App extends React.Component<AppProps, AppState> {
reject(new Error(t("errors.imageInsertError")));
} finally {
if (!showCursorImagePreview) {
resetCursor(this.canvas);
resetCursor(this.interactiveCanvas);
}
}
},
@ -6632,7 +6530,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (this.state.pendingImageElementId) {
setCursor(this.canvas, `url(${previewDataURL}) 4 4, auto`);
setCursor(this.interactiveCanvas, `url(${previewDataURL}) 4 4, auto`);
}
};
@ -6912,21 +6810,40 @@ class App extends React.Component<AppProps, AppState> {
});
}
private handleInteractiveCanvasRef = (canvas: HTMLCanvasElement) => {
// canvas is null when unmounting
if (canvas !== null) {
this.interactiveCanvas = canvas;
this.interactiveCanvas.addEventListener(EVENT.WHEEL, this.handleWheel, {
passive: false,
});
this.interactiveCanvas.addEventListener(
EVENT.TOUCH_START,
this.onTapStart,
);
this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTapEnd);
} else {
this.interactiveCanvas?.removeEventListener(
EVENT.WHEEL,
this.handleWheel,
);
this.interactiveCanvas?.removeEventListener(
EVENT.TOUCH_START,
this.onTapStart,
);
this.interactiveCanvas?.removeEventListener(
EVENT.TOUCH_END,
this.onTapEnd,
);
}
};
private handleCanvasRef = (canvas: HTMLCanvasElement) => {
// canvas is null when unmounting
if (canvas !== null) {
this.canvas = canvas;
this.rc = rough.canvas(this.canvas);
this.canvas.addEventListener(EVENT.WHEEL, this.handleWheel, {
passive: false,
});
this.canvas.addEventListener(EVENT.TOUCH_START, this.onTapStart);
this.canvas.addEventListener(EVENT.TOUCH_END, this.onTapEnd);
} else {
this.canvas?.removeEventListener(EVENT.WHEEL, this.handleWheel);
this.canvas?.removeEventListener(EVENT.TOUCH_START, this.onTapStart);
this.canvas?.removeEventListener(EVENT.TOUCH_END, this.onTapEnd);
}
};
@ -7108,7 +7025,7 @@ class App extends React.Component<AppProps, AppState> {
)
: this.state),
showHyperlinkPopup: false,
},
} as AppState,
() => {
this.setState({
contextMenu: { top, left, items: this.getContextMenuItems(type) },

View File

@ -58,6 +58,7 @@ interface LayerUIProps {
appState: UIAppState;
files: BinaryFiles;
canvas: HTMLCanvasElement | null;
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(

View File

@ -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),
}));
}}
>

View File

@ -0,0 +1,25 @@
import { ReactNode } from "react";
import { useMutatedElements } from "../../hooks/useMutatedElements";
import { AppState } from "../../types";
import { NonDeletedExcalidrawElement } from "../../element/types";
import Scene from "../../scene/Scene";
type CanvasesWrapperProps = {
appState: AppState;
scene: Scene;
children: (
elements: readonly NonDeletedExcalidrawElement[],
versionNonce: number | undefined,
) => ReactNode;
};
const CanvasesWrapper = (props: CanvasesWrapperProps) => {
const [elements, versionNonce] = useMutatedElements({
appState: props.appState,
scene: props.scene,
});
return <main>{props.children(elements, versionNonce)}</main>;
};
export default CanvasesWrapper;

View File

@ -0,0 +1,187 @@
import React, { useEffect, useRef } from "react";
import { renderInteractiveScene } from "../../renderer/renderScene";
import { 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 = {
canvas: HTMLCanvasElement | null;
elements: readonly NonDeletedExcalidrawElement[];
versionNonce: number | undefined;
selectionNonce: number | undefined;
appState: InteractiveCanvasAppState;
renderInteractiveSceneCallback: (
data: RenderInteractiveSceneCallback,
) => void;
handleCanvasRef: (canvas: HTMLCanvasElement) => void;
onContextMenu: DOMAttributes<HTMLCanvasElement>["onContextMenu"];
onPointerMove: DOMAttributes<HTMLCanvasElement>["onPointerMove"];
onPointerUp: DOMAttributes<HTMLCanvasElement>["onPointerUp"];
onPointerCancel: DOMAttributes<HTMLCanvasElement>["onPointerCancel"];
onTouchMove: DOMAttributes<HTMLCanvasElement>["onTouchMove"];
onPointerDown: DOMAttributes<HTMLCanvasElement>["onPointerDown"];
onDoubleClick: DOMAttributes<HTMLCanvasElement>["onDoubleClick"];
};
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 = getComputedStyle(
document.querySelector(".excalidraw")!,
).getPropertyValue("--color-selection");
renderInteractiveScene(
{
scale: window.devicePixelRatio,
elements: props.elements,
canvas: props.canvas,
appState: props.appState,
renderConfig: {
remotePointerViewportCoords: pointerViewportCoords,
remotePointerButton: cursorButton,
remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames,
remotePointerUserStates: pointerUserStates,
selectionColor,
renderScrollbars: false,
},
callback: props.renderInteractiveSceneCallback,
},
window.EXCALIDRAW_THROTTLE_NEXT_RENDER &&
window.EXCALIDRAW_THROTTLE_RENDER === true,
);
if (!window.EXCALIDRAW_THROTTLE_NEXT_RENDER) {
window.EXCALIDRAW_THROTTLE_NEXT_RENDER = true;
}
});
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 * window.devicePixelRatio}
height={props.appState.height * window.devicePixelRatio}
ref={props.handleCanvasRef}
onContextMenu={props.onContextMenu}
onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerCancel}
onTouchMove={props.onTouchMove}
onPointerDown={props.onPointerDown}
onDoubleClick={props.onDoubleClick}
>
{t("labels.drawingCanvas")}
</canvas>
);
};
const stripIrrelevantAppStateProps = (
appState: AppState,
): Omit<InteractiveCanvasAppState, "editingElement"> => ({
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
});
const areEqual = (
prevProps: InteractiveCanvasProps,
nextProps: InteractiveCanvasProps,
) => {
// This could be further optimised if needed, as we don't have to render interactive canvas on each mutation
if (
prevProps.selectionNonce !== nextProps.selectionNonce ||
prevProps.versionNonce !== nextProps.versionNonce
) {
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
stripIrrelevantAppStateProps(prevProps.appState as AppState),
stripIrrelevantAppStateProps(nextProps.appState as AppState),
);
};
export default React.memo(InteractiveCanvas, areEqual);

View File

@ -0,0 +1,104 @@
import React, { useEffect, useRef } from "react";
import { RoughCanvas } from "roughjs/bin/canvas";
import { renderStaticScene } from "../../renderer/renderScene";
import { 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 | null;
rc: RoughCanvas | null;
elements: readonly NonDeletedExcalidrawElement[];
versionNonce: number | undefined;
selectionNonce: number | undefined;
appState: StaticCanvasAppState;
renderConfig: StaticCanvasRenderConfig;
handleCanvasRef: (canvas: HTMLCanvasElement) => void;
};
const StaticCanvas = (props: StaticCanvasProps) => {
const isComponentMounted = useRef(false);
useEffect(() => {
if (!isComponentMounted.current) {
isComponentMounted.current = true;
return;
}
renderStaticScene(
{
scale: window.devicePixelRatio,
elements: props.elements,
canvas: props.canvas,
rc: props.rc!,
appState: props.appState,
renderConfig: props.renderConfig,
},
window.EXCALIDRAW_THROTTLE_NEXT_RENDER &&
window.EXCALIDRAW_THROTTLE_RENDER === true,
);
if (!window.EXCALIDRAW_THROTTLE_NEXT_RENDER) {
window.EXCALIDRAW_THROTTLE_NEXT_RENDER = true;
}
});
return (
<canvas
className="excalidraw__canvas static"
style={{
width: props.appState.width,
height: props.appState.height,
pointerEvents: "none",
}}
width={props.appState.width * window.devicePixelRatio}
height={props.appState.height * window.devicePixelRatio}
ref={props.handleCanvasRef}
/>
);
};
const stripIrrelevantAppStateProps = (
appState: AppState,
): Omit<
StaticCanvasAppState,
| "editingElement"
| "selectedElementIds"
| "editingGroupId"
| "frameToHighlight"
> => ({
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,
});
const areEqual = (
prevProps: StaticCanvasProps,
nextProps: StaticCanvasProps,
) => {
if (prevProps.versionNonce !== nextProps.versionNonce) {
return false;
}
return isShallowEqual(
// asserting AppState because we're being passed the whole AppState
// but resolve to only the InteractiveCanvas-relevant props
stripIrrelevantAppStateProps(prevProps.appState as AppState),
stripIrrelevantAppStateProps(nextProps.appState as AppState),
);
};
export default React.memo(StaticCanvas, areEqual);

View File

@ -0,0 +1,5 @@
import CanvasesWrapper from "./CanvasesWrapper";
import InteractiveCanvas from "./InteractiveCanvas";
import StaticCanvas from "./StaticCanvas";
export { CanvasesWrapper, InteractiveCanvas, StaticCanvas };

View File

@ -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,6 +70,10 @@
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)
}

View File

@ -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,

View File

@ -293,7 +293,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;

View File

@ -474,6 +474,7 @@ const maybeCalculateNewGapWhenScaling = (
return { elementId, gap: newGap, focus };
};
// TODO: this is a bottleneck, optimise
export const getEligibleElementsForBinding = (
elements: NonDeleted<ExcalidrawElement>[],
): SuggestedBinding[] => {

View File

@ -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";
@ -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);

View File

@ -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,

View File

@ -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";
@ -277,7 +277,7 @@ export const getTransformHandles = (
export const shouldShowBoundingBox = (
elements: NonDeletedExcalidrawElement[],
appState: AppState,
appState: InteractiveCanvasAppState,
) => {
if (appState.editingLinearElement) {
return false;

View File

@ -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";
@ -469,9 +469,13 @@ export const addElementsToFrame = (
}
let nextElements = allElements.slice();
// Optimisation since findIndex on "newElements" is slow
const nextElementsIndex = nextElements.reduce((acc, element, index) => {
acc[element.id] = index;
return acc;
}, {} as Record<string, number>);
const frameBoundary = findIndex(nextElements, (e) => e.frameId === frame.id);
for (const element of omitGroupsContainingFrames(
allElements,
_elementsToAdd,
@ -485,8 +489,8 @@ export const addElementsToFrame = (
false,
);
const frameIndex = findIndex(nextElements, (e) => e.id === frame.id);
const elementIndex = findIndex(nextElements, (e) => e.id === element.id);
const frameIndex = nextElementsIndex[frame.id];
const elementIndex = nextElementsIndex[element.id];
if (elementIndex < frameBoundary) {
nextElements = [
@ -648,7 +652,7 @@ export const omitGroupsContainingFrames = (
*/
export const getTargetFrame = (
element: ExcalidrawElement,
appState: AppState,
appState: StaticCanvasAppState,
) => {
const _element = isTextElement(element)
? getContainerElement(element) || element
@ -660,11 +664,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)

1
src/global.d.ts vendored
View File

@ -17,6 +17,7 @@ interface Window {
EXCALIDRAW_ASSET_PATH: string | undefined;
EXCALIDRAW_EXPORT_SOURCE: string;
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
EXCALIDRAW_THROTTLE_NEXT_RENDER: boolean;
gtag: Function;
sa_event: Function;
fathom: { trackEvent: Function };

View File

@ -4,21 +4,28 @@ 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";
export const selectGroup = (
groupId: GroupId,
appState: AppState,
appState: InteractiveCanvasAppState,
elements: readonly NonDeleted<ExcalidrawElement>[],
): AppState => {
const elementsInGroup = elements.filter((element) =>
element.groupIds.includes(groupId),
);
): InteractiveCanvasAppState => {
const elementsInGroup = elements.reduce((acc, element) => {
if (element.groupIds.includes(groupId)) {
acc[element.id] = true;
}
return acc;
}, {} as Record<string, boolean>);
if (elementsInGroup.length < 2) {
if (Object.keys(elementsInGroup).length < 2) {
if (
appState.selectedGroupIds[groupId] ||
appState.editingGroupId === groupId
@ -37,31 +44,120 @@ export const selectGroup = (
selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: true },
selectedElementIds: {
...appState.selectedElementIds,
...Object.fromEntries(
elementsInGroup.map((element) => [element.id, true]),
),
},
...elementsInGroup,
} as AppState["selectedElementIds"],
};
};
export const selectGroups = (function () {
let lastSelectedElements: readonly NonDeleted<ExcalidrawElement>[] | null =
null;
let lastElements: readonly NonDeleted<ExcalidrawElement>[] | null = null;
let lastAppState: InteractiveCanvasAppState | null = null;
const ret = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
elements: readonly NonDeleted<ExcalidrawElement>[],
appState: InteractiveCanvasAppState,
): InteractiveCanvasAppState => {
if (
lastAppState !== undefined &&
elements === lastElements &&
selectedElements === lastSelectedElements &&
appState.editingGroupId === lastAppState?.editingGroupId &&
appState.selectedGroupIds === lastAppState?.selectedGroupIds
) {
return lastAppState;
}
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, 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;
}, {} as Record<string, boolean>);
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;
lastAppState = {
...appState,
selectedGroupIds,
selectedElementIds: {
...appState.selectedElementIds,
...selectedElementIdsInGroups,
} as AppState["selectedElementIds"],
};
return lastAppState;
};
ret.clearCache = () => {
lastElements = null;
lastSelectedElements = null;
lastAppState = null;
};
return ret;
})();
/**
* 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);
@ -71,16 +167,19 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
* you're currently editing that group.
*/
export const selectGroupsForSelectedElements = (
appState: AppState,
appState: InteractiveCanvasAppState,
elements: readonly NonDeletedExcalidrawElement[],
prevAppState: AppState,
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,
): AppState => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
): InteractiveCanvasAppState => {
let nextAppState: InteractiveCanvasAppState = {
...appState,
selectedGroupIds: {},
};
const selectedElements = app
? app.scene.getSelectedElements({
@ -101,25 +200,7 @@ export const selectGroupsForSelectedElements = (
};
}
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,
);
nextAppState = selectGroups(selectedElements, elements, appState);
return nextAppState;
};
@ -128,9 +209,12 @@ export const selectGroupsForSelectedElements = (
// 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;
@ -146,6 +230,8 @@ export const selectGroupsFromGivenElements = (
}
}
// nextAppState = selectGroups(elements, elements, appState);
return nextAppState.selectedGroupIds;
};

View File

@ -0,0 +1,42 @@
import Scene from "../scene/Scene";
import { useMemo } from "react";
import { InteractiveCanvasAppState, StaticCanvasAppState } from "../types";
import { isImageElement } from "../element/typeChecks";
import { NonDeletedExcalidrawElement } from "../element/types";
export const useMutatedElements = ({
appState,
scene,
}: {
appState: InteractiveCanvasAppState | StaticCanvasAppState;
scene: Scene;
}): [readonly NonDeletedExcalidrawElement[], number | undefined] => {
const versionNonce = scene.getVersionNonce();
const nonDeletedElements = scene.getNonDeletedElements();
const elements = useMemo(() => {
return nonDeletedElements.filter((element) => {
if (isImageElement(element)) {
if (
// not placed on canvas yet (but in elements array)
appState.pendingImageElementId === element.id
) {
return false;
}
}
// don't render text element that's being currently edited (it's
// rendered on remote only)
return (
!appState.editingElement ||
appState.editingElement.type !== "text" ||
element.id !== appState.editingElement.id
);
});
}, [
nonDeletedElements,
appState.editingElement,
appState.pendingImageElementId,
]);
return [elements, versionNonce];
};

View File

@ -26,11 +26,17 @@ import { Drawable, Options } from "roughjs/bin/core";
import { RoughSVG } from "roughjs/bin/svg";
import { RoughGenerator } from "roughjs/bin/generator";
import { RenderConfig } from "../scene/types";
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,
@ -62,17 +68,18 @@ 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
@ -89,9 +96,9 @@ const getCanvasPadding = (element: ExcalidrawElement) =>
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;
@ -155,7 +162,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")!;
@ -195,17 +203,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,
@ -256,7 +264,8 @@ const drawElementOnCanvas = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
context.globalAlpha =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
@ -310,7 +319,7 @@ const drawElementOnCanvas = (
element.height,
);
} else {
drawImagePlaceholder(element, context, renderConfig.zoom.value);
drawImagePlaceholder(element, context, appState.zoom.value);
}
break;
}
@ -715,21 +724,22 @@ const generateElementShape = (
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
) {
@ -737,6 +747,7 @@ const generateElementWithCanvas = (
element,
zoom,
renderConfig,
appState,
);
elementWithCanvasCache.set(element, elementWithCanvas);
@ -748,9 +759,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);
@ -765,8 +776,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);
@ -864,9 +875,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,
@ -884,8 +895,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,
);
@ -896,40 +907,38 @@ const drawElementFromCanvas = (
// Clear the nested element we appended to the DOM
};
export const renderElement = (
export const renderSelectionElement = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
appState: AppState,
appState: InteractiveCanvasAppState,
) => {
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.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 / renderConfig.zoom.value;
const offset = 0.5 / appState.zoom.value;
context.fillRect(offset, offset, element.width, element.height);
context.lineWidth = 1 / renderConfig.zoom.value;
context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = " rgb(105, 101, 219)";
context.strokeRect(offset, offset, element.width, element.height);
context.restore();
}
break;
}
};
export const renderElement = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
const generator = rc.generator;
switch (element.type) {
case "frame": {
if (
!renderConfig.isExporting &&
@ -938,12 +947,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) {
@ -953,7 +962,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();
@ -970,22 +979,28 @@ export const renderElement = (
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;
@ -1000,8 +1015,8 @@ export const renderElement = (
generateElementShape(element, generator);
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)) {
@ -1019,7 +1034,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);
@ -1053,7 +1068,13 @@ export const renderElement = (
tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
drawElementOnCanvas(
element,
tempRc,
tempCanvasContext,
renderConfig,
appState,
);
tempCanvasContext.translate(shiftX, shiftY);
@ -1090,7 +1111,7 @@ export const renderElement = (
}
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
drawElementOnCanvas(element, rc, context, renderConfig, appState);
}
context.restore();
@ -1100,6 +1121,7 @@ export const renderElement = (
const elementWithCanvas = generateElementWithCanvas(
element,
renderConfig,
appState,
);
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
@ -1107,7 +1129,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
@ -1124,7 +1146,12 @@ export const renderElement = (
context.imageSmoothingEnabled = false;
}
drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
drawElementFromCanvas(
elementWithCanvas,
context,
renderConfig,
appState,
);
// reset
context.imageSmoothingEnabled = currentImageSmoothingStatus;

View File

@ -1,8 +1,14 @@
import { RoughCanvas } from "roughjs/bin/canvas";
import { RoughSVG } from "roughjs/bin/svg";
import oc from "open-color";
import { AppState, BinaryFiles, Point, Zoom } from "../types";
import {
InteractiveCanvasAppState,
StaticCanvasAppState,
BinaryFiles,
Point,
Zoom,
CommonCanvasAppState,
} from "../types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
@ -22,7 +28,12 @@ import {
} from "../element";
import { roundRect } from "./roundRect";
import { RenderConfig } from "../scene/types";
import {
InteractiveCanvasRenderConfig,
InteractiveSceneRenderConfig,
StaticCanvasRenderConfig,
StaticSceneRenderConfig,
} from "../scene/types";
import {
getScrollBars,
SCROLLBAR_COLOR,
@ -30,7 +41,11 @@ import {
} from "../scene/scrollbars";
import { getSelectedElements } from "../scene/selection";
import { renderElement, renderElementToSvg } from "./renderElement";
import {
renderElement,
renderElementToSvg,
renderSelectionElement,
} from "./renderElement";
import { getClientColor } from "../clients";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
@ -40,11 +55,7 @@ import {
selectGroupsFromGivenElements,
} from "../groups";
import { maxBindingGap } from "../element/collision";
import {
SuggestedBinding,
SuggestedPointBinding,
isBindingEnabled,
} from "../element/binding";
import { SuggestedBinding, SuggestedPointBinding } from "../element/binding";
import {
OMIT_SIDES_FOR_FRAME,
shouldShowBoundingBox,
@ -176,7 +187,7 @@ const strokeGrid = (
const renderSingleLinearPoint = (
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
appState: InteractiveCanvasAppState,
point: Point,
radius: number,
isSelected: boolean,
@ -195,23 +206,22 @@ const renderSingleLinearPoint = (
context,
point[0],
point[1],
radius / renderConfig.zoom.value,
radius / appState.zoom.value,
!isPhantomPoint,
);
};
const renderLinearPointHandles = (
context: CanvasRenderingContext2D,
appState: AppState,
renderConfig: RenderConfig,
appState: InteractiveCanvasAppState,
element: NonDeleted<ExcalidrawLinearElement>,
) => {
if (!appState.selectedLinearElement) {
return;
}
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
context.lineWidth = 1 / renderConfig.zoom.value;
context.translate(appState.scrollX, appState.scrollY);
context.lineWidth = 1 / appState.zoom.value;
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
const { POINT_HANDLE_SIZE } = LinearElementEditor;
@ -222,7 +232,7 @@ const renderLinearPointHandles = (
const isSelected =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
renderSingleLinearPoint(context, renderConfig, point, radius, isSelected);
renderSingleLinearPoint(context, appState, point, radius, isSelected);
});
//Rendering segment mid points
@ -246,17 +256,17 @@ const renderLinearPointHandles = (
if (appState.editingLinearElement) {
renderSingleLinearPoint(
context,
renderConfig,
appState,
segmentMidPoint,
radius,
false,
);
highlightPoint(segmentMidPoint, context, renderConfig);
highlightPoint(segmentMidPoint, context, appState);
} else {
highlightPoint(segmentMidPoint, context, renderConfig);
highlightPoint(segmentMidPoint, context, appState);
renderSingleLinearPoint(
context,
renderConfig,
appState,
segmentMidPoint,
radius,
false,
@ -265,7 +275,7 @@ const renderLinearPointHandles = (
} else if (appState.editingLinearElement || points.length === 2) {
renderSingleLinearPoint(
context,
renderConfig,
appState,
segmentMidPoint,
POINT_HANDLE_SIZE / 2,
false,
@ -280,7 +290,7 @@ const renderLinearPointHandles = (
const highlightPoint = (
point: Point,
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
appState: InteractiveCanvasAppState,
) => {
context.fillStyle = "rgba(105, 101, 219, 0.4)";
@ -288,14 +298,13 @@ const highlightPoint = (
context,
point[0],
point[1],
LinearElementEditor.POINT_HANDLE_SIZE / renderConfig.zoom.value,
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value,
false,
);
};
const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D,
appState: AppState,
renderConfig: RenderConfig,
appState: InteractiveCanvasAppState,
) => {
const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
if (
@ -314,21 +323,19 @@ const renderLinearElementPointHighlight = (
hoverPointIndex,
);
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
context.translate(appState.scrollX, appState.scrollY);
highlightPoint(point, context, renderConfig);
highlightPoint(point, context, appState);
context.restore();
};
const frameClip = (
frame: ExcalidrawFrameElement,
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
context.translate(
frame.x + renderConfig.scrollX,
frame.y + renderConfig.scrollY,
);
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
context.beginPath();
if (context.roundRect && !renderConfig.isExporting) {
context.roundRect(
@ -336,216 +343,156 @@ const frameClip = (
0,
frame.width,
frame.height,
FRAME_STYLE.radius / renderConfig.zoom.value,
FRAME_STYLE.radius / appState.zoom.value,
);
} else {
context.rect(0, 0, frame.width, frame.height);
}
context.clip();
context.translate(
-(frame.x + renderConfig.scrollX),
-(frame.y + renderConfig.scrollY),
-(frame.x + appState.scrollX),
-(frame.y + appState.scrollY),
);
};
export const _renderScene = ({
elements,
appState,
scale,
rc,
const getNormalizedCanvasDimensions = (
canvas: HTMLCanvasElement,
scale: number,
): [number, number] => {
// When doing calculations based on canvas width we should used normalized one
return [canvas.width / scale, canvas.height / scale];
};
const bootstrapCanvas = ({
canvas,
renderConfig,
}: {
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
scale: number;
rc: RoughCanvas;
canvas: HTMLCanvasElement;
renderConfig: RenderConfig;
}) =>
// extra options passed to the renderer
{
if (canvas === null) {
return { atLeastOneVisibleElement: false };
}
const {
renderScrollbars = false,
renderSelection = true,
renderGrid = true,
scale,
zoom,
normalizedWidth,
normalizedHeight,
theme,
isExporting,
} = renderConfig;
const selectionColor = renderConfig.selectionColor || oc.black;
viewBackgroundColor,
}: {
canvas: HTMLCanvasElement;
scale: number;
zoom: CommonCanvasAppState["zoom"];
normalizedWidth: number;
normalizedHeight: number;
theme?: CommonCanvasAppState["theme"];
isExporting?: StaticCanvasRenderConfig["isExporting"];
viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"];
}): CanvasRenderingContext2D => {
const context = canvas.getContext("2d")!;
context.setTransform(1, 0, 0, 1, 0, 0);
context.save();
context.scale(scale, scale);
// When doing calculations based on canvas width we should used normalized one
const normalizedCanvasWidth = canvas.width / scale;
const normalizedCanvasHeight = canvas.height / scale;
if (isExporting && renderConfig.theme === "dark") {
if (isExporting && theme === "dark") {
context.filter = THEME_FILTER;
}
// Paint background
if (typeof renderConfig.viewBackgroundColor === "string") {
if (typeof viewBackgroundColor === "string") {
const hasTransparence =
renderConfig.viewBackgroundColor === "transparent" ||
renderConfig.viewBackgroundColor.length === 5 || // #RGBA
renderConfig.viewBackgroundColor.length === 9 || // #RRGGBBA
/(hsla|rgba)\(/.test(renderConfig.viewBackgroundColor);
viewBackgroundColor === "transparent" ||
viewBackgroundColor.length === 5 || // #RGBA
viewBackgroundColor.length === 9 || // #RRGGBBA
/(hsla|rgba)\(/.test(viewBackgroundColor);
if (hasTransparence) {
context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.save();
context.fillStyle = renderConfig.viewBackgroundColor;
context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
context.fillStyle = viewBackgroundColor;
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
} else {
context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
// Apply zoom
context.save();
context.scale(renderConfig.zoom.value, renderConfig.zoom.value);
context.scale(zoom.value, zoom.value);
// Grid
if (renderGrid && appState.gridSize) {
strokeGrid(
context,
appState.gridSize,
-Math.ceil(renderConfig.zoom.value / appState.gridSize) *
appState.gridSize +
(renderConfig.scrollX % appState.gridSize),
-Math.ceil(renderConfig.zoom.value / appState.gridSize) *
appState.gridSize +
(renderConfig.scrollY % appState.gridSize),
normalizedCanvasWidth / renderConfig.zoom.value,
normalizedCanvasHeight / renderConfig.zoom.value,
);
return context;
};
const _renderInteractiveScene = ({
elements,
appState,
scale,
canvas,
renderConfig,
}: InteractiveSceneRenderConfig) => {
if (canvas === null) {
return { atLeastOneVisibleElement: false, elements };
}
// Paint visible elements
const visibleElements = elements.filter((element) =>
isVisibleElement(element, normalizedCanvasWidth, normalizedCanvasHeight, {
zoom: renderConfig.zoom,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
scrollX: renderConfig.scrollX,
scrollY: renderConfig.scrollY,
}),
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
const groupsToBeAddedToFrame = new Set<string>();
visibleElements.forEach((element) => {
if (
element.groupIds.length > 0 &&
appState.frameToHighlight &&
appState.selectedElementIds[element.id] &&
(elementOverlapsWithFrame(element, appState.frameToHighlight) ||
element.groupIds.find((groupId) =>
groupsToBeAddedToFrame.has(groupId),
))
) {
element.groupIds.forEach((groupId) =>
groupsToBeAddedToFrame.add(groupId),
);
}
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
zoom: appState.zoom,
});
let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
undefined;
// FIXME I: memo?
const visibleElements = elements.filter((element) =>
isVisibleElement(element, normalizedWidth, normalizedHeight, {
zoom: appState.zoom,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
scrollX: appState.scrollX,
scrollY: appState.scrollY,
}),
);
visibleElements.forEach((element) => {
try {
// - when exporting the whole canvas, we DO NOT apply clipping
// - when we are exporting a particular frame, apply clipping
// if the containing frame is not selected, apply clipping
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
frameId &&
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
(!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.clip))
) {
context.save();
const frame = getTargetFrame(element, appState);
if (frame && isElementInFrame(element, elements, appState)) {
frameClip(frame, context, renderConfig);
}
renderElement(element, rc, context, renderConfig, appState);
context.restore();
} else {
renderElement(element, rc, context, renderConfig, appState);
}
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
// ShapeCache returns empty hence making sure that we get the
// correct element from visible elements
if (appState.editingLinearElement?.elementId === element.id) {
if (element) {
editingLinearElement =
element as NonDeleted<ExcalidrawLinearElement>;
editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
}
}
if (!isExporting) {
renderLinkIcon(element, context, appState);
}
} catch (error: any) {
console.error(error);
}
});
if (editingLinearElement) {
renderLinearPointHandles(
context,
appState,
renderConfig,
editingLinearElement,
);
renderLinearPointHandles(context, appState, editingLinearElement);
}
// Paint selection element
if (appState.selectionElement) {
try {
renderElement(
appState.selectionElement,
rc,
context,
renderConfig,
appState,
);
renderSelectionElement(appState.selectionElement, context, appState);
} catch (error: any) {
console.error(error);
}
}
if (isBindingEnabled(appState)) {
if (appState.isBindingEnabled) {
appState.suggestedBindings
.filter((binding) => binding != null)
.forEach((suggestedBinding) => {
renderBindingHighlight(context, renderConfig, suggestedBinding!);
renderBindingHighlight(context, appState, suggestedBinding!);
});
}
if (appState.frameToHighlight) {
renderFrameHighlight(context, renderConfig, appState.frameToHighlight);
renderFrameHighlight(context, appState, appState.frameToHighlight);
}
if (appState.elementsToHighlight) {
renderElementsBoxHighlight(
context,
renderConfig,
appState.elementsToHighlight,
appState,
);
renderElementsBoxHighlight(context, appState, appState.elementsToHighlight);
}
const locallySelectedElements = getSelectedElements(elements, appState);
@ -563,7 +510,6 @@ export const _renderScene = ({
renderLinearPointHandles(
context,
appState,
renderConfig,
locallySelectedElements[0] as NonDeleted<ExcalidrawLinearElement>,
);
}
@ -572,22 +518,15 @@ export const _renderScene = ({
appState.selectedLinearElement &&
appState.selectedLinearElement.hoverPointIndex >= 0
) {
renderLinearElementPointHighlight(context, appState, renderConfig);
renderLinearElementPointHighlight(context, appState);
}
// Paint selected elements
if (
renderSelection &&
!appState.multiElement &&
!appState.editingLinearElement
) {
if (!appState.multiElement && !appState.editingLinearElement) {
const showBoundingBox = shouldShowBoundingBox(
locallySelectedElements,
appState,
);
const locallySelectedIds = locallySelectedElements.map(
(element) => element.id,
);
const isSingleLinearElementSelected =
locallySelectedElements.length === 1 &&
isLinearElement(locallySelectedElements[0]);
@ -601,16 +540,26 @@ export const _renderScene = ({
renderLinearPointHandles(
context,
appState,
renderConfig,
locallySelectedElements[0] as ExcalidrawLinearElement,
);
}
const selectionColor = renderConfig.selectionColor || oc.black;
if (showBoundingBox) {
// Optimisation for finding quickly relevant element ids
const locallySelectedIds = locallySelectedElements.reduce(
(acc, element) => {
acc[element.id] = true;
return acc;
},
{} as Record<string, boolean>,
);
const selections = elements.reduce((acc, element) => {
const selectionColors = [];
// local user
if (
locallySelectedIds.includes(element.id) &&
locallySelectedIds[element.id] &&
!isSelectedViaGroup(appState, element)
) {
selectionColors.push(selectionColor);
@ -619,7 +568,7 @@ export const _renderScene = ({
if (renderConfig.remoteSelectedElementIds[element.id]) {
selectionColors.push(
...renderConfig.remoteSelectedElementIds[element.id].map(
(socketId) => {
(socketId: string) => {
const background = getClientColor(socketId);
return background;
},
@ -672,37 +621,37 @@ export const _renderScene = ({
}
selections.forEach((selection) =>
renderSelectionBorder(context, renderConfig, selection),
renderSelectionBorder(context, appState, selection),
);
}
// Paint resize transformHandles
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
context.translate(appState.scrollX, appState.scrollY);
if (locallySelectedElements.length === 1) {
context.fillStyle = oc.white;
const transformHandles = getTransformHandles(
locallySelectedElements[0],
renderConfig.zoom,
appState.zoom,
"mouse", // when we render we don't know which pointer type so use mouse
);
if (!appState.viewModeEnabled && showBoundingBox) {
renderTransformHandles(
context,
renderConfig,
appState,
transformHandles,
locallySelectedElements[0].angle,
);
}
} else if (locallySelectedElements.length > 1 && !appState.isRotating) {
const dashedLinePadding =
(DEFAULT_SPACING * 2) / renderConfig.zoom.value;
const dashedLinePadding = (DEFAULT_SPACING * 2) / appState.zoom.value;
context.fillStyle = oc.white;
const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
const initialLineDash = context.getLineDash();
context.setLineDash([2 / renderConfig.zoom.value]);
context.setLineDash([2 / appState.zoom.value]);
const lineWidth = context.lineWidth;
context.lineWidth = 1 / renderConfig.zoom.value;
context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = selectionColor;
strokeRectWithRotation(
context,
@ -719,14 +668,20 @@ export const _renderScene = ({
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
renderConfig.zoom,
appState.zoom,
"mouse",
isFrameSelected
? OMIT_SIDES_FOR_FRAME
: OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
);
if (locallySelectedElements.some((element) => !element.locked)) {
renderTransformHandles(context, renderConfig, transformHandles, 0);
renderTransformHandles(
context,
renderConfig,
appState,
transformHandles,
0,
);
}
}
context.restore();
@ -747,14 +702,14 @@ export const _renderScene = ({
const isOutOfBounds =
x < 0 ||
x > normalizedCanvasWidth - width ||
x > normalizedWidth - width ||
y < 0 ||
y > normalizedCanvasHeight - height;
y > normalizedHeight - height;
x = Math.max(x, 0);
x = Math.min(x, normalizedCanvasWidth - width);
x = Math.min(x, normalizedWidth - width);
y = Math.max(y, 0);
y = Math.min(y, normalizedCanvasHeight - height);
y = Math.min(y, normalizedHeight - height);
const background = getClientColor(clientId);
@ -875,12 +830,12 @@ export const _renderScene = ({
// Paint scrollbars
let scrollBars;
if (renderScrollbars) {
if (renderConfig.renderScrollbars) {
scrollBars = getScrollBars(
elements,
normalizedCanvasWidth,
normalizedCanvasHeight,
renderConfig,
normalizedWidth,
normalizedHeight,
appState,
);
context.save();
@ -902,52 +857,172 @@ export const _renderScene = ({
}
context.restore();
return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
context.restore();
return {
scrollBars,
atLeastOneVisibleElement: visibleElements.length > 0,
elements,
};
};
const renderSceneThrottled = throttleRAF(
(config: {
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
scale: number;
rc: RoughCanvas;
canvas: HTMLCanvasElement;
renderConfig: RenderConfig;
callback?: (data: ReturnType<typeof _renderScene>) => void;
}) => {
const ret = _renderScene(config);
const _renderStaticScene = ({
elements,
appState,
scale,
rc,
canvas,
renderConfig,
}: StaticSceneRenderConfig) => {
if (canvas === null) {
return;
}
const { renderGrid = true, isExporting } = renderConfig;
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
zoom: appState.zoom,
theme: appState.theme,
isExporting,
viewBackgroundColor: appState.viewBackgroundColor,
});
// Grid
if (renderGrid && appState.gridSize) {
strokeGrid(
context,
appState.gridSize,
-Math.ceil(appState.zoom.value / appState.gridSize) * appState.gridSize +
(appState.scrollX % appState.gridSize),
-Math.ceil(appState.zoom.value / appState.gridSize) * appState.gridSize +
(appState.scrollY % appState.gridSize),
normalizedWidth / appState.zoom.value,
normalizedHeight / appState.zoom.value,
);
}
// Paint visible elements
const visibleElements = elements.filter((element) =>
isVisibleElement(element, normalizedWidth, normalizedHeight, {
zoom: appState.zoom,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
scrollX: appState.scrollX,
scrollY: appState.scrollY,
}),
);
const groupsToBeAddedToFrame = new Set<string>();
visibleElements.forEach((element) => {
if (
element.groupIds.length > 0 &&
appState.frameToHighlight &&
appState.selectedElementIds[element.id] &&
(elementOverlapsWithFrame(element, appState.frameToHighlight) ||
element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId)))
) {
element.groupIds.forEach((groupId) =>
groupsToBeAddedToFrame.add(groupId),
);
}
});
visibleElements.forEach((element) => {
try {
// - when exporting the whole canvas, we DO NOT apply clipping
// - when we are exporting a particular frame, apply clipping
// if the containing frame is not selected, apply clipping
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
frameId &&
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
(!renderConfig.isExporting &&
appState.frameRendering.enabled &&
appState.frameRendering.clip))
) {
context.save();
const frame = getTargetFrame(element, appState);
if (frame && isElementInFrame(element, elements, appState)) {
frameClip(frame, context, renderConfig, appState);
}
renderElement(element, rc, context, renderConfig, appState);
context.restore();
} else {
renderElement(element, rc, context, renderConfig, appState);
}
if (!isExporting) {
renderLinkIcon(element, context, appState);
}
} catch (error: any) {
console.error(error);
}
});
context.restore();
};
export const renderInteractiveScene = <
U extends typeof _renderInteractiveScene,
T extends boolean = false,
>(
renderConfig: InteractiveSceneRenderConfig,
throttle?: T,
): T extends true ? void : ReturnType<U> => {
if (throttle) {
/** throttled to animation framerate */
const renderFuncThrottled = throttleRAF(
(config) => {
const ret = _renderInteractiveScene(config);
config.callback?.(ret);
},
{ trailing: true },
);
/** renderScene throttled to animation framerate */
export const renderScene = <T extends boolean = false>(
config: {
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
scale: number;
rc: RoughCanvas;
canvas: HTMLCanvasElement;
renderConfig: RenderConfig;
callback?: (data: ReturnType<typeof _renderScene>) => void;
},
/** Whether to throttle rendering. Defaults to false.
* When throttling, no value is returned. Use the callback instead. */
throttle?: T,
): T extends true ? void : ReturnType<typeof _renderScene> => {
if (throttle) {
renderSceneThrottled(config);
return undefined as T extends true ? void : ReturnType<typeof _renderScene>;
renderFuncThrottled(renderConfig);
return undefined as T extends true ? void : ReturnType<U>;
}
const ret = _renderScene(config);
config.callback?.(ret);
return ret as T extends true ? void : ReturnType<typeof _renderScene>;
const ret = _renderInteractiveScene(renderConfig);
renderConfig.callback(ret);
return ret as T extends true ? void : ReturnType<U>;
};
export const renderStaticScene = (
renderConfig: StaticSceneRenderConfig,
throttle?: boolean,
) => {
if (throttle) {
/** throttled to animation framerate */
const renderFuncThrottled = throttleRAF(
(config) => {
_renderStaticScene(config);
},
{ trailing: true },
);
renderFuncThrottled(renderConfig);
return;
}
_renderStaticScene(renderConfig);
};
const renderTransformHandles = (
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
renderConfig: InteractiveCanvasRenderConfig,
appState: InteractiveCanvasAppState,
transformHandles: TransformHandles,
angle: number,
): void => {
@ -957,7 +1032,7 @@ const renderTransformHandles = (
const [x, y, width, height] = transformHandle;
context.save();
context.lineWidth = 1 / renderConfig.zoom.value;
context.lineWidth = 1 / appState.zoom.value;
if (renderConfig.selectionColor) {
context.strokeStyle = renderConfig.selectionColor;
}
@ -966,7 +1041,7 @@ const renderTransformHandles = (
// prefer round corners if roundRect API is available
} else if (context.roundRect) {
context.beginPath();
context.roundRect(x, y, width, height, 2 / renderConfig.zoom.value);
context.roundRect(x, y, width, height, 2 / appState.zoom.value);
context.fill();
context.stroke();
} else {
@ -989,7 +1064,7 @@ const renderTransformHandles = (
const renderSelectionBorder = (
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
appState: InteractiveCanvasAppState,
elementProperties: {
angle: number;
elementX1: number;
@ -1017,13 +1092,13 @@ const renderSelectionBorder = (
const elementWidth = elementX2 - elementX1;
const elementHeight = elementY2 - elementY1;
const linePadding = padding / renderConfig.zoom.value;
const lineWidth = 8 / renderConfig.zoom.value;
const spaceWidth = 4 / renderConfig.zoom.value;
const linePadding = padding / appState.zoom.value;
const lineWidth = 8 / appState.zoom.value;
const spaceWidth = 4 / appState.zoom.value;
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
context.lineWidth = 1 / renderConfig.zoom.value;
context.translate(appState.scrollX, appState.scrollY);
context.lineWidth = 1 / appState.zoom.value;
const count = selectionColors.length;
for (let index = 0; index < count; ++index) {
@ -1051,7 +1126,7 @@ const renderSelectionBorder = (
const renderBindingHighlight = (
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
appState: InteractiveCanvasAppState,
suggestedBinding: SuggestedBinding,
) => {
const renderHighlight = Array.isArray(suggestedBinding)
@ -1059,7 +1134,7 @@ const renderBindingHighlight = (
: renderBindingHighlightForBindableElement;
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
context.translate(appState.scrollX, appState.scrollY);
renderHighlight(context, suggestedBinding as any);
context.restore();
@ -1124,7 +1199,7 @@ const renderBindingHighlightForBindableElement = (
const renderFrameHighlight = (
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
appState: InteractiveCanvasAppState,
frame: NonDeleted<ExcalidrawFrameElement>,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
@ -1132,10 +1207,10 @@ const renderFrameHighlight = (
const height = y2 - y1;
context.strokeStyle = "rgb(0,118,255)";
context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / renderConfig.zoom.value;
context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / appState.zoom.value;
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
context.translate(appState.scrollX, appState.scrollY);
strokeRectWithRotation(
context,
x1,
@ -1146,16 +1221,15 @@ const renderFrameHighlight = (
y1 + height / 2,
frame.angle,
false,
FRAME_STYLE.radius / renderConfig.zoom.value,
FRAME_STYLE.radius / appState.zoom.value,
);
context.restore();
};
const renderElementsBoxHighlight = (
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
appState: InteractiveCanvasAppState,
elements: NonDeleted<ExcalidrawElement>[],
appState: AppState,
) => {
const individualElements = elements.filter(
(element) => element.groupIds.length === 0,
@ -1194,7 +1268,7 @@ const renderElementsBoxHighlight = (
individualElements.map((element) => getSelectionFromElements([element])),
)
.forEach((selection) =>
renderSelectionBorder(context, renderConfig, selection),
renderSelectionBorder(context, appState, selection),
);
};
@ -1228,7 +1302,7 @@ let linkCanvasCache: any;
const renderLinkIcon = (
element: NonDeletedExcalidrawElement,
context: CanvasRenderingContext2D,
appState: AppState,
appState: StaticCanvasAppState,
) => {
if (element.link && !appState.selectedElementIds[element.id]) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);

View File

@ -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();
}

View File

@ -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,21 @@ export const exportToCanvas = async (
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
renderScene({
renderStaticScene({
elements,
appState,
appState: {
...appState,
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
zoom: defaultAppState.zoom,
shouldCacheIgnoreZoom: false,
theme: appState.exportWithDarkMode ? "dark" : "light",
},
scale,
rc: rough.canvas(canvas),
canvas,
renderConfig: {
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",
imageCache,
renderScrollbars: false,
renderSelection: false,
renderGrid: false,
isExporting: true,
},

View File

@ -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(

View File

@ -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;

View File

@ -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;

View File

@ -1,33 +1,60 @@
import { ExcalidrawTextElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { RoughCanvas } from "roughjs/bin/canvas";
import {
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
AppClassProperties,
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 */
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 = {
elements: readonly NonDeletedExcalidrawElement[];
scale: number;
canvas: HTMLCanvasElement | null;
rc: RoughCanvas;
appState: StaticCanvasAppState;
renderConfig: StaticCanvasRenderConfig;
};
export type InteractiveSceneRenderConfig = {
elements: readonly NonDeletedExcalidrawElement[];
canvas: HTMLCanvasElement | null;
scale: number;
appState: InteractiveCanvasAppState;
renderConfig: InteractiveCanvasRenderConfig;
callback: (data: RenderInteractiveSceneCallback) => void;
};
export type SceneScroll = {

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,7 @@ Object {
"roundness": Object {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -42,7 +42,7 @@ Object {
"type": "arrow",
"updated": 1,
"version": 3,
"versionNonce": 449462985,
"versionNonce": 401146281,
"width": 30,
"x": 30,
"y": 20,
@ -69,14 +69,14 @@ Object {
"roundness": Object {
"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 @@ Object {
"roundness": Object {
"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 @@ Object {
"roundness": Object {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -157,7 +157,7 @@ Object {
"type": "line",
"updated": 1,
"version": 3,
"versionNonce": 449462985,
"versionNonce": 401146281,
"width": 30,
"x": 30,
"y": 20,
@ -184,14 +184,14 @@ Object {
"roundness": Object {
"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,

View File

@ -18,14 +18,14 @@ Object {
"roundness": Object {
"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 @@ Object {
"roundness": Object {
"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 @@ Object {
"roundness": Object {
"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 @@ Object {
"roundness": Object {
"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 @@ Object {
"roundness": Object {
"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 @@ Object {
"roundness": Object {
"type": 2,
},
"seed": 401146281,
"seed": 238820263,
"startArrowhead": null,
"startBinding": Object {
"elementId": "id0",
@ -218,7 +218,7 @@ Object {
"type": "line",
"updated": 1,
"version": 11,
"versionNonce": 1006504105,
"versionNonce": 1051383431,
"width": 81,
"x": 110,
"y": 49.981789081137734,

View File

@ -38,7 +38,7 @@ Object {
"roundness": Object {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -47,7 +47,7 @@ Object {
"type": "arrow",
"updated": 1,
"version": 7,
"versionNonce": 1150084233,
"versionNonce": 1505387817,
"width": 70,
"x": 30,
"y": 30,
@ -92,7 +92,7 @@ Object {
"roundness": Object {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -101,7 +101,7 @@ Object {
"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

View File

@ -31,7 +31,7 @@ Object {
"roundness": Object {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -40,7 +40,7 @@ Object {
"type": "arrow",
"updated": 1,
"version": 3,
"versionNonce": 449462985,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
@ -78,7 +78,7 @@ Object {
"roundness": Object {
"type": 2,
},
"seed": 337897,
"seed": 1278240551,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
@ -87,7 +87,7 @@ Object {
"type": "line",
"updated": 1,
"version": 3,
"versionNonce": 449462985,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
@ -112,14 +112,14 @@ Object {
"roundness": Object {
"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 @@ Object {
"roundness": Object {
"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 @@ Object {
"roundness": Object {
"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,

View File

@ -23,7 +23,7 @@ import { setDateTimeForTests } from "../utils";
import { LibraryItem } from "../types";
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`);
@ -39,10 +39,10 @@ const mouse = new Pointer("mouse");
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene");
const renderStaticScene = jest.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
@ -51,7 +51,7 @@ const { h } = window;
describe("contextMenu element", () => {
beforeEach(async () => {
localStorage.clear();
renderScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
@ -74,7 +74,7 @@ describe("contextMenu element", () => {
});
it("shows context menu for canvas", () => {
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -104,7 +104,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,
@ -158,7 +158,7 @@ describe("contextMenu element", () => {
API.setSelectedElements([rect1]);
// lower z-index
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 100,
clientY: 100,
@ -168,7 +168,7 @@ describe("contextMenu element", () => {
// higher z-index
API.setSelectedElements([rect2]);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 100,
clientY: 100,
@ -192,7 +192,7 @@ describe("contextMenu element", () => {
mouse.click(20, 0);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -245,7 +245,7 @@ describe("contextMenu element", () => {
Keyboard.keyPress(KEYS.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -284,7 +284,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,
@ -332,7 +332,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,
@ -345,7 +345,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,
@ -369,7 +369,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,
@ -385,7 +385,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,
@ -406,7 +406,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,
@ -429,7 +429,7 @@ describe("contextMenu element", () => {
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 40,
clientY: 40,
@ -451,7 +451,7 @@ describe("contextMenu element", () => {
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 10,
clientY: 10,
@ -473,7 +473,7 @@ describe("contextMenu element", () => {
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 40,
clientY: 40,
@ -494,7 +494,7 @@ describe("contextMenu element", () => {
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 10,
clientY: 10,
@ -519,7 +519,7 @@ describe("contextMenu element", () => {
mouse.click(10, 10);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -549,7 +549,7 @@ describe("contextMenu element", () => {
Keyboard.keyPress(KEYS.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,

View File

@ -14,10 +14,13 @@ import { reseed } from "../random";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene");
const renderInteractiveScene = jest.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = jest.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
@ -31,7 +34,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 });
@ -42,7 +45,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(4);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -62,7 +66,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 });
@ -73,7 +77,9 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(4);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -93,7 +99,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 });
@ -104,7 +110,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(4);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -124,7 +131,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 });
@ -135,7 +142,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -159,7 +167,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 });
@ -170,7 +178,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(9);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@ -202,7 +211,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 });
@ -210,7 +219,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(4);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@ -221,7 +231,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 });
@ -229,7 +239,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(4);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@ -240,7 +251,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 });
@ -248,7 +259,8 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(7);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(4);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@ -259,7 +271,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 });
@ -272,7 +284,8 @@ describe("Test dragCreate", () => {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(8);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@ -283,7 +296,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 });
@ -296,7 +309,8 @@ describe("Test dragCreate", () => {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(8);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});

View File

@ -268,7 +268,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();
@ -295,6 +295,6 @@ export class API {
},
},
});
fireEvent(GlobalTestState.canvas, fileDropEvent);
fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
};
}

View File

@ -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",

View File

@ -25,7 +25,9 @@ import {
import * as textElementUtils from "../element/textElement";
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
const renderScene = jest.spyOn(Renderer, "renderScene");
// FIXME I: add specific tests for render of both components (when they should both, when just one, when just second, what is the order of renders - first static, then interactive, etc., all tests)
const renderInteractiveScene = jest.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = jest.spyOn(Renderer, "renderStaticScene");
const { h } = window;
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
@ -33,18 +35,23 @@ 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 />);
container = comp.container;
canvas = container.querySelector("canvas")!;
canvas = container.querySelector("canvas.static")!;
canvas.width = 1000;
canvas.height = 1000;
interactiveCanvas = container.querySelector("canvas.interactive")!;
interactiveCanvas.width = 1000;
interactiveCanvas.height = 1000;
});
const p1: Point = [20, 20];
@ -119,26 +126,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],
});
@ -171,12 +178,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(3);
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(4);
expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(`
Array [
@ -198,14 +207,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],
@ -215,13 +224,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],
@ -269,7 +278,8 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
expect(renderScene).toHaveBeenCalledTimes(15);
expect(renderInteractiveScene).toHaveBeenCalledTimes(12);
expect(renderStaticScene).toHaveBeenCalledTimes(4);
expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(`
@ -306,7 +316,9 @@ describe("Test Linear Elements", () => {
// update roundness
fireEvent.click(screen.getByTitle("Round"));
expect(renderScene).toHaveBeenCalledTimes(12);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
h.elements[0] as ExcalidrawLinearElement,
h.state,
@ -350,7 +362,9 @@ describe("Test Linear Elements", () => {
// Move the element
drag(startPoint, endPoint);
expect(renderScene).toHaveBeenCalledTimes(16);
expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect([line.x, line.y]).toEqual([
points[0][0] + deltaX,
points[0][1] + deltaY,
@ -407,7 +421,9 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[1] + delta,
]);
expect(renderScene).toHaveBeenCalledTimes(21);
expect(renderInteractiveScene).toHaveBeenCalledTimes(17);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(line.points.length).toEqual(5);
expect((h.elements[0] as ExcalidrawLinearElement).points)
@ -446,7 +462,8 @@ describe("Test Linear Elements", () => {
// Drag from first point
drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
expect(renderScene).toHaveBeenCalledTimes(16);
expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
expect([newPoints[0][0], newPoints[0][1]]).toEqual([
@ -472,7 +489,8 @@ describe("Test Linear Elements", () => {
// Drag from first point
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
expect(renderScene).toHaveBeenCalledTimes(16);
expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
expect([newPoints[0][0], newPoints[0][1]]).toEqual([
@ -506,7 +524,8 @@ describe("Test Linear Elements", () => {
// delete 3rd point
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderScene).toHaveBeenCalledTimes(22);
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
const newMidPoints = LinearElementEditor.getEditorMidPoints(
line,
@ -552,8 +571,8 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[0] + delta,
lastSegmentMidpoint[1] + delta,
]);
expect(renderScene).toHaveBeenCalledTimes(21);
expect(renderInteractiveScene).toHaveBeenCalledTimes(17);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(line.points.length).toEqual(5);
expect((h.elements[0] as ExcalidrawLinearElement).points)
@ -628,7 +647,8 @@ describe("Test Linear Elements", () => {
// Drag from first point
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
expect(renderScene).toHaveBeenCalledTimes(16);
expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
expect([newPoints[0][0], newPoints[0][1]]).toEqual([
@ -1205,7 +1225,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,
@ -1230,7 +1250,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,

View File

@ -16,10 +16,12 @@ import { KEYS } from "../keys";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene");
const renderInteractiveScene = jest.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = jest.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
@ -28,7 +30,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
@ -38,20 +40,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(4);
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]);
@ -77,7 +82,8 @@ describe("move element", () => {
// select the second rectangles
new Pointer("mouse").clickOn(rectB);
expect(renderScene).toHaveBeenCalledTimes(23);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(13);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@ -86,7 +92,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);
@ -94,7 +101,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();
@ -110,7 +118,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
@ -120,13 +128,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(4);
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 });
@ -140,7 +150,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);

View File

@ -14,10 +14,12 @@ import { reseed } from "../random";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene");
const renderInteractiveScene = jest.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = jest.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
@ -38,11 +40,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(4);
expect(h.elements.length).toEqual(0);
});
@ -52,11 +55,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(4);
expect(h.elements.length).toEqual(0);
});
@ -66,11 +70,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(4);
expect(h.elements.length).toEqual(0);
});
});
@ -82,7 +87,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 });
@ -102,7 +107,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(15);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
@ -125,7 +131,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 });
@ -145,7 +151,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(15);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;

View File

@ -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,
@ -43,7 +43,7 @@ describe("<Excalidraw/>", () => {
).toBe(0);
expect(h.state.zenModeEnabled).toBe(true);
fireEvent.contextMenu(GlobalTestState.canvas, {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
@ -93,7 +93,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,
@ -112,7 +112,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,

View File

@ -20,7 +20,7 @@ import { FONT_FAMILY } from "../constants";
const { h } = window;
const renderScene = jest.spyOn(Renderer, "renderScene");
const renderStaticScene = jest.spyOn(Renderer, "renderStaticScene");
const mouse = new Pointer("mouse");
const finger1 = new Pointer("touch", 1);
@ -32,7 +32,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`);
@ -47,7 +47,7 @@ beforeEach(async () => {
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
localStorage.clear();
renderScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
@ -1054,6 +1054,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 });

View File

@ -13,10 +13,10 @@ import { KEYS } from "../keys";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene");
const renderStaticScene = jest.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});

View File

@ -17,10 +17,12 @@ import { SHAPES } from "../shapes";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene");
const renderInteractiveScene = jest.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = jest.spyOn(Renderer, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
@ -132,7 +134,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 });
@ -151,10 +153,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(2);
const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull();
expect(selectionElement.type).toEqual("selection");
@ -171,11 +174,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(2);
const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull();
expect(selectionElement.type).toEqual("selection");
@ -192,12 +196,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(2);
expect(h.state.selectionElement).toBeNull();
});
});
@ -213,7 +218,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");
@ -232,7 +237,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(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -242,7 +248,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");
@ -261,7 +267,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(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -271,7 +278,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");
@ -290,7 +297,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(5);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -300,7 +308,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");
@ -332,7 +340,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(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -341,7 +350,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");
@ -373,7 +382,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(6);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

View File

@ -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) => {

View File

@ -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,
);
});
});
});

View File

@ -94,7 +94,7 @@ const populateElements = (
),
...appState,
selectedElementIds,
});
} as AppState);
return selectedElementIds;
};

View File

@ -98,6 +98,51 @@ export type LastActiveTool =
export type SidebarName = string;
export type SidebarTabName = string;
export type CommonCanvasAppState = {
zoom: AppState["zoom"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
width: AppState["width"];
height: AppState["height"];
viewModeEnabled: AppState["viewModeEnabled"];
editingElement: AppState["editingElement"];
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 = CommonCanvasAppState & {
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
/** null indicates transparent bg */
viewBackgroundColor?: AppState["viewBackgroundColor"];
exportScale: AppState["exportScale"];
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
gridSize: AppState["gridSize"];
frameRendering: AppState["frameRendering"];
};
export type InteractiveCanvasAppState = CommonCanvasAppState & {
// renderInteractiveScene
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;
@ -434,6 +479,7 @@ export type AppProps = Merge<
export type AppClassProperties = {
props: AppProps;
canvas: HTMLCanvasElement | null;
interactiveCanvas: HTMLCanvasElement | null;
focusContainer(): void;
library: Library;
imageCache: Map<

View File

@ -392,22 +392,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;
@ -439,7 +442,7 @@ export const setEraserCursor = (
}
setCursor(
canvas,
interactiveCanvas,
`url(${previewDataURL}) ${cursorImageSizePx / 2} ${
cursorImageSizePx / 2
}, auto`,
@ -447,23 +450,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;
}
};