From b1c6bfcf40cba255d208d8d525060684b10cb3cc Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:07:00 +0200 Subject: [PATCH 01/17] chore(docker): bump node (#11208) --- .codesandbox/Dockerfile | 2 +- Dockerfile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codesandbox/Dockerfile b/.codesandbox/Dockerfile index fd5b38d1e8..ce8c857650 100644 --- a/.codesandbox/Dockerfile +++ b/.codesandbox/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-bullseye +FROM node:24-bullseye # Vite wants to open the browser using `open`, so we # need to install those utils. diff --git a/Dockerfile b/Dockerfile index c08385d654..e15b425704 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=${BUILDPLATFORM} node:18 AS build +FROM --platform=${BUILDPLATFORM} node:24 AS build WORKDIR /opt/node_app @@ -13,7 +13,7 @@ ARG NODE_ENV=production RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker -FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine +FROM nginx:1.27-alpine COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html From 2e1a529c6781b1534d27ca79198dccd40b97ec7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Sat, 25 Apr 2026 12:03:50 +0200 Subject: [PATCH 02/17] fix(editor): remove extremely large arrows on restore (#11235) * fix: Temp fix for elbow arrow at restore Co-authored-by: Copilot Signed-off-by: Mark Tolmacs * fix: Speculative fixes to avoid Infinity Co-authored-by: Copilot Signed-off-by: Mark Tolmacs * validate/remove arrow size after point normalization & move binding repairs back * validate even simple arrows * remove x/y check * remove duplicate constant --------- Signed-off-by: Mark Tolmacs Co-authored-by: Copilot Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/element/src/binding.ts | 10 +++--- packages/element/src/elbowArrow.ts | 4 +-- packages/excalidraw/data/restore.ts | 47 +++++++++++++++++++++++++---- packages/math/src/constants.ts | 2 -- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 566ef3c4e4..3f80ffbae2 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1943,9 +1943,9 @@ export const calculateFixedPointForElbowArrowBinding = ( return { fixedPoint: normalizeFixedPoint([ (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) / - hoveredElement.width, + Math.max(hoveredElement.width, PRECISION), (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) / - hoveredElement.height, + Math.max(hoveredElement.height, PRECISION), ]), }; }; @@ -1976,9 +1976,11 @@ export const calculateFixedPointForNonElbowArrowBinding = ( // Calculate the ratio relative to the element's bounds const fixedPointX = - (nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width; + (nonRotatedPoint[0] - hoveredElement.x) / + Math.max(hoveredElement.width, PRECISION); const fixedPointY = - (nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height; + (nonRotatedPoint[1] - hoveredElement.y) / + Math.max(hoveredElement.height, PRECISION); return { fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]), diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 9543b4182f..024b846b12 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -2124,8 +2124,8 @@ const normalizeArrowElementUpdate = ( offsetY < -MAX_POS || offsetY > MAX_POS || offsetX + points[points.length - 1][0] < -MAX_POS || - offsetY + points[points.length - 1][0] > MAX_POS || - offsetX + points[points.length - 1][1] < -MAX_POS || + offsetX + points[points.length - 1][0] > MAX_POS || + offsetY + points[points.length - 1][1] < -MAX_POS || offsetY + points[points.length - 1][1] > MAX_POS ) { console.error( diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 7a8b9e586c..7ffe9b712f 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -96,6 +96,8 @@ type RestoredAppState = Omit< "offsetTop" | "offsetLeft" | "width" | "height" >; +const MAX_ARROW_PX = 75_000; + export const AllowedExcalidrawActiveTools: Record< AppState["activeTool"]["type"], boolean @@ -467,8 +469,8 @@ export const restoreElement = ( element.endArrowhead === undefined ? "arrow" : normalizeArrowhead(element.endArrowhead); - const x: number | undefined = element.x; - const y: number | undefined = element.y; + const x = element.x as number | undefined; + const y = element.y as number | undefined; const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one !Array.isArray(element.points) || element.points.length < 2 ? [pointFrom(0, 0), pointFrom(element.width, element.height)] @@ -493,8 +495,8 @@ export const restoreElement = ( startArrowhead, endArrowhead, points, - x, - y, + x: x ?? 0, + y: y ?? 0, elbowed: (element as ExcalidrawArrowElement).elbowed, ...getSizeFromPoints(points), }; @@ -513,12 +515,44 @@ export const restoreElement = ( }) : restoreElementWithProperties(element as ExcalidrawArrowElement, base); - return { + const normalizedRestoredElement = { ...restoredElement, ...LinearElementEditor.getNormalizeElementPointsAndCoords( restoredElement, ), }; + + // Last resort fix for extremely large arrows + if ( + normalizedRestoredElement.width > MAX_ARROW_PX || + normalizedRestoredElement.height > MAX_ARROW_PX + ) { + console.error( + `Removing extremely large arrow ${ + normalizedRestoredElement.id + } (type: ${ + isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple" + }, width: ${normalizedRestoredElement.width}, height: ${ + normalizedRestoredElement.height + }, x: ${normalizedRestoredElement.x}, y: ${ + normalizedRestoredElement.y + })`, + ); + return { + ...normalizedRestoredElement, + x: 0, + y: 0, + width: 100, + height: 100, + points: [ + pointFrom(0, 0), + pointFrom(100, 100), + ], + isDeleted: true, + }; + } + + return normalizedRestoredElement; } // generic elements @@ -666,6 +700,7 @@ export const restoreElements = ( const existingElementsMap = existingElements ? arrayToMap(existingElements) : null; + const restoredElements = syncInvalidIndices( (targetElements || []).reduce((elements, element) => { // filtering out selection, which is legacy, no longer kept in elements, @@ -762,7 +797,7 @@ export const restoreElements = ( } } - // NOTE (mtolmacs): Temporary fix for extremely large arrows + // NOTE (mtolmacs): Temporary fix for invalid/self-bound elbow arrows // Need to iterate again so we have attached text nodes in elementsMap return restoredElements.map((element) => { if ( diff --git a/packages/math/src/constants.ts b/packages/math/src/constants.ts index ce39e42682..6ba5eb8f7f 100644 --- a/packages/math/src/constants.ts +++ b/packages/math/src/constants.ts @@ -1,5 +1,3 @@ -export const PRECISION = 10e-5; - // Legendre-Gauss abscissae (x values) and weights for n=24 // Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html export const LegendreGaussN24TValues = [ From 43fa4b56028478f53dbb65045df1f9f7737c4321 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:23:10 +0200 Subject: [PATCH 03/17] fix: frame selection and membership (#11250) fix: element fully overlapping frame --- packages/element/src/frame.ts | 6 ++- packages/element/src/selection.ts | 8 ++-- packages/element/tests/frame.test.tsx | 44 +++++++++++++++++++- packages/excalidraw/tests/selection.test.tsx | 26 ++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 787c2692f9..6138633ff4 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -19,6 +19,7 @@ import { getElementAbsoluteCoords, doBoundsIntersect, getElementBounds, + boundsContainBounds, } from "./bounds"; import { mutateElement } from "./mutateElement"; import { getBoundTextElement, getContainerElement } from "./textElement"; @@ -101,8 +102,9 @@ export const isElementContainingFrame = ( frame: ExcalidrawFrameLikeElement, elementsMap: ElementsMap, ) => { - return getElementsWithinSelection([frame], element, elementsMap).some( - (e) => e.id === frame.id, + return boundsContainBounds( + getElementBounds(element, elementsMap), + getElementBounds(frame, elementsMap), ); }; diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index 8e9c4e8086..88bb1c2c80 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -34,7 +34,6 @@ import { elementOverlapsWithFrame, getContainingFrame, getFrameChildren, - isElementIntersectingFrame, } from "./frame"; import { LinearElementEditor } from "./linearElementEditor"; @@ -170,7 +169,7 @@ export const getElementsWithinSelection = ( const associatedFrame = getContainingFrame(element, elementsMap); if ( associatedFrame && - isElementIntersectingFrame(element, associatedFrame, elementsMap) + elementOverlapsWithFrame(element, associatedFrame, elementsMap) ) { const frameAABB = getElementBounds(associatedFrame, elementsMap); elementAABB = [ @@ -209,10 +208,9 @@ export const getElementsWithinSelection = ( if (boundsContainBounds(selectionBounds, commonAABB)) { if (framesInSelection && isFrameLikeElement(element)) { framesInSelection.add(element.id); - } else { - elementsInSelection.push(element); - continue; } + elementsInSelection.push(element); + continue; } // 2. Handle the case where the label is overlapped by the selection box diff --git a/packages/element/tests/frame.test.tsx b/packages/element/tests/frame.test.tsx index 47f2160ac3..e92267130a 100644 --- a/packages/element/tests/frame.test.tsx +++ b/packages/element/tests/frame.test.tsx @@ -2,6 +2,7 @@ import { convertToExcalidrawElements, Excalidraw, } from "@excalidraw/excalidraw"; +import { arrayToMap } from "@excalidraw/common"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; @@ -10,7 +11,12 @@ import { render, } from "@excalidraw/excalidraw/tests/test-utils"; -import type { ExcalidrawElement } from "../src/types"; +import { elementOverlapsWithFrame } from "../src/frame"; + +import type { + ExcalidrawElement, + ExcalidrawFrameLikeElement, +} from "../src/types"; const { h } = window; const mouse = new Pointer("mouse"); @@ -125,6 +131,26 @@ describe("adding elements to frames", () => { }); }); + it("should treat an element fully containing a frame as overlapping the frame", () => { + const containingRect = API.createElement({ + type: "rectangle", + x: -50, + y: -50, + width: 250, + height: 250, + }); + + API.setElements([containingRect, frame]); + + expect( + elementOverlapsWithFrame( + containingRect, + frame as ExcalidrawFrameLikeElement, + arrayToMap(h.elements), + ), + ).toBe(true); + }); + const commonTestCases = async ( func: typeof resizeFrameOverElement | typeof dragElementIntoFrame, ) => { @@ -415,6 +441,22 @@ describe("adding elements to frames", () => { describe("dragging elements into the frame", async () => { await commonTestCases(dragElementIntoFrame); + it("should add a dragged element fully containing the frame", () => { + const containingRect = API.createElement({ + type: "rectangle", + x: 220, + y: 20, + width: 300, + height: 300, + }); + + API.setElements([frame, containingRect]); + + dragElementIntoFrame(frame, containingRect); + + expect(API.getElement(containingRect).frameId).toBe(frame.id); + }); + it.skip("should drag element inside, duplicate it and keep it in frame", () => { API.setElements([frame, rect2]); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index ef0bd46b2c..5fd7a303d7 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -615,6 +615,32 @@ describe("box-selection overlap mode", () => { assertSelectedElements([]); }); + + it("should not select a framed element when selection only overlaps its clipped-out outline", () => { + const frame = API.createElement({ + type: "frame", + x: 100, + y: 100, + width: 100, + height: 100, + }); + const rect1 = API.createElement({ + type: "rectangle", + x: 50, + y: 50, + width: 200, + height: 200, + frameId: frame.id, + backgroundColor: "red", + fillStyle: "solid", + }); + + API.setElements([frame, rect1]); + + boxSelect(40, 170, 70, 220); + + assertSelectedElements([]); + }); }); describe("inner box-selection", () => { From 278cd357724b17e1119b6c76416520c42958d0e3 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:49:40 +0200 Subject: [PATCH 04/17] feat(editor): scale video embeddables based on zoom (#11251) --- packages/excalidraw/components/App.tsx | 75 +++++++++++++++++--------- packages/excalidraw/css/styles.scss | 8 +++ 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4db823c2d4..299304eb0e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -603,6 +603,8 @@ const YOUTUBE_VIDEO_STATES = new Map< ValueOf >(); +const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4; + let IS_PLAIN_PASTE = false; let IS_PLAIN_PASTE_TIMER = 0; let PLAIN_PASTE_TOAST_SHOWN = false; @@ -1735,6 +1737,18 @@ class App extends React.Component { this.state.activeEmbeddable?.element === el && this.state.activeEmbeddable?.state === "hover"; + // scale video embeds based on zoom (capped) so that smaller embeds + // on canvas when zoomed are still of legible quality + // (note: for some embed types like gdrive, the quality is poor when + // scaling mid playback and works only when you initially start the + // playback at the higher zoom level) + const shouldScaleEmbeddableViewport = src?.type === "video"; + const embeddableViewportScale = clamp( + shouldScaleEmbeddableViewport ? scale : 1, + 0.75, + MAX_EMBEDDABLE_VIEWPORT_SCALE, + ); + return (
{ padding: `${el.strokeWidth}px`, }} > - {(isEmbeddableElement(el) - ? this.props.renderEmbeddable?.(el, this.state) - : null) ?? ( -