From cde46793f8db57187616b9c869027c41dfdc9862 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sun, 13 Jul 2025 19:19:10 +0200 Subject: [PATCH 01/10] feat: support timestamps for youtube video emebds (#9737) --- packages/element/src/embeddable.ts | 34 ++++- packages/element/tests/embeddable.test.ts | 153 ++++++++++++++++++++++ 2 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 packages/element/tests/embeddable.test.ts diff --git a/packages/element/src/embeddable.ts b/packages/element/src/embeddable.ts index 78dc26fe2f..71c75cc23a 100644 --- a/packages/element/src/embeddable.ts +++ b/packages/element/src/embeddable.ts @@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired; const embeddedLinkCache = new Map(); const RE_YOUTUBE = - /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/; + /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/; const RE_VIMEO = /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; @@ -56,6 +56,35 @@ const RE_REDDIT = const RE_REDDIT_EMBED = /^ { + let timeParam: string | null | undefined; + + try { + const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`); + timeParam = + urlObj.searchParams.get("t") || urlObj.searchParams.get("start"); + } catch (error) { + const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/); + timeParam = timeMatch?.[1]; + } + + if (!timeParam) { + return 0; + } + + if (/^\d+$/.test(timeParam)) { + return parseInt(timeParam, 10); + } + + const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/); + if (!timeMatch) { + return 0; + } + + const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch; + return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); +}; + const ALLOWED_DOMAINS = new Set([ "youtube.com", "youtu.be", @@ -113,7 +142,8 @@ export const getEmbedLink = ( let aspectRatio = { w: 560, h: 840 }; const ytLink = link.match(RE_YOUTUBE); if (ytLink?.[2]) { - const time = ytLink[3] ? `&start=${ytLink[3]}` : ``; + const startTime = parseYouTubeTimestamp(originalLink); + const time = startTime > 0 ? `&start=${startTime}` : ``; const isPortrait = link.includes("shorts"); type = "video"; switch (ytLink[1]) { diff --git a/packages/element/tests/embeddable.test.ts b/packages/element/tests/embeddable.test.ts new file mode 100644 index 0000000000..7f585e866f --- /dev/null +++ b/packages/element/tests/embeddable.test.ts @@ -0,0 +1,153 @@ +import { getEmbedLink } from "../src/embeddable"; + +describe("YouTube timestamp parsing", () => { + it("should parse YouTube URLs with timestamp in seconds", () => { + const testCases = [ + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90", + expectedStart: 90, + }, + { + url: "https://youtu.be/dQw4w9WgXcQ?t=120", + expectedStart: 120, + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150", + expectedStart: 150, + }, + ]; + + testCases.forEach(({ url, expectedStart }) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain(`start=${expectedStart}`); + } + }); + }); + + it("should parse YouTube URLs with timestamp in time format", () => { + const testCases = [ + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s", + expectedStart: 90, // 1*60 + 30 + }, + { + url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s", + expectedStart: 165, // 2*60 + 45 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s", + expectedStart: 3723, // 1*3600 + 2*60 + 3 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s", + expectedStart: 45, + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m", + expectedStart: 300, // 5*60 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h", + expectedStart: 7200, // 2*3600 + }, + ]; + + testCases.forEach(({ url, expectedStart }) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain(`start=${expectedStart}`); + } + }); + }); + + it("should handle YouTube URLs without timestamps", () => { + const testCases = [ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtu.be/dQw4w9WgXcQ", + "https://www.youtube.com/embed/dQw4w9WgXcQ", + ]; + + testCases.forEach((url) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).not.toContain("start="); + } + }); + }); + + it("should handle YouTube shorts URLs with timestamps", () => { + const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain("start=30"); + } + // Shorts should have portrait aspect ratio + expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 }); + }); + + it("should handle playlist URLs with timestamps", () => { + const url = + "https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain("start=60"); + expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ"); + } + }); + + it("should handle malformed or edge case timestamps", () => { + const testCases = [ + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc", + expectedStart: 0, // Invalid timestamp should default to 0 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=", + expectedStart: 0, // Empty timestamp should default to 0 + }, + { + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0", + expectedStart: 0, // Zero timestamp should be handled + }, + ]; + + testCases.forEach(({ url, expectedStart }) => { + const result = getEmbedLink(url); + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + if (expectedStart === 0) { + expect(result.link).not.toContain("start="); + } else { + expect(result.link).toContain(`start=${expectedStart}`); + } + } + }); + }); + + it("should preserve other URL parameters", () => { + const url = + "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toContain("start=90"); + expect(result.link).toContain("enablejsapi=1"); + } + }); +}); From 0cfa53b7649c5f856bd73b58cb147b04f15e5b39 Mon Sep 17 00:00:00 2001 From: Christopher Tangonan <161169629+cTangonan123@users.noreply.github.com> Date: Tue, 15 Jul 2025 03:43:42 -0700 Subject: [PATCH 02/10] fix: aligning and distributing elements and nested groups while editing a group (#9721) --- packages/element/src/align.ts | 11 +- packages/element/src/distribute.ts | 11 +- packages/element/src/groups.ts | 77 ++++ packages/element/tests/align.test.tsx | 420 ++++++++++++++++++ packages/excalidraw/actions/actionAlign.tsx | 15 +- .../excalidraw/actions/actionDistribute.tsx | 9 +- 6 files changed, 534 insertions(+), 9 deletions(-) diff --git a/packages/element/src/align.ts b/packages/element/src/align.ts index 546bbbfa48..3068aee8d1 100644 --- a/packages/element/src/align.ts +++ b/packages/element/src/align.ts @@ -1,6 +1,8 @@ +import type { AppState } from "@excalidraw/excalidraw/types"; + import { updateBoundElements } from "./binding"; import { getCommonBoundingBox } from "./bounds"; -import { getMaximumGroups } from "./groups"; +import { getSelectedElementsByGroup } from "./groups"; import type { Scene } from "./Scene"; @@ -16,11 +18,12 @@ export const alignElements = ( selectedElements: ExcalidrawElement[], alignment: Alignment, scene: Scene, + appState: Readonly, ): ExcalidrawElement[] => { - const elementsMap = scene.getNonDeletedElementsMap(); - const groups: ExcalidrawElement[][] = getMaximumGroups( + const groups: ExcalidrawElement[][] = getSelectedElementsByGroup( selectedElements, - elementsMap, + scene.getNonDeletedElementsMap(), + appState, ); const selectionBoundingBox = getCommonBoundingBox(selectedElements); diff --git a/packages/element/src/distribute.ts b/packages/element/src/distribute.ts index da79837da6..add3522acc 100644 --- a/packages/element/src/distribute.ts +++ b/packages/element/src/distribute.ts @@ -1,7 +1,9 @@ +import type { AppState } from "@excalidraw/excalidraw/types"; + import { getCommonBoundingBox } from "./bounds"; import { newElementWith } from "./mutateElement"; -import { getMaximumGroups } from "./groups"; +import { getSelectedElementsByGroup } from "./groups"; import type { ElementsMap, ExcalidrawElement } from "./types"; @@ -14,6 +16,7 @@ export const distributeElements = ( selectedElements: ExcalidrawElement[], elementsMap: ElementsMap, distribution: Distribution, + appState: Readonly, ): ExcalidrawElement[] => { const [start, mid, end, extent] = distribution.axis === "x" @@ -21,7 +24,11 @@ export const distributeElements = ( : (["minY", "midY", "maxY", "height"] as const); const bounds = getCommonBoundingBox(selectedElements); - const groups = getMaximumGroups(selectedElements, elementsMap) + const groups = getSelectedElementsByGroup( + selectedElements, + elementsMap, + appState, + ) .map((group) => [group, getCommonBoundingBox(group)] as const) .sort((a, b) => a[1][mid] - b[1][mid]); diff --git a/packages/element/src/groups.ts b/packages/element/src/groups.ts index 1cd1536e11..40f787a01b 100644 --- a/packages/element/src/groups.ts +++ b/packages/element/src/groups.ts @@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types"; import { getBoundTextElement } from "./textElement"; +import { isBoundToContainer } from "./typeChecks"; + import { makeNextSelectedElementIds, getSelectedElements } from "./selection"; import type { @@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = ( return copy; }; + +// given a list of selected elements, return the element grouped by their immediate group selected state +// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order +export const getSelectedElementsByGroup = ( + selectedElements: ExcalidrawElement[], + elementsMap: ElementsMap, + appState: Readonly, +): ExcalidrawElement[][] => { + const selectedGroupIds = getSelectedGroupIds(appState); + const unboundElements = selectedElements.filter( + (element) => !isBoundToContainer(element), + ); + const groups: Map = new Map(); + const elements: Map = new Map(); + + // helper function to add an element to the elements map + const addToElementsMap = (element: ExcalidrawElement) => { + // elements + const currentElementMembers = elements.get(element.id) || []; + const boundTextElement = getBoundTextElement(element, elementsMap); + + if (boundTextElement) { + currentElementMembers.push(boundTextElement); + } + elements.set(element.id, [...currentElementMembers, element]); + }; + + // helper function to add an element to the groups map + const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => { + // groups + const currentGroupMembers = groups.get(groupId) || []; + const boundTextElement = getBoundTextElement(element, elementsMap); + + if (boundTextElement) { + currentGroupMembers.push(boundTextElement); + } + groups.set(groupId, [...currentGroupMembers, element]); + }; + + // helper function to handle the case where a single group is selected + // and all elements selected are within the group, it will respect group hierarchy in accordance to + // their nested grouping order + const handleSingleSelectedGroupCase = ( + element: ExcalidrawElement, + selectedGroupId: GroupId, + ) => { + const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0); + const nestedGroupCount = element.groupIds.slice( + 0, + indexOfSelectedGroupId, + ).length; + return nestedGroupCount > 0 + ? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1]) + : addToElementsMap(element); + }; + + const isAllInSameGroup = selectedElements.every((element) => + isSelectedViaGroup(appState, element), + ); + + unboundElements.forEach((element) => { + const selectedGroupId = getSelectedGroupIdForElement( + element, + appState.selectedGroupIds, + ); + if (!selectedGroupId) { + addToElementsMap(element); + } else if (selectedGroupIds.length === 1 && isAllInSameGroup) { + handleSingleSelectedGroupCase(element, selectedGroupId); + } else { + addToGroupsMap(element, selectedGroupId); + } + }); + return Array.from(groups.values()).concat(Array.from(elements.values())); +}; diff --git a/packages/element/tests/align.test.tsx b/packages/element/tests/align.test.tsx index afffb72cb4..b796793690 100644 --- a/packages/element/tests/align.test.tsx +++ b/packages/element/tests/align.test.tsx @@ -589,4 +589,424 @@ describe("aligning", () => { expect(API.getSelectedElements()[2].x).toEqual(250); expect(API.getSelectedElements()[3].x).toEqual(150); }); + + const createGroupAndSelectInEditGroupMode = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + // select the first element. + // The second rectangle is already reselected because it was the last element created + mouse.reset(); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(10, 0); + mouse.click(); + }); + + API.executeAction(actionGroup); + mouse.reset(); + mouse.moveTo(10, 0); + mouse.doubleClick(); + + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.click(); + mouse.moveTo(100, 100); + mouse.click(); + }); + }; + + it("aligns elements within a group while in group edit mode correctly to the top", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignTop); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(0); + }); + it("aligns elements within a group while in group edit mode correctly to the bottom", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignBottom); + + expect(API.getSelectedElements()[0].y).toEqual(100); + expect(API.getSelectedElements()[1].y).toEqual(100); + }); + it("aligns elements within a group while in group edit mode correctly to the left", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignLeft); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(0); + }); + it("aligns elements within a group while in group edit mode correctly to the right", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignRight); + + expect(API.getSelectedElements()[0].x).toEqual(100); + expect(API.getSelectedElements()[1].x).toEqual(100); + }); + it("aligns elements within a group while in group edit mode correctly to the vertical center", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignVerticallyCentered); + + expect(API.getSelectedElements()[0].y).toEqual(50); + expect(API.getSelectedElements()[1].y).toEqual(50); + }); + it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => { + createGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignHorizontallyCentered); + + expect(API.getSelectedElements()[0].x).toEqual(50); + expect(API.getSelectedElements()[1].x).toEqual(50); + }); + + const createNestedGroupAndSelectInEditGroupMode = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + // Select the first element. + // The second rectangle is already reselected because it was the last element created + mouse.reset(); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(10, 0); + mouse.click(); + }); + + API.executeAction(actionGroup); + + mouse.reset(); + mouse.moveTo(200, 200); + // create third element + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + // third element is already selected, select the initial group and group together + mouse.reset(); + + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(10, 0); + mouse.click(); + }); + + API.executeAction(actionGroup); + + // double click to enter edit mode + mouse.doubleClick(); + + // select nested group and other element within the group + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(200, 200); + mouse.click(); + }); + }; + + it("aligns element and nested group while in group edit mode correctly to the top", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignTop); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(0); + }); + it("aligns element and nested group while in group edit mode correctly to the bottom", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignBottom); + + expect(API.getSelectedElements()[0].y).toEqual(100); + expect(API.getSelectedElements()[1].y).toEqual(200); + expect(API.getSelectedElements()[2].y).toEqual(200); + }); + it("aligns element and nested group while in group edit mode correctly to the left", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignLeft); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(0); + }); + it("aligns element and nested group while in group edit mode correctly to the right", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignRight); + + expect(API.getSelectedElements()[0].x).toEqual(100); + expect(API.getSelectedElements()[1].x).toEqual(200); + expect(API.getSelectedElements()[2].x).toEqual(200); + }); + it("aligns element and nested group while in group edit mode correctly to the vertical center", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignVerticallyCentered); + + expect(API.getSelectedElements()[0].y).toEqual(50); + expect(API.getSelectedElements()[1].y).toEqual(150); + expect(API.getSelectedElements()[2].y).toEqual(100); + }); + it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => { + createNestedGroupAndSelectInEditGroupMode(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignHorizontallyCentered); + + expect(API.getSelectedElements()[0].x).toEqual(50); + expect(API.getSelectedElements()[1].x).toEqual(150); + expect(API.getSelectedElements()[2].x).toEqual(100); + }); + + const createAndSelectSingleGroup = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + // Select the first element. + // The second rectangle is already reselected because it was the last element created + mouse.reset(); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(10, 0); + mouse.click(); + }); + + API.executeAction(actionGroup); + }; + + it("aligns elements within a single-selected group correctly to the top", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignTop); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(0); + }); + it("aligns elements within a single-selected group correctly to the bottom", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignBottom); + + expect(API.getSelectedElements()[0].y).toEqual(100); + expect(API.getSelectedElements()[1].y).toEqual(100); + }); + it("aligns elements within a single-selected group correctly to the left", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignLeft); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(0); + }); + it("aligns elements within a single-selected group correctly to the right", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignRight); + + expect(API.getSelectedElements()[0].x).toEqual(100); + expect(API.getSelectedElements()[1].x).toEqual(100); + }); + it("aligns elements within a single-selected group correctly to the vertical center", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + + API.executeAction(actionAlignVerticallyCentered); + + expect(API.getSelectedElements()[0].y).toEqual(50); + expect(API.getSelectedElements()[1].y).toEqual(50); + }); + it("aligns elements within a single-selected group correctly to the horizontal center", () => { + createAndSelectSingleGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + + API.executeAction(actionAlignHorizontallyCentered); + + expect(API.getSelectedElements()[0].x).toEqual(50); + expect(API.getSelectedElements()[1].x).toEqual(50); + }); + + const createAndSelectSingleGroupWithNestedGroup = () => { + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(100, 100); + + UI.clickTool("rectangle"); + mouse.down(0, 0); + mouse.up(100, 100); + + // Select the first element. + // The second rectangle is already reselected because it was the last element created + mouse.reset(); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.moveTo(10, 0); + mouse.click(); + }); + + API.executeAction(actionGroup); + + mouse.reset(); + UI.clickTool("rectangle"); + mouse.down(200, 200); + mouse.up(100, 100); + + // Add group to current selection + mouse.restorePosition(10, 0); + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.click(); + }); + + // Create the nested group + API.executeAction(actionGroup); + }; + it("aligns elements within a single-selected group containing a nested group correctly to the top", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignTop); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(0); + }); + it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignBottom); + + expect(API.getSelectedElements()[0].y).toEqual(100); + expect(API.getSelectedElements()[1].y).toEqual(200); + expect(API.getSelectedElements()[2].y).toEqual(200); + }); + it("aligns elements within a single-selected group containing a nested group correctly to the left", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignLeft); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(0); + }); + it("aligns elements within a single-selected group containing a nested group correctly to the right", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignRight); + + expect(API.getSelectedElements()[0].x).toEqual(100); + expect(API.getSelectedElements()[1].x).toEqual(200); + expect(API.getSelectedElements()[2].x).toEqual(200); + }); + it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].y).toEqual(0); + expect(API.getSelectedElements()[1].y).toEqual(100); + expect(API.getSelectedElements()[2].y).toEqual(200); + + API.executeAction(actionAlignVerticallyCentered); + + expect(API.getSelectedElements()[0].y).toEqual(50); + expect(API.getSelectedElements()[1].y).toEqual(150); + expect(API.getSelectedElements()[2].y).toEqual(100); + }); + it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => { + createAndSelectSingleGroupWithNestedGroup(); + + expect(API.getSelectedElements()[0].x).toEqual(0); + expect(API.getSelectedElements()[1].x).toEqual(100); + expect(API.getSelectedElements()[2].x).toEqual(200); + + API.executeAction(actionAlignHorizontallyCentered); + + expect(API.getSelectedElements()[0].x).toEqual(50); + expect(API.getSelectedElements()[1].x).toEqual(150); + expect(API.getSelectedElements()[2].x).toEqual(100); + }); }); diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index de5cd2c1e4..63a887635b 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -10,6 +10,8 @@ import { alignElements } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; +import { getSelectedElementsByGroup } from "@excalidraw/element"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { Alignment } from "@excalidraw/element"; @@ -38,7 +40,11 @@ export const alignActionsPredicate = ( ) => { const selectedElements = app.scene.getSelectedElements(appState); return ( - selectedElements.length > 1 && + getSelectedElementsByGroup( + selectedElements, + app.scene.getNonDeletedElementsMap(), + appState as Readonly, + ).length > 1 && // TODO enable aligning frames when implemented properly !selectedElements.some((el) => isFrameLikeElement(el)) ); @@ -52,7 +58,12 @@ const alignSelectedElements = ( ) => { const selectedElements = app.scene.getSelectedElements(appState); - const updatedElements = alignElements(selectedElements, alignment, app.scene); + const updatedElements = alignElements( + selectedElements, + alignment, + app.scene, + appState, + ); const updatedElementsMap = arrayToMap(updatedElements); diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index bd823ec01a..f02906741c 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; +import { getSelectedElementsByGroup } from "@excalidraw/element"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { Distribution } from "@excalidraw/element"; @@ -31,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types"; const enableActionGroup = (appState: AppState, app: AppClassProperties) => { const selectedElements = app.scene.getSelectedElements(appState); return ( - selectedElements.length > 1 && + getSelectedElementsByGroup( + selectedElements, + app.scene.getNonDeletedElementsMap(), + appState as Readonly, + ).length > 2 && // TODO enable distributing frames when implemented properly !selectedElements.some((el) => isFrameLikeElement(el)) ); @@ -49,6 +55,7 @@ const distributeSelectedElements = ( selectedElements, app.scene.getNonDeletedElementsMap(), distribution, + appState, ); const updatedElementsMap = arrayToMap(updatedElements); From 678dff25eddfc43319299495556ab9f6029bc719 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:59:55 +0200 Subject: [PATCH 03/10] fix: ellipsify MainMenu and CommandPalette items (#9743) * fix: ellipsify MainMenu and CommandPalette items * fix lint --- .../CommandPalette/CommandPalette.scss | 1 + .../CommandPalette/CommandPalette.tsx | 4 +- packages/excalidraw/components/Ellipsify.tsx | 18 +++++ packages/excalidraw/components/InlineIcon.tsx | 1 + .../components/dropdownMenu/DropdownMenu.scss | 3 + .../dropdownMenu/DropdownMenuItemContent.tsx | 4 +- packages/excalidraw/index.tsx | 1 + .../__snapshots__/excalidraw.test.tsx.snap | 78 +++++++++++++++---- 8 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 packages/excalidraw/components/Ellipsify.tsx diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.scss b/packages/excalidraw/components/CommandPalette/CommandPalette.scss index ebb7e4fa5e..90db95db69 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.scss +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.scss @@ -108,6 +108,7 @@ $verticalBreakpoint: 861px; display: flex; align-items: center; gap: 0.25rem; + overflow: hidden; } } diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 740fa01620..3c6f110d27 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { useStable } from "../../hooks/useStable"; +import { Ellipsify } from "../Ellipsify"; + import * as defaultItems from "./defaultCommandPaletteItems"; import "./CommandPalette.scss"; @@ -964,7 +966,7 @@ const CommandItem = ({ } /> )} - {command.label} + {command.label} {showShortcut && command.shortcut && ( diff --git a/packages/excalidraw/components/Ellipsify.tsx b/packages/excalidraw/components/Ellipsify.tsx new file mode 100644 index 0000000000..dd21af6f15 --- /dev/null +++ b/packages/excalidraw/components/Ellipsify.tsx @@ -0,0 +1,18 @@ +export const Ellipsify = ({ + children, + ...rest +}: { children: React.ReactNode } & React.HTMLAttributes) => { + return ( + + {children} + + ); +}; diff --git a/packages/excalidraw/components/InlineIcon.tsx b/packages/excalidraw/components/InlineIcon.tsx index 75cc29d08d..c80045e5e8 100644 --- a/packages/excalidraw/components/InlineIcon.tsx +++ b/packages/excalidraw/components/InlineIcon.tsx @@ -7,6 +7,7 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => { display: "inline-block", lineHeight: 0, verticalAlign: "middle", + flex: "0 0 auto", }} > {icon} diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index e48f6d71e7..95d258c46b 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -19,6 +19,8 @@ border-radius: var(--border-radius-lg); position: relative; transition: box-shadow 0.5s ease-in-out; + display: flex; + flex-direction: column; &.zen-mode { box-shadow: none; @@ -100,6 +102,7 @@ align-items: center; cursor: pointer; border-radius: var(--border-radius-md); + flex: 1 0 auto; @media screen and (min-width: 1921px) { height: 2.25rem; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx index b2f9e7e0a9..aea13230b8 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx @@ -1,5 +1,7 @@ import { useDevice } from "../App"; +import { Ellipsify } from "../Ellipsify"; + import type { JSX } from "react"; const MenuItemContent = ({ @@ -18,7 +20,7 @@ const MenuItemContent = ({ <> {icon &&
{icon}
}
- {children} + {children}
{shortcut && !device.editor.isMobile && (
{shortcut}
diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 478ecc42f0..a592e2ea91 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -281,6 +281,7 @@ export { Sidebar } from "./components/Sidebar/Sidebar"; export { Button } from "./components/Button"; export { Footer }; export { MainMenu }; +export { Ellipsify } from "./components/Ellipsify"; export { useDevice } from "./components/App"; export { WelcomeScreen }; export { LiveCollaborationTrigger }; diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index bb87746c0d..ae4728e0c7 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -15,7 +15,11 @@ exports[` > > should render main menu with host menu it > > should render main menu with host menu it
> > should render main menu with host menu it