From 068895db0eed082505788a2db0c6d63664e857df Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:20:07 +0100 Subject: [PATCH 001/174] feat: expose more collaborator status icons (#7777) --- excalidraw-app/index.scss | 2 +- .../excalidraw/actions/actionNavigate.tsx | 92 +++++-- packages/excalidraw/clients.ts | 229 +++++++++++++++++- packages/excalidraw/components/App.tsx | 4 +- packages/excalidraw/components/Avatar.tsx | 15 +- packages/excalidraw/components/LayerUI.scss | 7 + packages/excalidraw/components/UserList.scss | 108 +++++++-- packages/excalidraw/components/UserList.tsx | 228 ++++++++++------- .../components/canvases/InteractiveCanvas.tsx | 52 ++-- packages/excalidraw/components/icons.tsx | 25 +- packages/excalidraw/constants.ts | 8 + packages/excalidraw/css/variables.module.scss | 14 +- packages/excalidraw/laser-trails.ts | 2 +- packages/excalidraw/locales/en.json | 5 +- .../excalidraw/renderer/interactiveScene.ts | 168 ++----------- packages/excalidraw/scene/types.ts | 13 +- packages/excalidraw/types.ts | 7 +- packages/excalidraw/utils.ts | 8 + 18 files changed, 652 insertions(+), 335 deletions(-) diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index 021442753..24741b062 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -8,7 +8,7 @@ .top-right-ui { display: flex; justify-content: center; - align-items: center; + align-items: flex-start; } .footer-center { diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index ea65584fe..5c60a029d 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -1,10 +1,15 @@ import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; import { GoToCollaboratorComponentProps } from "../components/UserList"; -import { eyeIcon } from "../components/icons"; +import { + eyeIcon, + microphoneIcon, + microphoneMutedIcon, +} from "../components/icons"; import { t } from "../i18n"; import { Collaborator } from "../types"; import { register } from "./register"; +import clsx from "clsx"; export const actionGoToCollaborator = register({ name: "goToCollaborator", @@ -39,14 +44,45 @@ export const actionGoToCollaborator = register({ }; }, PanelComponent: ({ updateData, data, appState }) => { - const { clientId, collaborator, withName, isBeingFollowed } = + const { socketId, collaborator, withName, isBeingFollowed } = data as GoToCollaboratorComponentProps; - const background = getClientColor(clientId); + const background = getClientColor(socketId, collaborator); + + const statusClassNames = clsx({ + "is-followed": isBeingFollowed, + "is-current-user": collaborator.isCurrentUser === true, + "is-speaking": collaborator.isSpeaking, + "is-in-call": collaborator.isInCall, + "is-muted": collaborator.isMuted, + }); + + const statusIconJSX = collaborator.isInCall ? ( + collaborator.isSpeaking ? ( +
+
+
+
+
+ ) : collaborator.isMuted ? ( +
+ {microphoneMutedIcon} +
+ ) : ( +
{microphoneIcon}
+ ) + ) : null; return withName ? (
updateData(collaborator)} > {}} name={collaborator.username || ""} src={collaborator.avatarUrl} - isBeingFollowed={isBeingFollowed} - isCurrentUser={collaborator.isCurrentUser === true} + className={statusClassNames} />
{collaborator.username}
-
- {eyeIcon} +
+ {isBeingFollowed && ( +
+ {eyeIcon} +
+ )} + {statusIconJSX}
) : ( - { - updateData(collaborator); - }} - name={collaborator.username || ""} - src={collaborator.avatarUrl} - isBeingFollowed={isBeingFollowed} - isCurrentUser={collaborator.isCurrentUser === true} - /> +
+ { + updateData(collaborator); + }} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + className={statusClassNames} + /> + {statusIconJSX && ( +
+ {statusIconJSX} +
+ )} +
); }, }); diff --git a/packages/excalidraw/clients.ts b/packages/excalidraw/clients.ts index 354098918..439080bd5 100644 --- a/packages/excalidraw/clients.ts +++ b/packages/excalidraw/clients.ts @@ -1,3 +1,18 @@ +import { + COLOR_CHARCOAL_BLACK, + COLOR_VOICE_CALL, + COLOR_WHITE, + THEME, +} from "./constants"; +import { roundRect } from "./renderer/roundRect"; +import { InteractiveCanvasRenderConfig } from "./scene/types"; +import { + Collaborator, + InteractiveCanvasAppState, + SocketId, + UserIdleState, +} from "./types"; + function hashToInteger(id: string) { let hash = 0; if (id.length === 0) { @@ -11,14 +26,12 @@ function hashToInteger(id: string) { } export const getClientColor = ( - /** - * any uniquely identifying key, such as user id or socket id - */ - id: string, + socketId: SocketId, + collaborator: Collaborator | undefined, ) => { // to get more even distribution in case `id` is not uniformly distributed to // begin with, we hash it - const hash = Math.abs(hashToInteger(id)); + const hash = Math.abs(hashToInteger(collaborator?.id || socketId)); // we want to get a multiple of 10 number in the range of 0-360 (in other // words a hue value of step size 10). There are 37 such values including 0. const hue = (hash % 37) * 10; @@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => { firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?" ).toUpperCase(); }; + +export const renderRemoteCursors = ({ + context, + renderConfig, + appState, + normalizedWidth, + normalizedHeight, +}: { + context: CanvasRenderingContext2D; + renderConfig: InteractiveCanvasRenderConfig; + appState: InteractiveCanvasAppState; + normalizedWidth: number; + normalizedHeight: number; +}) => { + // Paint remote pointers + for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) { + let { x, y } = pointer; + + const collaborator = appState.collaborators.get(socketId); + + x -= appState.offsetLeft; + y -= appState.offsetTop; + + const width = 11; + const height = 14; + + const isOutOfBounds = + x < 0 || + x > normalizedWidth - width || + y < 0 || + y > normalizedHeight - height; + + x = Math.max(x, 0); + x = Math.min(x, normalizedWidth - width); + y = Math.max(y, 0); + y = Math.min(y, normalizedHeight - height); + + const background = getClientColor(socketId, collaborator); + + context.save(); + context.strokeStyle = background; + context.fillStyle = background; + + const userState = renderConfig.remotePointerUserStates.get(socketId); + const isInactive = + isOutOfBounds || + userState === UserIdleState.IDLE || + userState === UserIdleState.AWAY; + + if (isInactive) { + context.globalAlpha = 0.3; + } + + if (renderConfig.remotePointerButton.get(socketId) === "down") { + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 3; + context.strokeStyle = "#ffffff88"; + context.stroke(); + context.closePath(); + + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 1; + context.strokeStyle = background; + context.stroke(); + context.closePath(); + } + + // TODO remove the dark theme color after we stop inverting canvas colors + const IS_SPEAKING_COLOR = + appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL; + + const isSpeaking = collaborator?.isSpeaking; + + if (isSpeaking) { + // cursor outline for currently speaking user + context.fillStyle = IS_SPEAKING_COLOR; + context.strokeStyle = IS_SPEAKING_COLOR; + context.lineWidth = 10; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.stroke(); + context.fill(); + } + + // Background (white outline) for arrow + context.fillStyle = COLOR_WHITE; + context.strokeStyle = COLOR_WHITE; + context.lineWidth = 6; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.stroke(); + context.fill(); + + // Arrow + context.fillStyle = background; + context.strokeStyle = background; + context.lineWidth = 2; + context.lineJoin = "round"; + context.beginPath(); + if (isInactive) { + context.moveTo(x - 1, y - 1); + context.lineTo(x - 1, y + 15); + context.lineTo(x + 5, y + 10); + context.lineTo(x + 12, y + 9); + context.closePath(); + context.fill(); + } else { + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.fill(); + context.stroke(); + } + + const username = renderConfig.remotePointerUsernames.get(socketId) || ""; + + if (!isOutOfBounds && username) { + context.font = "600 12px sans-serif"; // font has to be set before context.measureText() + + const offsetX = (isSpeaking ? x + 0 : x) + width / 2; + const offsetY = (isSpeaking ? y + 0 : y) + height + 2; + const paddingHorizontal = 5; + const paddingVertical = 3; + const measure = context.measureText(username); + const measureHeight = + measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; + const finalHeight = Math.max(measureHeight, 12); + + const boxX = offsetX - 1; + const boxY = offsetY - 1; + const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2; + const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2; + if (context.roundRect) { + context.beginPath(); + context.roundRect(boxX, boxY, boxWidth, boxHeight, 8); + context.fillStyle = background; + context.fill(); + context.strokeStyle = COLOR_WHITE; + context.stroke(); + + if (isSpeaking) { + context.beginPath(); + context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8); + context.strokeStyle = IS_SPEAKING_COLOR; + context.stroke(); + } + } else { + roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE); + } + context.fillStyle = COLOR_CHARCOAL_BLACK; + + context.fillText( + username, + offsetX + paddingHorizontal + 1, + offsetY + + paddingVertical + + measure.actualBoundingBoxAscent + + Math.floor((finalHeight - measureHeight) / 2) + + 2, + ); + + // draw three vertical bars signalling someone is speaking + if (isSpeaking) { + context.fillStyle = IS_SPEAKING_COLOR; + const barheight = 8; + const margin = 8; + const gap = 5; + context.fillRect( + boxX + boxWidth + margin, + boxY + (boxHeight / 2 - barheight / 2), + 2, + barheight, + ); + context.fillRect( + boxX + boxWidth + margin + gap, + boxY + (boxHeight / 2 - (barheight * 2) / 2), + 2, + barheight * 2, + ); + context.fillRect( + boxX + boxWidth + margin + gap * 2, + boxY + (boxHeight / 2 - barheight / 2), + 2, + barheight, + ); + } + } + + context.restore(); + context.closePath(); + } +}; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 97ce14662..b02d919d4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -89,6 +89,7 @@ import { TOOL_TYPE, EDITOR_LS_KEYS, isIOS, + supportsResizeObserver, } from "../constants"; import { ExportedElements, exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; @@ -476,9 +477,6 @@ export const useExcalidrawSetAppState = () => export const useExcalidrawActionManager = () => useContext(ExcalidrawActionManagerContext); -const supportsResizeObserver = - typeof window !== "undefined" && "ResizeObserver" in window; - let didTapTwice: boolean = false; let tappedTwiceTimer = 0; let isHoldingSpace: boolean = false; diff --git a/packages/excalidraw/components/Avatar.tsx b/packages/excalidraw/components/Avatar.tsx index b7b1bf962..9ddc319c6 100644 --- a/packages/excalidraw/components/Avatar.tsx +++ b/packages/excalidraw/components/Avatar.tsx @@ -9,8 +9,7 @@ type AvatarProps = { color: string; name: string; src?: string; - isBeingFollowed?: boolean; - isCurrentUser: boolean; + className?: string; }; export const Avatar = ({ @@ -18,22 +17,14 @@ export const Avatar = ({ onClick, name, src, - isBeingFollowed, - isCurrentUser, + className, }: AvatarProps) => { const shortName = getNameInitial(name); const [error, setError] = useState(false); const loadImg = !error && src; const style = loadImg ? undefined : { background: color }; return ( -
+
{loadImg ? ( * { + pointer-events: var(--ui-pointerEvents); + } } &__footer { diff --git a/packages/excalidraw/components/UserList.scss b/packages/excalidraw/components/UserList.scss index fceb1e7c4..86c3179ad 100644 --- a/packages/excalidraw/components/UserList.scss +++ b/packages/excalidraw/components/UserList.scss @@ -1,16 +1,25 @@ @import "../css/variables.module"; .excalidraw { + --avatar-size: 1.75rem; + --avatarList-gap: 0.625rem; + --userList-padding: var(--space-factor); + + .UserList-wrapper { + display: flex; + width: 100%; + justify-content: flex-end; + pointer-events: none !important; + } + .UserList { pointer-events: none; - /*github corner*/ - padding: var(--space-factor) var(--space-factor) var(--space-factor) - var(--space-factor); + padding: var(--userList-padding); display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; - gap: 0.625rem; + gap: var(--avatarList-gap); &:empty { display: none; @@ -18,15 +27,16 @@ box-sizing: border-box; - // can fit max 4 avatars (3 avatars + show more) in a column - max-height: 120px; + --max-size: calc( + var(--avatar-size) * var(--max-avatars, 2) + var(--avatarList-gap) * + (var(--max-avatars, 2) - 1) + var(--userList-padding) * 2 + ); - // can fit max 4 avatars (3 avatars + show more) when there's enough space - max-width: 120px; + // max width & height set to fix the max-avatars + max-height: var(--max-size); + max-width: var(--max-size); // Tweak in 30px increments to fit more/fewer avatars in a row/column ^^ - - overflow: hidden; } .UserList > * { @@ -45,10 +55,11 @@ @include avatarStyles; background-color: var(--color-gray-20); border: 0 !important; - font-size: 0.5rem; + font-size: 0.625rem; font-weight: 400; flex-shrink: 0; color: var(--color-gray-100); + font-weight: bold; } .UserList__collaborator-name { @@ -57,13 +68,82 @@ white-space: nowrap; } - .UserList__collaborator-follow-status-icon { + .UserList__collaborator--avatar-only { + position: relative; + display: flex; + flex: 0 0 auto; + .UserList__collaborator-status-icon { + --size: 14px; + position: absolute; + display: flex; + flex: 0 0 auto; + bottom: -0.25rem; + right: -0.25rem; + width: var(--size); + height: var(--size); + svg { + flex: 0 0 auto; + width: var(--size); + height: var(--size); + } + } + } + + .UserList__collaborator-status-icons { margin-left: auto; flex: 0 0 auto; - width: 1rem; + min-width: 2.25rem; + gap: 0.25rem; + justify-content: flex-end; display: flex; } + .UserList__collaborator.is-muted + .UserList__collaborator-status-icon-microphone-muted { + color: var(--color-danger); + filter: drop-shadow(0px 0px 0px rgba(0, 0, 0, 0.5)); + } + + .UserList__collaborator-status-icon-speaking-indicator { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: space-between; + width: 1rem; + padding: 0 3px; + box-sizing: border-box; + + div { + width: 0.125rem; + height: 0.4rem; + // keep this in sync with constants.ts + background-color: #a2f1a6; + } + + div:nth-of-type(1) { + animation: speaking-indicator-anim 1s -0.45s ease-in-out infinite; + } + + div:nth-of-type(2) { + animation: speaking-indicator-anim 1s -0.9s ease-in-out infinite; + } + + div:nth-of-type(3) { + animation: speaking-indicator-anim 1s -0.15s ease-in-out infinite; + } + } + + @keyframes speaking-indicator-anim { + 0%, + 100% { + transform: scaleY(1); + } + + 50% { + transform: scaleY(2); + } + } + --userlist-hint-bg-color: var(--color-gray-10); --userlist-hint-heading-color: var(--color-gray-80); --userlist-hint-text-color: var(--color-gray-60); @@ -80,7 +160,7 @@ position: static; top: auto; margin-top: 0; - max-height: 12rem; + max-height: 50vh; overflow-y: auto; padding: 0.25rem 0.5rem; border-top: 1px solid var(--userlist-collaborators-border-color); diff --git a/packages/excalidraw/components/UserList.tsx b/packages/excalidraw/components/UserList.tsx index ba01b52dc..ced759333 100644 --- a/packages/excalidraw/components/UserList.tsx +++ b/packages/excalidraw/components/UserList.tsx @@ -1,6 +1,6 @@ import "./UserList.scss"; -import React from "react"; +import React, { useLayoutEffect } from "react"; import clsx from "clsx"; import { Collaborator, SocketId } from "../types"; import { Tooltip } from "./Tooltip"; @@ -12,9 +12,11 @@ import { Island } from "./Island"; import { searchIcon } from "./icons"; import { t } from "../i18n"; import { isShallowEqual } from "../utils"; +import { supportsResizeObserver } from "../constants"; +import { MarkRequired } from "../utility-types"; export type GoToCollaboratorComponentProps = { - clientId: ClientId; + socketId: SocketId; collaborator: Collaborator; withName: boolean; isBeingFollowed: boolean; @@ -23,45 +25,41 @@ export type GoToCollaboratorComponentProps = { /** collaborator user id or socket id (fallback) */ type ClientId = string & { _brand: "UserId" }; -const FIRST_N_AVATARS = 3; +const DEFAULT_MAX_AVATARS = 4; const SHOW_COLLABORATORS_FILTER_AT = 8; const ConditionalTooltipWrapper = ({ shouldWrap, children, - clientId, username, }: { shouldWrap: boolean; children: React.ReactNode; username?: string | null; - clientId: ClientId; }) => shouldWrap ? ( - - {children} - + {children} ) : ( - {children} + {children} ); const renderCollaborator = ({ actionManager, collaborator, - clientId, + socketId, withName = false, shouldWrapWithTooltip = false, isBeingFollowed, }: { actionManager: ActionManager; collaborator: Collaborator; - clientId: ClientId; + socketId: SocketId; withName?: boolean; shouldWrapWithTooltip?: boolean; isBeingFollowed: boolean; }) => { const data: GoToCollaboratorComponentProps = { - clientId, + socketId, collaborator, withName, isBeingFollowed, @@ -70,8 +68,7 @@ const renderCollaborator = ({ return ( @@ -82,7 +79,13 @@ const renderCollaborator = ({ type UserListUserObject = Pick< Collaborator, - "avatarUrl" | "id" | "socketId" | "username" + | "avatarUrl" + | "id" + | "socketId" + | "username" + | "isInCall" + | "isSpeaking" + | "isMuted" >; type UserListProps = { @@ -97,13 +100,19 @@ const collaboratorComparatorKeys = [ "id", "socketId", "username", + "isInCall", + "isSpeaking", + "isMuted", ] as const; export const UserList = React.memo( ({ className, mobile, collaborators, userToFollow }: UserListProps) => { const actionManager = useExcalidrawActionManager(); - const uniqueCollaboratorsMap = new Map(); + const uniqueCollaboratorsMap = new Map< + ClientId, + MarkRequired + >(); collaborators.forEach((collaborator, socketId) => { const userId = (collaborator.id || socketId) as ClientId; @@ -114,115 +123,147 @@ export const UserList = React.memo( ); }); - const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter( - ([_, collaborator]) => collaborator.username?.trim(), - ); + const uniqueCollaboratorsArray = Array.from( + uniqueCollaboratorsMap.values(), + ).filter((collaborator) => collaborator.username?.trim()); const [searchTerm, setSearchTerm] = React.useState(""); - if (uniqueCollaboratorsArray.length === 0) { - return null; - } + const userListWrapper = React.useRef(null); + + useLayoutEffect(() => { + if (userListWrapper.current) { + const updateMaxAvatars = (width: number) => { + const maxAvatars = Math.max(1, Math.min(8, Math.floor(width / 38))); + setMaxAvatars(maxAvatars); + }; + + updateMaxAvatars(userListWrapper.current.clientWidth); + + if (!supportsResizeObserver) { + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width } = entry.contentRect; + updateMaxAvatars(width); + } + }); + + resizeObserver.observe(userListWrapper.current); + + return () => { + resizeObserver.disconnect(); + }; + } + }, []); + + const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS); const searchTermNormalized = searchTerm.trim().toLowerCase(); const filteredCollaborators = searchTermNormalized - ? uniqueCollaboratorsArray.filter(([, collaborator]) => + ? uniqueCollaboratorsArray.filter((collaborator) => collaborator.username?.toLowerCase().includes(searchTerm), ) : uniqueCollaboratorsArray; const firstNCollaborators = uniqueCollaboratorsArray.slice( 0, - FIRST_N_AVATARS, + maxAvatars - 1, ); - const firstNAvatarsJSX = firstNCollaborators.map( - ([clientId, collaborator]) => - renderCollaborator({ - actionManager, - collaborator, - clientId, - shouldWrapWithTooltip: true, - isBeingFollowed: collaborator.socketId === userToFollow, - }), + const firstNAvatarsJSX = firstNCollaborators.map((collaborator) => + renderCollaborator({ + actionManager, + collaborator, + socketId: collaborator.socketId, + shouldWrapWithTooltip: true, + isBeingFollowed: collaborator.socketId === userToFollow, + }), ); return mobile ? (
- {uniqueCollaboratorsArray.map(([clientId, collaborator]) => + {uniqueCollaboratorsArray.map((collaborator) => renderCollaborator({ actionManager, collaborator, - clientId, + socketId: collaborator.socketId, shouldWrapWithTooltip: true, isBeingFollowed: collaborator.socketId === userToFollow, }), )}
) : ( -
- {firstNAvatarsJSX} +
+
+ {firstNAvatarsJSX} - {uniqueCollaboratorsArray.length > FIRST_N_AVATARS && ( - { - if (!isOpen) { - setSearchTerm(""); - } - }} - > - - +{uniqueCollaboratorsArray.length - FIRST_N_AVATARS} - - maxAvatars - 1 && ( + { + if (!isOpen) { + setSearchTerm(""); + } }} - align="end" - sideOffset={10} > - - {uniqueCollaboratorsArray.length >= - SHOW_COLLABORATORS_FILTER_AT && ( -
- {searchIcon} - { - setSearchTerm(e.target.value); - }} - /> -
- )} -
- {filteredCollaborators.length === 0 && ( -
- {t("userList.search.empty")} + + +{uniqueCollaboratorsArray.length - maxAvatars + 1} + + + + {uniqueCollaboratorsArray.length >= + SHOW_COLLABORATORS_FILTER_AT && ( +
+ {searchIcon} + { + setSearchTerm(e.target.value); + }} + />
)} -
- {t("userList.hint.text")} +
+ {filteredCollaborators.length === 0 && ( +
+ {t("userList.search.empty")} +
+ )} +
+ {t("userList.hint.text")} +
+ {filteredCollaborators.map((collaborator) => + renderCollaborator({ + actionManager, + collaborator, + socketId: collaborator.socketId, + withName: true, + isBeingFollowed: collaborator.socketId === userToFollow, + }), + )}
- {filteredCollaborators.map(([clientId, collaborator]) => - renderCollaborator({ - actionManager, - collaborator, - clientId, - withName: true, - isBeingFollowed: collaborator.socketId === userToFollow, - }), - )} -
-
-
- - )} + + + + )} +
); }, @@ -236,10 +277,15 @@ export const UserList = React.memo( return false; } + const nextCollaboratorSocketIds = next.collaborators.keys(); + for (const [socketId, collaborator] of prev.collaborators) { const nextCollaborator = next.collaborators.get(socketId); if ( !nextCollaborator || + // this checks order of collaborators in the map is the same + // as previous render + socketId !== nextCollaboratorSocketIds.next().value || !isShallowEqual( collaborator, nextCollaborator, diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index e76d8ae68..163756d57 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -66,42 +66,46 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { return; } - const cursorButton: { - [id: string]: string | undefined; - } = {}; - const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] = - {}; + const remotePointerButton: InteractiveCanvasRenderConfig["remotePointerButton"] = + new Map(); + const remotePointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] = + new Map(); const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] = - {}; - const pointerUsernames: { [id: string]: string } = {}; - const pointerUserStates: { [id: string]: string } = {}; + new Map(); + const remotePointerUsernames: InteractiveCanvasRenderConfig["remotePointerUsernames"] = + new Map(); + const remotePointerUserStates: InteractiveCanvasRenderConfig["remotePointerUserStates"] = + new Map(); props.appState.collaborators.forEach((user, socketId) => { if (user.selectedElementIds) { for (const id of Object.keys(user.selectedElementIds)) { - if (!(id in remoteSelectedElementIds)) { - remoteSelectedElementIds[id] = []; + if (!remoteSelectedElementIds.has(id)) { + remoteSelectedElementIds.set(id, []); } - remoteSelectedElementIds[id].push(socketId); + remoteSelectedElementIds.get(id)!.push(socketId); } } if (!user.pointer) { return; } if (user.username) { - pointerUsernames[socketId] = user.username; + remotePointerUsernames.set(socketId, user.username); } if (user.userState) { - pointerUserStates[socketId] = user.userState; + remotePointerUserStates.set(socketId, user.userState); } - pointerViewportCoords[socketId] = sceneCoordsToViewportCoords( - { - sceneX: user.pointer.x, - sceneY: user.pointer.y, - }, - props.appState, + remotePointerViewportCoords.set( + socketId, + sceneCoordsToViewportCoords( + { + sceneX: user.pointer.x, + sceneY: user.pointer.y, + }, + props.appState, + ), ); - cursorButton[socketId] = user.button; + remotePointerButton.set(socketId, user.button); }); const selectionColor = @@ -120,11 +124,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { scale: window.devicePixelRatio, appState: props.appState, renderConfig: { - remotePointerViewportCoords: pointerViewportCoords, - remotePointerButton: cursorButton, + remotePointerViewportCoords, + remotePointerButton, remoteSelectedElementIds, - remotePointerUsernames: pointerUsernames, - remotePointerUserStates: pointerUserStates, + remotePointerUsernames, + remotePointerUserStates, selectionColor, renderScrollbars: false, }, diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 967ae1976..063253f69 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1798,7 +1798,7 @@ export const fullscreenIcon = createIcon( ); export const eyeIcon = createIcon( - + @@ -1837,3 +1837,26 @@ export const searchIcon = createIcon( , tablerIconProps, ); + +export const microphoneIcon = createIcon( + + + + + + + , + tablerIconProps, +); + +export const microphoneMutedIcon = createIcon( + + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 09e497564..ad87cb9e1 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -20,6 +20,9 @@ export const isIOS = export const isBrave = () => (navigator as any).brave?.isBrave?.name === "isBrave"; +export const supportsResizeObserver = + typeof window !== "undefined" && "ResizeObserver" in window; + export const APP_NAME = "Excalidraw"; export const DRAGGING_THRESHOLD = 10; // px @@ -144,6 +147,11 @@ export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERSION = "{version}"; export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2; +export const COLOR_WHITE = "#ffffff"; +export const COLOR_CHARCOAL_BLACK = "#1e1e1e"; +// keep this in sync with CSS +export const COLOR_VOICE_CALL = "#a2f1a6"; + export const CANVAS_ONLY_ACTIONS = ["selectAll"]; export const GRID_SIZE = 20; // TODO make it configurable? diff --git a/packages/excalidraw/css/variables.module.scss b/packages/excalidraw/css/variables.module.scss index 247e3f840..71097ba3e 100644 --- a/packages/excalidraw/css/variables.module.scss +++ b/packages/excalidraw/css/variables.module.scss @@ -116,8 +116,8 @@ } @mixin avatarStyles { - width: 1.25rem; - height: 1.25rem; + width: var(--avatar-size, 1.5rem); + height: var(--avatar-size, 1.5rem); position: relative; border-radius: 100%; outline-offset: 2px; @@ -131,6 +131,10 @@ color: var(--color-gray-90); flex: 0 0 auto; + &:active { + transform: scale(0.94); + } + &-img { width: 100%; height: 100%; @@ -144,14 +148,14 @@ right: -3px; bottom: -3px; left: -3px; - border: 1px solid var(--avatar-border-color); border-radius: 100%; } - &--is-followed::before { + &.is-followed::before { border-color: var(--color-primary-hover); + box-shadow: 0 0 0 1px var(--color-primary-hover); } - &--is-current-user { + &.is-current-user { cursor: auto; } } diff --git a/packages/excalidraw/laser-trails.ts b/packages/excalidraw/laser-trails.ts index 49a0de5be..a58efddef 100644 --- a/packages/excalidraw/laser-trails.ts +++ b/packages/excalidraw/laser-trails.ts @@ -84,7 +84,7 @@ export class LaserTrails implements Trail { if (!this.collabTrails.has(key)) { trail = new AnimatedTrail(this.animationFrameHandler, this.app, { ...this.getTrailOptions(), - fill: () => getClientColor(key), + fill: () => getClientColor(key, collabolator), }); trail.start(this.container); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index ac9108a32..1213bc318 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -534,7 +534,10 @@ }, "hint": { "text": "Click on user to follow", - "followStatus": "You're currently following this user" + "followStatus": "You're currently following this user", + "inCall": "User is in a voice call", + "micMuted": "User's microphone is muted", + "isSpeaking": "User is speaking" } } } diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index a6d997770..0fd814e89 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -15,7 +15,7 @@ import { } from "../scene/scrollbars"; import { renderSelectionElement } from "../renderer/renderElement"; -import { getClientColor } from "../clients"; +import { getClientColor, renderRemoteCursors } from "../clients"; import { isSelectedViaGroup, getSelectedGroupIds, @@ -29,7 +29,7 @@ import { TransformHandleType, } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; -import { InteractiveCanvasAppState, Point, UserIdleState } from "../types"; +import { InteractiveCanvasAppState, Point } from "../types"; import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants"; import { renderSnaps } from "../renderer/renderSnaps"; @@ -726,14 +726,18 @@ const _renderInteractiveScene = ({ selectionColors.push(selectionColor); } // remote users - if (renderConfig.remoteSelectedElementIds[element.id]) { + const remoteClients = renderConfig.remoteSelectedElementIds.get( + element.id, + ); + if (remoteClients) { selectionColors.push( - ...renderConfig.remoteSelectedElementIds[element.id].map( - (socketId: string) => { - const background = getClientColor(socketId); - return background; - }, - ), + ...remoteClients.map((socketId) => { + const background = getClientColor( + socketId, + appState.collaborators.get(socketId), + ); + return background; + }), ); } @@ -747,7 +751,7 @@ const _renderInteractiveScene = ({ elementX2, elementY2, selectionColors, - dashed: !!renderConfig.remoteSelectedElementIds[element.id], + dashed: !!remoteClients, cx, cy, activeEmbeddable: @@ -858,143 +862,13 @@ const _renderInteractiveScene = ({ // Reset zoom context.restore(); - // Paint remote pointers - for (const clientId in renderConfig.remotePointerViewportCoords) { - let { x, y } = renderConfig.remotePointerViewportCoords[clientId]; - - x -= appState.offsetLeft; - y -= appState.offsetTop; - - const width = 11; - const height = 14; - - const isOutOfBounds = - x < 0 || - x > normalizedWidth - width || - y < 0 || - y > normalizedHeight - height; - - x = Math.max(x, 0); - x = Math.min(x, normalizedWidth - width); - y = Math.max(y, 0); - y = Math.min(y, normalizedHeight - height); - - const background = getClientColor(clientId); - - context.save(); - context.strokeStyle = background; - context.fillStyle = background; - - const userState = renderConfig.remotePointerUserStates[clientId]; - const isInactive = - isOutOfBounds || - userState === UserIdleState.IDLE || - userState === UserIdleState.AWAY; - - if (isInactive) { - context.globalAlpha = 0.3; - } - - if ( - renderConfig.remotePointerButton && - renderConfig.remotePointerButton[clientId] === "down" - ) { - context.beginPath(); - context.arc(x, y, 15, 0, 2 * Math.PI, false); - context.lineWidth = 3; - context.strokeStyle = "#ffffff88"; - context.stroke(); - context.closePath(); - - context.beginPath(); - context.arc(x, y, 15, 0, 2 * Math.PI, false); - context.lineWidth = 1; - context.strokeStyle = background; - context.stroke(); - context.closePath(); - } - - // Background (white outline) for arrow - context.fillStyle = oc.white; - context.strokeStyle = oc.white; - context.lineWidth = 6; - context.lineJoin = "round"; - context.beginPath(); - context.moveTo(x, y); - context.lineTo(x + 0, y + 14); - context.lineTo(x + 4, y + 9); - context.lineTo(x + 11, y + 8); - context.closePath(); - context.stroke(); - context.fill(); - - // Arrow - context.fillStyle = background; - context.strokeStyle = background; - context.lineWidth = 2; - context.lineJoin = "round"; - context.beginPath(); - if (isInactive) { - context.moveTo(x - 1, y - 1); - context.lineTo(x - 1, y + 15); - context.lineTo(x + 5, y + 10); - context.lineTo(x + 12, y + 9); - context.closePath(); - context.fill(); - } else { - context.moveTo(x, y); - context.lineTo(x + 0, y + 14); - context.lineTo(x + 4, y + 9); - context.lineTo(x + 11, y + 8); - context.closePath(); - context.fill(); - context.stroke(); - } - - const username = renderConfig.remotePointerUsernames[clientId] || ""; - - if (!isOutOfBounds && username) { - context.font = "600 12px sans-serif"; // font has to be set before context.measureText() - - const offsetX = x + width / 2; - const offsetY = y + height + 2; - const paddingHorizontal = 5; - const paddingVertical = 3; - const measure = context.measureText(username); - const measureHeight = - measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; - const finalHeight = Math.max(measureHeight, 12); - - const boxX = offsetX - 1; - const boxY = offsetY - 1; - const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2; - const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2; - if (context.roundRect) { - context.beginPath(); - context.roundRect(boxX, boxY, boxWidth, boxHeight, 8); - context.fillStyle = background; - context.fill(); - context.strokeStyle = oc.white; - context.stroke(); - } else { - roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, oc.white); - } - context.fillStyle = oc.black; - - context.fillText( - username, - offsetX + paddingHorizontal + 1, - offsetY + - paddingVertical + - measure.actualBoundingBoxAscent + - Math.floor((finalHeight - measureHeight) / 2) + - 2, - ); - } - - context.restore(); - context.closePath(); - } + renderRemoteCursors({ + context, + renderConfig, + appState, + normalizedWidth, + normalizedHeight, + }); // Paint scrollbars let scrollBars; diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 02aa3b7bf..63a49fec5 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -1,6 +1,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable } from "roughjs/bin/core"; import { + ExcalidrawElement, ExcalidrawTextElement, NonDeletedElementsMap, NonDeletedExcalidrawElement, @@ -13,6 +14,8 @@ import { ElementsPendingErasure, InteractiveCanvasAppState, StaticCanvasAppState, + SocketId, + UserIdleState, } from "../types"; import { MakeBrand } from "../utility-types"; @@ -46,11 +49,11 @@ export type SVGRenderConfig = { 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 }; + remoteSelectedElementIds: Map; + remotePointerViewportCoords: Map; + remotePointerUserStates: Map; + remotePointerUsernames: Map; + remotePointerButton: Map; selectionColor?: string; // extra options passed to the renderer // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index fefb82c2c..2729bc037 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -61,6 +61,9 @@ export type Collaborator = Readonly<{ id?: string; socketId?: SocketId; isCurrentUser?: boolean; + isInCall?: boolean; + isSpeaking?: boolean; + isMuted?: boolean; }>; export type CollaboratorPointer = { @@ -319,9 +322,9 @@ export interface AppState { y: number; } | null; objectsSnapModeEnabled: boolean; - /** the user's clientId & username who is being followed on the canvas */ + /** the user's socket id & username who is being followed on the canvas */ userToFollow: UserToFollow | null; - /** the clientIds of the users following the current user */ + /** the socket ids of the users following the current user */ followedBy: Set; } diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index d27445dfa..493dce340 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -791,6 +791,14 @@ export const isShallowEqual = < const aKeys = Object.keys(objA); const bKeys = Object.keys(objB); if (aKeys.length !== bKeys.length) { + if (debug) { + console.warn( + `%cisShallowEqual: objects don't have same properties ->`, + "color: #8B4000", + objA, + objB, + ); + } return false; } From 15bfa626b41c3981aed557666f67be3453243b29 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:41:06 +0100 Subject: [PATCH 002/174] feat: support to not render remote cursor & username (#7130) --- .../components/canvases/InteractiveCanvas.tsx | 2 +- packages/excalidraw/constants.ts | 1 + packages/excalidraw/index.tsx | 8 +++++- packages/excalidraw/laser-trails.ts | 25 +++++++++++-------- packages/excalidraw/types.ts | 13 ++++++++++ 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 163756d57..e5cd60f62 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -86,7 +86,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { remoteSelectedElementIds.get(id)!.push(socketId); } } - if (!user.pointer) { + if (!user.pointer || user.pointer.renderCursor === false) { return; } if (user.username) { diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index ad87cb9e1..29659f86a 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -31,6 +31,7 @@ export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_TRANSLATE_AMOUNT = 1; export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30; export const SHIFT_LOCKING_ANGLE = Math.PI / 12; +export const DEFAULT_LASER_COLOR = "red"; export const CURSOR_TYPE = { TEXT: "text", CROSSHAIR: "crosshair", diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 66eb91044..e1dc29e66 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -237,7 +237,13 @@ export { getFreeDrawSvgPath } from "./renderer/renderElement"; export { mergeLibraryItems, getLibraryItemsHash } from "./data/library"; export { isLinearElement } from "./element/typeChecks"; -export { FONT_FAMILY, THEME, MIME_TYPES, ROUNDNESS } from "./constants"; +export { + FONT_FAMILY, + THEME, + MIME_TYPES, + ROUNDNESS, + DEFAULT_LASER_COLOR, +} from "./constants"; export { mutateElement, diff --git a/packages/excalidraw/laser-trails.ts b/packages/excalidraw/laser-trails.ts index a58efddef..e2ef258b0 100644 --- a/packages/excalidraw/laser-trails.ts +++ b/packages/excalidraw/laser-trails.ts @@ -5,6 +5,7 @@ import type App from "./components/App"; import { SocketId } from "./types"; import { easeOut } from "./utils"; import { getClientColor } from "./clients"; +import { DEFAULT_LASER_COLOR } from "./constants"; export class LaserTrails implements Trail { public localTrail: AnimatedTrail; @@ -20,7 +21,7 @@ export class LaserTrails implements Trail { this.localTrail = new AnimatedTrail(animationFrameHandler, app, { ...this.getTrailOptions(), - fill: () => "red", + fill: () => DEFAULT_LASER_COLOR, }); } @@ -78,13 +79,15 @@ export class LaserTrails implements Trail { return; } - for (const [key, collabolator] of this.app.state.collaborators.entries()) { + for (const [key, collaborator] of this.app.state.collaborators.entries()) { let trail!: AnimatedTrail; if (!this.collabTrails.has(key)) { trail = new AnimatedTrail(this.animationFrameHandler, this.app, { ...this.getTrailOptions(), - fill: () => getClientColor(key, collabolator), + fill: () => + collaborator.pointer?.laserColor || + getClientColor(key, collaborator), }); trail.start(this.container); @@ -93,21 +96,21 @@ export class LaserTrails implements Trail { trail = this.collabTrails.get(key)!; } - if (collabolator.pointer && collabolator.pointer.tool === "laser") { - if (collabolator.button === "down" && !trail.hasCurrentTrail) { - trail.startPath(collabolator.pointer.x, collabolator.pointer.y); + if (collaborator.pointer && collaborator.pointer.tool === "laser") { + if (collaborator.button === "down" && !trail.hasCurrentTrail) { + trail.startPath(collaborator.pointer.x, collaborator.pointer.y); } if ( - collabolator.button === "down" && + collaborator.button === "down" && trail.hasCurrentTrail && - !trail.hasLastPoint(collabolator.pointer.x, collabolator.pointer.y) + !trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y) ) { - trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y); + trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); } - if (collabolator.button === "up" && trail.hasCurrentTrail) { - trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y); + if (collaborator.button === "up" && trail.hasCurrentTrail) { + trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); trail.endPath(); } } diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 2729bc037..d7f701ff8 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -70,6 +70,19 @@ export type CollaboratorPointer = { x: number; y: number; tool: "pointer" | "laser"; + /** + * Whether to render cursor + username. Useful when you only want to render + * laser trail. + * + * @default true + */ + renderCursor?: boolean; + /** + * Explicit laser color. + * + * @default string collaborator's cursor color + */ + laserColor?: string; }; export type DataURL = string & { _brand: "DataURL" }; From 7949aa1f1c0010866206ac4e7109139e2589aeb3 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 28 Mar 2024 16:44:29 +0530 Subject: [PATCH 003/174] feat: upgrade mermaid-to-excalidraw to 0.3.0 (#7819) --- packages/excalidraw/package.json | 2 +- yarn.lock | 132 ++++++++++++++++++++----------- 2 files changed, 87 insertions(+), 47 deletions(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 5e5c52b21..0b12d46fa 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -58,7 +58,7 @@ "dependencies": { "@braintree/sanitize-url": "6.0.2", "@excalidraw/laser-pointer": "1.3.1", - "@excalidraw/mermaid-to-excalidraw": "0.2.0", + "@excalidraw/mermaid-to-excalidraw": "0.3.0", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", diff --git a/yarn.lock b/yarn.lock index 61def89e7..e9dc642c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1980,7 +1980,7 @@ resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f" integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg== -"@braintree/sanitize-url@^6.0.2": +"@braintree/sanitize-url@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" integrity sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A== @@ -2147,13 +2147,13 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-0.2.0.tgz#1e0395cd2b1ce9f6898109f5dbf545f558f159cc" - integrity sha512-FR+Lw9dt+mQxsrmRL7YNU2wrlNXD16ZLyuNoKrPzPy+Ds3utzY1+/2UNeNu7FMSUO4hKdkrmyO+PDp9OvOhuKw== +"@excalidraw/mermaid-to-excalidraw@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-0.3.0.tgz#94c438133fc66db6b920e237abda5152b62e6cb0" + integrity sha512-eyFN8y2ES3HFtETZWZZBakkSB5ROfnHJeCLeBlMgrIk1fxbXpPtxlu2VwGNpqPjDiCfV5FYnx7FaZ4CRiVRVMg== dependencies: "@excalidraw/markdown-to-text" "0.1.2" - mermaid "10.2.3" + mermaid "10.9.0" nanoid "4.0.2" "@excalidraw/prettier-config@1.0.2": @@ -3295,6 +3295,23 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.0.tgz#23509ebc1fa32f1b4d50d6a66c4032d5b8eaabdc" integrity sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw== +"@types/d3-scale-chromatic@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz#fc0db9c10e789c351f4c42d96f31f2e4df8f5644" + integrity sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw== + +"@types/d3-scale@^4.0.3": + version "4.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb" + integrity sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ== + dependencies: + "@types/d3-time" "*" + +"@types/d3-time@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.3.tgz#3c186bbd9d12b9d84253b6be6487ca56b54f88be" + integrity sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw== + "@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -4986,13 +5003,6 @@ cose-base@^1.0.0: dependencies: layout-base "^1.0.0" -cose-base@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cose-base/-/cose-base-2.2.0.tgz#1c395c35b6e10bb83f9769ca8b817d614add5c01" - integrity sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g== - dependencies: - layout-base "^2.0.0" - cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" @@ -5094,21 +5104,21 @@ cytoscape-cose-bilkent@^4.1.0: dependencies: cose-base "^1.0.0" -cytoscape-fcose@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz#e4d6f6490df4fab58ae9cea9e5c3ab8d7472f471" - integrity sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ== - dependencies: - cose-base "^2.2.0" - -cytoscape@^3.23.0: - version "3.27.0" - resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.27.0.tgz#5141cd694570807c91075b609181bce102e0bb88" - integrity sha512-pPZJilfX9BxESwujODz5pydeGi+FBrXq1rcaB1mfhFXXFJ9GjE6CNndAk+8jPzoXGD+16LtSS4xlYEIUiW4Abg== +cytoscape@^3.28.1: + version "3.28.1" + resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.28.1.tgz#f32c3e009bdf32d47845a16a4cd2be2bbc01baf7" + integrity sha512-xyItz4O/4zp9/239wCcH8ZcFuuZooEeF8KHRmzjDfGdXsj3OG9MFSMA0pJE0uX3uCN/ygof6hHf4L7lst+JaDg== dependencies: heap "^0.2.6" lodash "^4.17.21" +"d3-array@1 - 2": + version "2.12.1" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81" + integrity sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ== + dependencies: + internmap "^1.0.0" + "d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0: version "3.2.4" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" @@ -5225,6 +5235,11 @@ d3-hierarchy@3: dependencies: d3-color "1 - 3" +d3-path@1: + version "1.0.9" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" + integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== + "d3-path@1 - 3", d3-path@3, d3-path@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" @@ -5245,6 +5260,14 @@ d3-random@3: resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-3.0.1.tgz#d4926378d333d9c0bfd1e6fa0194d30aebaa20f4" integrity sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ== +d3-sankey@^0.12.3: + version "0.12.3" + resolved "https://registry.yarnpkg.com/d3-sankey/-/d3-sankey-0.12.3.tgz#b3c268627bd72e5d80336e8de6acbfec9d15d01d" + integrity sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ== + dependencies: + d3-array "1 - 2" + d3-shape "^1.2.0" + d3-scale-chromatic@3: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#15b4ceb8ca2bb0dcb6d1a641ee03d59c3b62376a" @@ -5276,6 +5299,13 @@ d3-shape@3: dependencies: d3-path "^3.1.0" +d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + "d3-time-format@2 - 4", d3-time-format@4: version "4.1.0" resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" @@ -5565,10 +5595,10 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" -dompurify@3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.3.tgz#4b115d15a091ddc96f232bcef668550a2f6f1430" - integrity sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ== +dompurify@^3.0.5: + version "3.0.11" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.11.tgz#c163f5816eaac6aeef35dae2b77fca0504564efe" + integrity sha512-Fan4uMuyB26gFV3ovPoEoQbxRRPfTu3CvImyZnhGq5fsIEO+gEFLp45ISFt+kQBWsK5ulDdT0oV28jS1UrwQLg== dotenv@16.0.1: version "16.0.1" @@ -5602,10 +5632,10 @@ electron-to-chromium@^1.4.601: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.608.tgz#ff567c51dde4892ae330860c7d9f19571e9e1d69" integrity sha512-J2f/3iIIm3Mo0npneITZ2UPe4B1bg8fTNrFjD8715F/k1BvbviRuqYGkET1PgprrczXYTHFvotbBOmUp6KE0uA== -elkjs@^0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e" - integrity sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ== +elkjs@^0.9.0: + version "0.9.2" + resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.9.2.tgz#3d4ef6f17fde06a5d7eaa3063bb875e25e59e972" + integrity sha512-2Y/RaA1pdgSHpY0YG4TYuYCD2wh97CRvu22eLG3Kz0pgQ/6KbIFTxsTnDc4MH/6hFlg2L/9qXrDMG0nMjP63iw== emoji-regex@^8.0.0: version "8.0.0" @@ -6871,6 +6901,11 @@ internal-slot@^1.0.3, internal-slot@^1.0.4, internal-slot@^1.0.5: resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== +internmap@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" + integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== + interpret@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" @@ -7381,6 +7416,13 @@ jsonpointer@^5.0.0: array-includes "^3.1.5" object.assign "^4.1.3" +katex@^0.16.9: + version "0.16.10" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.10.tgz#6f81b71ac37ff4ec7556861160f53bc5f058b185" + integrity sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA== + dependencies: + commander "^8.3.0" + khroma@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/khroma/-/khroma-2.1.0.tgz#45f2ce94ce231a437cf5b63c2e886e6eb42bbbb1" @@ -7418,11 +7460,6 @@ layout-base@^1.0.0: resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-1.0.2.tgz#1291e296883c322a9dd4c5dd82063721b53e26e2" integrity sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg== -layout-base@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-2.0.1.tgz#d0337913586c90f9c2c075292069f5c2da5dd285" - integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg== - leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -7710,20 +7747,23 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -mermaid@10.2.3: - version "10.2.3" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.2.3.tgz#789d3b582c5da8c69aa4a7c0e2b826562c8c8b12" - integrity sha512-cMVE5s9PlQvOwfORkyVpr5beMsLdInrycAosdr+tpZ0WFjG4RJ/bUHST7aTgHNJbujHkdBRAm+N50P3puQOfPw== +mermaid@10.9.0: + version "10.9.0" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.0.tgz#4d1272fbe434bd8f3c2c150554dc8a23a9bf9361" + integrity sha512-swZju0hFox/B/qoLKK0rOxxgh8Cf7rJSfAUc1u8fezVihYMvrJAS45GzAxTVf4Q+xn9uMgitBcmWk7nWGXOs/g== dependencies: - "@braintree/sanitize-url" "^6.0.2" - cytoscape "^3.23.0" + "@braintree/sanitize-url" "^6.0.1" + "@types/d3-scale" "^4.0.3" + "@types/d3-scale-chromatic" "^3.0.0" + cytoscape "^3.28.1" cytoscape-cose-bilkent "^4.1.0" - cytoscape-fcose "^2.1.0" d3 "^7.4.0" + d3-sankey "^0.12.3" dagre-d3-es "7.0.10" dayjs "^1.11.7" - dompurify "3.0.3" - elkjs "^0.8.2" + dompurify "^3.0.5" + elkjs "^0.9.0" + katex "^0.16.9" khroma "^2.0.0" lodash-es "^4.17.21" mdast-util-from-markdown "^1.3.0" From 65bc500598b70be00c8d770f49928ff66f77470b Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 28 Mar 2024 14:52:23 +0100 Subject: [PATCH 004/174] fix: `excalidrawAPI.toggleSidebar` not switching between tabs correctly (#7821) --- packages/excalidraw/components/App.tsx | 18 +++- .../components/Sidebar/Sidebar.test.tsx | 82 ++++++++++++++++++- .../components/Sidebar/SidebarTab.tsx | 2 +- 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b02d919d4..b920a1037 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3684,17 +3684,29 @@ class App extends React.Component { tab, force, }: { - name: SidebarName; + name: SidebarName | null; tab?: SidebarTabName; force?: boolean; }): boolean => { let nextName; if (force === undefined) { - nextName = this.state.openSidebar?.name === name ? null : name; + nextName = + this.state.openSidebar?.name === name && + this.state.openSidebar?.tab === tab + ? null + : name; } else { nextName = force ? name : null; } - this.setState({ openSidebar: nextName ? { name: nextName, tab } : null }); + + const nextState: AppState["openSidebar"] = nextName + ? { name: nextName } + : null; + if (nextState && tab) { + nextState.tab = tab; + } + + this.setState({ openSidebar: nextState }); return !!nextName; }; diff --git a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx index 9787f9a73..6b60418b5 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx @@ -85,7 +85,7 @@ describe("Sidebar", () => { }); }); - it("should toggle sidebar using props.toggleMenu()", async () => { + it("should toggle sidebar using excalidrawAPI.toggleSidebar()", async () => { const { container } = await render( @@ -158,6 +158,20 @@ describe("Sidebar", () => { const sidebars = container.querySelectorAll(".sidebar"); expect(sidebars.length).toBe(1); }); + + // closing sidebar using `{ name: null }` + // ------------------------------------------------------------------------- + expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true); + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).not.toBe(null); + }); + + expect(window.h.app.toggleSidebar({ name: null })).toBe(false); + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).toBe(null); + }); }); }); @@ -329,4 +343,70 @@ describe("Sidebar", () => { ); }); }); + + describe("Sidebar.tab", () => { + it("should toggle sidebars tabs correctly", async () => { + const { container } = await render( + + + + Library + Comments + + + , + ); + + await withExcalidrawDimensions( + { width: 1920, height: 1080 }, + async () => { + expect( + container.querySelector( + "[role=tabpanel][data-testid=library]", + ), + ).toBeNull(); + + // open library sidebar + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "library" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=library]", + ), + ).not.toBeNull(); + + // switch to comments tab + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).not.toBeNull(); + + // toggle sidebar closed + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(false); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).toBeNull(); + + // toggle sidebar open + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).not.toBeNull(); + }, + ); + }); + }); }); diff --git a/packages/excalidraw/components/Sidebar/SidebarTab.tsx b/packages/excalidraw/components/Sidebar/SidebarTab.tsx index 741a69fd1..f7eacc1b1 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTab.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTab.tsx @@ -10,7 +10,7 @@ export const SidebarTab = ({ children: React.ReactNode; } & React.HTMLAttributes) => { return ( - + {children} ); From 6b523563d804d48be260399fe45e30c394e07065 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 28 Mar 2024 14:58:47 +0100 Subject: [PATCH 005/174] fix: ejs support in html files (#7822) --- excalidraw-app/index.html | 8 +- excalidraw-app/package.json | 4 +- excalidraw-app/vite.config.mts | 4 + yarn.lock | 243 +++++++++++++++++++++++++++++++-- 4 files changed, 240 insertions(+), 19 deletions(-) diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 66f3afdab..2e1fa1adb 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -78,7 +78,7 @@ } - <% if ("%PROD%" === "true") { %> + <% if (typeof PROD != 'undefined' && PROD == true) { %> - <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %> + <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && + VITE_APP_DEV_DISABLE_LIVE_RELOAD != true) { %> - <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && - VITE_APP_DEV_DISABLE_LIVE_RELOAD != true) { %> - <% } %> From cd50aa719fa5dcb77beb9f736725fa744e5ba6ba Mon Sep 17 00:00:00 2001 From: Arnost Pleskot Date: Mon, 8 Apr 2024 16:46:24 +0200 Subject: [PATCH 012/174] feat: add system mode to the theme selector (#7853) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/App.tsx | 37 ++++------ excalidraw-app/components/AppMainMenu.tsx | 9 ++- excalidraw-app/index.html | 28 ++++++-- excalidraw-app/useHandleAppTheme.ts | 70 +++++++++++++++++++ packages/excalidraw/CHANGELOG.md | 1 + packages/excalidraw/actions/actionCanvas.tsx | 4 +- packages/excalidraw/components/App.tsx | 6 +- .../excalidraw/components/DarkModeToggle.tsx | 4 +- packages/excalidraw/components/RadioGroup.tsx | 7 +- .../components/dropdownMenu/DropdownMenu.scss | 22 ++++++ .../DropdownMenuItemContentRadio.tsx | 51 ++++++++++++++ packages/excalidraw/components/icons.tsx | 17 +++-- .../components/main-menu/DefaultItems.tsx | 64 +++++++++++++++-- packages/excalidraw/data/magic.ts | 3 +- .../hooks/useCreatePortalContainer.ts | 3 +- packages/excalidraw/locales/en.json | 2 + packages/excalidraw/renderer/helpers.ts | 4 +- packages/excalidraw/renderer/renderElement.ts | 5 +- packages/excalidraw/renderer/renderSnaps.ts | 3 +- packages/excalidraw/scene/export.ts | 3 +- setupTests.ts | 14 ++++ 21 files changed, 301 insertions(+), 56 deletions(-) create mode 100644 excalidraw-app/useHandleAppTheme.ts create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 1bf126924..56033ec15 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -17,7 +17,6 @@ import { FileId, NonDeletedExcalidrawElement, OrderedExcalidrawElement, - Theme, } from "../packages/excalidraw/element/types"; import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState"; import { t } from "../packages/excalidraw/i18n"; @@ -124,6 +123,7 @@ import { exportToPlus, share, } from "../packages/excalidraw/components/icons"; +import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; polyfill(); @@ -303,6 +303,9 @@ const ExcalidrawWrapper = () => { const [langCode, setLangCode] = useAtom(appLangCodeAtom); const isCollabDisabled = isRunningInIframe(); + const [appTheme, setAppTheme] = useAtom(appThemeAtom); + const { editorTheme } = useHandleAppTheme(); + // initial state // --------------------------------------------------------------------------- @@ -566,23 +569,6 @@ const ExcalidrawWrapper = () => { languageDetector.cacheUserLanguage(langCode); }, [langCode]); - const [theme, setTheme] = useState( - () => - (localStorage.getItem( - STORAGE_KEYS.LOCAL_STORAGE_THEME, - ) as Theme | null) || - // FIXME migration from old LS scheme. Can be removed later. #5660 - importFromLocalStorage().appState?.theme || - THEME.LIGHT, - ); - - useEffect(() => { - localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme); - // currently only used for body styling during init (see public/index.html), - // but may change in the future - document.documentElement.classList.toggle("dark", theme === THEME.DARK); - }, [theme]); - const onChange = ( elements: readonly OrderedExcalidrawElement[], appState: AppState, @@ -592,8 +578,6 @@ const ExcalidrawWrapper = () => { collabAPI.syncElements(elements); } - setTheme(appState.theme); - // this check is redundant, but since this is a hot path, it's best // not to evaludate the nested expression every time if (!LocalData.isSavePaused()) { @@ -798,7 +782,7 @@ const ExcalidrawWrapper = () => { detectScroll={false} handleKeyboardGlobally={true} autoFocus={true} - theme={theme} + theme={editorTheme} renderTopRightUI={(isMobile) => { if (isMobile || !collabAPI || isCollabDisabled) { return null; @@ -820,6 +804,8 @@ const ExcalidrawWrapper = () => { onCollabDialogOpen={onCollabDialogOpen} isCollaborating={isCollaborating} isCollabEnabled={!isCollabDisabled} + theme={appTheme} + setTheme={(theme) => setAppTheme(theme)} /> { } }, }, - CommandPalette.defaultItems.toggleTheme, + { + ...CommandPalette.defaultItems.toggleTheme, + perform: () => { + setAppTheme( + editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK, + ); + }, + }, ]} /> diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 813d620c8..fe3f36c9e 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -1,5 +1,6 @@ import React from "react"; import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; +import { Theme } from "../../packages/excalidraw/element/types"; import { MainMenu } from "../../packages/excalidraw/index"; import { LanguageList } from "./LanguageList"; @@ -7,6 +8,8 @@ export const AppMainMenu: React.FC<{ onCollabDialogOpen: () => any; isCollaborating: boolean; isCollabEnabled: boolean; + theme: Theme | "system"; + setTheme: (theme: Theme | "system") => void; }> = React.memo((props) => { return ( @@ -35,7 +38,11 @@ export const AppMainMenu: React.FC<{ - + diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 4d0e2eaa5..db5bd6457 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -64,12 +64,30 @@ `), - intrinsicSize: { w: 550, h: 720 }, - }; - } + intrinsicSize: { w: 550, h: 720 }, + }; embeddedLinkCache.set(link, ret); return ret; } @@ -313,8 +290,8 @@ export const maybeParseEmbedSrc = (str: string): string => { } const gistMatch = str.match(RE_GH_GIST_EMBED); - if (gistMatch && gistMatch.length === 2) { - return gistMatch[1]; + if (gistMatch && gistMatch.length === 3) { + return `https://gist.github.com/${gistMatch[1]}/${gistMatch[2]}`; } if (RE_GIPHY.test(str)) { @@ -325,6 +302,7 @@ export const maybeParseEmbedSrc = (str: string): string => { if (match && match.length === 2) { return match[1]; } + return str; }; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 8d3a0d4ef..2ee9a12b0 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -111,6 +111,7 @@ export type IframeData = | { intrinsicSize: { w: number; h: number }; error?: Error; + sandbox?: { allowSameOrigin?: boolean }; } & ( | { type: "video" | "generic"; link: string } | { type: "document"; srcdoc: (theme: Theme) => string } From 4689a6b300d8756b54fdbaa7a3f1f8db60b9cc78 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 12 Apr 2024 18:58:51 +0800 Subject: [PATCH 020/174] fix: hit test for closed sharp curves (#7881) --- packages/excalidraw/components/App.tsx | 1 + packages/utils/geometry/shape.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 322096d63..6a0fd1031 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -4383,6 +4383,7 @@ class App extends React.Component { return shouldTestInside(element) ? getClosedCurveShape( + element, roughShape, [element.x, element.y], element.angle, diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index 1fbcd7935..87c0fe099 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -20,6 +20,7 @@ import { ExcalidrawFreeDrawElement, ExcalidrawIframeElement, ExcalidrawImageElement, + ExcalidrawLinearElement, ExcalidrawRectangleElement, ExcalidrawSelectionElement, ExcalidrawTextElement, @@ -233,12 +234,12 @@ export const getFreedrawShape = ( }; export const getClosedCurveShape = ( + element: ExcalidrawLinearElement, roughShape: Drawable, startingPoint: Point = [0, 0], angleInRadian: number, center: Point, ): GeometricShape => { - const ops = getCurvePathOps(roughShape); const transform = (p: Point) => pointRotate( [p[0] + startingPoint[0], p[1] + startingPoint[1]], @@ -246,6 +247,15 @@ export const getClosedCurveShape = ( center, ); + if (element.roundness === null) { + return { + type: "polygon", + data: close(element.points.map((p) => transform(p as Point))), + }; + } + + const ops = getCurvePathOps(roughShape); + const points: Point[] = []; let odd = false; for (const operation of ops) { From afcde542f9e991b4b671df191aa155e1ebee6006 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 12 Apr 2024 20:51:17 +0200 Subject: [PATCH 021/174] fix: parse embeddable srcdoc urls strictly (#7884) --- packages/excalidraw/data/url.ts | 6 ++++- packages/excalidraw/element/embeddable.ts | 30 ++++++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/excalidraw/data/url.ts b/packages/excalidraw/data/url.ts index 2655c141d..dae576068 100644 --- a/packages/excalidraw/data/url.ts +++ b/packages/excalidraw/data/url.ts @@ -1,11 +1,15 @@ import { sanitizeUrl } from "@braintree/sanitize-url"; +export const sanitizeHTMLAttribute = (html: string) => { + return html.replace(/"/g, """); +}; + export const normalizeLink = (link: string) => { link = link.trim(); if (!link) { return link; } - return sanitizeUrl(link); + return sanitizeUrl(sanitizeHTMLAttribute(link)); }; export const isLocalLink = (link: string | null) => { diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index 40213aff8..8b55a5441 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -11,6 +11,7 @@ import { ExcalidrawIframeLikeElement, IframeData, } from "./types"; +import { sanitizeHTMLAttribute } from "../data/url"; const embeddedLinkCache = new Map(); @@ -21,12 +22,13 @@ const RE_VIMEO = /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/; -const RE_GH_GIST = /^https:\/\/gist\.github\.com/; +const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/; const RE_GH_GIST_EMBED = - /https?:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)\.js["']/i; + /^ twitter embeds -const RE_TWITTER = /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com/; +const RE_TWITTER = + /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/; const RE_TWITTER_EMBED = /^ createSrcDoc( - ` `, + ` `, ), intrinsicSize: { w: 480, h: 480 }, sandbox: { allowSameOrigin: true }, @@ -167,11 +175,15 @@ export const getEmbedLink = ( } if (RE_GH_GIST.test(link)) { + const [, user, gistId] = link.match(RE_GH_GIST)!; + const safeURL = sanitizeHTMLAttribute( + `https://gist.github.com/${user}/${gistId}`, + ); const ret: IframeData = { type: "document", srcdoc: () => createSrcDoc(` - + `), intrinsicSize: { w: 550, h: 720 }, + sandbox: { allowSameOrigin }, }; embeddedLinkCache.set(link, ret); return ret; } - embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type }); - return { link, intrinsicSize: aspectRatio, type }; + embeddedLinkCache.set(link, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; }; export const createPlaceholderEmbeddableLabel = ( @@ -265,34 +319,39 @@ export const actionSetEmbeddableAsActiveTool = register({ }, }); -const validateHostname = ( +const matchHostname = ( url: string, /** using a Set assumes it already contains normalized bare domains */ allowedHostnames: Set | string, -): boolean => { +): string | null => { try { const { hostname } = new URL(url); const bareDomain = hostname.replace(/^www\./, ""); - const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace( - /^([^.]+)/, - "*", - ); if (allowedHostnames instanceof Set) { - return ( - ALLOWED_DOMAINS.has(bareDomain) || - ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded) + if (ALLOWED_DOMAINS.has(bareDomain)) { + return bareDomain; + } + + const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace( + /^([^.]+)/, + "*", ); + if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) { + return bareDomainWithFirstSubdomainWildcarded; + } + return null; } - if (bareDomain === allowedHostnames.replace(/^www\./, "")) { - return true; + const bareAllowedHostname = allowedHostnames.replace(/^www\./, ""); + if (bareDomain === bareAllowedHostname) { + return bareAllowedHostname; } } catch (error) { // ignore } - return false; + return null; }; export const maybeParseEmbedSrc = (str: string): string => { @@ -342,7 +401,7 @@ export const embeddableURLValidator = ( if (url.match(domain)) { return true; } - } else if (validateHostname(url, domain)) { + } else if (matchHostname(url, domain)) { return true; } } @@ -350,5 +409,5 @@ export const embeddableURLValidator = ( } } - return validateHostname(url, ALLOWED_DOMAINS); + return !!matchHostname(url, ALLOWED_DOMAINS); }; From 890ed9f31fd95f302ad4f7a9e0bb64b75bd93a7e Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 13 Apr 2024 19:12:29 +0200 Subject: [PATCH 024/174] feat: add "toggle grid" to command palette (#7887) --- .../actions/actionToggleGridMode.tsx | 5 ++- .../CommandPalette/CommandPalette.tsx | 1 + packages/excalidraw/components/HelpDialog.tsx | 2 +- packages/excalidraw/components/icons.tsx | 13 ++++++ packages/excalidraw/locales/en.json | 2 +- .../__snapshots__/contextmenu.test.tsx.snap | 45 ++++++++++++++++++- packages/excalidraw/tests/excalidraw.test.tsx | 2 +- 7 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx index 412da0119..46e1879d9 100644 --- a/packages/excalidraw/actions/actionToggleGridMode.tsx +++ b/packages/excalidraw/actions/actionToggleGridMode.tsx @@ -2,10 +2,13 @@ import { CODES, KEYS } from "../keys"; import { register } from "./register"; import { GRID_SIZE } from "../constants"; import { AppState } from "../types"; +import { gridIcon } from "../components/icons"; export const actionToggleGridMode = register({ name: "gridMode", - label: "labels.showGrid", + icon: gridIcon, + keywords: ["snap"], + label: "labels.toggleGrid", viewMode: true, trackEvent: { category: "canvas", diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index f021632b8..17480319f 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -309,6 +309,7 @@ function CommandPaletteInner({ actionManager.actions.zoomToFit, actionManager.actions.zenMode, actionManager.actions.viewMode, + actionManager.actions.gridMode, actionManager.actions.objectsSnapMode, actionManager.actions.toggleShortcuts, actionManager.actions.selectAll, diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index 23c9f8f47..c362889b3 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -273,7 +273,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { shortcuts={[getShortcutKey("Alt+S")]} /> , tablerIconProps, ); + +export const gridIcon = createIcon( + + + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index a0a5a1958..fb5b148d3 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -87,7 +87,7 @@ "group": "Group selection", "ungroup": "Ungroup selection", "collaborators": "Collaborators", - "showGrid": "Show grid", + "toggleGrid": "Toggle grid", "addToLibrary": "Add to library", "removeFromLibrary": "Remove from library", "libraryLoadingMessage": "Loading library…", diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 5723ebe60..579ce84d2 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -7870,8 +7870,51 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "separator", { "checked": [Function], + "icon": , "keyTest": [Function], - "label": "labels.showGrid", + "keywords": [ + "snap", + ], + "label": "labels.toggleGrid", "name": "gridMode", "perform": [Function], "predicate": [Function], diff --git a/packages/excalidraw/tests/excalidraw.test.tsx b/packages/excalidraw/tests/excalidraw.test.tsx index 98d0e9006..be7efc914 100644 --- a/packages/excalidraw/tests/excalidraw.test.tsx +++ b/packages/excalidraw/tests/excalidraw.test.tsx @@ -101,7 +101,7 @@ describe("", () => { clientY: 1, }); const contextMenu = document.querySelector(".context-menu"); - fireEvent.click(queryByText(contextMenu as HTMLElement, "Show grid")!); + fireEvent.click(queryByText(contextMenu as HTMLElement, "Toggle grid")!); expect(h.state.gridSize).toBe(GRID_SIZE); }); From f92f04c13c80375e5327204fec9a5388f785085d Mon Sep 17 00:00:00 2001 From: johnd99 <87199350+johnd99@users.noreply.github.com> Date: Mon, 15 Apr 2024 05:11:27 -0400 Subject: [PATCH 025/174] fix: Correct unit from 'eg' to 'deg' (#7891) --- excalidraw-app/collab/CollabError.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/excalidraw-app/collab/CollabError.scss b/excalidraw-app/collab/CollabError.scss index 085dc5609..bb774d00a 100644 --- a/excalidraw-app/collab/CollabError.scss +++ b/excalidraw-app/collab/CollabError.scss @@ -23,7 +23,7 @@ transform: rotate(10deg); } 50% { - transform: rotate(0eg); + transform: rotate(0deg); } 75% { transform: rotate(-10deg); From bbcca06b94550fcb03c0563be5f08cea7f4211fe Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 17 Apr 2024 19:31:12 +0800 Subject: [PATCH 026/174] fix: collision regressions from vector geometry rewrite (#7902) --- packages/excalidraw/components/App.tsx | 24 ++++++++++++------- packages/utils/geometry/shape.ts | 33 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6a0fd1031..8ceb362a5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -230,6 +230,7 @@ import { getEllipseShape, getFreedrawShape, getPolygonShape, + getSelectionBoxShape, } from "../../utils/geometry/shape"; import { isPointInShape } from "../../utils/collision"; import { @@ -416,7 +417,6 @@ import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { getRenderOpacity } from "../renderer/renderElement"; import { hitElementBoundText, - hitElementBoundingBox, hitElementBoundingBoxOnly, hitElementItself, shouldTestInside, @@ -4462,10 +4462,18 @@ class App extends React.Component { // If we're hitting element with highest z-index only on its bounding box // while also hitting other element figure, the latter should be considered. - return isPointInShape( - [x, y], - this.getElementShape(elementWithHighestZIndex), - ) + return hitElementItself({ + x, + y, + element: elementWithHighestZIndex, + shape: this.getElementShape(elementWithHighestZIndex), + // when overlapping, we would like to be more precise + // this also avoids the need to update past tests + threshold: this.getHitThreshold() / 2, + frameNameBound: isFrameLikeElement(elementWithHighestZIndex) + ? this.frameNameBoundsCache.get(elementWithHighestZIndex) + : null, + }) ? elementWithHighestZIndex : allHitElements[allHitElements.length - 2]; } @@ -4540,13 +4548,13 @@ class App extends React.Component { this.state.selectedElementIds[element.id] && shouldShowBoundingBox([element], this.state) ) { - return hitElementBoundingBox( - x, - y, + const selectionShape = getSelectionBoxShape( element, this.scene.getNonDeletedElementsMap(), this.getHitThreshold(), ); + + return isPointInShape([x, y], selectionShape); } // take bound text element into consideration for hit collision as well diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index 87c0fe099..53ab9ff8e 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -12,8 +12,11 @@ * to pure shapes */ +import { getElementAbsoluteCoords } from "../../excalidraw/element"; import { + ElementsMap, ExcalidrawDiamondElement, + ExcalidrawElement, ExcalidrawEllipseElement, ExcalidrawEmbeddableElement, ExcalidrawFrameLikeElement, @@ -133,6 +136,36 @@ export const getPolygonShape = ( }; }; +// return the selection box for an element, possibly rotated as well +export const getSelectionBoxShape = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, + padding = 10, +) => { + let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + true, + ); + + x1 -= padding; + x2 += padding; + y1 -= padding; + y2 += padding; + + const angleInDegrees = angleToDegrees(element.angle); + const center: Point = [cx, cy]; + const topLeft = pointRotate([x1, y1], angleInDegrees, center); + const topRight = pointRotate([x2, y1], angleInDegrees, center); + const bottomLeft = pointRotate([x1, y2], angleInDegrees, center); + const bottomRight = pointRotate([x2, y2], angleInDegrees, center); + + return { + type: "polygon", + data: [topLeft, topRight, bottomRight, bottomLeft], + } as GeometricShape; +}; + // ellipse export const getEllipseShape = ( element: ExcalidrawEllipseElement, From 5211b003b88fa5baf2620059ee21d5b864a13d97 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 17 Apr 2024 13:48:04 +0200 Subject: [PATCH 027/174] fix: double text rendering on edit (#7904) --- packages/excalidraw/renderer/staticScene.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts index c2b5f218a..b7dcdd59a 100644 --- a/packages/excalidraw/renderer/staticScene.ts +++ b/packages/excalidraw/renderer/staticScene.ts @@ -281,7 +281,7 @@ const _renderStaticScene = ({ ); } - const boundTextElement = getBoundTextElement(element, allElementsMap); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { renderElement( boundTextElement, From 530617be90df8d41f93c7c885d3097565c4c7f6d Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Wed, 17 Apr 2024 13:01:24 +0100 Subject: [PATCH 028/174] feat: multiplayer undo / redo (#7348) --- .../excalidraw/api/props/excalidraw-api.mdx | 4 +- excalidraw-app/App.tsx | 2 +- excalidraw-app/collab/Collab.tsx | 17 +- excalidraw-app/data/index.ts | 2 +- excalidraw-app/tests/collab.test.tsx | 210 +- packages/excalidraw/CHANGELOG.md | 11 +- .../excalidraw/actions/actionAddToLibrary.ts | 7 +- packages/excalidraw/actions/actionAlign.tsx | 13 +- .../excalidraw/actions/actionBoundText.tsx | 7 +- packages/excalidraw/actions/actionCanvas.tsx | 39 +- .../excalidraw/actions/actionClipboard.tsx | 27 +- .../actions/actionDeleteSelected.tsx | 11 +- .../excalidraw/actions/actionDistribute.tsx | 5 +- .../actions/actionDuplicateSelection.tsx | 12 +- .../excalidraw/actions/actionElementLock.ts | 5 +- packages/excalidraw/actions/actionExport.tsx | 26 +- .../excalidraw/actions/actionFinalize.tsx | 9 +- packages/excalidraw/actions/actionFlip.ts | 5 +- packages/excalidraw/actions/actionFrame.ts | 13 +- packages/excalidraw/actions/actionGroup.tsx | 31 +- packages/excalidraw/actions/actionHistory.tsx | 132 +- .../excalidraw/actions/actionLinearEditor.ts | 3 +- packages/excalidraw/actions/actionLink.tsx | 3 +- packages/excalidraw/actions/actionMenu.tsx | 7 +- .../excalidraw/actions/actionNavigate.tsx | 5 +- .../excalidraw/actions/actionProperties.tsx | 31 +- .../excalidraw/actions/actionSelectAll.ts | 3 +- packages/excalidraw/actions/actionStyles.ts | 7 +- .../actions/actionToggleGridMode.tsx | 3 +- .../actions/actionToggleObjectsSnapMode.tsx | 3 +- .../excalidraw/actions/actionToggleStats.tsx | 3 +- .../actions/actionToggleViewMode.tsx | 3 +- .../actions/actionToggleZenMode.tsx | 3 +- packages/excalidraw/actions/actionZindex.tsx | 9 +- packages/excalidraw/actions/manager.tsx | 6 +- packages/excalidraw/actions/types.ts | 8 +- packages/excalidraw/change.ts | 1529 ++ packages/excalidraw/components/Actions.scss | 1 + packages/excalidraw/components/App.tsx | 226 +- packages/excalidraw/components/ToolButton.tsx | 9 +- packages/excalidraw/components/ToolIcon.scss | 19 +- packages/excalidraw/constants.ts | 1 + packages/excalidraw/css/theme.scss | 4 + packages/excalidraw/css/variables.module.scss | 9 + packages/excalidraw/data/reconcile.ts | 2 +- packages/excalidraw/data/restore.ts | 2 +- packages/excalidraw/element/binding.ts | 497 +- packages/excalidraw/element/embeddable.ts | 3 +- .../excalidraw/element/linearElementEditor.ts | 6 +- packages/excalidraw/element/mutateElement.ts | 5 +- packages/excalidraw/element/sizeHelpers.ts | 3 + packages/excalidraw/element/textElement.ts | 8 +- packages/excalidraw/element/typeChecks.ts | 3 +- packages/excalidraw/element/types.ts | 12 +- packages/excalidraw/groups.ts | 18 + packages/excalidraw/history.ts | 427 +- packages/excalidraw/hooks/useEmitter.ts | 21 + packages/excalidraw/scene/Scene.ts | 8 +- packages/excalidraw/store.ts | 332 + .../__snapshots__/contextmenu.test.tsx.snap | 4468 ++-- .../tests/__snapshots__/history.test.tsx.snap | 18005 +++++++++++++++ .../tests/__snapshots__/move.test.tsx.snap | 10 +- .../regressionTests.test.tsx.snap | 18736 ++++++---------- .../excalidraw/tests/contextmenu.test.tsx | 2 +- packages/excalidraw/tests/helpers/api.ts | 13 +- packages/excalidraw/tests/helpers/ui.ts | 12 + packages/excalidraw/tests/history.test.tsx | 4615 +++- packages/excalidraw/tests/move.test.tsx | 20 +- .../excalidraw/tests/regressionTests.test.tsx | 19 +- packages/excalidraw/types.ts | 20 +- packages/excalidraw/utils.ts | 12 + 71 files changed, 34885 insertions(+), 14877 deletions(-) create mode 100644 packages/excalidraw/change.ts create mode 100644 packages/excalidraw/hooks/useEmitter.ts create mode 100644 packages/excalidraw/store.ts create mode 100644 packages/excalidraw/tests/__snapshots__/history.test.tsx.snap diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx index ffff19fb0..9f12c115d 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx @@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git | API | Signature | Usage | | --- | --- | --- | | [updateScene](#updatescene) | `function` | updates the scene with the sceneData | -| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData | +| [updateLibrary](#updatelibrary) | `function` | updates the library | | [addFiles](#addfiles) | `function` | add files data to the appState | | [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. | | [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene | @@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the | `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene | | `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. | | `collaborators` | MapCollaborator> | The list of collaborators to be updated in the scene. | -| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. | +| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. | ```jsx live function App() { diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 90127d987..798791591 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -438,7 +438,7 @@ const ExcalidrawWrapper = () => { excalidrawAPI.updateScene({ ...data.scene, ...restore(data.scene, null, null, { repairBindings: true }), - commitToHistory: true, + commitToStore: true, }); } }); diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index def3810f9..1bf83da5e 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -356,7 +356,6 @@ class Collab extends PureComponent { this.excalidrawAPI.updateScene({ elements, - commitToHistory: false, }); } }; @@ -501,14 +500,12 @@ class Collab extends PureComponent { } return element; }); - // remove deleted elements from elements array & history to ensure we don't + // remove deleted elements from elements array to ensure we don't // expose potentially sensitive user data in case user manually deletes // existing elements (or clears scene), which would otherwise be persisted // to database even if deleted before creating the room. - this.excalidrawAPI.history.clear(); this.excalidrawAPI.updateScene({ elements, - commitToHistory: true, }); this.saveCollabRoomToFirebase(getSyncableElements(elements)); @@ -544,9 +541,7 @@ class Collab extends PureComponent { const remoteElements = decryptedData.payload.elements; const reconciledElements = this._reconcileElements(remoteElements); - this.handleRemoteSceneUpdate(reconciledElements, { - init: true, - }); + this.handleRemoteSceneUpdate(reconciledElements); // noop if already resolved via init from firebase scenePromise.resolve({ elements: reconciledElements, @@ -745,19 +740,11 @@ class Collab extends PureComponent { private handleRemoteSceneUpdate = ( elements: ReconciledExcalidrawElement[], - { init = false }: { init?: boolean } = {}, ) => { this.excalidrawAPI.updateScene({ elements, - commitToHistory: !!init, }); - // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack - // when we receive any messages from another peer. This UX can be pretty rough -- if you - // undo, a user makes a change, and then try to redo, your element(s) will be lost. However, - // right now we think this is the right tradeoff. - this.excalidrawAPI.history.clear(); - this.loadImageFiles(); }; diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 10c97fd20..6cf575412 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -269,7 +269,7 @@ export const loadScene = async ( // in the scene database/localStorage, and instead fetch them async // from a different database files: data.files, - commitToHistory: false, + commitToStore: false, }; }; diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx index 2e2f1332a..1fd8ecdbc 100644 --- a/excalidraw-app/tests/collab.test.tsx +++ b/excalidraw-app/tests/collab.test.tsx @@ -1,13 +1,18 @@ import { vi } from "vitest"; import { + act, render, updateSceneData, waitFor, } from "../../packages/excalidraw/tests/test-utils"; import ExcalidrawApp from "../App"; import { API } from "../../packages/excalidraw/tests/helpers/api"; -import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory"; import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex"; +import { + createRedoAction, + createUndoAction, +} from "../../packages/excalidraw/actions/actionHistory"; +import { newElementWith } from "../../packages/excalidraw"; const { h } = window; @@ -58,39 +63,188 @@ vi.mock("socket.io-client", () => { }; }); +/** + * These test would deserve to be extended by testing collab with (at least) two clients simultanouesly, + * while having access to both scenes, appstates stores, histories and etc. + * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. + */ describe("collaboration", () => { - it("creating room should reset deleted elements", async () => { + it("should allow to undo / redo even on force-deleted elements", async () => { await render(); - // To update the scene with deleted elements before starting collab + const rect1Props = { + type: "rectangle", + id: "A", + height: 200, + width: 100, + } as const; + + const rect2Props = { + type: "rectangle", + id: "B", + width: 100, + height: 200, + } as const; + + const rect1 = API.createElement({ ...rect1Props }); + const rect2 = API.createElement({ ...rect2Props }); + updateSceneData({ - elements: syncInvalidIndices([ - API.createElement({ type: "rectangle", id: "A" }), - API.createElement({ - type: "rectangle", - id: "B", - isDeleted: true, - }), - ]), - }); - await waitFor(() => { - expect(h.elements).toEqual([ - expect.objectContaining({ id: "A" }), - expect.objectContaining({ id: "B", isDeleted: true }), - ]); - expect(API.getStateHistory().length).toBe(1); - }); - window.collab.startCollaboration(null); - await waitFor(() => { - expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); - expect(API.getStateHistory().length).toBe(1); + elements: syncInvalidIndices([rect1, rect2]), + commitToStore: true, + }); + + updateSceneData({ + elements: syncInvalidIndices([ + rect1, + newElementWith(h.elements[1], { isDeleted: true }), + ]), + commitToStore: true, }); - const undoAction = createUndoAction(h.history); - // noop - h.app.actionManager.executeAction(undoAction); await waitFor(() => { - expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); - expect(API.getStateHistory().length).toBe(1); + expect(API.getUndoStack().length).toBe(2); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + }); + + // one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server + window.collab.startCollaboration(null); + + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + // we never delete from the local snapshot as it is used for correct diff calculation + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); + }); + + const undoAction = createUndoAction(h.history, h.store); + act(() => h.app.actionManager.executeAction(undoAction)); + + // with explicit undo (as addition) we expect our item to be restored from the snapshot! + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false }), + ]); + }); + + // simulate force deleting the element remotely + updateSceneData({ + elements: syncInvalidIndices([rect1]), + }); + + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); + }); + + const redoAction = createRedoAction(h.history, h.store); + act(() => h.app.actionManager.executeAction(redoAction)); + + // with explicit redo (as removal) we again restore the element from the snapshot! + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + }); + + act(() => h.app.actionManager.executeAction(undoAction)); + + // simulate local update + updateSceneData({ + elements: syncInvalidIndices([ + h.elements[0], + newElementWith(h.elements[1], { x: 100 }), + ]), + commitToStore: true, + }); + + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), + ]); + }); + + act(() => h.app.actionManager.executeAction(undoAction)); + + // we expect to iterate the stack to the first visible change + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }), + ]); + }); + + // simulate force deleting the element remotely + updateSceneData({ + elements: syncInvalidIndices([rect1]), + }); + + // snapshot was correctly updated and marked the element as deleted + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }), + ]); + expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); + }); + + act(() => h.app.actionManager.executeAction(redoAction)); + + // with explicit redo (as update) we again restored the element from the snapshot! + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining({ id: "A", isDeleted: false }), + expect.objectContaining({ id: "B", isDeleted: true, x: 100 }), + ]); + expect(h.history.isRedoStackEmpty).toBeTruthy(); + expect(h.elements).toEqual([ + expect.objectContaining({ id: "A", isDeleted: false }), + expect.objectContaining({ id: "B", isDeleted: true, x: 100 }), + ]); }); }); }); diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index b6fcd36fa..9c5d4f564 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -15,9 +15,14 @@ Please add the latest change on the top under the correct section. ### Features +- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) + - `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853) + - Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) + - Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655) + - Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) - Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638). @@ -30,6 +35,10 @@ Please add the latest change on the top under the correct section. ### Breaking Changes +- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348). + +### Breaking Changes + - `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) - `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties. @@ -92,8 +101,6 @@ define: { - Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343) ---- - ## 0.17.0 (2023-11-14) ### Features diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts index ccb7fad62..93fddf0c4 100644 --- a/packages/excalidraw/actions/actionAddToLibrary.ts +++ b/packages/excalidraw/actions/actionAddToLibrary.ts @@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement"; import { randomId } from "../random"; import { t } from "../i18n"; import { LIBRARY_DISABLED_TYPES } from "../constants"; +import { StoreAction } from "../store"; export const actionAddToLibrary = register({ name: "addToLibrary", @@ -17,7 +18,7 @@ export const actionAddToLibrary = register({ for (const type of LIBRARY_DISABLED_TYPES) { if (selectedElements.some((element) => element.type === type)) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t(`errors.libraryElementTypeError.${type}`), @@ -41,7 +42,7 @@ export const actionAddToLibrary = register({ }) .then(() => { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, toast: { message: t("toast.addedToLibrary") }, @@ -50,7 +51,7 @@ export const actionAddToLibrary = register({ }) .catch((error) => { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: error.message, diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index ddcb1415f..179b3e138 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -15,6 +15,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; import { KEYS } from "../keys"; import { isSomeElementSelected } from "../scene"; +import { StoreAction } from "../store"; import { AppClassProperties, AppState, UIAppState } from "../types"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; @@ -70,7 +71,7 @@ export const actionAlignTop = register({ position: "start", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -103,7 +104,7 @@ export const actionAlignBottom = register({ position: "end", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -136,7 +137,7 @@ export const actionAlignLeft = register({ position: "start", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -169,7 +170,7 @@ export const actionAlignRight = register({ position: "end", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -202,7 +203,7 @@ export const actionAlignVerticallyCentered = register({ position: "center", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => ( @@ -231,7 +232,7 @@ export const actionAlignHorizontallyCentered = register({ position: "center", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => ( diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 1fcf80fd0..7d04b1afa 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -34,6 +34,7 @@ import { Mutable } from "../utility-types"; import { arrayToMap, getFontString } from "../utils"; import { register } from "./register"; import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; export const actionUnbindText = register({ name: "unbindText", @@ -85,7 +86,7 @@ export const actionUnbindText = register({ return { elements, appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); @@ -161,7 +162,7 @@ export const actionBindText = register({ return { elements: pushTextAboveContainer(elements, container, textElement), appState: { ...appState, selectedElementIds: { [container.id]: true } }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); @@ -320,7 +321,7 @@ export const actionWrapTextInContainer = register({ ...appState, selectedElementIds: containerIds, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 90492b321..0503e50f7 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -10,7 +10,13 @@ import { ZoomResetIcon, } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; -import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; +import { + CURSOR_TYPE, + MAX_ZOOM, + MIN_ZOOM, + THEME, + ZOOM_STEP, +} from "../constants"; import { getCommonBounds, getNonDeletedElements } from "../element"; import { ExcalidrawElement } from "../element/types"; import { t } from "../i18n"; @@ -31,6 +37,7 @@ import { import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { SceneBounds } from "../element/bounds"; import { setCursor } from "../cursor"; +import { StoreAction } from "../store"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", @@ -46,7 +53,9 @@ export const actionChangeViewBackgroundColor = register({ perform: (_, appState, value) => { return { appState: { ...appState, ...value }, - commitToHistory: !!value.viewBackgroundColor, + storeAction: !!value.viewBackgroundColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => { @@ -102,7 +111,7 @@ export const actionClearCanvas = register({ ? { ...appState.activeTool, type: "selection" } : appState.activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); @@ -127,16 +136,17 @@ export const actionZoomIn = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - PanelComponent: ({ updateData }) => ( + PanelComponent: ({ updateData, appState }) => ( = MAX_ZOOM} onClick={() => { updateData(null); }} @@ -167,16 +177,17 @@ export const actionZoomOut = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - PanelComponent: ({ updateData }) => ( + PanelComponent: ({ updateData, appState }) => ( { updateData(null); }} @@ -207,7 +218,7 @@ export const actionResetZoom = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ updateData, appState }) => ( @@ -282,8 +293,8 @@ export const zoomToFitBounds = ({ // Apply clamping to newZoomValue to be between 10% and 3000% newZoomValue = Math.min( - Math.max(newZoomValue, 0.1), - 30.0, + Math.max(newZoomValue, MIN_ZOOM), + MAX_ZOOM, ) as NormalizedZoomValue; let appStateWidth = appState.width; @@ -328,7 +339,7 @@ export const zoomToFitBounds = ({ scrollY, zoom: { value: newZoomValue }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }; @@ -447,7 +458,7 @@ export const actionToggleTheme = register({ theme: value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, @@ -485,7 +496,7 @@ export const actionToggleEraserTool = register({ activeEmbeddable: null, activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event.key === KEYS.E, @@ -524,7 +535,7 @@ export const actionToggleHandTool = register({ activeEmbeddable: null, activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index bb488245c..e4f998d01 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -14,6 +14,7 @@ import { isTextElement } from "../element"; import { t } from "../i18n"; import { isFirefox } from "../constants"; import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionCopy = register({ name: "copy", @@ -31,7 +32,7 @@ export const actionCopy = register({ await copyToClipboard(elementsToCopy, app.files, event); } catch (error: any) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: error.message, @@ -40,7 +41,7 @@ export const actionCopy = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, // don't supply a shortcut since we handle this conditionally via onCopy event @@ -66,7 +67,7 @@ export const actionPaste = register({ if (isFirefox) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("hints.firefox_clipboard_write"), @@ -75,7 +76,7 @@ export const actionPaste = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("errors.asyncPasteFailedOnRead"), @@ -88,7 +89,7 @@ export const actionPaste = register({ } catch (error: any) { console.error(error); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("errors.asyncPasteFailedOnParse"), @@ -97,7 +98,7 @@ export const actionPaste = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, // don't supply a shortcut since we handle this conditionally via onCopy event @@ -124,7 +125,7 @@ export const actionCopyAsSvg = register({ perform: async (elements, appState, _data, app) => { if (!app.canvas) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } @@ -147,7 +148,7 @@ export const actionCopyAsSvg = register({ }, ); return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } catch (error: any) { console.error(error); @@ -156,7 +157,7 @@ export const actionCopyAsSvg = register({ ...appState, errorMessage: error.message, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, @@ -174,7 +175,7 @@ export const actionCopyAsPng = register({ perform: async (elements, appState, _data, app) => { if (!app.canvas) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } const selectedElements = app.scene.getSelectedElements({ @@ -208,7 +209,7 @@ export const actionCopyAsPng = register({ }), }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } catch (error: any) { console.error(error); @@ -217,7 +218,7 @@ export const actionCopyAsPng = register({ ...appState, errorMessage: error.message, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, @@ -252,7 +253,7 @@ export const copyText = register({ throw new Error(t("errors.copyToSystemClipboardFailed")); } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, predicate: (elements, appState, _, app) => { diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 602d73725..4ab6fa411 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -13,6 +13,7 @@ import { fixBindingsAfterDeletion } from "../element/binding"; import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; import { updateActiveTool } from "../utils"; import { TrashIcon } from "../components/icons"; +import { StoreAction } from "../store"; const deleteSelectedElements = ( elements: readonly ExcalidrawElement[], @@ -112,7 +113,7 @@ export const actionDeleteSelected = register({ ...nextAppState, editingLinearElement: null, }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; } @@ -144,7 +145,7 @@ export const actionDeleteSelected = register({ : [0], }, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } let { elements: nextElements, appState: nextAppState } = @@ -164,10 +165,12 @@ export const actionDeleteSelected = register({ multiElement: null, activeEmbeddable: null, }, - commitToHistory: isSomeElementSelected( + storeAction: isSomeElementSelected( getNonDeletedElements(elements), appState, - ), + ) + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, keyTest: (event, appState, elements) => diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index f3075e5a3..522fbb305 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -11,6 +11,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; import { CODES, KEYS } from "../keys"; import { isSomeElementSelected } from "../scene"; +import { StoreAction } from "../store"; import { AppClassProperties, AppState } from "../types"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; @@ -58,7 +59,7 @@ export const distributeHorizontally = register({ space: "between", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -89,7 +90,7 @@ export const distributeVertically = register({ space: "between", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 46d021a21..0b4957f59 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -32,6 +32,7 @@ import { getSelectedElements, } from "../scene/selection"; import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; export const actionDuplicateSelection = register({ name: "duplicateSelection", @@ -54,13 +55,13 @@ export const actionDuplicateSelection = register({ return { elements, appState: ret.appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } return { ...duplicateElements(elements, appState), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, @@ -241,9 +242,10 @@ const duplicateElements = ( } // step (3) - const finalElements = finalElementsReversed.reverse(); - - syncMovedIndices(finalElements, arrayToMap([...oldElements, ...newElements])); + const finalElements = syncMovedIndices( + finalElementsReversed.reverse(), + arrayToMap(newElements), + ); // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts index 7200dca21..83600871e 100644 --- a/packages/excalidraw/actions/actionElementLock.ts +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -4,6 +4,7 @@ import { isFrameLikeElement } from "../element/typeChecks"; import { ExcalidrawElement } from "../element/types"; import { KEYS } from "../keys"; import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; import { arrayToMap } from "../utils"; import { register } from "./register"; @@ -66,7 +67,7 @@ export const actionToggleElementLock = register({ ? null : appState.selectedLinearElement, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event, appState, elements, app) => { @@ -111,7 +112,7 @@ export const actionUnlockAllElements = register({ lockedElements.map((el) => [el.id, true]), ), }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, label: "labels.elementLock.unlockAll", diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index eaa1d514f..7b767ecb8 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -19,13 +19,17 @@ import { nativeFileSystemSupported } from "../data/filesystem"; import { Theme } from "../element/types"; import "../components/ToolIcon.scss"; +import { StoreAction } from "../store"; export const actionChangeProjectName = register({ name: "changeProjectName", label: "labels.fileTitle", trackEvent: false, perform: (_elements, appState, value) => { - return { appState: { ...appState, name: value }, commitToHistory: false }; + return { + appState: { ...appState, name: value }, + storeAction: StoreAction.NONE, + }; }, PanelComponent: ({ appState, updateData, appProps, data, app }) => ( { return { appState: { ...appState, exportScale: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ elements: allElements, appState, updateData }) => { @@ -94,7 +98,7 @@ export const actionChangeExportBackground = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, exportBackground: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( @@ -114,7 +118,7 @@ export const actionChangeExportEmbedScene = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, exportEmbedScene: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( @@ -156,7 +160,7 @@ export const actionSaveToActiveFile = register({ : await saveAsJSON(elements, appState, app.files, app.getName()); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, fileHandle, @@ -178,7 +182,7 @@ export const actionSaveToActiveFile = register({ } else { console.warn(error); } - return { commitToHistory: false }; + return { storeAction: StoreAction.NONE }; } }, keyTest: (event) => @@ -203,7 +207,7 @@ export const actionSaveFileToDisk = register({ app.getName(), ); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, openDialog: null, @@ -217,7 +221,7 @@ export const actionSaveFileToDisk = register({ } else { console.warn(error); } - return { commitToHistory: false }; + return { storeAction: StoreAction.NONE }; } }, keyTest: (event) => @@ -256,7 +260,7 @@ export const actionLoadScene = register({ elements: loadedElements, appState: loadedAppState, files, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } catch (error: any) { if (error?.name === "AbortError") { @@ -267,7 +271,7 @@ export const actionLoadScene = register({ elements, appState: { ...appState, errorMessage: error.message }, files: app.files, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, @@ -281,7 +285,7 @@ export const actionExportWithDarkMode = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, exportWithDarkMode: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 88ff366b6..e4b0861a6 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -15,6 +15,7 @@ import { import { isBindingElement, isLinearElement } from "../element/typeChecks"; import { AppState } from "../types"; import { resetCursor } from "../cursor"; +import { StoreAction } from "../store"; export const actionFinalize = register({ name: "finalize", @@ -48,8 +49,9 @@ export const actionFinalize = register({ ...appState, cursorButton: "up", editingLinearElement: null, + selectedLinearElement: null, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } } @@ -90,7 +92,9 @@ export const actionFinalize = register({ }); } } + if (isInvisiblySmallElement(multiPointElement)) { + // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want newElements = newElements.filter( (el) => el.id !== multiPointElement.id, ); @@ -186,7 +190,8 @@ export const actionFinalize = register({ : appState.selectedLinearElement, pendingImageElementId: null, }, - commitToHistory: appState.activeTool.type === "freedraw", + // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event, appState) => diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index d821b200d..565756f94 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -18,6 +18,7 @@ import { } from "../element/binding"; import { updateFrameMembershipOfSelectedElements } from "../frame"; import { flipHorizontal, flipVertical } from "../components/icons"; +import { StoreAction } from "../store"; export const actionFlipHorizontal = register({ name: "flipHorizontal", @@ -38,7 +39,7 @@ export const actionFlipHorizontal = register({ app, ), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event.shiftKey && event.code === CODES.H, @@ -63,7 +64,7 @@ export const actionFlipVertical = register({ app, ), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 019533c59..3471ed5b5 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -9,6 +9,7 @@ import { setCursorForShape } from "../cursor"; import { register } from "./register"; import { isFrameLikeElement } from "../element/typeChecks"; import { frameToolIcon } from "../components/icons"; +import { StoreAction } from "../store"; const isSingleFrameSelected = ( appState: UIAppState, @@ -44,14 +45,14 @@ export const actionSelectAllElementsInFrame = register({ return acc; }, {} as Record), }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; } return { elements, appState, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, predicate: (elements, appState, _, app) => @@ -75,14 +76,14 @@ export const actionRemoveAllElementsFromFrame = register({ [selectedElement.id]: true, }, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } return { elements, appState, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, predicate: (elements, appState, _, app) => @@ -104,7 +105,7 @@ export const actionupdateFrameRendering = register({ enabled: !appState.frameRendering.enabled, }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState: AppState) => appState.frameRendering.enabled, @@ -134,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({ type: "frame", }), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index cda66ae5a..51f49ccea 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -17,7 +17,11 @@ import { import { getNonDeletedElements } from "../element"; import { randomId } from "../random"; import { ToolButton } from "../components/ToolButton"; -import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; +import { + ExcalidrawElement, + ExcalidrawTextElement, + OrderedExcalidrawElement, +} from "../element/types"; import { AppClassProperties, AppState } from "../types"; import { isBoundToContainer } from "../element/typeChecks"; import { @@ -28,6 +32,7 @@ import { replaceAllElementsInFrame, } from "../frame"; import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { if (elements.length >= 2) { @@ -72,7 +77,7 @@ export const actionGroup = register({ }); if (selectedElements.length < 2) { // nothing to group - return { appState, elements, commitToHistory: false }; + return { appState, elements, storeAction: StoreAction.NONE }; } // if everything is already grouped into 1 group, there is nothing to do const selectedGroupIds = getSelectedGroupIds(appState); @@ -92,7 +97,7 @@ export const actionGroup = register({ ]); if (combinedSet.size === elementIdsInGroup.size) { // no incremental ids in the selected ids - return { appState, elements, commitToHistory: false }; + return { appState, elements, storeAction: StoreAction.NONE }; } } @@ -134,19 +139,19 @@ export const actionGroup = register({ // to the z order of the highest element in the layer stack const elementsInGroup = getElementsInGroup(nextElements, newGroupId); const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; - const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup); + const lastGroupElementIndex = nextElements.lastIndexOf( + lastElementInGroup as OrderedExcalidrawElement, + ); const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1); const elementsBeforeGroup = nextElements .slice(0, lastGroupElementIndex) .filter( (updatedElement) => !isElementInGroup(updatedElement, newGroupId), ); - const reorderedElements = [ - ...elementsBeforeGroup, - ...elementsInGroup, - ...elementsAfterGroup, - ]; - syncMovedIndices(reorderedElements, arrayToMap(elementsInGroup)); + const reorderedElements = syncMovedIndices( + [...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup], + arrayToMap(elementsInGroup), + ); return { appState: { @@ -158,7 +163,7 @@ export const actionGroup = register({ ), }, elements: reorderedElements, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, predicate: (elements, appState, _, app) => @@ -188,7 +193,7 @@ export const actionUngroup = register({ const elementsMap = arrayToMap(elements); if (groupIds.length === 0) { - return { appState, elements, commitToHistory: false }; + return { appState, elements, storeAction: StoreAction.NONE }; } let nextElements = [...elements]; @@ -261,7 +266,7 @@ export const actionUngroup = register({ return { appState: { ...appState, ...updateAppState }, elements: nextElements, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index fad459003..05c832fd2 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -2,113 +2,117 @@ import { Action, ActionResult } from "./types"; import { UndoIcon, RedoIcon } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; -import History, { HistoryEntry } from "../history"; -import { ExcalidrawElement } from "../element/types"; +import { History, HistoryChangedEvent } from "../history"; import { AppState } from "../types"; import { KEYS } from "../keys"; -import { newElementWith } from "../element/mutateElement"; -import { fixBindingsAfterDeletion } from "../element/binding"; import { arrayToMap } from "../utils"; import { isWindows } from "../constants"; -import { syncInvalidIndices } from "../fractionalIndex"; +import { SceneElementsMap } from "../element/types"; +import { IStore, StoreAction } from "../store"; +import { useEmitter } from "../hooks/useEmitter"; const writeData = ( - prevElements: readonly ExcalidrawElement[], - appState: AppState, - updater: () => HistoryEntry | null, + appState: Readonly, + updater: () => [SceneElementsMap, AppState] | void, ): ActionResult => { - const commitToHistory = false; if ( !appState.multiElement && !appState.resizingElement && !appState.editingElement && !appState.draggingElement ) { - const data = updater(); - if (data === null) { - return { commitToHistory }; + const result = updater(); + + if (!result) { + return { storeAction: StoreAction.NONE }; } - const prevElementMap = arrayToMap(prevElements); - const nextElements = data.elements; - const nextElementMap = arrayToMap(nextElements); - - const deletedElements = prevElements.filter( - (prevElement) => !nextElementMap.has(prevElement.id), - ); - const elements = nextElements - .map((nextElement) => - newElementWith( - prevElementMap.get(nextElement.id) || nextElement, - nextElement, - ), - ) - .concat( - deletedElements.map((prevElement) => - newElementWith(prevElement, { isDeleted: true }), - ), - ); - fixBindingsAfterDeletion(elements, deletedElements); - // TODO: will be replaced in #7348 - syncInvalidIndices(elements); + const [nextElementsMap, nextAppState] = result; + const nextElements = Array.from(nextElementsMap.values()); return { - elements, - appState: { ...appState, ...data.appState }, - commitToHistory, - syncHistory: true, + appState: nextAppState, + elements: nextElements, + storeAction: StoreAction.UPDATE, }; } - return { commitToHistory }; + + return { storeAction: StoreAction.NONE }; }; -type ActionCreator = (history: History) => Action; +type ActionCreator = (history: History, store: IStore) => Action; -export const createUndoAction: ActionCreator = (history) => ({ +export const createUndoAction: ActionCreator = (history, store) => ({ name: "undo", label: "buttons.undo", icon: UndoIcon, trackEvent: { category: "history" }, viewMode: false, perform: (elements, appState) => - writeData(elements, appState, () => history.undoOnce()), + writeData(appState, () => + history.undo( + arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` + appState, + store.snapshot, + ), + ), keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.Z && !event.shiftKey, - PanelComponent: ({ updateData, data }) => ( - - ), - commitToHistory: () => false, + PanelComponent: ({ updateData, data }) => { + const { isUndoStackEmpty } = useEmitter( + history.onHistoryChangedEmitter, + new HistoryChangedEvent(), + ); + + return ( + + ); + }, }); -export const createRedoAction: ActionCreator = (history) => ({ +export const createRedoAction: ActionCreator = (history, store) => ({ name: "redo", label: "buttons.redo", icon: RedoIcon, trackEvent: { category: "history" }, viewMode: false, perform: (elements, appState) => - writeData(elements, appState, () => history.redoOnce()), + writeData(appState, () => + history.redo( + arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` + appState, + store.snapshot, + ), + ), keyTest: (event) => (event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key.toLowerCase() === KEYS.Z) || (isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y), - PanelComponent: ({ updateData, data }) => ( - - ), - commitToHistory: () => false, + PanelComponent: ({ updateData, data }) => { + const { isRedoStackEmpty } = useEmitter( + history.onHistoryChangedEmitter, + new HistoryChangedEvent(), + ); + + return ( + + ); + }, }); diff --git a/packages/excalidraw/actions/actionLinearEditor.ts b/packages/excalidraw/actions/actionLinearEditor.ts index 5b76868f6..020df8b6f 100644 --- a/packages/excalidraw/actions/actionLinearEditor.ts +++ b/packages/excalidraw/actions/actionLinearEditor.ts @@ -2,6 +2,7 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette" import { LinearElementEditor } from "../element/linearElementEditor"; import { isLinearElement } from "../element/typeChecks"; import { ExcalidrawLinearElement } from "../element/types"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleLinearEditor = register({ @@ -41,7 +42,7 @@ export const actionToggleLinearEditor = register({ ...appState, editingLinearElement, }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; }, }); diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx index 21e3a4e1a..ae6197486 100644 --- a/packages/excalidraw/actions/actionLink.tsx +++ b/packages/excalidraw/actions/actionLink.tsx @@ -5,6 +5,7 @@ import { isEmbeddableElement } from "../element/typeChecks"; import { t } from "../i18n"; import { KEYS } from "../keys"; import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; import { getShortcutKey } from "../utils"; import { register } from "./register"; @@ -24,7 +25,7 @@ export const actionLink = register({ showHyperlinkPopup: "editor", openMenu: null, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, trackEvent: { category: "hyperlink", action: "click" }, diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx index 45a97eeba..84a5d1be4 100644 --- a/packages/excalidraw/actions/actionMenu.tsx +++ b/packages/excalidraw/actions/actionMenu.tsx @@ -4,6 +4,7 @@ import { t } from "../i18n"; import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { register } from "./register"; import { KEYS } from "../keys"; +import { StoreAction } from "../store"; export const actionToggleCanvasMenu = register({ name: "toggleCanvasMenu", @@ -14,7 +15,7 @@ export const actionToggleCanvasMenu = register({ ...appState, openMenu: appState.openMenu === "canvas" ? null : "canvas", }, - commitToHistory: false, + storeAction: StoreAction.NONE, }), PanelComponent: ({ appState, updateData }) => ( ( event.key === KEYS.QUESTION_MARK, diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index c60185657..9e401f4e2 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -7,6 +7,7 @@ import { microphoneMutedIcon, } from "../components/icons"; import { t } from "../i18n"; +import { StoreAction } from "../store"; import { Collaborator } from "../types"; import { register } from "./register"; import clsx from "clsx"; @@ -27,7 +28,7 @@ export const actionGoToCollaborator = register({ ...appState, userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } @@ -41,7 +42,7 @@ export const actionGoToCollaborator = register({ // Close mobile menu openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ updateData, data, appState }) => { diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 562f04b35..8ff2b40e7 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -96,6 +96,7 @@ import { import { hasStrokeColor } from "../scene/comparisons"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; +import { StoreAction } from "../store"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -231,7 +232,7 @@ const changeFontSize = ( ? [...newFontSizes][0] : fallbackValue ?? appState.currentItemFontSize, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }; @@ -261,7 +262,9 @@ export const actionChangeStrokeColor = register({ ...appState, ...value, }, - commitToHistory: !!value.currentItemStrokeColor, + storeAction: !!value.currentItemStrokeColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => ( @@ -305,7 +308,9 @@ export const actionChangeBackgroundColor = register({ ...appState, ...value, }, - commitToHistory: !!value.currentItemBackgroundColor, + storeAction: !!value.currentItemBackgroundColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => ( @@ -349,7 +354,7 @@ export const actionChangeFillStyle = register({ }), ), appState: { ...appState, currentItemFillStyle: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { @@ -422,7 +427,7 @@ export const actionChangeStrokeWidth = register({ }), ), appState: { ...appState, currentItemStrokeWidth: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -477,7 +482,7 @@ export const actionChangeSloppiness = register({ }), ), appState: { ...appState, currentItemRoughness: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -528,7 +533,7 @@ export const actionChangeStrokeStyle = register({ }), ), appState: { ...appState, currentItemStrokeStyle: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -583,7 +588,7 @@ export const actionChangeOpacity = register({ true, ), appState: { ...appState, currentItemOpacity: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -758,7 +763,7 @@ export const actionChangeFontFamily = register({ ...appState, currentItemFontFamily: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -859,7 +864,7 @@ export const actionChangeTextAlign = register({ ...appState, currentItemTextAlign: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -949,7 +954,7 @@ export const actionChangeVerticalAlign = register({ appState: { ...appState, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -1030,7 +1035,7 @@ export const actionChangeRoundness = register({ ...appState, currentItemRoundness: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { @@ -1182,7 +1187,7 @@ export const actionChangeArrowhead = register({ ? "currentItemStartArrowhead" : "currentItemEndArrowhead"]: value.type, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index 2d682166f..7cc7a0e28 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -7,6 +7,7 @@ import { isLinearElement } from "../element/typeChecks"; import { LinearElementEditor } from "../element/linearElementEditor"; import { excludeElementsInFramesFromSelection } from "../scene/selection"; import { selectAllIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionSelectAll = register({ name: "selectAll", @@ -50,7 +51,7 @@ export const actionSelectAll = register({ ? new LinearElementEditor(elements[0]) : null, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A, diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 8c0bc5370..fa8c6b9a3 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -26,6 +26,7 @@ import { import { getSelectedElements } from "../scene"; import { ExcalidrawTextElement } from "../element/types"; import { paintIcon } from "../components/icons"; +import { StoreAction } from "../store"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; @@ -54,7 +55,7 @@ export const actionCopyStyles = register({ ...appState, toast: { message: t("toast.copyStyles") }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => @@ -71,7 +72,7 @@ export const actionPasteStyles = register({ const pastedElement = elementsCopied[0]; const boundTextElement = elementsCopied[1]; if (!isExcalidrawElement(pastedElement)) { - return { elements, commitToHistory: false }; + return { elements, storeAction: StoreAction.NONE }; } const selectedElements = getSelectedElements(elements, appState, { @@ -160,7 +161,7 @@ export const actionPasteStyles = register({ } return element; }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx index 46e1879d9..da5ab6b44 100644 --- a/packages/excalidraw/actions/actionToggleGridMode.tsx +++ b/packages/excalidraw/actions/actionToggleGridMode.tsx @@ -3,6 +3,7 @@ import { register } from "./register"; import { GRID_SIZE } from "../constants"; import { AppState } from "../types"; import { gridIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionToggleGridMode = register({ name: "gridMode", @@ -21,7 +22,7 @@ export const actionToggleGridMode = register({ gridSize: this.checked!(appState) ? null : GRID_SIZE, objectsSnapModeEnabled: false, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState: AppState) => appState.gridSize !== null, diff --git a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx index 2f9a148c0..586293d08 100644 --- a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx +++ b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx @@ -1,5 +1,6 @@ import { magnetIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleObjectsSnapMode = register({ @@ -18,7 +19,7 @@ export const actionToggleObjectsSnapMode = register({ objectsSnapModeEnabled: !this.checked!(appState), gridSize: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.objectsSnapModeEnabled, diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index 74d0e0410..fc1e70a47 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -1,6 +1,7 @@ import { register } from "./register"; import { CODES, KEYS } from "../keys"; import { abacusIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionToggleStats = register({ name: "stats", @@ -15,7 +16,7 @@ export const actionToggleStats = register({ ...appState, showStats: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.showStats, diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx index f3c5e4da6..87dbb94ea 100644 --- a/packages/excalidraw/actions/actionToggleViewMode.tsx +++ b/packages/excalidraw/actions/actionToggleViewMode.tsx @@ -1,5 +1,6 @@ import { eyeIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleViewMode = register({ @@ -18,7 +19,7 @@ export const actionToggleViewMode = register({ ...appState, viewModeEnabled: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.viewModeEnabled, diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx index fd397582a..86261443f 100644 --- a/packages/excalidraw/actions/actionToggleZenMode.tsx +++ b/packages/excalidraw/actions/actionToggleZenMode.tsx @@ -1,5 +1,6 @@ import { coffeeIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleZenMode = register({ @@ -18,7 +19,7 @@ export const actionToggleZenMode = register({ ...appState, zenModeEnabled: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.zenModeEnabled, diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx index 7b68a00d5..716688811 100644 --- a/packages/excalidraw/actions/actionZindex.tsx +++ b/packages/excalidraw/actions/actionZindex.tsx @@ -15,6 +15,7 @@ import { SendToBackIcon, } from "../components/icons"; import { isDarwin } from "../constants"; +import { StoreAction } from "../store"; export const actionSendBackward = register({ name: "sendBackward", @@ -25,7 +26,7 @@ export const actionSendBackward = register({ return { elements: moveOneLeft(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyPriority: 40, @@ -54,7 +55,7 @@ export const actionBringForward = register({ return { elements: moveOneRight(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyPriority: 40, @@ -83,7 +84,7 @@ export const actionSendToBack = register({ return { elements: moveAllLeft(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -120,7 +121,7 @@ export const actionBringToFront = register({ return { elements: moveAllRight(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx index 90dfe6088..b5e36e855 100644 --- a/packages/excalidraw/actions/manager.tsx +++ b/packages/excalidraw/actions/manager.tsx @@ -7,7 +7,7 @@ import { PanelComponentProps, ActionSource, } from "./types"; -import { ExcalidrawElement } from "../element/types"; +import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types"; import { AppClassProperties, AppState } from "../types"; import { trackEvent } from "../analytics"; import { isPromiseLike } from "../utils"; @@ -46,13 +46,13 @@ export class ActionManager { updater: (actionResult: ActionResult | Promise) => void; getAppState: () => Readonly; - getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; + getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[]; app: AppClassProperties; constructor( updater: UpdaterFn, getAppState: () => AppState, - getElementsIncludingDeleted: () => readonly ExcalidrawElement[], + getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[], app: AppClassProperties, ) { this.updater = (actionResult) => { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 18503363f..e904bfa02 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -1,5 +1,5 @@ import React from "react"; -import { ExcalidrawElement } from "../element/types"; +import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types"; import { AppClassProperties, AppState, @@ -8,6 +8,7 @@ import { UIAppState, } from "../types"; import { MarkOptional } from "../utility-types"; +import { StoreAction } from "../store"; export type ActionSource = | "ui" @@ -25,14 +26,13 @@ export type ActionResult = "offsetTop" | "offsetLeft" | "width" | "height" > | null; files?: BinaryFiles | null; - commitToHistory: boolean; - syncHistory?: boolean; + storeAction: keyof typeof StoreAction; replaceFiles?: boolean; } | false; type ActionFn = ( - elements: readonly ExcalidrawElement[], + elements: readonly OrderedExcalidrawElement[], appState: Readonly, formData: any, app: AppClassProperties, diff --git a/packages/excalidraw/change.ts b/packages/excalidraw/change.ts new file mode 100644 index 000000000..b8c88f54f --- /dev/null +++ b/packages/excalidraw/change.ts @@ -0,0 +1,1529 @@ +import { ENV } from "./constants"; +import { + BoundElement, + BindableElement, + BindableProp, + BindingProp, + bindingProperties, + updateBoundElements, +} from "./element/binding"; +import { LinearElementEditor } from "./element/linearElementEditor"; +import { + ElementUpdate, + mutateElement, + newElementWith, +} from "./element/mutateElement"; +import { + getBoundTextElementId, + redrawTextBoundingBox, +} from "./element/textElement"; +import { + hasBoundTextElement, + isBindableElement, + isBoundToContainer, + isTextElement, +} from "./element/typeChecks"; +import { + ExcalidrawElement, + ExcalidrawLinearElement, + ExcalidrawTextElement, + NonDeleted, + OrderedExcalidrawElement, + SceneElementsMap, +} from "./element/types"; +import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; +import { getNonDeletedGroupIds } from "./groups"; +import { getObservedAppState } from "./store"; +import { + AppState, + ObservedAppState, + ObservedElementsAppState, + ObservedStandaloneAppState, +} from "./types"; +import { SubtypeOf, ValueOf } from "./utility-types"; +import { + arrayToMap, + arrayToObject, + assertNever, + isShallowEqual, + toBrandedType, +} from "./utils"; + +/** + * Represents the difference between two objects of the same type. + * + * Both `deleted` and `inserted` partials represent the same set of added, removed or updated properties, where: + * - `deleted` is a set of all the deleted values + * - `inserted` is a set of all the inserted (added, updated) values + * + * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load. + */ +class Delta { + private constructor( + public readonly deleted: Partial, + public readonly inserted: Partial, + ) {} + + public static create( + deleted: Partial, + inserted: Partial, + modifier?: (delta: Partial) => Partial, + modifierOptions?: "deleted" | "inserted", + ) { + const modifiedDeleted = + modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted; + const modifiedInserted = + modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted; + + return new Delta(modifiedDeleted, modifiedInserted); + } + + /** + * Calculates the delta between two objects. + * + * @param prevObject - The previous state of the object. + * @param nextObject - The next state of the object. + * + * @returns new delta instance. + */ + public static calculate( + prevObject: T, + nextObject: T, + modifier?: (partial: Partial) => Partial, + postProcess?: ( + deleted: Partial, + inserted: Partial, + ) => [Partial, Partial], + ): Delta { + if (prevObject === nextObject) { + return Delta.empty(); + } + + const deleted = {} as Partial; + const inserted = {} as Partial; + + // O(n^3) here for elements, but it's not as bad as it looks: + // - we do this only on store recordings, not on every frame (not for ephemerals) + // - we do this only on previously detected changed elements + // - we do shallow compare only on the first level of properties (not going any deeper) + // - # of properties is reasonably small + for (const key of this.distinctKeysIterator( + "full", + prevObject, + nextObject, + )) { + deleted[key as keyof T] = prevObject[key]; + inserted[key as keyof T] = nextObject[key]; + } + + const [processedDeleted, processedInserted] = postProcess + ? postProcess(deleted, inserted) + : [deleted, inserted]; + + return Delta.create(processedDeleted, processedInserted, modifier); + } + + public static empty() { + return new Delta({}, {}); + } + + public static isEmpty(delta: Delta): boolean { + return ( + !Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length + ); + } + + /** + * Merges deleted and inserted object partials. + */ + public static mergeObjects( + prev: T, + added: T, + removed: T, + ) { + const cloned = { ...prev }; + + for (const key of Object.keys(removed)) { + delete cloned[key]; + } + + return { ...cloned, ...added }; + } + + /** + * Merges deleted and inserted array partials. + */ + public static mergeArrays( + prev: readonly T[] | null, + added: readonly T[] | null | undefined, + removed: readonly T[] | null | undefined, + predicate?: (value: T) => string, + ) { + return Object.values( + Delta.mergeObjects( + arrayToObject(prev ?? [], predicate), + arrayToObject(added ?? [], predicate), + arrayToObject(removed ?? [], predicate), + ), + ); + } + + /** + * Diff object partials as part of the `postProcess`. + */ + public static diffObjects>( + deleted: Partial, + inserted: Partial, + property: K, + setValue: (prevValue: V | undefined) => V, + ) { + if (!deleted[property] && !inserted[property]) { + return; + } + + if ( + typeof deleted[property] === "object" || + typeof inserted[property] === "object" + ) { + type RecordLike = Record; + + const deletedObject: RecordLike = deleted[property] ?? {}; + const insertedObject: RecordLike = inserted[property] ?? {}; + + const deletedDifferences = Delta.getLeftDifferences( + deletedObject, + insertedObject, + ).reduce((acc, curr) => { + acc[curr] = setValue(deletedObject[curr]); + return acc; + }, {} as RecordLike); + + const insertedDifferences = Delta.getRightDifferences( + deletedObject, + insertedObject, + ).reduce((acc, curr) => { + acc[curr] = setValue(insertedObject[curr]); + return acc; + }, {} as RecordLike); + + if ( + Object.keys(deletedDifferences).length || + Object.keys(insertedDifferences).length + ) { + Reflect.set(deleted, property, deletedDifferences); + Reflect.set(inserted, property, insertedDifferences); + } else { + Reflect.deleteProperty(deleted, property); + Reflect.deleteProperty(inserted, property); + } + } + } + + /** + * Diff array partials as part of the `postProcess`. + */ + public static diffArrays( + deleted: Partial, + inserted: Partial, + property: K, + groupBy: (value: V extends ArrayLike ? T : never) => string, + ) { + if (!deleted[property] && !inserted[property]) { + return; + } + + if (Array.isArray(deleted[property]) || Array.isArray(inserted[property])) { + const deletedArray = ( + Array.isArray(deleted[property]) ? deleted[property] : [] + ) as []; + const insertedArray = ( + Array.isArray(inserted[property]) ? inserted[property] : [] + ) as []; + + const deletedDifferences = arrayToObject( + Delta.getLeftDifferences( + arrayToObject(deletedArray, groupBy), + arrayToObject(insertedArray, groupBy), + ), + ); + const insertedDifferences = arrayToObject( + Delta.getRightDifferences( + arrayToObject(deletedArray, groupBy), + arrayToObject(insertedArray, groupBy), + ), + ); + + if ( + Object.keys(deletedDifferences).length || + Object.keys(insertedDifferences).length + ) { + const deletedValue = deletedArray.filter( + (x) => deletedDifferences[groupBy ? groupBy(x) : String(x)], + ); + const insertedValue = insertedArray.filter( + (x) => insertedDifferences[groupBy ? groupBy(x) : String(x)], + ); + + Reflect.set(deleted, property, deletedValue); + Reflect.set(inserted, property, insertedValue); + } else { + Reflect.deleteProperty(deleted, property); + Reflect.deleteProperty(inserted, property); + } + } + } + + /** + * Compares if object1 contains any different value compared to the object2. + */ + public static isLeftDifferent( + object1: T, + object2: T, + skipShallowCompare = false, + ): boolean { + const anyDistinctKey = this.distinctKeysIterator( + "left", + object1, + object2, + skipShallowCompare, + ).next().value; + + return !!anyDistinctKey; + } + + /** + * Compares if object2 contains any different value compared to the object1. + */ + public static isRightDifferent( + object1: T, + object2: T, + skipShallowCompare = false, + ): boolean { + const anyDistinctKey = this.distinctKeysIterator( + "right", + object1, + object2, + skipShallowCompare, + ).next().value; + + return !!anyDistinctKey; + } + + /** + * Returns all the object1 keys that have distinct values. + */ + public static getLeftDifferences( + object1: T, + object2: T, + skipShallowCompare = false, + ) { + return Array.from( + this.distinctKeysIterator("left", object1, object2, skipShallowCompare), + ); + } + + /** + * Returns all the object2 keys that have distinct values. + */ + public static getRightDifferences( + object1: T, + object2: T, + skipShallowCompare = false, + ) { + return Array.from( + this.distinctKeysIterator("right", object1, object2, skipShallowCompare), + ); + } + + /** + * Iterator comparing values of object properties based on the passed joining strategy. + * + * @yields keys of properties with different values + * + * WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that. + */ + private static *distinctKeysIterator( + join: "left" | "right" | "full", + object1: T, + object2: T, + skipShallowCompare = false, + ) { + if (object1 === object2) { + return; + } + + let keys: string[] = []; + + if (join === "left") { + keys = Object.keys(object1); + } else if (join === "right") { + keys = Object.keys(object2); + } else if (join === "full") { + keys = Array.from( + new Set([...Object.keys(object1), ...Object.keys(object2)]), + ); + } else { + assertNever( + join, + `Unknown distinctKeysIterator's join param "${join}"`, + true, + ); + } + + for (const key of keys) { + const object1Value = object1[key as keyof T]; + const object2Value = object2[key as keyof T]; + + if (object1Value !== object2Value) { + if ( + !skipShallowCompare && + typeof object1Value === "object" && + typeof object2Value === "object" && + object1Value !== null && + object2Value !== null && + isShallowEqual(object1Value, object2Value) + ) { + continue; + } + + yield key; + } + } + } +} + +/** + * Encapsulates the modifications captured as `Delta`/s. + */ +interface Change { + /** + * Inverses the `Delta`s inside while creating a new `Change`. + */ + inverse(): Change; + + /** + * Applies the `Change` to the previous object. + * + * @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change. + */ + applyTo(previous: T, ...options: unknown[]): [T, boolean]; + + /** + * Checks whether there are actually `Delta`s. + */ + isEmpty(): boolean; +} + +export class AppStateChange implements Change { + private constructor(private readonly delta: Delta) {} + + public static calculate( + prevAppState: T, + nextAppState: T, + ): AppStateChange { + const delta = Delta.calculate( + prevAppState, + nextAppState, + undefined, + AppStateChange.postProcess, + ); + + return new AppStateChange(delta); + } + + public static empty() { + return new AppStateChange(Delta.create({}, {})); + } + + public inverse(): AppStateChange { + const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted); + return new AppStateChange(inversedDelta); + } + + public applyTo( + appState: AppState, + nextElements: SceneElementsMap, + ): [AppState, boolean] { + try { + const { + selectedElementIds: removedSelectedElementIds = {}, + selectedGroupIds: removedSelectedGroupIds = {}, + } = this.delta.deleted; + + const { + selectedElementIds: addedSelectedElementIds = {}, + selectedGroupIds: addedSelectedGroupIds = {}, + selectedLinearElementId, + editingLinearElementId, + ...directlyApplicablePartial + } = this.delta.inserted; + + const mergedSelectedElementIds = Delta.mergeObjects( + appState.selectedElementIds, + addedSelectedElementIds, + removedSelectedElementIds, + ); + + const mergedSelectedGroupIds = Delta.mergeObjects( + appState.selectedGroupIds, + addedSelectedGroupIds, + removedSelectedGroupIds, + ); + + const selectedLinearElement = + selectedLinearElementId && nextElements.has(selectedLinearElementId) + ? new LinearElementEditor( + nextElements.get( + selectedLinearElementId, + ) as NonDeleted, + ) + : null; + + const editingLinearElement = + editingLinearElementId && nextElements.has(editingLinearElementId) + ? new LinearElementEditor( + nextElements.get( + editingLinearElementId, + ) as NonDeleted, + ) + : null; + + const nextAppState = { + ...appState, + ...directlyApplicablePartial, + selectedElementIds: mergedSelectedElementIds, + selectedGroupIds: mergedSelectedGroupIds, + selectedLinearElement: + typeof selectedLinearElementId !== "undefined" + ? selectedLinearElement // element was either inserted or deleted + : appState.selectedLinearElement, // otherwise assign what we had before + editingLinearElement: + typeof editingLinearElementId !== "undefined" + ? editingLinearElement // element was either inserted or deleted + : appState.editingLinearElement, // otherwise assign what we had before + }; + + const constainsVisibleChanges = this.filterInvisibleChanges( + appState, + nextAppState, + nextElements, + ); + + return [nextAppState, constainsVisibleChanges]; + } catch (e) { + // shouldn't really happen, but just in case + console.error(`Couldn't apply appstate change`, e); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + + return [appState, false]; + } + } + + public isEmpty(): boolean { + return Delta.isEmpty(this.delta); + } + + /** + * It is necessary to post process the partials in case of reference values, + * for which we need to calculate the real diff between `deleted` and `inserted`. + */ + private static postProcess( + deleted: Partial, + inserted: Partial, + ): [Partial, Partial] { + try { + Delta.diffObjects( + deleted, + inserted, + "selectedElementIds", + // ts language server has a bit trouble resolving this, so we are giving it a little push + (_) => true as ValueOf, + ); + Delta.diffObjects( + deleted, + inserted, + "selectedGroupIds", + (prevValue) => (prevValue ?? false) as ValueOf, + ); + } catch (e) { + // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it + console.error(`Couldn't postprocess appstate change deltas.`); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [deleted, inserted]; + } + } + + /** + * Mutates `nextAppState` be filtering out state related to deleted elements. + * + * @returns `true` if a visible change is found, `false` otherwise. + */ + private filterInvisibleChanges( + prevAppState: AppState, + nextAppState: AppState, + nextElements: SceneElementsMap, + ): boolean { + // TODO: #7348 we could still get an empty undo/redo, as we assume that previous appstate does not contain references to deleted elements + // which is not always true - i.e. now we do cleanup appstate during history, but we do not do it during remote updates + const prevObservedAppState = getObservedAppState(prevAppState); + const nextObservedAppState = getObservedAppState(nextAppState); + + const containsStandaloneDifference = Delta.isRightDifferent( + AppStateChange.stripElementsProps(prevObservedAppState), + AppStateChange.stripElementsProps(nextObservedAppState), + ); + + const containsElementsDifference = Delta.isRightDifferent( + AppStateChange.stripStandaloneProps(prevObservedAppState), + AppStateChange.stripStandaloneProps(nextObservedAppState), + ); + + if (!containsStandaloneDifference && !containsElementsDifference) { + // no change in appstate was detected + return false; + } + + const visibleDifferenceFlag = { + value: containsStandaloneDifference, + }; + + if (containsElementsDifference) { + // filter invisible changes on each iteration + const changedElementsProps = Delta.getRightDifferences( + AppStateChange.stripStandaloneProps(prevObservedAppState), + AppStateChange.stripStandaloneProps(nextObservedAppState), + ) as Array; + + let nonDeletedGroupIds = new Set(); + + if ( + changedElementsProps.includes("editingGroupId") || + changedElementsProps.includes("selectedGroupIds") + ) { + // this one iterates through all the non deleted elements, so make sure it's not done twice + nonDeletedGroupIds = getNonDeletedGroupIds(nextElements); + } + + // check whether delta properties are related to the existing non-deleted elements + for (const key of changedElementsProps) { + switch (key) { + case "selectedElementIds": + nextAppState[key] = AppStateChange.filterSelectedElements( + nextAppState[key], + nextElements, + visibleDifferenceFlag, + ); + + break; + case "selectedGroupIds": + nextAppState[key] = AppStateChange.filterSelectedGroups( + nextAppState[key], + nonDeletedGroupIds, + visibleDifferenceFlag, + ); + + break; + case "editingGroupId": + const editingGroupId = nextAppState[key]; + + if (!editingGroupId) { + // previously there was an editingGroup (assuming visible), now there is none + visibleDifferenceFlag.value = true; + } else if (nonDeletedGroupIds.has(editingGroupId)) { + // previously there wasn't an editingGroup, now there is one which is visible + visibleDifferenceFlag.value = true; + } else { + // there was assigned an editingGroup now, but it's related to deleted element + nextAppState[key] = null; + } + + break; + case "selectedLinearElementId": + case "editingLinearElementId": + const appStateKey = AppStateChange.convertToAppStateKey(key); + const linearElement = nextAppState[appStateKey]; + + if (!linearElement) { + // previously there was a linear element (assuming visible), now there is none + visibleDifferenceFlag.value = true; + } else { + const element = nextElements.get(linearElement.elementId); + + if (element && !element.isDeleted) { + // previously there wasn't a linear element, now there is one which is visible + visibleDifferenceFlag.value = true; + } else { + // there was assigned a linear element now, but it's deleted + nextAppState[appStateKey] = null; + } + } + + break; + default: { + assertNever( + key, + `Unknown ObservedElementsAppState's key "${key}"`, + true, + ); + } + } + } + } + + return visibleDifferenceFlag.value; + } + + private static convertToAppStateKey( + key: keyof Pick< + ObservedElementsAppState, + "selectedLinearElementId" | "editingLinearElementId" + >, + ): keyof Pick { + switch (key) { + case "selectedLinearElementId": + return "selectedLinearElement"; + case "editingLinearElementId": + return "editingLinearElement"; + } + } + + private static filterSelectedElements( + selectedElementIds: AppState["selectedElementIds"], + elements: SceneElementsMap, + visibleDifferenceFlag: { value: boolean }, + ) { + const ids = Object.keys(selectedElementIds); + + if (!ids.length) { + // previously there were ids (assuming related to visible elements), now there are none + visibleDifferenceFlag.value = true; + return selectedElementIds; + } + + const nextSelectedElementIds = { ...selectedElementIds }; + + for (const id of ids) { + const element = elements.get(id); + + if (element && !element.isDeleted) { + // there is a selected element id related to a visible element + visibleDifferenceFlag.value = true; + } else { + delete nextSelectedElementIds[id]; + } + } + + return nextSelectedElementIds; + } + + private static filterSelectedGroups( + selectedGroupIds: AppState["selectedGroupIds"], + nonDeletedGroupIds: Set, + visibleDifferenceFlag: { value: boolean }, + ) { + const ids = Object.keys(selectedGroupIds); + + if (!ids.length) { + // previously there were ids (assuming related to visible groups), now there are none + visibleDifferenceFlag.value = true; + return selectedGroupIds; + } + + const nextSelectedGroupIds = { ...selectedGroupIds }; + + for (const id of Object.keys(nextSelectedGroupIds)) { + if (nonDeletedGroupIds.has(id)) { + // there is a selected group id related to a visible group + visibleDifferenceFlag.value = true; + } else { + delete nextSelectedGroupIds[id]; + } + } + + return nextSelectedGroupIds; + } + + private static stripElementsProps( + delta: Partial, + ): Partial { + // WARN: Do not remove the type-casts as they here to ensure proper type checks + const { + editingGroupId, + selectedGroupIds, + selectedElementIds, + editingLinearElementId, + selectedLinearElementId, + ...standaloneProps + } = delta as ObservedAppState; + + return standaloneProps as SubtypeOf< + typeof standaloneProps, + ObservedStandaloneAppState + >; + } + + private static stripStandaloneProps( + delta: Partial, + ): Partial { + // WARN: Do not remove the type-casts as they here to ensure proper type checks + const { name, viewBackgroundColor, ...elementsProps } = + delta as ObservedAppState; + + return elementsProps as SubtypeOf< + typeof elementsProps, + ObservedElementsAppState + >; + } +} + +type ElementPartial = Omit, "seed">; + +/** + * Elements change is a low level primitive to capture a change between two sets of elements. + * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions. + */ +export class ElementsChange implements Change { + private constructor( + private readonly added: Map>, + private readonly removed: Map>, + private readonly updated: Map>, + ) {} + + public static create( + added: Map>, + removed: Map>, + updated: Map>, + options = { shouldRedistribute: false }, + ) { + let change: ElementsChange; + + if (options.shouldRedistribute) { + const nextAdded = new Map>(); + const nextRemoved = new Map>(); + const nextUpdated = new Map>(); + + const deltas = [...added, ...removed, ...updated]; + + for (const [id, delta] of deltas) { + if (this.satisfiesAddition(delta)) { + nextAdded.set(id, delta); + } else if (this.satisfiesRemoval(delta)) { + nextRemoved.set(id, delta); + } else { + nextUpdated.set(id, delta); + } + } + + change = new ElementsChange(nextAdded, nextRemoved, nextUpdated); + } else { + change = new ElementsChange(added, removed, updated); + } + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + ElementsChange.validate(change, "added", this.satisfiesAddition); + ElementsChange.validate(change, "removed", this.satisfiesRemoval); + ElementsChange.validate(change, "updated", this.satisfiesUpdate); + } + + return change; + } + + private static satisfiesAddition = ({ + deleted, + inserted, + }: Delta) => + // dissallowing added as "deleted", which could cause issues when resolving conflicts + deleted.isDeleted === true && !inserted.isDeleted; + + private static satisfiesRemoval = ({ + deleted, + inserted, + }: Delta) => + !deleted.isDeleted && inserted.isDeleted === true; + + private static satisfiesUpdate = ({ + deleted, + inserted, + }: Delta) => !!deleted.isDeleted === !!inserted.isDeleted; + + private static validate( + change: ElementsChange, + type: "added" | "removed" | "updated", + satifies: (delta: Delta) => boolean, + ) { + for (const [id, delta] of change[type].entries()) { + if (!satifies(delta)) { + console.error( + `Broken invariant for "${type}" delta, element "${id}", delta:`, + delta, + ); + throw new Error(`ElementsChange invariant broken for element "${id}".`); + } + } + } + + /** + * Calculates the `Delta`s between the previous and next set of elements. + * + * @param prevElements - Map representing the previous state of elements. + * @param nextElements - Map representing the next state of elements. + * + * @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements. + */ + public static calculate( + prevElements: Map, + nextElements: Map, + ): ElementsChange { + if (prevElements === nextElements) { + return ElementsChange.empty(); + } + + const added = new Map>(); + const removed = new Map>(); + const updated = new Map>(); + + // this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements + for (const prevElement of prevElements.values()) { + const nextElement = nextElements.get(prevElement.id); + + if (!nextElement) { + const deleted = { ...prevElement, isDeleted: false } as ElementPartial; + const inserted = { isDeleted: true } as ElementPartial; + + const delta = Delta.create( + deleted, + inserted, + ElementsChange.stripIrrelevantProps, + ); + + removed.set(prevElement.id, delta); + } + } + + for (const nextElement of nextElements.values()) { + const prevElement = prevElements.get(nextElement.id); + + if (!prevElement) { + const deleted = { isDeleted: true } as ElementPartial; + const inserted = { + ...nextElement, + isDeleted: false, + } as ElementPartial; + + const delta = Delta.create( + deleted, + inserted, + ElementsChange.stripIrrelevantProps, + ); + + added.set(nextElement.id, delta); + + continue; + } + + if (prevElement.versionNonce !== nextElement.versionNonce) { + const delta = Delta.calculate( + prevElement, + nextElement, + ElementsChange.stripIrrelevantProps, + ElementsChange.postProcess, + ); + + if ( + // making sure we don't get here some non-boolean values (i.e. undefined, null, etc.) + typeof prevElement.isDeleted === "boolean" && + typeof nextElement.isDeleted === "boolean" && + prevElement.isDeleted !== nextElement.isDeleted + ) { + // notice that other props could have been updated as well + if (prevElement.isDeleted && !nextElement.isDeleted) { + added.set(nextElement.id, delta); + } else { + removed.set(nextElement.id, delta); + } + + continue; + } + + // making sure there are at least some changes + if (!Delta.isEmpty(delta)) { + updated.set(nextElement.id, delta); + } + } + } + + return ElementsChange.create(added, removed, updated); + } + + public static empty() { + return ElementsChange.create(new Map(), new Map(), new Map()); + } + + public inverse(): ElementsChange { + const inverseInternal = (deltas: Map>) => { + const inversedDeltas = new Map>(); + + for (const [id, delta] of deltas.entries()) { + inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted)); + } + + return inversedDeltas; + }; + + const added = inverseInternal(this.added); + const removed = inverseInternal(this.removed); + const updated = inverseInternal(this.updated); + + // notice we inverse removed with added not to break the invariants + return ElementsChange.create(removed, added, updated); + } + + public isEmpty(): boolean { + return ( + this.added.size === 0 && + this.removed.size === 0 && + this.updated.size === 0 + ); + } + + /** + * Update delta/s based on the existing elements. + * + * @param elements current elements + * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated + * @returns new instance with modified delta/s + */ + public applyLatestChanges(elements: SceneElementsMap): ElementsChange { + const modifier = + (element: OrderedExcalidrawElement) => (partial: ElementPartial) => { + const latestPartial: { [key: string]: unknown } = {}; + + for (const key of Object.keys(partial) as Array) { + // do not update following props: + // - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys + switch (key) { + case "boundElements": + latestPartial[key] = partial[key]; + break; + default: + latestPartial[key] = element[key]; + } + } + + return latestPartial; + }; + + const applyLatestChangesInternal = ( + deltas: Map>, + ) => { + const modifiedDeltas = new Map>(); + + for (const [id, delta] of deltas.entries()) { + const existingElement = elements.get(id); + + if (existingElement) { + const modifiedDelta = Delta.create( + delta.deleted, + delta.inserted, + modifier(existingElement), + "inserted", + ); + + modifiedDeltas.set(id, modifiedDelta); + } else { + modifiedDeltas.set(id, delta); + } + } + + return modifiedDeltas; + }; + + const added = applyLatestChangesInternal(this.added); + const removed = applyLatestChangesInternal(this.removed); + const updated = applyLatestChangesInternal(this.updated); + + return ElementsChange.create(added, removed, updated, { + shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated + }); + } + + public applyTo( + elements: SceneElementsMap, + snapshot: Map, + ): [SceneElementsMap, boolean] { + let nextElements = toBrandedType(new Map(elements)); + let changedElements: Map; + + const flags = { + containsVisibleDifference: false, + containsZindexDifference: false, + }; + + // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) + try { + const applyDeltas = ElementsChange.createApplier( + nextElements, + snapshot, + flags, + ); + + const addedElements = applyDeltas(this.added); + const removedElements = applyDeltas(this.removed); + const updatedElements = applyDeltas(this.updated); + + const affectedElements = this.resolveConflicts(elements, nextElements); + + // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues + changedElements = new Map([ + ...addedElements, + ...removedElements, + ...updatedElements, + ...affectedElements, + ]); + } catch (e) { + console.error(`Couldn't apply elements change`, e); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + + // should not really happen, but just in case we cannot apply deltas, let's return the previous elements with visible change set to `true` + // even though there is obviously no visible change, returning `false` could be dangerous, as i.e.: + // in the worst case, it could lead into iterating through the whole stack with no possibility to redo + // instead, the worst case when returning `true` is an empty undo / redo + return [elements, true]; + } + + try { + // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state + ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements); + ElementsChange.redrawBoundArrows(nextElements, changedElements); + + // the following reorder performs also mutations, but only on new instances of changed elements + // (unless something goes really bad and it fallbacks to fixing all invalid indices) + nextElements = ElementsChange.reorderElements( + nextElements, + changedElements, + flags, + ); + } catch (e) { + console.error( + `Couldn't mutate elements after applying elements change`, + e, + ); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [nextElements, flags.containsVisibleDifference]; + } + } + + private static createApplier = ( + nextElements: SceneElementsMap, + snapshot: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => { + const getElement = ElementsChange.createGetter( + nextElements, + snapshot, + flags, + ); + + return (deltas: Map>) => + Array.from(deltas.entries()).reduce((acc, [id, delta]) => { + const element = getElement(id, delta.inserted); + + if (element) { + const newElement = ElementsChange.applyDelta(element, delta, flags); + nextElements.set(newElement.id, newElement); + acc.set(newElement.id, newElement); + } + + return acc; + }, new Map()); + }; + + private static createGetter = + ( + elements: SceneElementsMap, + snapshot: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => + (id: string, partial: ElementPartial) => { + let element = elements.get(id); + + if (!element) { + // always fallback to the local snapshot, in cases when we cannot find the element in the elements array + element = snapshot.get(id); + + if (element) { + // as the element was brought from the snapshot, it automatically results in a possible zindex difference + flags.containsZindexDifference = true; + + // as the element was force deleted, we need to check if adding it back results in a visible change + if ( + partial.isDeleted === false || + (partial.isDeleted !== true && element.isDeleted === false) + ) { + flags.containsVisibleDifference = true; + } + } + } + + return element; + }; + + private static applyDelta( + element: OrderedExcalidrawElement, + delta: Delta, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + } = { + // by default we don't care about about the flags + containsVisibleDifference: true, + containsZindexDifference: true, + }, + ) { + const { boundElements, ...directlyApplicablePartial } = delta.inserted; + + if ( + delta.deleted.boundElements?.length || + delta.inserted.boundElements?.length + ) { + const mergedBoundElements = Delta.mergeArrays( + element.boundElements, + delta.inserted.boundElements, + delta.deleted.boundElements, + (x) => x.id, + ); + + Object.assign(directlyApplicablePartial, { + boundElements: mergedBoundElements, + }); + } + + if (!flags.containsVisibleDifference) { + // strip away fractional as even if it would be different, it doesn't have to result in visible change + const { index, ...rest } = directlyApplicablePartial; + const containsVisibleDifference = + ElementsChange.checkForVisibleDifference(element, rest); + + flags.containsVisibleDifference = containsVisibleDifference; + } + + if (!flags.containsZindexDifference) { + flags.containsZindexDifference = + delta.deleted.index !== delta.inserted.index; + } + + return newElementWith(element, directlyApplicablePartial); + } + + /** + * Check for visible changes regardless of whether they were removed, added or updated. + */ + private static checkForVisibleDifference( + element: OrderedExcalidrawElement, + partial: ElementPartial, + ) { + if (element.isDeleted && partial.isDeleted !== false) { + // when it's deleted and partial is not false, it cannot end up with a visible change + return false; + } + + if (element.isDeleted && partial.isDeleted === false) { + // when we add an element, it results in a visible change + return true; + } + + if (element.isDeleted === false && partial.isDeleted) { + // when we remove an element, it results in a visible change + return true; + } + + // check for any difference on a visible element + return Delta.isRightDifferent(element, partial); + } + + /** + * Resolves conflicts for all previously added, removed and updated elements. + * Updates the previous deltas with all the changes after conflict resolution. + * + * @returns all elements affected by the conflict resolution + */ + private resolveConflicts( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + ) { + const nextAffectedElements = new Map(); + const updater = ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => { + const nextElement = nextElements.get(element.id); // only ever modify next element! + if (!nextElement) { + return; + } + + let affectedElement: OrderedExcalidrawElement; + + if (prevElements.get(element.id) === nextElement) { + // create the new element instance in case we didn't modify the element yet + // so that we won't end up in an incosistent state in case we would fail in the middle of mutations + affectedElement = newElementWith( + nextElement, + updates as ElementUpdate, + ); + } else { + affectedElement = mutateElement( + nextElement, + updates as ElementUpdate, + ); + } + + nextAffectedElements.set(affectedElement.id, affectedElement); + nextElements.set(affectedElement.id, affectedElement); + }; + + // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound + for (const [id] of this.removed) { + ElementsChange.unbindAffected(prevElements, nextElements, id, updater); + } + + // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound + for (const [id] of this.added) { + ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + } + + // updated delta is affecting the binding only in case it contains changed binding or bindable property + for (const [id] of Array.from(this.updated).filter(([_, delta]) => + Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) => + bindingProperties.has(prop as BindingProp | BindableProp), + ), + )) { + const updatedElement = nextElements.get(id); + if (!updatedElement || updatedElement.isDeleted) { + // skip fixing bindings for updates on deleted elements + continue; + } + + ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + } + + // filter only previous elements, which were now affected + const prevAffectedElements = new Map( + Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), + ); + + // calculate complete deltas for affected elements, and assign them back to all the deltas + // technically we could do better here if perf. would become an issue + const { added, removed, updated } = ElementsChange.calculate( + prevAffectedElements, + nextAffectedElements, + ); + + for (const [id, delta] of added) { + this.added.set(id, delta); + } + + for (const [id, delta] of removed) { + this.removed.set(id, delta); + } + + for (const [id, delta] of updated) { + this.updated.set(id, delta); + } + + return nextAffectedElements; + } + + /** + * Non deleted affected elements of removed elements (before and after applying delta), + * should be unbound ~ bindings should not point from non deleted into the deleted element/s. + */ + private static unbindAffected( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + id: string, + updater: ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => void, + ) { + // the instance could have been updated, so make sure we are passing the latest element to each function below + const prevElement = () => prevElements.get(id); // element before removal + const nextElement = () => nextElements.get(id); // element after removal + + BoundElement.unbindAffected(nextElements, prevElement(), updater); + BoundElement.unbindAffected(nextElements, nextElement(), updater); + + BindableElement.unbindAffected(nextElements, prevElement(), updater); + BindableElement.unbindAffected(nextElements, nextElement(), updater); + } + + /** + * Non deleted affected elements of added or updated element/s (before and after applying delta), + * should be rebound (if possible) with the current element ~ bindings should be bidirectional. + */ + private static rebindAffected( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + id: string, + updater: ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => void, + ) { + // the instance could have been updated, so make sure we are passing the latest element to each function below + const prevElement = () => prevElements.get(id); // element before addition / update + const nextElement = () => nextElements.get(id); // element after addition / update + + BoundElement.unbindAffected(nextElements, prevElement(), updater); + BoundElement.rebindAffected(nextElements, nextElement(), updater); + + BindableElement.unbindAffected( + nextElements, + prevElement(), + (element, updates) => { + // we cannot rebind arrows with bindable element so we don't unbind them at all during rebind (we still need to unbind them on removal) + // TODO: #7348 add startBinding / endBinding to the `BoundElement` context so that we could rebind arrows and remove this condition + if (isTextElement(element)) { + updater(element, updates); + } + }, + ); + BindableElement.rebindAffected(nextElements, nextElement(), updater); + } + + private static redrawTextBoundingBoxes( + elements: SceneElementsMap, + changed: Map, + ) { + const boxesToRedraw = new Map< + string, + { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement } + >(); + + for (const element of changed.values()) { + if (isBoundToContainer(element)) { + const { containerId } = element as ExcalidrawTextElement; + const container = containerId ? elements.get(containerId) : undefined; + + if (container) { + boxesToRedraw.set(container.id, { + container, + boundText: element as ExcalidrawTextElement, + }); + } + } + + if (hasBoundTextElement(element)) { + const boundTextElementId = getBoundTextElementId(element); + const boundText = boundTextElementId + ? elements.get(boundTextElementId) + : undefined; + + if (boundText) { + boxesToRedraw.set(element.id, { + container: element, + boundText: boundText as ExcalidrawTextElement, + }); + } + } + } + + for (const { container, boundText } of boxesToRedraw.values()) { + if (container.isDeleted || boundText.isDeleted) { + // skip redraw if one of them is deleted, as it would not result in a meaningful redraw + continue; + } + + redrawTextBoundingBox(boundText, container, elements, false); + } + } + + private static redrawBoundArrows( + elements: SceneElementsMap, + changed: Map, + ) { + for (const element of changed.values()) { + if (!element.isDeleted && isBindableElement(element)) { + updateBoundElements(element, elements); + } + } + } + + private static reorderElements( + elements: SceneElementsMap, + changed: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) { + if (!flags.containsZindexDifference) { + return elements; + } + + const previous = Array.from(elements.values()); + const reordered = orderByFractionalIndex([...previous]); + + if ( + !flags.containsVisibleDifference && + Delta.isRightDifferent(previous, reordered, true) + ) { + // we found a difference in order! + flags.containsVisibleDifference = true; + } + + // let's synchronize all invalid indices of moved elements + return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements; + } + + /** + * It is necessary to post process the partials in case of reference values, + * for which we need to calculate the real diff between `deleted` and `inserted`. + */ + private static postProcess( + deleted: ElementPartial, + inserted: ElementPartial, + ): [ElementPartial, ElementPartial] { + try { + Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); + } catch (e) { + // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it + console.error(`Couldn't postprocess elements change deltas.`); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [deleted, inserted]; + } + } + + private static stripIrrelevantProps( + partial: Partial, + ): ElementPartial { + const { id, updated, version, versionNonce, seed, ...strippedPartial } = + partial; + + return strippedPartial; + } +} diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index df0d73755..5826628de 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -12,6 +12,7 @@ font-size: 0.875rem !important; width: var(--lg-button-size); height: var(--lg-button-size); + svg { width: var(--lg-icon-size) !important; height: var(--lg-icon-size) !important; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 8ceb362a5..f9c41074b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -183,6 +183,7 @@ import { ExcalidrawIframeElement, ExcalidrawEmbeddableElement, Ordered, + OrderedExcalidrawElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -194,7 +195,7 @@ import { isSelectedViaGroup, selectGroupsForSelectedElements, } from "../groups"; -import History from "../history"; +import { History } from "../history"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { CODES, @@ -278,11 +279,12 @@ import { muteFSAbortError, isTestEnv, easeOut, - arrayToMap, updateStable, addEventListener, normalizeEOL, getDateTime, + isShallowEqual, + arrayToMap, } from "../utils"; import { createSrcDoc, @@ -410,6 +412,7 @@ import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; import FollowMode from "./FollowMode/FollowMode"; +import { IStore, Store, StoreAction } from "../store"; import { AnimationFrameHandler } from "../animation-frame-handler"; import { AnimatedTrail } from "../animated-trail"; import { LaserTrails } from "../laser-trails"; @@ -540,6 +543,7 @@ class App extends React.Component { public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; public id: string; + private store: IStore; private history: History; private excalidrawContainerValue: { container: HTMLDivElement | null; @@ -665,6 +669,10 @@ class App extends React.Component { this.canvas = document.createElement("canvas"); this.rc = rough.canvas(this.canvas); this.renderer = new Renderer(this.scene); + + this.store = new Store(); + this.history = new History(); + if (excalidrawAPI) { const api: ExcalidrawImperativeAPI = { updateScene: this.updateScene, @@ -714,10 +722,14 @@ class App extends React.Component { onSceneUpdated: this.onSceneUpdated, }); this.history = new History(); - this.actionManager.registerAll(actions); - this.actionManager.registerAction(createUndoAction(this.history)); - this.actionManager.registerAction(createRedoAction(this.history)); + this.actionManager.registerAll(actions); + this.actionManager.registerAction( + createUndoAction(this.history, this.store), + ); + this.actionManager.registerAction( + createRedoAction(this.history, this.store), + ); } private onWindowMessage(event: MessageEvent) { @@ -2092,12 +2104,12 @@ class App extends React.Component { if (shouldUpdateStrokeColor) { this.syncActionResult({ appState: { ...this.state, currentItemStrokeColor: color }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } else { this.syncActionResult({ appState: { ...this.state, currentItemBackgroundColor: color }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } } else { @@ -2111,6 +2123,7 @@ class App extends React.Component { } return el; }), + commitToStore: true, }); } }, @@ -2135,10 +2148,14 @@ class App extends React.Component { editingElement = element; } }); - this.scene.replaceAllElements(actionResult.elements); - if (actionResult.commitToHistory) { - this.history.resumeRecording(); + + if (actionResult.storeAction === StoreAction.UPDATE) { + this.store.shouldUpdateSnapshot(); + } else if (actionResult.storeAction === StoreAction.CAPTURE) { + this.store.shouldCaptureIncrement(); } + + this.scene.replaceAllElements(actionResult.elements); } if (actionResult.files) { @@ -2149,8 +2166,10 @@ class App extends React.Component { } if (actionResult.appState || editingElement || this.state.contextMenu) { - if (actionResult.commitToHistory) { - this.history.resumeRecording(); + if (actionResult.storeAction === StoreAction.UPDATE) { + this.store.shouldUpdateSnapshot(); + } else if (actionResult.storeAction === StoreAction.CAPTURE) { + this.store.shouldCaptureIncrement(); } let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false; @@ -2180,34 +2199,24 @@ class App extends React.Component { editingElement = null; } - this.setState( - (state) => { - // using Object.assign instead of spread to fool TS 4.2.2+ into - // regarding the resulting type as not containing undefined - // (which the following expression will never contain) - return Object.assign(actionResult.appState || {}, { - // NOTE this will prevent opening context menu using an action - // or programmatically from the host, so it will need to be - // rewritten later - contextMenu: null, - editingElement, - viewModeEnabled, - zenModeEnabled, - gridSize, - theme, - name, - errorMessage, - }); - }, - () => { - if (actionResult.syncHistory) { - this.history.setCurrentState( - this.state, - this.scene.getElementsIncludingDeleted(), - ); - } - }, - ); + this.setState((state) => { + // using Object.assign instead of spread to fool TS 4.2.2+ into + // regarding the resulting type as not containing undefined + // (which the following expression will never contain) + return Object.assign(actionResult.appState || {}, { + // NOTE this will prevent opening context menu using an action + // or programmatically from the host, so it will need to be + // rewritten later + contextMenu: null, + editingElement, + viewModeEnabled, + zenModeEnabled, + gridSize, + theme, + name, + errorMessage, + }); + }); } }, ); @@ -2231,6 +2240,10 @@ class App extends React.Component { this.history.clear(); }; + private resetStore = () => { + this.store.clear(); + }; + /** * Resets scene & history. * ! Do not use to clear scene user action ! @@ -2243,6 +2256,7 @@ class App extends React.Component { isLoading: opts?.resetLoadingState ? false : state.isLoading, theme: this.state.theme, })); + this.resetStore(); this.resetHistory(); }, ); @@ -2327,10 +2341,11 @@ class App extends React.Component { // seems faster even in browsers that do fire the loadingdone event. this.fonts.loadFontsForElements(scene.elements); + this.resetStore(); this.resetHistory(); this.syncActionResult({ ...scene, - commitToHistory: true, + storeAction: StoreAction.UPDATE, }); }; @@ -2420,9 +2435,17 @@ class App extends React.Component { configurable: true, value: this.history, }, + store: { + configurable: true, + value: this.store, + }, }); } + this.store.onStoreIncrementEmitter.on((increment) => { + this.history.record(increment.elementsChange, increment.appStateChange); + }); + this.scene.addCallback(this.onSceneUpdated); this.addEventListeners(); @@ -2479,6 +2502,7 @@ class App extends React.Component { this.laserTrails.stop(); this.eraserTrail.stop(); this.onChangeEmitter.clear(); + this.store.onStoreIncrementEmitter.clear(); ShapeCache.destroy(); SnapCache.destroy(); clearTimeout(touchTimeout); @@ -2623,7 +2647,8 @@ class App extends React.Component { componentDidUpdate(prevProps: AppProps, prevState: AppState) { this.updateEmbeddables(); const elements = this.scene.getElementsIncludingDeleted(); - const elementsMap = this.scene.getNonDeletedElementsMap(); + const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap(); if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); @@ -2739,7 +2764,7 @@ class App extends React.Component { this.state.editingLinearElement && !this.state.selectedElementIds[this.state.editingLinearElement.elementId] ) { - // defer so that the commitToHistory flag isn't reset via current update + // defer so that the storeAction flag isn't reset via current update setTimeout(() => { // execute only if the condition still holds when the deferred callback // executes (it can be scheduled multiple times depending on how @@ -2778,13 +2803,14 @@ class App extends React.Component { LinearElementEditor.getPointAtIndexGlobalCoordinates( multiElement, -1, - elementsMap, + nonDeletedElementsMap, ), ), this, ); } - this.history.record(this.state, elements); + + this.store.capture(elementsMap, this.state); // Do not notify consumers if we're still loading the scene. Among other // potential issues, this fixes a case where the tab isn't focused during @@ -3154,7 +3180,7 @@ class App extends React.Component { this.files = { ...this.files, ...opts.files }; } - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); const nextElementsToSelect = excludeElementsInFramesFromSelection(newElements); @@ -3389,7 +3415,7 @@ class App extends React.Component { PLAIN_PASTE_TOAST_SHOWN = true; } - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } setAppState: React.Component["setState"] = ( @@ -3657,10 +3683,51 @@ class App extends React.Component { elements?: SceneData["elements"]; appState?: Pick | null; collaborators?: SceneData["collaborators"]; - commitToHistory?: SceneData["commitToHistory"]; + commitToStore?: SceneData["commitToStore"]; }) => { - if (sceneData.commitToHistory) { - this.history.resumeRecording(); + const nextElements = syncInvalidIndices(sceneData.elements ?? []); + + if (sceneData.commitToStore) { + this.store.shouldCaptureIncrement(); + } + + if (sceneData.elements || sceneData.appState) { + let nextCommittedAppState = this.state; + let nextCommittedElements: Map; + + if (sceneData.appState) { + nextCommittedAppState = { + ...this.state, + ...sceneData.appState, // Here we expect just partial appState + }; + } + + const prevElements = this.scene.getElementsIncludingDeleted(); + + if (sceneData.elements) { + /** + * We need to schedule a snapshot update, as in case `commitToStore` is false (i.e. remote update), + * as it's essential for computing local changes after the async action is completed (i.e. not to include remote changes in the diff). + * + * This is also a breaking change for all local `updateScene` calls without set `commitToStore` to true, + * as it makes such updates impossible to undo (previously they were undone coincidentally with the switch to the whole snapshot captured by the history). + * + * WARN: be careful here as moving it elsewhere could break the history for remote client without noticing + * - we need to find a way to test two concurrent client updates simultaneously, while having access to both stores & histories. + */ + this.store.shouldUpdateSnapshot(); + + // TODO#7348: deprecate once exchanging just store increments between clients + nextCommittedElements = this.store.ignoreUncomittedElements( + arrayToMap(prevElements), + arrayToMap(nextElements), + ); + } else { + nextCommittedElements = arrayToMap(prevElements); + } + + // WARN: Performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter + this.store.capture(nextCommittedElements, nextCommittedAppState); } if (sceneData.appState) { @@ -3668,7 +3735,7 @@ class App extends React.Component { } if (sceneData.elements) { - this.scene.replaceAllElements(sceneData.elements); + this.scene.replaceAllElements(nextElements); } if (sceneData.collaborators) { @@ -3896,7 +3963,7 @@ class App extends React.Component { this.state.editingLinearElement.elementId !== selectedElements[0].id ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.setState({ editingLinearElement: new LinearElementEditor( selectedElement, @@ -4308,7 +4375,7 @@ class App extends React.Component { ]); } if (!isDeleted || isExistingElement) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } this.setState({ @@ -4793,7 +4860,7 @@ class App extends React.Component { (!this.state.editingLinearElement || this.state.editingLinearElement.elementId !== selectedElements[0].id) ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.setState({ editingLinearElement: new LinearElementEditor(selectedElements[0]), }); @@ -4818,6 +4885,7 @@ class App extends React.Component { getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds); if (selectedGroupId) { + this.store.shouldCaptureIncrement(); this.setState((prevState) => ({ ...prevState, ...selectGroupsForSelectedElements( @@ -6300,7 +6368,7 @@ class App extends React.Component { const ret = LinearElementEditor.handlePointerDown( event, this.state, - this.history, + this.store, pointerDownState.origin, linearElementEditor, this, @@ -7848,7 +7916,7 @@ class App extends React.Component { if (isLinearElement(draggingElement)) { if (draggingElement!.points.length > 1) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } const pointerCoords = viewportCoordsToSceneCoords( childEvent, @@ -7917,14 +7985,16 @@ class App extends React.Component { isInvisiblySmallElement(draggingElement) ) { // remove invisible element which was added in onPointerDown - this.scene.replaceAllElements( - this.scene + // update the store snapshot, so that invisible elements are not captured by the store + this.updateScene({ + elements: this.scene .getElementsIncludingDeleted() .filter((el) => el.id !== draggingElement.id), - ); - this.setState({ - draggingElement: null, + appState: { + draggingElement: null, + }, }); + return; } @@ -8086,15 +8156,16 @@ class App extends React.Component { } if (resizingElement) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } if (resizingElement && isInvisiblySmallElement(resizingElement)) { - this.scene.replaceAllElements( - this.scene + // update the store snapshot, so that invisible elements are not captured by the store + this.updateScene({ + elements: this.scene .getElementsIncludingDeleted() .filter((el) => el.id !== resizingElement.id), - ); + }); } // handle frame membership for resizing frames and/or selected elements @@ -8395,9 +8466,13 @@ class App extends React.Component { if ( activeTool.type !== "selection" || - isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) + isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) || + !isShallowEqual( + this.state.previousSelectedElementIds, + this.state.selectedElementIds, + ) ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { @@ -8475,7 +8550,7 @@ class App extends React.Component { this.elementsPendingErasure = new Set(); if (didChange) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.scene.replaceAllElements(elements); } }; @@ -9038,7 +9113,7 @@ class App extends React.Component { isLoading: false, }, replaceFiles: true, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); return; } catch (error: any) { @@ -9118,12 +9193,13 @@ class App extends React.Component { ) => { file = await normalizeFile(file); try { + const elements = this.scene.getElementsIncludingDeleted(); let ret; try { ret = await loadSceneOrLibraryFromBlob( file, this.state, - this.scene.getElementsIncludingDeleted(), + elements, fileHandle, ); } catch (error: any) { @@ -9152,6 +9228,13 @@ class App extends React.Component { } if (ret.type === MIME_TYPES.excalidraw) { + // Restore the fractional indices by mutating elements and update the + // store snapshot, otherwise we would end up with duplicate indices + syncInvalidIndices(elements.concat(ret.data.elements)); + this.store.snapshot = this.store.snapshot.clone( + arrayToMap(elements), + this.state, + ); this.setState({ isLoading: true }); this.syncActionResult({ ...ret.data, @@ -9160,7 +9243,7 @@ class App extends React.Component { isLoading: false, }, replaceFiles: true, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } else if (ret.type === MIME_TYPES.excalidrawlib) { await this.library @@ -9770,6 +9853,7 @@ declare global { setState: React.Component["setState"]; app: InstanceType; history: History; + store: Store; }; } } diff --git a/packages/excalidraw/components/ToolButton.tsx b/packages/excalidraw/components/ToolButton.tsx index 2dace89d7..e6d14ba08 100644 --- a/packages/excalidraw/components/ToolButton.tsx +++ b/packages/excalidraw/components/ToolButton.tsx @@ -25,6 +25,7 @@ type ToolButtonBaseProps = { hidden?: boolean; visible?: boolean; selected?: boolean; + disabled?: boolean; className?: string; style?: CSSProperties; isLoading?: boolean; @@ -124,10 +125,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { type={type} onClick={onClick} ref={innerRef} - disabled={isLoading || props.isLoading} + disabled={isLoading || props.isLoading || !!props.disabled} > {(props.icon || props.label) && ( - diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index 3295c4a04..4d6e95af5 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -108,6 +108,7 @@ function Picker({
{options.map((option, i) => (
@@ -542,17 +556,6 @@ const LayerUI = ({ showExitZenModeBtn={showExitZenModeBtn} renderWelcomeScreen={renderWelcomeScreen} /> - {appState.showStats && ( - { - actionManager.executeAction(actionToggleStats); - }} - renderCustomStats={renderCustomStats} - /> - )} {appState.scrolledOutside && (
)} diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 5dc2a1981..7fb014c5e 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -30,6 +30,12 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); let stats: HTMLElement | null = null; let elementStats: HTMLElement | null | undefined = null; +const editInput = (input: HTMLInputElement, value: string) => { + input.focus(); + fireEvent.change(input, { target: { value } }); + input.blur(); +}; + const getStatsProperty = (label: string) => { if (elementStats) { const properties = elementStats?.querySelector(".statsItem"); @@ -53,9 +59,7 @@ const testInputProperty = ( ) as HTMLInputElement; expect(input).not.toBeNull(); expect(input.value).toBe(initialValue.toString()); - input?.focus(); - input.value = nextValue.toString(); - input?.blur(); + editInput(input, String(nextValue)); if (property === "angle") { expect(element[property]).toBe(degreeToRadian(Number(nextValue))); } else if (property === "fontSize" && isTextElement(element)) { @@ -172,17 +176,13 @@ describe("stats for a generic element", () => { ) as HTMLInputElement; expect(input).not.toBeNull(); expect(input.value).toBe(rectangle.width.toString()); - input?.focus(); - input.value = "123.123"; - input?.blur(); + editInput(input, "123.123"); expect(h.elements.length).toBe(1); expect(rectangle.id).toBe(rectangleId); expect(input.value).toBe("123.12"); expect(rectangle.width).toBe(123.12); - input?.focus(); - input.value = "88.98766"; - input?.blur(); + editInput(input, "88.98766"); expect(input.value).toBe("88.99"); expect(rectangle.width).toBe(88.99); }); @@ -335,9 +335,7 @@ describe("stats for a non-generic element", () => { ) as HTMLInputElement; expect(input).not.toBeNull(); expect(input.value).toBe(text.fontSize.toString()); - input?.focus(); - input.value = "36"; - input?.blur(); + editInput(input, "36"); expect(text.fontSize).toBe(36); // cannot change width or height @@ -347,9 +345,7 @@ describe("stats for a non-generic element", () => { expect(height).toBeUndefined(); // min font size is 4 - input.focus(); - input.value = "0"; - input.blur(); + editInput(input, "0"); expect(text.fontSize).not.toBe(0); expect(text.fontSize).toBe(4); }); @@ -471,16 +467,12 @@ describe("stats for multiple elements", () => { ) as HTMLInputElement; expect(angle.value).toBe("0"); - width.focus(); - width.value = "250"; - width.blur(); + editInput(width, "250"); h.elements.forEach((el) => { expect(el.width).toBe(250); }); - height.focus(); - height.value = "450"; - height.blur(); + editInput(height, "450"); h.elements.forEach((el) => { expect(el.height).toBe(450); }); @@ -501,7 +493,6 @@ describe("stats for multiple elements", () => { mouse.up(200, 100); const frame = API.createElement({ - id: "id0", type: "frame", x: 150, width: 150, @@ -545,17 +536,13 @@ describe("stats for multiple elements", () => { expect(fontSize).not.toBeNull(); // changing width does not affect text - width.focus(); - width.value = "200"; - width.blur(); + editInput(width, "200"); expect(rectangle?.width).toBe(200); expect(frame.width).toBe(200); expect(text?.width).not.toBe(200); - angle.focus(); - angle.value = "40"; - angle.blur(); + editInput(angle, "40"); const angleInRadian = degreeToRadian(40); expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4); @@ -595,9 +582,7 @@ describe("stats for multiple elements", () => { expect(x).not.toBeNull(); expect(Number(x.value)).toBe(x1); - x.focus(); - x.value = "300"; - x.blur(); + editInput(x, "300"); expect(h.elements[0].x).toBe(300); expect(h.elements[1].x).toBe(400); @@ -610,9 +595,7 @@ describe("stats for multiple elements", () => { expect(y).not.toBeNull(); expect(Number(y.value)).toBe(y1); - y.focus(); - y.value = "200"; - y.blur(); + editInput(y, "200"); expect(h.elements[0].y).toBe(200); expect(h.elements[1].y).toBe(300); @@ -630,26 +613,20 @@ describe("stats for multiple elements", () => { expect(height).not.toBeNull(); expect(Number(height.value)).toBe(200); - width.focus(); - width.value = "400"; - width.blur(); + editInput(width, "400"); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup); let newGroupWidth = x2 - x1; expect(newGroupWidth).toBeCloseTo(400, 4); - width.focus(); - width.value = "300"; - width.blur(); + editInput(width, "300"); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup); newGroupWidth = x2 - x1; expect(newGroupWidth).toBeCloseTo(300, 4); - height.focus(); - height.value = "500"; - height.blur(); + editInput(height, "500"); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup); const newGroupHeight = y2 - y1; diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 53ad767ef..1408b8a68 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -17,9 +17,23 @@ import type { ExcalidrawElement, NonDeletedExcalidrawElement, } from "../../element/types"; +import { + getSelectedGroupIds, + getElementsInGroup, + isInGroup, +} from "../../groups"; import { rotate } from "../../math"; +import type { AppState } from "../../types"; import { getFontString } from "../../utils"; +export type StatsInputProperty = + | "x" + | "y" + | "width" + | "height" + | "angle" + | "fontSize"; + export const SMALLEST_DELTA = 0.01; export const isPropertyEditable = ( @@ -100,12 +114,14 @@ export const resizeElement = ( nextWidth: number, nextHeight: number, keepAspectRatio: boolean, - latestElement: ExcalidrawElement, origElement: ExcalidrawElement, elementsMap: ElementsMap, - originalElementsMap: Map, shouldInformMutation = true, ) => { + const latestElement = elementsMap.get(origElement.id); + if (!latestElement) { + return; + } let boundTextFont: { fontSize?: number } = {}; const boundTextElement = getBoundTextElement(latestElement, elementsMap); @@ -181,12 +197,15 @@ export const resizeElement = ( export const moveElement = ( newTopLeftX: number, newTopLeftY: number, - latestElement: ExcalidrawElement, originalElement: ExcalidrawElement, elementsMap: ElementsMap, originalElementsMap: ElementsMap, shouldInformMutation = true, ) => { + const latestElement = elementsMap.get(originalElement.id); + if (!latestElement) { + return; + } const [cx, cy] = [ originalElement.x + originalElement.width / 2, originalElement.y + originalElement.height / 2, @@ -236,3 +255,24 @@ export const moveElement = ( ); } }; + +export const getAtomicUnits = ( + targetElements: readonly ExcalidrawElement[], + appState: AppState, +) => { + const selectedGroupIds = getSelectedGroupIds(appState); + const _atomicUnits = selectedGroupIds.map((gid) => { + return getElementsInGroup(targetElements, gid).reduce((acc, el) => { + acc[el.id] = true; + return acc; + }, {} as AtomicUnit); + }); + targetElements + .filter((el) => !isInGroup(el)) + .forEach((el) => { + _atomicUnits.push({ + [el.id]: true, + }); + }); + return _atomicUnits; +}; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index a00249142..a1f447b28 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -90,7 +90,7 @@ const shouldResetImageFilter = ( }; const getCanvasPadding = (element: ExcalidrawElement) => - element.type === "freedraw" ? element.strokeWidth * 12 : 20; + element.type === "freedraw" ? element.strokeWidth * 12 : 200; export const getRenderOpacity = ( element: ExcalidrawElement, diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index fb3cc20fc..6f478b310 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -2,7 +2,6 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import type { Drawable } from "roughjs/bin/core"; import type { ExcalidrawElement, - ExcalidrawTextElement, NonDeletedElementsMap, NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, @@ -96,10 +95,6 @@ export type SceneScroll = { scrollY: number; }; -export interface Scene { - elements: ExcalidrawTextElement[]; -} - export type ExportType = | "png" | "clipboard" From ba8c09d529379f36c3f66648f00a023432556471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Fri, 28 Jun 2024 12:23:10 +0200 Subject: [PATCH 075/174] fix: Incorrect point offsetting in LinearElementEditor.movePoints() (#8145) The LinearElementEditor.movePoints() function incorrectly calculates the offset for local linear element points when multiple targetPoints are provided, one of those target points is index === 0 AND the other points are moved in the negative direction, and ending up with negative local coordinates. Signed-off-by: Mark Tolmacs --- .../excalidraw/element/linearElementEditor.ts | 7 +++- .../tests/linearElementEditor.test.tsx | 40 +++++++++++++++---- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 48b33d150..244bc275a 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1165,7 +1165,7 @@ export class LinearElementEditor { const nextPoints = points.map((point, idx) => { const selectedPointData = targetPoints.find((p) => p.index === idx); if (selectedPointData) { - if (selectedOriginPoint) { + if (selectedPointData.index === 0) { return point; } @@ -1174,7 +1174,10 @@ export class LinearElementEditor { const deltaY = selectedPointData.point[1] - points[selectedPointData.index][1]; - return [point[0] + deltaX, point[1] + deltaY] as const; + return [ + point[0] + deltaX - offsetX, + point[1] + deltaY - offsetY, + ] as const; } return offsetX || offsetY ? ([point[0] - offsetX, point[1] - offsetY] as const) diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 692f0b1e4..00273f51d 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -27,6 +27,7 @@ import * as textElementUtils from "../element/textElement"; import { ROUNDNESS, VERTICAL_ALIGN } from "../constants"; import { vi } from "vitest"; import { arrayToMap } from "../utils"; +import React from "react"; const renderInteractiveScene = vi.spyOn( InteractiveCanvas, @@ -972,10 +973,10 @@ describe("Test Linear Elements", () => { ]); expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) .toMatchInlineSnapshot(` - "Online whiteboard - collaboration made - easy" - `); + "Online whiteboard + collaboration made + easy" + `); }); it("should bind text to arrow when clicked on arrow and enter pressed", async () => { @@ -1006,10 +1007,10 @@ describe("Test Linear Elements", () => { ]); expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) .toMatchInlineSnapshot(` - "Online whiteboard - collaboration made - easy" - `); + "Online whiteboard + collaboration made + easy" + `); }); it("should not bind text to line when double clicked", async () => { @@ -1349,4 +1350,27 @@ describe("Test Linear Elements", () => { expect(label.y).toBe(0); }); }); + + describe("Test moving linear element points", () => { + it("should move the endpoint in the negative direction correctly when the start point is also moved in the positive direction", async () => { + const line = createThreePointerLinearElement("arrow"); + const [origStartX, origStartY] = [line.x, line.y]; + + LinearElementEditor.movePoints(line, [ + { index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] }, + { + index: line.points.length - 1, + point: [ + line.points[line.points.length - 1][0] - 10, + line.points[line.points.length - 1][1] - 10, + ], + }, + ]); + expect(line.x).toBe(origStartX + 10); + expect(line.y).toBe(origStartY + 10); + + expect(line.points[line.points.length - 1][0]).toBe(20); + expect(line.points[line.points.length - 1][1]).toBe(-20); + }); + }); }); From abbeed3d5f4ac9d2ff39250dd89b55ea5c9e4ea4 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 28 Jun 2024 13:52:29 +0200 Subject: [PATCH 076/174] feat: support Stats bound text `fontSize` editing (#8187) --- .../excalidraw/components/Stats/DragInput.tsx | 58 ++++---- .../excalidraw/components/Stats/FontSize.tsx | 65 +++++---- .../components/Stats/MultiFontSize.tsx | 137 +++++++++++------- .../excalidraw/components/Stats/index.tsx | 30 ++-- .../components/Stats/stats.test.tsx | 75 +++++++--- 5 files changed, 215 insertions(+), 150 deletions(-) diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx index 0218e8369..463aaa281 100644 --- a/packages/excalidraw/components/Stats/DragInput.tsx +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -15,33 +15,42 @@ import "./DragInput.scss"; import type { AppState } from "../../types"; import { cloneJSON } from "../../utils"; -export type DragInputCallbackType = (props: { +export type DragInputCallbackType< + P extends StatsInputProperty, + E = ExcalidrawElement, +> = (props: { accumulatedChange: number; instantChange: number; - originalElements: readonly ExcalidrawElement[]; + originalElements: readonly E[]; originalElementsMap: ElementsMap; shouldKeepAspectRatio: boolean; shouldChangeByStepSize: boolean; nextValue?: number; - property: T; + property: P; scene: Scene; originalAppState: AppState; }) => void; -interface StatsDragInputProps { +interface StatsDragInputProps< + T extends StatsInputProperty, + E = ExcalidrawElement, +> { label: string | React.ReactNode; icon?: React.ReactNode; value: number | "Mixed"; - elements: readonly ExcalidrawElement[]; + elements: readonly E[]; editable?: boolean; shouldKeepAspectRatio?: boolean; - dragInputCallback: DragInputCallbackType; + dragInputCallback: DragInputCallbackType; property: T; scene: Scene; appState: AppState; } -const StatsDragInput = ({ +const StatsDragInput = < + T extends StatsInputProperty, + E extends ExcalidrawElement = ExcalidrawElement, +>({ label, icon, dragInputCallback, @@ -52,7 +61,7 @@ const StatsDragInput = ({ property, scene, appState, -}: StatsDragInputProps) => { +}: StatsDragInputProps) => { const app = useApp(); const inputRef = useRef(null); const labelRef = useRef(null); @@ -61,7 +70,7 @@ const StatsDragInput = ({ const stateRef = useRef<{ originalAppState: AppState; - originalElements: readonly ExcalidrawElement[]; + originalElements: readonly E[]; lastUpdatedValue: string; updatePending: boolean; }>(null!); @@ -82,7 +91,7 @@ const StatsDragInput = ({ const handleInputValue = ( updatedValue: string, - elements: readonly ExcalidrawElement[], + elements: readonly E[], appState: AppState, ) => { if (!stateRef.current.updatePending) { @@ -173,9 +182,18 @@ const StatsDragInput = ({ y: number; } | null = null; - let originalElements: ExcalidrawElement[] | null = null; let originalElementsMap: Map | null = - null; + app.scene + .getNonDeletedElements() + .reduce((acc: ElementsMap, element) => { + acc.set(element.id, deepCopyElement(element)); + return acc; + }, new Map()); + + let originalElements: readonly E[] | null = elements.map( + (element) => originalElementsMap!.get(element.id) as E, + ); + const originalAppState: AppState = cloneJSON(appState); let accumulatedChange: number | null = null; @@ -183,21 +201,6 @@ const StatsDragInput = ({ document.body.classList.add("excalidraw-cursor-resize"); const onPointerMove = (event: PointerEvent) => { - if (!originalElementsMap) { - originalElementsMap = app.scene - .getNonDeletedElements() - .reduce((acc, element) => { - acc.set(element.id, deepCopyElement(element)); - return acc; - }, new Map() as ElementsMap); - } - - if (!originalElements) { - originalElements = elements.map( - (element) => originalElementsMap!.get(element.id)!, - ); - } - if (!accumulatedChange) { accumulatedChange = 0; } @@ -205,6 +208,7 @@ const StatsDragInput = ({ if ( lastPointer && originalElementsMap !== null && + originalElements !== null && accumulatedChange !== null ) { const instantChange = event.clientX - lastPointer.x; diff --git a/packages/excalidraw/components/Stats/FontSize.tsx b/packages/excalidraw/components/Stats/FontSize.tsx index 8ed136f4f..13dc6dbee 100644 --- a/packages/excalidraw/components/Stats/FontSize.tsx +++ b/packages/excalidraw/components/Stats/FontSize.tsx @@ -1,5 +1,7 @@ -import type { ExcalidrawTextElement } from "../../element/types"; -import { refreshTextDimensions } from "../../element/newElement"; +import type { + ExcalidrawElement, + ExcalidrawTextElement, +} from "../../element/types"; import StatsDragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { mutateElement } from "../../element/mutateElement"; @@ -7,10 +9,12 @@ import { getStepSizedValue } from "./utils"; import { fontSizeIcon } from "../icons"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; -import { isTextElement } from "../../element"; +import { isTextElement, redrawTextBoundingBox } from "../../element"; +import { hasBoundTextElement } from "../../element/typeChecks"; +import { getBoundTextElement } from "../../element/textElement"; interface FontSizeProps { - element: ExcalidrawTextElement; + element: ExcalidrawElement; scene: Scene; appState: AppState; property: "fontSize"; @@ -20,7 +24,8 @@ const MIN_FONT_SIZE = 4; const STEP_SIZE = 4; const handleFontSizeChange: DragInputCallbackType< - FontSizeProps["property"] + FontSizeProps["property"], + ExcalidrawTextElement > = ({ accumulatedChange, originalElements, @@ -36,50 +41,52 @@ const handleFontSizeChange: DragInputCallbackType< if (!latestElement || !isTextElement(latestElement)) { return; } + + let nextFontSize; + if (nextValue !== undefined) { - const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); - - const newElement = { - ...latestElement, - fontSize: nextFontSize, - }; - const updates = refreshTextDimensions(newElement, null, elementsMap); - mutateElement(latestElement, { - ...updates, - fontSize: nextFontSize, - }); - return; - } - - if (origElement.type === "text") { + nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); + } else if (origElement.type === "text") { const originalFontSize = Math.round(origElement.fontSize); const changeInFontSize = Math.round(accumulatedChange); - let nextFontSize = Math.max( + nextFontSize = Math.max( originalFontSize + changeInFontSize, MIN_FONT_SIZE, ); if (shouldChangeByStepSize) { nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); } - const newElement = { - ...latestElement, - fontSize: nextFontSize, - }; - const updates = refreshTextDimensions(newElement, null, elementsMap); + } + + if (nextFontSize) { mutateElement(latestElement, { - ...updates, fontSize: nextFontSize, }); + redrawTextBoundingBox( + latestElement, + scene.getContainerElement(latestElement), + scene.getNonDeletedElementsMap(), + ); } } }; const FontSize = ({ element, scene, appState, property }: FontSizeProps) => { + const _element = isTextElement(element) + ? element + : hasBoundTextElement(element) + ? getBoundTextElement(element, scene.getNonDeletedElementsMap()) + : null; + + if (!_element) { + return null; + } + return ( - elements.filter( - (el) => - el && !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el), - ) as ExcalidrawTextElement[]; + elements.reduce( + (acc: ExcalidrawTextElement[], el) => { + if (!el || isInGroup(el)) { + return acc; + } + if (isTextElement(el)) { + acc.push(el); + return acc; + } + if (hasBoundTextElement(el)) { + const boundTextElement = getBoundTextElement(el, elementsMap); + if (boundTextElement) { + acc.push(boundTextElement); + return acc; + } + } + + return acc; + }, + + [], + ); const handleFontSizeChange: DragInputCallbackType< - MultiFontSizeProps["property"] + MultiFontSizeProps["property"], + ExcalidrawTextElement > = ({ accumulatedChange, originalElements, @@ -41,71 +64,67 @@ const handleFontSizeChange: DragInputCallbackType< scene, }) => { const elementsMap = scene.getNonDeletedElementsMap(); - const latestTextElements = getApplicableTextElements( - originalElements.map((el) => elementsMap.get(el.id)), - ); + const latestTextElements = originalElements.map((el) => + elementsMap.get(el.id), + ) as ExcalidrawTextElement[]; + + let nextFontSize; if (nextValue) { - const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); + nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); - for (const textElement of latestTextElements.map((el) => - elementsMap.get(el.id), - )) { - if (!textElement || !isTextElement(textElement)) { - continue; - } - const newElement = { - ...textElement, - fontSize: nextFontSize, - }; - const updates = refreshTextDimensions(newElement, null, elementsMap); + for (const textElement of latestTextElements) { mutateElement( textElement, { - ...updates, fontSize: nextFontSize, }, false, ); + + redrawTextBoundingBox( + textElement, + scene.getContainerElement(textElement), + elementsMap, + false, + ); } scene.triggerUpdate(); - return; - } + } else { + const originalTextElements = originalElements as ExcalidrawTextElement[]; - const originalTextElements = originalElements.filter( - (el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el), - ) as ExcalidrawTextElement[]; + for (let i = 0; i < latestTextElements.length; i++) { + const latestElement = latestTextElements[i]; + const originalElement = originalTextElements[i]; - for (let i = 0; i < latestTextElements.length; i++) { - const latestElement = latestTextElements[i]; - const originalElement = originalTextElements[i]; + const originalFontSize = Math.round(originalElement.fontSize); + const changeInFontSize = Math.round(accumulatedChange); + let nextFontSize = Math.max( + originalFontSize + changeInFontSize, + MIN_FONT_SIZE, + ); + if (shouldChangeByStepSize) { + nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); + } + mutateElement( + latestElement, + { + fontSize: nextFontSize, + }, + false, + ); - const originalFontSize = Math.round(originalElement.fontSize); - const changeInFontSize = Math.round(accumulatedChange); - let nextFontSize = Math.max( - originalFontSize + changeInFontSize, - MIN_FONT_SIZE, - ); - if (shouldChangeByStepSize) { - nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); + redrawTextBoundingBox( + latestElement, + scene.getContainerElement(latestElement), + elementsMap, + false, + ); } - const newElement = { - ...latestElement, - fontSize: nextFontSize, - }; - const updates = refreshTextDimensions(newElement, null, elementsMap); - mutateElement( - latestElement, - { - ...updates, - fontSize: nextFontSize, - }, - false, - ); - } - scene.triggerUpdate(); + scene.triggerUpdate(); + } }; const MultiFontSize = ({ @@ -113,8 +132,14 @@ const MultiFontSize = ({ scene, appState, property, + elementsMap, }: MultiFontSizeProps) => { - const latestTextElements = getApplicableTextElements(elements); + const latestTextElements = getApplicableTextElements(elements, elementsMap); + + if (!latestTextElements.length) { + return null; + } + const fontSizes = latestTextElements.map( (textEl) => Math.round(textEl.fontSize * 10) / 10, ); @@ -125,7 +150,7 @@ const MultiFontSize = ({ - {singleElement.type === "text" && ( - - )} +
)} @@ -278,14 +275,13 @@ export const StatsInner = memo( scene={scene} appState={appState} /> - {multipleElements.some((el) => isTextElement(el)) && ( - - )} +
)} diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 7fb014c5e..16e8e733c 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -11,7 +11,7 @@ import * as StaticScene from "../../renderer/staticScene"; import { vi } from "vitest"; import { reseed } from "../../random"; import { setDateTimeForTests } from "../../utils"; -import { Excalidraw } from "../.."; +import { Excalidraw, mutateElement } from "../.."; import { t } from "../../i18n"; import type { ExcalidrawElement, @@ -37,10 +37,14 @@ const editInput = (input: HTMLInputElement, value: string) => { }; const getStatsProperty = (label: string) => { + const elementStats = UI.queryStats()?.querySelector("#elementStats"); + if (elementStats) { const properties = elementStats?.querySelector(".statsItem"); - return properties?.querySelector?.( - `.drag-input-container[data-testid="${label}"]`, + return ( + properties?.querySelector?.( + `.drag-input-container[data-testid="${label}"]`, + ) || null ); } @@ -57,7 +61,7 @@ const testInputProperty = ( const input = getStatsProperty(label)?.querySelector( ".drag-input", ) as HTMLInputElement; - expect(input).not.toBeNull(); + expect(input).toBeDefined(); expect(input.value).toBe(initialValue.toString()); editInput(input, String(nextValue)); if (property === "angle") { @@ -131,8 +135,8 @@ describe("stats for a generic element", () => { }); it("should open stats", () => { - expect(stats).not.toBeNull(); - expect(elementStats).not.toBeNull(); + expect(stats).toBeDefined(); + expect(elementStats).toBeDefined(); // title const title = elementStats?.querySelector("h3"); @@ -140,18 +144,18 @@ describe("stats for a generic element", () => { // element type const elementType = elementStats?.querySelector(".elementType"); - expect(elementType).not.toBeNull(); + expect(elementType).toBeDefined(); expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle")); // properties const properties = elementStats?.querySelector(".statsItem"); - expect(properties?.childNodes).not.toBeNull(); + expect(properties?.childNodes).toBeDefined(); ["X", "Y", "W", "H", "A"].forEach((label) => () => { expect( properties?.querySelector?.( `.drag-input-container[data-testid="${label}"]`, ), - ).not.toBeNull(); + ).toBeDefined(); }); }); @@ -174,7 +178,7 @@ describe("stats for a generic element", () => { const input = getStatsProperty("W")?.querySelector( ".drag-input", ) as HTMLInputElement; - expect(input).not.toBeNull(); + expect(input).toBeDefined(); expect(input.value).toBe(rectangle.width.toString()); editInput(input, "123.123"); expect(h.elements.length).toBe(1); @@ -333,7 +337,7 @@ describe("stats for a non-generic element", () => { const input = getStatsProperty("F")?.querySelector( ".drag-input", ) as HTMLInputElement; - expect(input).not.toBeNull(); + expect(input).toBeDefined(); expect(input.value).toBe(text.fontSize.toString()); editInput(input, "36"); expect(text.fontSize).toBe(36); @@ -366,7 +370,7 @@ describe("stats for a non-generic element", () => { elementStats = stats?.querySelector("#elementStats"); - expect(elementStats).not.toBeNull(); + expect(elementStats).toBeDefined(); // cannot change angle const angle = getStatsProperty("A")?.querySelector(".drag-input"); @@ -387,7 +391,7 @@ describe("stats for a non-generic element", () => { }, }); elementStats = stats?.querySelector("#elementStats"); - expect(elementStats).not.toBeNull(); + expect(elementStats).toBeDefined(); const widthToHeight = image.width / image.height; // when width or height is changed, the aspect ratio is preserved @@ -399,6 +403,35 @@ describe("stats for a non-generic element", () => { expect(image.height).toBe(80); expect(image.width / image.height).toBe(widthToHeight); }); + + it("should display fontSize for bound text", () => { + const container = API.createElement({ + type: "rectangle", + width: 200, + height: 100, + }); + const text = API.createElement({ + type: "text", + width: 200, + height: 100, + containerId: container.id, + fontSize: 20, + }); + mutateElement(container, { + boundElements: [{ type: "text", id: text.id }], + }); + h.elements = [container, text]; + + API.setSelectedElements([container]); + const fontSize = getStatsProperty("F")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(fontSize).toBeDefined(); + + editInput(fontSize, "40"); + + expect(text.fontSize).toBe(40); + }); }); // multiple elements @@ -515,25 +548,25 @@ describe("stats for multiple elements", () => { const width = getStatsProperty("W")?.querySelector( ".drag-input", ) as HTMLInputElement; - expect(width).not.toBeNull(); + expect(width).toBeDefined(); expect(width.value).toBe("Mixed"); const height = getStatsProperty("H")?.querySelector( ".drag-input", ) as HTMLInputElement; - expect(height).not.toBeNull(); + expect(height).toBeDefined(); expect(height.value).toBe("Mixed"); const angle = getStatsProperty("A")?.querySelector( ".drag-input", ) as HTMLInputElement; - expect(angle).not.toBeNull(); + expect(angle).toBeDefined(); expect(angle.value).toBe("0"); const fontSize = getStatsProperty("F")?.querySelector( ".drag-input", ) as HTMLInputElement; - expect(fontSize).not.toBeNull(); + expect(fontSize).toBeDefined(); // changing width does not affect text editInput(width, "200"); @@ -579,7 +612,7 @@ describe("stats for multiple elements", () => { ".drag-input", ) as HTMLInputElement; - expect(x).not.toBeNull(); + expect(x).toBeDefined(); expect(Number(x.value)).toBe(x1); editInput(x, "300"); @@ -592,7 +625,7 @@ describe("stats for multiple elements", () => { ".drag-input", ) as HTMLInputElement; - expect(y).not.toBeNull(); + expect(y).toBeDefined(); expect(Number(y.value)).toBe(y1); editInput(y, "200"); @@ -604,13 +637,13 @@ describe("stats for multiple elements", () => { const width = getStatsProperty("W")?.querySelector( ".drag-input", ) as HTMLInputElement; - expect(width).not.toBeNull(); + expect(width).toBeDefined(); expect(Number(width.value)).toBe(200); const height = getStatsProperty("H")?.querySelector( ".drag-input", ) as HTMLInputElement; - expect(height).not.toBeNull(); + expect(height).toBeDefined(); expect(Number(height.value)).toBe(200); editInput(width, "400"); From 04668d8263b35bf76f1390b25abeeed4181820f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Fri, 28 Jun 2024 15:28:48 +0200 Subject: [PATCH 077/174] fix: Binding after duplicating is now applied for both the old and duplicate shapes (#8185) Using ALT/OPT + drag to clone does not transfer the bindings (or leaves the duplicates in place of the old one , which are also not bound). Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/binding.ts | 9 ++++- packages/excalidraw/tests/binding.test.tsx | 47 +++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 8d1a728ea..ba0a110c6 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -707,6 +707,9 @@ export const fixBindingsAfterDuplication = ( const allBoundElementIds: Set = new Set(); const allBindableElementIds: Set = new Set(); const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld"; + const duplicateIdToOldId = new Map( + [...oldIdToDuplicatedId].map(([key, value]) => [value, key]), + ); oldElements.forEach((oldElement) => { const { boundElements } = oldElement; if (boundElements != null && boundElements.length > 0) { @@ -756,7 +759,11 @@ export const fixBindingsAfterDuplication = ( sceneElements .filter(({ id }) => allBindableElementIds.has(id)) .forEach((bindableElement) => { - const { boundElements } = bindableElement; + const oldElementId = duplicateIdToOldId.get(bindableElement.id); + const { boundElements } = sceneElements.find( + ({ id }) => id === oldElementId, + )!; + if (boundElements != null && boundElements.length > 0) { mutateElement(bindableElement, { boundElements: boundElements.map((boundElement) => diff --git a/packages/excalidraw/tests/binding.test.tsx b/packages/excalidraw/tests/binding.test.tsx index ece2d2fe3..9bc9947c7 100644 --- a/packages/excalidraw/tests/binding.test.tsx +++ b/packages/excalidraw/tests/binding.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render } from "./test-utils"; -import { Excalidraw } from "../index"; +import { Excalidraw, isLinearElement } from "../index"; import { UI, Pointer, Keyboard } from "./helpers/ui"; import { getTransformHandles } from "../element/transformHandles"; import { API } from "./helpers/api"; @@ -433,4 +433,49 @@ describe("element binding", () => { expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).toBe(null); }); + + it("should not unbind when duplicating via selection group", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + y: 200, + width: 200, + height: 500, + }); + const arrow = UI.createElement("arrow", { + x: 210, + y: 250, + width: 177, + height: 1, + }); + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); + + mouse.downAt(-100, -100); + mouse.moveTo(650, 750); + mouse.up(0, 0); + + expect(API.getSelectedElements().length).toBe(3); + + mouse.moveTo(5, 5); + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.downAt(5, 5); + mouse.moveTo(1000, 1000); + mouse.up(0, 0); + + expect(window.h.elements.length).toBe(6); + window.h.elements.forEach((element) => { + if (isLinearElement(element)) { + expect(element.startBinding).not.toBe(null); + expect(element.endBinding).not.toBe(null); + } else { + expect(element.boundElements).not.toBe(null); + } + }); + }); + }); }); From 66a2f242962b480dc855b761dd2411d632cbab59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Mon, 1 Jul 2024 09:45:31 +0200 Subject: [PATCH 078/174] fix: Add binding update to manual stat changes (#8183) Manual stats changes now respect previous element bindings. --- .../excalidraw/actions/actionFinalize.tsx | 7 +- packages/excalidraw/actions/actionFlip.ts | 2 +- packages/excalidraw/components/App.tsx | 142 +++++++----------- .../excalidraw/components/Stats/Angle.tsx | 5 +- .../components/Stats/MultiDimension.tsx | 12 +- .../components/Stats/MultiPosition.tsx | 12 +- .../components/Stats/stats.test.tsx | 88 +++++++++++ packages/excalidraw/components/Stats/utils.ts | 45 ++++-- packages/excalidraw/element/binding.ts | 106 +++++++------ .../excalidraw/element/linearElementEditor.ts | 7 +- packages/excalidraw/shapes.tsx | 62 ++++++++ packages/excalidraw/types.ts | 1 - 12 files changed, 327 insertions(+), 162 deletions(-) diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 9661154f7..15956b3a3 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -131,7 +131,12 @@ export const actionFinalize = register({ -1, arrayToMap(elements), ); - maybeBindLinearElement(multiPointElement, appState, { x, y }, app); + maybeBindLinearElement( + multiPointElement, + appState, + { x, y }, + elementsMap, + ); } } diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 0aab7f903..3f521d27f 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -124,7 +124,7 @@ const flipElements = ( bindOrUnbindLinearElements( selectedElements.filter(isLinearElement), - app, + elementsMap, isBindingEnabled(appState), [], ); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 3fcde774a..7be529594 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -225,16 +225,9 @@ import type { ScrollBars, } from "../scene/types"; import { getStateForZoom } from "../scene/zoom"; -import { findShapeByKey } from "../shapes"; +import { findShapeByKey, getElementShape } from "../shapes"; import type { GeometricShape } from "../../utils/geometry/shape"; -import { - getClosedCurveShape, - getCurveShape, - getEllipseShape, - getFreedrawShape, - getPolygonShape, - getSelectionBoxShape, -} from "../../utils/geometry/shape"; +import { getSelectionBoxShape } from "../../utils/geometry/shape"; import { isPointInShape } from "../../utils/collision"; import type { AppClassProperties, @@ -424,7 +417,6 @@ import { hitElementBoundText, hitElementBoundingBoxOnly, hitElementItself, - shouldTestInside, } from "../element/collision"; import { textWysiwyg } from "../element/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; @@ -2819,7 +2811,7 @@ class App extends React.Component { nonDeletedElementsMap, ), ), - this, + this.scene.getNonDeletedElementsMap(), ); } @@ -4008,7 +4000,7 @@ class App extends React.Component { this.setState({ suggestedBindings: getSuggestedBindingsForArrows( selectedElements, - this, + this.scene.getNonDeletedElementsMap(), ), }); @@ -4179,7 +4171,7 @@ class App extends React.Component { if (isArrowKey(event.key)) { bindOrUnbindLinearElements( this.scene.getSelectedElements(this.state).filter(isLinearElement), - this, + this.scene.getNonDeletedElementsMap(), isBindingEnabled(this.state), this.state.selectedLinearElement?.selectedPointsIndices ?? [], ); @@ -4491,59 +4483,6 @@ class App extends React.Component { return null; } - /** - * get the pure geometric shape of an excalidraw element - * which is then used for hit detection - */ - public getElementShape(element: ExcalidrawElement): GeometricShape { - switch (element.type) { - case "rectangle": - case "diamond": - case "frame": - case "magicframe": - case "embeddable": - case "image": - case "iframe": - case "text": - case "selection": - return getPolygonShape(element); - case "arrow": - case "line": { - const roughShape = - ShapeCache.get(element)?.[0] ?? - ShapeCache.generateElementShape(element, null)[0]; - const [, , , , cx, cy] = getElementAbsoluteCoords( - element, - this.scene.getNonDeletedElementsMap(), - ); - - return shouldTestInside(element) - ? getClosedCurveShape( - element, - roughShape, - [element.x, element.y], - element.angle, - [cx, cy], - ) - : getCurveShape(roughShape, [element.x, element.y], element.angle, [ - cx, - cy, - ]); - } - - case "ellipse": - return getEllipseShape(element); - - case "freedraw": { - const [, , , , cx, cy] = getElementAbsoluteCoords( - element, - this.scene.getNonDeletedElementsMap(), - ); - return getFreedrawShape(element, [cx, cy], shouldTestInside(element)); - } - } - } - private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null { const boundTextElement = getBoundTextElement( element, @@ -4552,18 +4491,24 @@ class App extends React.Component { if (boundTextElement) { if (element.type === "arrow") { - return this.getElementShape({ - ...boundTextElement, - // arrow's bound text accurate position is not stored in the element's property - // but rather calculated and returned from the following static method - ...LinearElementEditor.getBoundTextElementPosition( - element, - boundTextElement, - this.scene.getNonDeletedElementsMap(), - ), - }); + return getElementShape( + { + ...boundTextElement, + // arrow's bound text accurate position is not stored in the element's property + // but rather calculated and returned from the following static method + ...LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + this.scene.getNonDeletedElementsMap(), + ), + }, + this.scene.getNonDeletedElementsMap(), + ); } - return this.getElementShape(boundTextElement); + return getElementShape( + boundTextElement, + this.scene.getNonDeletedElementsMap(), + ); } return null; @@ -4602,7 +4547,10 @@ class App extends React.Component { x, y, element: elementWithHighestZIndex, - shape: this.getElementShape(elementWithHighestZIndex), + shape: getElementShape( + elementWithHighestZIndex, + this.scene.getNonDeletedElementsMap(), + ), // when overlapping, we would like to be more precise // this also avoids the need to update past tests threshold: this.getElementHitThreshold() / 2, @@ -4707,7 +4655,7 @@ class App extends React.Component { x, y, element, - shape: this.getElementShape(element), + shape: getElementShape(element, this.scene.getNonDeletedElementsMap()), threshold: this.getElementHitThreshold(), frameNameBound: isFrameLikeElement(element) ? this.frameNameBoundsCache.get(element) @@ -4739,7 +4687,10 @@ class App extends React.Component { x, y, element: elements[index], - shape: this.getElementShape(elements[index]), + shape: getElementShape( + elements[index], + this.scene.getNonDeletedElementsMap(), + ), threshold: this.getElementHitThreshold(), }) ) { @@ -4997,7 +4948,10 @@ class App extends React.Component { x: sceneX, y: sceneY, element: container, - shape: this.getElementShape(container), + shape: getElementShape( + container, + this.scene.getNonDeletedElementsMap(), + ), threshold: this.getElementHitThreshold(), }) ) { @@ -5689,7 +5643,10 @@ class App extends React.Component { x: scenePointerX, y: scenePointerY, element, - shape: this.getElementShape(element), + shape: getElementShape( + element, + this.scene.getNonDeletedElementsMap(), + ), }) ) { hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( @@ -6808,7 +6765,7 @@ class App extends React.Component { const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this, + this.scene.getNonDeletedElementsMap(), ); this.scene.insertElement(element); this.setState({ @@ -7070,7 +7027,7 @@ class App extends React.Component { }); const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this, + this.scene.getNonDeletedElementsMap(), ); this.scene.insertElement(element); @@ -7540,7 +7497,7 @@ class App extends React.Component { this.setState({ suggestedBindings: getSuggestedBindingsForArrows( selectedElements, - this, + this.scene.getNonDeletedElementsMap(), ), }); @@ -8061,7 +8018,7 @@ class App extends React.Component { draggingElement, this.state, pointerCoords, - this, + this.scene.getNonDeletedElementsMap(), ); } this.setState({ suggestedBindings: [], startBoundElement: null }); @@ -8551,7 +8508,10 @@ class App extends React.Component { x: pointerDownState.origin.x, y: pointerDownState.origin.y, element: hitElement, - shape: this.getElementShape(hitElement), + shape: getElementShape( + hitElement, + this.scene.getNonDeletedElementsMap(), + ), threshold: this.getElementHitThreshold(), frameNameBound: isFrameLikeElement(hitElement) ? this.frameNameBoundsCache.get(hitElement) @@ -8619,7 +8579,7 @@ class App extends React.Component { bindOrUnbindLinearElements( linearElements, - this, + this.scene.getNonDeletedElementsMap(), isBindingEnabled(this.state), this.state.selectedLinearElement?.selectedPointsIndices ?? [], ); @@ -9107,7 +9067,7 @@ class App extends React.Component { }): void => { const hoveredBindableElement = getHoveredElementForBinding( pointerCoords, - this, + this.scene.getNonDeletedElementsMap(), ); this.setState({ suggestedBindings: @@ -9134,7 +9094,7 @@ class App extends React.Component { (acc: NonDeleted[], coords) => { const hoveredBindableElement = getHoveredElementForBinding( coords, - this, + this.scene.getNonDeletedElementsMap(), ); if ( hoveredBindableElement != null && @@ -9666,7 +9626,7 @@ class App extends React.Component { ) { const suggestedBindings = getSuggestedBindingsForArrows( selectedElements, - this, + this.scene.getNonDeletedElementsMap(), ); const elementsToHighlight = new Set(); diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index 23cae2b6b..6727a3956 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -6,7 +6,7 @@ import { degreeToRadian, radianToDegree } from "../../math"; import { angleIcon } from "../icons"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; -import { getStepSizedValue, isPropertyEditable } from "./utils"; +import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; @@ -33,11 +33,13 @@ const handleDegreeChange: DragInputCallbackType = ({ if (!latestElement) { return; } + if (nextValue !== undefined) { const nextAngle = degreeToRadian(nextValue); mutateElement(latestElement, { angle: nextAngle, }); + updateBindings(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { @@ -63,6 +65,7 @@ const handleDegreeChange: DragInputCallbackType = ({ mutateElement(latestElement, { angle: nextAngle, }); + updateBindings(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index e6fd715e9..89b746a5f 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -7,7 +7,11 @@ import { getBoundTextElement, handleBindTextResize, } from "../../element/textElement"; -import type { ElementsMap, ExcalidrawElement } from "../../element/types"; +import type { + ElementsMap, + ExcalidrawElement, + NonDeletedSceneElementsMap, +} from "../../element/types"; import type Scene from "../../scene/Scene"; import type { AppState, Point } from "../../types"; import DragInput from "./DragInput"; @@ -20,7 +24,7 @@ import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; interface MultiDimensionProps { property: "width" | "height"; elements: readonly ExcalidrawElement[]; - elementsMap: ElementsMap; + elementsMap: NonDeletedSceneElementsMap; atomicUnits: AtomicUnit[]; scene: Scene; appState: AppState; @@ -60,7 +64,7 @@ const resizeElementInGroup = ( scale: number, latestElement: ExcalidrawElement, origElement: ExcalidrawElement, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, originalElementsMap: ElementsMap, ) => { const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); @@ -103,7 +107,7 @@ const resizeGroup = ( property: MultiDimensionProps["property"], latestElements: ExcalidrawElement[], originalElements: ExcalidrawElement[], - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, originalElementsMap: ElementsMap, ) => { // keep aspect ratio for groups diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index c7f3491b4..e20390848 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -1,4 +1,8 @@ -import type { ElementsMap, ExcalidrawElement } from "../../element/types"; +import type { + ElementsMap, + ExcalidrawElement, + NonDeletedSceneElementsMap, +} from "../../element/types"; import { rotate } from "../../math"; import type Scene from "../../scene/Scene"; import StatsDragInput from "./DragInput"; @@ -27,7 +31,7 @@ const moveElements = ( changeInTopY: number, elements: readonly ExcalidrawElement[], originalElements: readonly ExcalidrawElement[], - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, originalElementsMap: ElementsMap, ) => { for (let i = 0; i < elements.length; i++) { @@ -66,8 +70,9 @@ const moveGroupTo = ( nextX: number, nextY: number, originalElements: ExcalidrawElement[], - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, originalElementsMap: ElementsMap, + scene: Scene, ) => { const [x1, y1, ,] = getCommonBounds(originalElements); const offsetX = nextX - x1; @@ -146,6 +151,7 @@ const handlePositionChange: DragInputCallbackType< elementsInUnit.map((el) => el.original), elementsMap, originalElementsMap, + scene, ); } else { const origElement = elementsInUnit[0]?.original; diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 16e8e733c..365abf775 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -15,6 +15,7 @@ import { Excalidraw, mutateElement } from "../.."; import { t } from "../../i18n"; import type { ExcalidrawElement, + ExcalidrawLinearElement, ExcalidrawTextElement, } from "../../element/types"; import { degreeToRadian, rotate } from "../../math"; @@ -23,6 +24,7 @@ import { getCommonBounds, isTextElement } from "../../element"; import { API } from "../../tests/helpers/api"; import { actionGroup } from "../../actions"; import { isInGroup } from "../../groups"; +import React from "react"; const { h } = window; const mouse = new Pointer("mouse"); @@ -99,6 +101,92 @@ describe("step sized value", () => { }); }); +describe("binding with linear elements", () => { + beforeEach(async () => { + localStorage.clear(); + renderStaticScene.mockClear(); + reseed(19); + setDateTimeForTests("201933152653"); + + await render(); + + h.elements = []; + + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 1, + clientY: 1, + }); + const contextMenu = UI.queryContextMenu(); + fireEvent.click(queryByTestId(contextMenu!, "stats")!); + stats = UI.queryStats(); + + UI.clickTool("rectangle"); + mouse.down(); + mouse.up(200, 100); + + UI.clickTool("arrow"); + mouse.down(5, 0); + mouse.up(300, 50); + + elementStats = stats?.querySelector("#elementStats"); + }); + + beforeAll(() => { + mockBoundingClientRect(); + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("should remain bound to linear element on small position change", async () => { + const linear = h.elements[1] as ExcalidrawLinearElement; + const inputX = getStatsProperty("X")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(linear.startBinding).not.toBe(null); + expect(inputX).not.toBeNull(); + editInput(inputX, String("204")); + expect(linear.startBinding).not.toBe(null); + }); + + it("should remain bound to linear element on small angle change", async () => { + const linear = h.elements[1] as ExcalidrawLinearElement; + const inputAngle = getStatsProperty("A")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(linear.startBinding).not.toBe(null); + editInput(inputAngle, String("1")); + expect(linear.startBinding).not.toBe(null); + }); + + it("should unbind linear element on large position change", async () => { + const linear = h.elements[1] as ExcalidrawLinearElement; + const inputX = getStatsProperty("X")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(linear.startBinding).not.toBe(null); + expect(inputX).not.toBeNull(); + editInput(inputX, String("254")); + expect(linear.startBinding).toBe(null); + }); + + it("should remain bound to linear element on small angle change", async () => { + const linear = h.elements[1] as ExcalidrawLinearElement; + const inputAngle = getStatsProperty("A")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(linear.startBinding).not.toBe(null); + editInput(inputAngle, String("45")); + expect(linear.startBinding).toBe(null); + }); +}); + // single element describe("stats for a generic element", () => { beforeEach(async () => { diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 1408b8a68..591fcff90 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -1,4 +1,7 @@ -import { updateBoundElements } from "../../element/binding"; +import { + bindOrUnbindLinearElements, + updateBoundElements, +} from "../../element/binding"; import { mutateElement } from "../../element/mutateElement"; import { measureFontSizeFromWidth, @@ -11,11 +14,16 @@ import { getBoundTextMaxWidth, handleBindTextResize, } from "../../element/textElement"; -import { isFrameLikeElement, isTextElement } from "../../element/typeChecks"; +import { + isFrameLikeElement, + isLinearElement, + isTextElement, +} from "../../element/typeChecks"; import type { ElementsMap, ExcalidrawElement, NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, } from "../../element/types"; import { getSelectedGroupIds, @@ -115,7 +123,7 @@ export const resizeElement = ( nextHeight: number, keepAspectRatio: boolean, origElement: ExcalidrawElement, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, shouldInformMutation = true, ) => { const latestElement = elementsMap.get(origElement.id); @@ -156,6 +164,12 @@ export const resizeElement = ( }, shouldInformMutation, ); + updateBindings(latestElement, elementsMap, { + newSize: { + width: nextWidth, + height: nextHeight, + }, + }); if (boundTextElement) { boundTextFont = { @@ -179,13 +193,6 @@ export const resizeElement = ( } } - updateBoundElements(latestElement, elementsMap, { - newSize: { - width: nextWidth, - height: nextHeight, - }, - }); - if (boundTextElement && boundTextFont) { mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize, @@ -198,7 +205,7 @@ export const moveElement = ( newTopLeftX: number, newTopLeftY: number, originalElement: ExcalidrawElement, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, originalElementsMap: ElementsMap, shouldInformMutation = true, ) => { @@ -237,6 +244,7 @@ export const moveElement = ( }, shouldInformMutation, ); + updateBindings(latestElement, elementsMap); const boundTextElement = getBoundTextElement( originalElement, @@ -276,3 +284,18 @@ export const getAtomicUnits = ( }); return _atomicUnits; }; + +export const updateBindings = ( + latestElement: ExcalidrawElement, + elementsMap: NonDeletedSceneElementsMap, + options?: { + simultaneouslyUpdated?: readonly ExcalidrawElement[]; + newSize?: { width: number; height: number }; + }, +) => { + if (isLinearElement(latestElement)) { + bindOrUnbindLinearElements([latestElement], elementsMap, true, []); + } else { + updateBoundElements(latestElement, elementsMap, options); + } +}; diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index ba0a110c6..1bec39239 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -25,7 +25,7 @@ import type { } from "./types"; import { getElementAbsoluteCoords } from "./bounds"; -import type { AppClassProperties, AppState, Point } from "../types"; +import type { AppState, Point } from "../types"; import { isPointOnShape } from "../../utils/collision"; import { getElementAtPosition } from "../scene"; import { @@ -43,6 +43,7 @@ import { LinearElementEditor } from "./linearElementEditor"; import { arrayToMap, tupleToCoors } from "../utils"; import { KEYS } from "../keys"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; +import { getElementShape } from "../shapes"; export type SuggestedBinding = | NonDeleted @@ -179,9 +180,8 @@ const bindOrUnbindLinearElementEdge = ( const getOriginalBindingIfStillCloseOfLinearElementEdge = ( linearElement: NonDeleted, edge: "start" | "end", - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted | null => { - const elementsMap = app.scene.getNonDeletedElementsMap(); const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap); const elementId = edge === "start" @@ -189,7 +189,10 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = ( : linearElement.endBinding?.elementId; if (elementId) { const element = elementsMap.get(elementId); - if (isBindableElement(element) && bindingBorderTest(element, coors, app)) { + if ( + isBindableElement(element) && + bindingBorderTest(element, coors, elementsMap) + ) { return element; } } @@ -199,13 +202,13 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = ( const getOriginalBindingsIfStillCloseToArrowEnds = ( linearElement: NonDeleted, - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): (NonDeleted | null)[] => ["start", "end"].map((edge) => getOriginalBindingIfStillCloseOfLinearElementEdge( linearElement, edge as "start" | "end", - app, + elementsMap, ), ); @@ -213,7 +216,7 @@ const getBindingStrategyForDraggingArrowEndpoints = ( selectedElement: NonDeleted, isBindingEnabled: boolean, draggingPoints: readonly number[], - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): (NonDeleted | null | "keep")[] => { const startIdx = 0; const endIdx = selectedElement.points.length - 1; @@ -221,37 +224,57 @@ const getBindingStrategyForDraggingArrowEndpoints = ( const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; const start = startDragged ? isBindingEnabled - ? getElligibleElementForBindingElement(selectedElement, "start", app) + ? getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + ) : null // If binding is disabled and start is dragged, break all binds : // We have to update the focus and gap of the binding, so let's rebind - getElligibleElementForBindingElement(selectedElement, "start", app); + getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + ); const end = endDragged ? isBindingEnabled - ? getElligibleElementForBindingElement(selectedElement, "end", app) + ? getElligibleElementForBindingElement( + selectedElement, + "end", + elementsMap, + ) : null // If binding is disabled and end is dragged, break all binds : // We have to update the focus and gap of the binding, so let's rebind - getElligibleElementForBindingElement(selectedElement, "end", app); + getElligibleElementForBindingElement(selectedElement, "end", elementsMap); return [start, end]; }; const getBindingStrategyForDraggingArrowOrJoints = ( selectedElement: NonDeleted, - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, isBindingEnabled: boolean, ): (NonDeleted | null | "keep")[] => { const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( selectedElement, - app, + elementsMap, ); const start = startIsClose ? isBindingEnabled - ? getElligibleElementForBindingElement(selectedElement, "start", app) + ? getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + ) : null : null; const end = endIsClose ? isBindingEnabled - ? getElligibleElementForBindingElement(selectedElement, "end", app) + ? getElligibleElementForBindingElement( + selectedElement, + "end", + elementsMap, + ) : null : null; @@ -260,7 +283,7 @@ const getBindingStrategyForDraggingArrowOrJoints = ( export const bindOrUnbindLinearElements = ( selectedElements: NonDeleted[], - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, isBindingEnabled: boolean, draggingPoints: readonly number[] | null, ): void => { @@ -271,27 +294,22 @@ export const bindOrUnbindLinearElements = ( selectedElement, isBindingEnabled, draggingPoints ?? [], - app, + elementsMap, ) : // The arrow itself (the shaft) or the inner joins are dragged getBindingStrategyForDraggingArrowOrJoints( selectedElement, - app, + elementsMap, isBindingEnabled, ); - bindOrUnbindLinearElement( - selectedElement, - start, - end, - app.scene.getNonDeletedElementsMap(), - ); + bindOrUnbindLinearElement(selectedElement, start, end, elementsMap); }); }; export const getSuggestedBindingsForArrows = ( selectedElements: NonDeleted[], - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): SuggestedBinding[] => { // HOT PATH: Bail out if selected elements list is too large if (selectedElements.length > 50) { @@ -302,7 +320,7 @@ export const getSuggestedBindingsForArrows = ( selectedElements .filter(isLinearElement) .flatMap((element) => - getOriginalBindingsIfStillCloseToArrowEnds(element, app), + getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap), ) .filter( (element): element is NonDeleted => @@ -324,17 +342,20 @@ export const maybeBindLinearElement = ( linearElement: NonDeleted, appState: AppState, pointerCoords: { x: number; y: number }, - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): void => { if (appState.startBoundElement != null) { bindLinearElement( linearElement, appState.startBoundElement, "start", - app.scene.getNonDeletedElementsMap(), + elementsMap, ); } - const hoveredElement = getHoveredElementForBinding(pointerCoords, app); + const hoveredElement = getHoveredElementForBinding( + pointerCoords, + elementsMap, + ); if ( hoveredElement != null && !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( @@ -343,12 +364,7 @@ export const maybeBindLinearElement = ( "end", ) ) { - bindLinearElement( - linearElement, - hoveredElement, - "end", - app.scene.getNonDeletedElementsMap(), - ); + bindLinearElement(linearElement, hoveredElement, "end", elementsMap); } }; @@ -432,13 +448,13 @@ export const getHoveredElementForBinding = ( x: number; y: number; }, - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted | null => { const hoveredElement = getElementAtPosition( - app.scene.getNonDeletedElements(), + [...elementsMap].map(([_, value]) => value), (element) => isBindableElement(element, false) && - bindingBorderTest(element, pointerCoords, app), + bindingBorderTest(element, pointerCoords, elementsMap), ); return hoveredElement as NonDeleted | null; }; @@ -662,15 +678,11 @@ const maybeCalculateNewGapWhenScaling = ( const getElligibleElementForBindingElement = ( linearElement: NonDeleted, startOrEnd: "start" | "end", - app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted | null => { return getHoveredElementForBinding( - getLinearElementEdgeCoors( - linearElement, - startOrEnd, - app.scene.getNonDeletedElementsMap(), - ), - app, + getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), + elementsMap, ); }; @@ -834,10 +846,10 @@ const newBoundElements = ( const bindingBorderTest = ( element: NonDeleted, { x, y }: { x: number; y: number }, - app: AppClassProperties, + elementsMap: ElementsMap, ): boolean => { const threshold = maxBindingGap(element, element.width, element.height); - const shape = app.getElementShape(element); + const shape = getElementShape(element, elementsMap); return isPointOnShape([x, y], shape, threshold); }; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 244bc275a..971922762 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -381,7 +381,7 @@ export class LinearElementEditor { elementsMap, ), ), - app, + elementsMap, ) : null; @@ -715,7 +715,10 @@ export class LinearElementEditor { }, selectedPointsIndices: [element.points.length - 1], lastUncommittedPoint: null, - endBindingElement: getHoveredElementForBinding(scenePointer, app), + endBindingElement: getHoveredElementForBinding( + scenePointer, + elementsMap, + ), }; ret.didAddPoint = true; diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx index a8b9b5301..5c0e98676 100644 --- a/packages/excalidraw/shapes.tsx +++ b/packages/excalidraw/shapes.tsx @@ -1,3 +1,11 @@ +import { + getClosedCurveShape, + getCurveShape, + getEllipseShape, + getFreedrawShape, + getPolygonShape, + type GeometricShape, +} from "../utils/geometry/shape"; import { ArrowIcon, DiamondIcon, @@ -10,7 +18,11 @@ import { SelectionIcon, TextIcon, } from "./components/icons"; +import { getElementAbsoluteCoords } from "./element"; +import { shouldTestInside } from "./element/collision"; +import type { ElementsMap, ExcalidrawElement } from "./element/types"; import { KEYS } from "./keys"; +import { ShapeCache } from "./scene/ShapeCache"; export const SHAPES = [ { @@ -97,3 +109,53 @@ export const findShapeByKey = (key: string) => { }); return shape?.value || null; }; + +/** + * get the pure geometric shape of an excalidraw element + * which is then used for hit detection + */ +export const getElementShape = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): GeometricShape => { + switch (element.type) { + case "rectangle": + case "diamond": + case "frame": + case "magicframe": + case "embeddable": + case "image": + case "iframe": + case "text": + case "selection": + return getPolygonShape(element); + case "arrow": + case "line": { + const roughShape = + ShapeCache.get(element)?.[0] ?? + ShapeCache.generateElementShape(element, null)[0]; + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); + + return shouldTestInside(element) + ? getClosedCurveShape( + element, + roughShape, + [element.x, element.y], + element.angle, + [cx, cy], + ) + : getCurveShape(roughShape, [element.x, element.y], element.angle, [ + cx, + cy, + ]); + } + + case "ellipse": + return getEllipseShape(element); + + case "freedraw": { + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); + return getFreedrawShape(element, [cx, cy], shouldTestInside(element)); + } + } +}; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index cb428f695..5c2e18851 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -614,7 +614,6 @@ export type AppClassProperties = { setOpenDialog: App["setOpenDialog"]; insertEmbeddableElement: App["insertEmbeddableElement"]; onMagicframeToolSelect: App["onMagicframeToolSelect"]; - getElementShape: App["getElementShape"]; getName: App["getName"]; }; From 1d5b41dabb9f7ec15aa07360f8cf727bc27d5cf1 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 1 Jul 2024 14:04:58 +0200 Subject: [PATCH 079/174] fix: stop updating text versions on init (#8191) --- packages/excalidraw/element/mutateElement.ts | 4 +--- packages/excalidraw/scene/Fonts.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index de0adeeff..1d7e9d46e 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -107,8 +107,6 @@ export const mutateElement = >( export const newElementWith = ( element: TElement, updates: ElementUpdate, - /** pass `true` to always regenerate */ - force = false, ): TElement => { let didChange = false; for (const key in updates) { @@ -125,7 +123,7 @@ export const newElementWith = ( } } - if (!didChange && !force) { + if (!didChange) { return element; } diff --git a/packages/excalidraw/scene/Fonts.ts b/packages/excalidraw/scene/Fonts.ts index ff241a40f..cc5088142 100644 --- a/packages/excalidraw/scene/Fonts.ts +++ b/packages/excalidraw/scene/Fonts.ts @@ -1,5 +1,5 @@ import { isTextElement } from "../element"; -import { newElementWith } from "../element/mutateElement"; +import { getContainerElement } from "../element/textElement"; import type { ExcalidrawElement, ExcalidrawTextElement, @@ -46,14 +46,18 @@ export class Fonts { let didUpdate = false; - this.scene.mapElements((element) => { + const elementsMap = this.scene.getNonDeletedElementsMap(); + + for (const element of this.scene.getNonDeletedElements()) { if (isTextElement(element)) { didUpdate = true; ShapeCache.delete(element); - return newElementWith(element, {}, true); + const container = getContainerElement(element, elementsMap); + if (container) { + ShapeCache.delete(container); + } } - return element; - }); + } if (didUpdate) { this.scene.triggerUpdate(); From 2e1f08c796db133090c49ca679934678492cb204 Mon Sep 17 00:00:00 2001 From: zsviczian Date: Tue, 2 Jul 2024 22:08:02 +0200 Subject: [PATCH 080/174] fix: memory leak - scene.destroy() and window.launchQueue (#8198) --- packages/excalidraw/components/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 7be529594..093f6bfdf 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2490,7 +2490,9 @@ class App extends React.Component { } public componentWillUnmount() { + (window as any).launchQueue?.setConsumer(() => {}); this.renderer.destroy(); + this.scene.destroy(); this.scene = new Scene(); this.fonts = new Fonts({ scene: this.scene }); this.renderer = new Renderer(this.scene); @@ -2499,7 +2501,6 @@ class App extends React.Component { this.resizeObserver?.disconnect(); this.unmounted = true; this.removeEventListeners(); - this.scene.destroy(); this.library.destroy(); this.laserTrails.stop(); this.eraserTrail.stop(); From d9258a736b71451193672f478ec62c0b6d57b2dc Mon Sep 17 00:00:00 2001 From: DDDDD12138 <43703884+DDDDD12138@users.noreply.github.com> Date: Thu, 4 Jul 2024 23:34:16 +0800 Subject: [PATCH 081/174] chore: Consolidate i18n import in LanguageList component (#8201) --- excalidraw-app/components/LanguageList.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/excalidraw-app/components/LanguageList.tsx b/excalidraw-app/components/LanguageList.tsx index 8370d2f3e..e4065f589 100644 --- a/excalidraw-app/components/LanguageList.tsx +++ b/excalidraw-app/components/LanguageList.tsx @@ -1,8 +1,7 @@ import { useSetAtom } from "jotai"; import React from "react"; import { appLangCodeAtom } from "../App"; -import { useI18n } from "../../packages/excalidraw/i18n"; -import { languages } from "../../packages/excalidraw/i18n"; +import { useI18n, languages } from "../../packages/excalidraw/i18n"; export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { const { t, langCode } = useI18n(); From 148b895f462e02e2b18649caf57abc0416fd6d4a Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 4 Jul 2024 17:55:35 +0200 Subject: [PATCH 082/174] feat: smarter preferred lang detection (#8205) --- excalidraw-app/App.tsx | 29 ++++--------------- .../LanguageList.tsx | 2 +- .../app-language/language-detector.ts | 25 ++++++++++++++++ excalidraw-app/app-language/language-state.ts | 15 ++++++++++ excalidraw-app/components/AppMainMenu.tsx | 2 +- 5 files changed, 48 insertions(+), 25 deletions(-) rename excalidraw-app/{components => app-language}/LanguageList.tsx (93%) create mode 100644 excalidraw-app/app-language/language-detector.ts create mode 100644 excalidraw-app/app-language/language-state.ts diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index ec091d95b..a11ea59b3 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -1,5 +1,4 @@ import polyfill from "../packages/excalidraw/polyfill"; -import LanguageDetector from "i18next-browser-languagedetector"; import { useCallback, useEffect, useRef, useState } from "react"; import { trackEvent } from "../packages/excalidraw/analytics"; import { getDefaultAppState } from "../packages/excalidraw/appState"; @@ -22,7 +21,6 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef import { t } from "../packages/excalidraw/i18n"; import { Excalidraw, - defaultLang, LiveCollaborationTrigger, TTDDialog, TTDDialogTrigger, @@ -93,7 +91,7 @@ import { import { AppMainMenu } from "./components/AppMainMenu"; import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; import { AppFooter } from "./components/AppFooter"; -import { atom, Provider, useAtom, useAtomValue } from "jotai"; +import { Provider, useAtom, useAtomValue } from "jotai"; import { useAtomWithInitialValue } from "../packages/excalidraw/jotai"; import { appJotaiStore } from "./app-jotai"; @@ -121,6 +119,8 @@ import { youtubeIcon, } from "../packages/excalidraw/components/icons"; import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; +import { getPreferredLanguage } from "./app-language/language-detector"; +import { useAppLangCode } from "./app-language/language-state"; polyfill(); @@ -172,11 +172,6 @@ if (window.self !== window.top) { } } -const languageDetector = new LanguageDetector(); -languageDetector.init({ - languageUtils: {}, -}); - const shareableLinkConfirmDialog = { title: t("overwriteConfirm.modal.shareableLink.title"), description: ( @@ -322,19 +317,15 @@ const initializeScene = async (opts: { return { scene: null, isExternalScene: false }; }; -const detectedLangCode = languageDetector.detect() || defaultLang.code; -export const appLangCodeAtom = atom( - Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode, -); - const ExcalidrawWrapper = () => { const [errorMessage, setErrorMessage] = useState(""); - const [langCode, setLangCode] = useAtom(appLangCodeAtom); const isCollabDisabled = isRunningInIframe(); const [appTheme, setAppTheme] = useAtom(appThemeAtom); const { editorTheme } = useHandleAppTheme(); + const [langCode, setLangCode] = useAppLangCode(); + // initial state // --------------------------------------------------------------------------- @@ -490,11 +481,7 @@ const ExcalidrawWrapper = () => { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { const localDataState = importFromLocalStorage(); const username = importUsernameFromLocalStorage(); - let langCode = languageDetector.detect() || defaultLang.code; - if (Array.isArray(langCode)) { - langCode = langCode[0]; - } - setLangCode(langCode); + setLangCode(getPreferredLanguage()); excalidrawAPI.updateScene({ ...localDataState, storeAction: StoreAction.UPDATE, @@ -595,10 +582,6 @@ const ExcalidrawWrapper = () => { }; }, [excalidrawAPI]); - useEffect(() => { - languageDetector.cacheUserLanguage(langCode); - }, [langCode]); - const onChange = ( elements: readonly OrderedExcalidrawElement[], appState: AppState, diff --git a/excalidraw-app/components/LanguageList.tsx b/excalidraw-app/app-language/LanguageList.tsx similarity index 93% rename from excalidraw-app/components/LanguageList.tsx rename to excalidraw-app/app-language/LanguageList.tsx index e4065f589..08142b1f6 100644 --- a/excalidraw-app/components/LanguageList.tsx +++ b/excalidraw-app/app-language/LanguageList.tsx @@ -1,7 +1,7 @@ import { useSetAtom } from "jotai"; import React from "react"; -import { appLangCodeAtom } from "../App"; import { useI18n, languages } from "../../packages/excalidraw/i18n"; +import { appLangCodeAtom } from "./language-state"; export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { const { t, langCode } = useI18n(); diff --git a/excalidraw-app/app-language/language-detector.ts b/excalidraw-app/app-language/language-detector.ts new file mode 100644 index 000000000..acf77d631 --- /dev/null +++ b/excalidraw-app/app-language/language-detector.ts @@ -0,0 +1,25 @@ +import LanguageDetector from "i18next-browser-languagedetector"; +import { defaultLang, languages } from "../../packages/excalidraw"; + +export const languageDetector = new LanguageDetector(); + +languageDetector.init({ + languageUtils: {}, +}); + +export const getPreferredLanguage = () => { + const detectedLanguages = languageDetector.detect(); + + const detectedLanguage = Array.isArray(detectedLanguages) + ? detectedLanguages[0] + : detectedLanguages; + + const initialLanguage = + (detectedLanguage + ? // region code may not be defined if user uses generic preferred language + // (e.g. chinese vs instead of chienese-simplified) + languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code + : null) || defaultLang.code; + + return initialLanguage; +}; diff --git a/excalidraw-app/app-language/language-state.ts b/excalidraw-app/app-language/language-state.ts new file mode 100644 index 000000000..5198a8ea8 --- /dev/null +++ b/excalidraw-app/app-language/language-state.ts @@ -0,0 +1,15 @@ +import { atom, useAtom } from "jotai"; +import { useEffect } from "react"; +import { getPreferredLanguage, languageDetector } from "./language-detector"; + +export const appLangCodeAtom = atom(getPreferredLanguage()); + +export const useAppLangCode = () => { + const [langCode, setLangCode] = useAtom(appLangCodeAtom); + + useEffect(() => { + languageDetector.cacheUserLanguage(langCode); + }, [langCode]); + + return [langCode, setLangCode] as const; +}; diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 9f99d03de..eb3f24caf 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -6,7 +6,7 @@ import { import type { Theme } from "../../packages/excalidraw/element/types"; import { MainMenu } from "../../packages/excalidraw/index"; import { isExcalidrawPlusSignedUser } from "../app_constants"; -import { LanguageList } from "./LanguageList"; +import { LanguageList } from "../app-language/LanguageList"; export const AppMainMenu: React.FC<{ onCollabDialogOpen: () => any; From db2c235cd4441a0abc11c4a2a1d59160f9a88a0d Mon Sep 17 00:00:00 2001 From: Alexandre Lemoine <47149440+AlexandreLemoine40@users.noreply.github.com> Date: Mon, 8 Jul 2024 10:52:05 +0200 Subject: [PATCH 083/174] Fix : exportToCanvas() doc example (#8127) --- dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx index ef59054c4..5c075de86 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx @@ -90,7 +90,7 @@ function App() {
- setExcalidrawAPI(api)} + setExcalidrawAPI(api)} />
From f5221d521bc69b40d91e22eeb9b6bc10e8196d1e Mon Sep 17 00:00:00 2001 From: Hamir Mahal Date: Mon, 8 Jul 2024 01:56:25 -0700 Subject: [PATCH 084/174] ci: upgrade gh actions checkout and setup-node to v4 (#8168) fix: usage of `node12 which is deprecated` --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ecf2ca2f..ac66fe1ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,9 +9,9 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup Node.js 18.x - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 18.x - name: Install and test From 96eeec5119bd03019af2d052e5f295fc87fd2a1a Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 8 Jul 2024 18:35:13 +0200 Subject: [PATCH 085/174] feat: bump max file size (#8220) --- packages/excalidraw/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 426854f9c..031db6f8a 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -274,7 +274,7 @@ export const DEFAULT_EXPORT_PADDING = 10; // px export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; -export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024; +export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024; export const SVG_NS = "http://www.w3.org/2000/svg"; From e52c2cd0b63fcd8699084e4e8d4f3a4399679cac Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 9 Jul 2024 12:16:14 +0200 Subject: [PATCH 086/174] fix: log allowed events (#8224) --- .env.development | 2 +- .env.production | 4 ++-- excalidraw-app/package.json | 4 ++-- packages/excalidraw/analytics.ts | 17 ++++++++++------- packages/excalidraw/vite-env.d.ts | 1 + 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.env.development b/.env.development index 95e21ff87..85eb32533 100644 --- a/.env.development +++ b/.env.development @@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW= # whether to disable live reload / HMR. Usuaully what you want to do when # debugging Service Workers. VITE_APP_DEV_DISABLE_LIVE_RELOAD= -VITE_APP_DISABLE_TRACKING=true +VITE_APP_ENABLE_TRACKING=true FAST_REFRESH=false diff --git a/.env.production b/.env.production index 0c715854a..a1bb6b1b0 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,4 @@ -VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ +VITE_APP_ENABLE_TRACKINGVITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com @@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' -VITE_APP_DISABLE_TRACKING= +VITE_APP_ENABLE_TRACKING=false diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json index 8b82d01ad..f066cebc7 100644 --- a/excalidraw-app/package.json +++ b/excalidraw-app/package.json @@ -31,8 +31,8 @@ "prettier": "@excalidraw/prettier-config", "scripts": { "build-node": "node ./scripts/build-node.js", - "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build", - "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", + "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build", + "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build", "build:version": "node ../scripts/build-version.js", "build": "yarn build:app && yarn build:version", "start": "yarn && vite", diff --git a/packages/excalidraw/analytics.ts b/packages/excalidraw/analytics.ts index bd4b6191e..600d962b4 100644 --- a/packages/excalidraw/analytics.ts +++ b/packages/excalidraw/analytics.ts @@ -1,6 +1,6 @@ // place here categories that you want to track. We want to track just a // small subset of categories at a given time. -const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[]; +const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]); export const trackEvent = ( category: string, @@ -9,17 +9,20 @@ export const trackEvent = ( value?: number, ) => { try { - // prettier-ignore if ( - typeof window === "undefined" - || import.meta.env.VITE_WORKER_ID - // comment out to debug locally - || import.meta.env.PROD + typeof window === "undefined" || + import.meta.env.VITE_WORKER_ID || + import.meta.env.VITE_APP_ENABLE_TRACKING !== "true" ) { return; } - if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) { + if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) { + return; + } + + if (import.meta.env.DEV) { + // comment out to debug in dev return; } diff --git a/packages/excalidraw/vite-env.d.ts b/packages/excalidraw/vite-env.d.ts index c4ab79c37..2a0f77bbd 100644 --- a/packages/excalidraw/vite-env.d.ts +++ b/packages/excalidraw/vite-env.d.ts @@ -43,6 +43,7 @@ interface ImportMetaEnv { VITE_APP_COLLAPSE_OVERLAY: string; // Enable eslint in dev server VITE_APP_ENABLE_ESLINT: string; + VITE_APP_ENABLE_TRACKING: string; VITE_PKG_NAME: string; VITE_PKG_VERSION: string; From d25a7d365b951d84d7c031186ada2275943d4f08 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 10 Jul 2024 20:57:43 +0530 Subject: [PATCH 087/174] feat: upgrade mermaid-to-excalidraw to v1.1.0 (#8226) * feat: upgrade mermaid-to-excalidraw to v1.1.0 * fixes * upgrade and remove config as its redundant * lint * upgrade to v1.1.0 --- packages/excalidraw/components/App.tsx | 5 +---- .../excalidraw/components/TTDDialog/common.ts | 17 ++++------------- packages/excalidraw/package.json | 2 +- yarn.lock | 8 ++++---- 4 files changed, 10 insertions(+), 22 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 093f6bfdf..d84a9febd 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -49,7 +49,6 @@ import { import type { PastedMixedContent } from "../clipboard"; import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; import type { EXPORT_IMAGE_TYPES } from "../constants"; -import { DEFAULT_FONT_SIZE } from "../constants"; import { APP_NAME, CURSOR_TYPE, @@ -3055,9 +3054,7 @@ class App extends React.Component { try { const { elements: skeletonElements, files } = - await api.parseMermaidToExcalidraw(data.text, { - fontSize: DEFAULT_FONT_SIZE, - }); + await api.parseMermaidToExcalidraw(data.text); const elements = convertToExcalidrawElements(skeletonElements, { regenerateIds: true, diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts index 07135afcf..ddaa93045 100644 --- a/packages/excalidraw/components/TTDDialog/common.ts +++ b/packages/excalidraw/components/TTDDialog/common.ts @@ -1,10 +1,6 @@ -import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw"; +import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw"; import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces"; -import { - DEFAULT_EXPORT_PADDING, - DEFAULT_FONT_SIZE, - EDITOR_LS_KEYS, -} from "../../constants"; +import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants"; import { convertToExcalidrawElements, exportToCanvas } from "../../index"; import type { NonDeletedExcalidrawElement } from "../../element/types"; import type { AppClassProperties, BinaryFiles } from "../../types"; @@ -38,7 +34,7 @@ export interface MermaidToExcalidrawLibProps { api: Promise<{ parseMermaidToExcalidraw: ( definition: string, - options: MermaidOptions, + config?: MermaidConfig, ) => Promise; }>; } @@ -78,15 +74,10 @@ export const convertMermaidToExcalidraw = async ({ let ret; try { - ret = await api.parseMermaidToExcalidraw(mermaidDefinition, { - fontSize: DEFAULT_FONT_SIZE, - }); + ret = await api.parseMermaidToExcalidraw(mermaidDefinition); } catch (err: any) { ret = await api.parseMermaidToExcalidraw( mermaidDefinition.replace(/"/g, "'"), - { - fontSize: DEFAULT_FONT_SIZE, - }, ); } const { elements, files } = ret; diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 30b3b2b39..7279346c8 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -58,7 +58,7 @@ "dependencies": { "@braintree/sanitize-url": "6.0.2", "@excalidraw/laser-pointer": "1.3.1", - "@excalidraw/mermaid-to-excalidraw": "1.0.0", + "@excalidraw/mermaid-to-excalidraw": "1.1.0", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", diff --git a/yarn.lock b/yarn.lock index b9124e646..dc1c07c76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1930,10 +1930,10 @@ resolved "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.0.0.tgz#8c058d2a43230425cba96d01e4a669a2d7c586a2" - integrity sha512-RGSoJBY2gFag6mQOIwa3OakTrvAZYx0bwvnr5ojuCZInih8Fxhje4X1WZfsaQx+GATEH8Ioq3O3b1FPDg4nKjQ== +"@excalidraw/mermaid-to-excalidraw@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.0.tgz#a24a7aa3ad2e4f671054fdb670a8508bab463814" + integrity sha512-YP2roqrImzek1SpUAeToSTNhH5Gfw9ogdI5KHp7c+I/mX7SEW8oNqqX7CP+oHcUgNF6RrYIkqSrnMRN9/3EGLg== dependencies: "@excalidraw/markdown-to-text" "0.1.2" mermaid "10.9.0" From 6fbc44fd1fcdb5b3035ddd9407d00762b8b18cd8 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 11 Jul 2024 14:33:35 +0200 Subject: [PATCH 088/174] fix: messed up env variable (#8231) --- .env.production | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.production b/.env.production index a1bb6b1b0..64e696847 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,4 @@ -VITE_APP_ENABLE_TRACKINGVITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ +VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com From df8875a497b2111ec8c21963d66a2e2396d15bbd Mon Sep 17 00:00:00 2001 From: BlueGreenMagick Date: Sun, 14 Jul 2024 17:44:47 +0900 Subject: [PATCH 089/174] fix: freedraw jittering (#8238) --- packages/excalidraw/renderer/renderElement.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index a1f447b28..c6ec5f565 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -471,16 +471,7 @@ const drawElementFromCanvas = ( const element = elementWithCanvas.element; const padding = getCanvasPadding(element); const zoom = elementWithCanvas.scale; - let [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); - - // Free draw elements will otherwise "shuffle" as the min x and y change - if (isFreeDrawElement(element)) { - x1 = Math.floor(x1); - x2 = Math.ceil(x2); - y1 = Math.floor(y1); - y2 = Math.ceil(y2); - } - + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio; const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio; From 43b2476dfe1836a6ee4ea60e04e54f16c082c4eb Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 22 Jul 2024 11:47:16 +0200 Subject: [PATCH 090/174] fix: revert default element canvas padding change (#8266) --- packages/excalidraw/renderer/renderElement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index c6ec5f565..be9e6d81c 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -90,7 +90,7 @@ const shouldResetImageFilter = ( }; const getCanvasPadding = (element: ExcalidrawElement) => - element.type === "freedraw" ? element.strokeWidth * 12 : 200; + element.type === "freedraw" ? element.strokeWidth * 12 : 20; export const getRenderOpacity = ( element: ExcalidrawElement, From bd7b778f418d26a9ee8299d4def7e4663c8a0a43 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 23 Jul 2024 11:17:32 +0530 Subject: [PATCH 091/174] perf: cache the temp canvas created for labeled arrows (#8267) * perf: cache the temp canvas created for labeled arrows * use allEleemntsMap so bound text element can be retrieved when editing * remove logs * fix rotation * pass isRotating * feat: cache `element.angle` instead of relying on `appState.isRotating` --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/renderer/renderElement.ts | 165 ++++++++++-------- 1 file changed, 93 insertions(+), 72 deletions(-) diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index be9e6d81c..52d7bd32c 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -118,11 +118,13 @@ export interface ExcalidrawElementWithCanvas { canvas: HTMLCanvasElement; theme: AppState["theme"]; scale: number; + angle: number; zoomValue: AppState["zoom"]["value"]; canvasOffsetX: number; canvasOffsetY: number; boundTextElementVersion: number | null; containingFrameOpacity: number; + boundTextCanvas: HTMLCanvasElement; } const cappedElementCanvasSize = ( @@ -182,7 +184,7 @@ const cappedElementCanvasSize = ( const generateElementCanvas = ( element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, + elementsMap: NonDeletedSceneElementsMap, zoom: Zoom, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, @@ -234,8 +236,72 @@ const generateElementCanvas = ( } drawElementOnCanvas(element, rc, context, renderConfig, appState); + context.restore(); + const boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextCanvas = document.createElement("canvas"); + const boundTextCanvasContext = boundTextCanvas.getContext("2d")!; + + if (isArrowElement(element) && boundTextElement) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + // Take max dimensions of arrow canvas so that when canvas is rotated + // the arrow doesn't get clipped + const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); + boundTextCanvas.width = + maxDim * window.devicePixelRatio * scale + padding * scale * 10; + boundTextCanvas.height = + maxDim * window.devicePixelRatio * scale + padding * scale * 10; + boundTextCanvasContext.translate( + boundTextCanvas.width / 2, + boundTextCanvas.height / 2, + ); + boundTextCanvasContext.rotate(element.angle); + boundTextCanvasContext.drawImage( + canvas!, + -canvas.width / 2, + -canvas.height / 2, + canvas.width, + canvas.height, + ); + + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + elementsMap, + ); + + boundTextCanvasContext.rotate(-element.angle); + const offsetX = (boundTextCanvas.width - canvas!.width) / 2; + const offsetY = (boundTextCanvas.height - canvas!.height) / 2; + const shiftX = + boundTextCanvas.width / 2 - + (boundTextCx - x1) * window.devicePixelRatio * scale - + offsetX - + padding * scale; + + const shiftY = + boundTextCanvas.height / 2 - + (boundTextCy - y1) * window.devicePixelRatio * scale - + offsetY - + padding * scale; + boundTextCanvasContext.translate(-shiftX, -shiftY); + // Clear the bound text area + boundTextCanvasContext.clearRect( + -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + scale, + -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + scale, + (boundTextElement.width + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + scale, + (boundTextElement.height + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + scale, + ); + } + return { element, canvas, @@ -248,6 +314,8 @@ const generateElementCanvas = ( getBoundTextElement(element, elementsMap)?.version || null, containingFrameOpacity: getContainingFrame(element, elementsMap)?.opacity || 100, + boundTextCanvas, + angle: element.angle, }; }; @@ -423,7 +491,7 @@ export const elementWithCanvasCache = new WeakMap< const generateElementWithCanvas = ( element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, + elementsMap: NonDeletedSceneElementsMap, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { @@ -433,8 +501,8 @@ const generateElementWithCanvas = ( prevElementWithCanvas && prevElementWithCanvas.zoomValue !== zoom.value && !appState?.shouldCacheIgnoreZoom; - const boundTextElementVersion = - getBoundTextElement(element, elementsMap)?.version || null; + const boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextElementVersion = boundTextElement?.version || null; const containingFrameOpacity = getContainingFrame(element, elementsMap)?.opacity || 100; @@ -444,7 +512,14 @@ const generateElementWithCanvas = ( shouldRegenerateBecauseZoom || prevElementWithCanvas.theme !== appState.theme || prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion || - prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity + prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity || + // since we rotate the canvas when copying from cached canvas, we don't + // regenerate the cached canvas. But we need to in case of labels which are + // cached alongside the arrow, and we want the labels to remain unrotated + // with respect to the arrow. + (isArrowElement(element) && + boundTextElement && + element.angle !== prevElementWithCanvas.angle) ) { const elementWithCanvas = generateElementCanvas( element, @@ -481,75 +556,21 @@ const drawElementFromCanvas = ( const boundTextElement = getBoundTextElement(element, allElementsMap); if (isArrowElement(element) && boundTextElement) { - const tempCanvas = document.createElement("canvas"); - const tempCanvasContext = tempCanvas.getContext("2d")!; - - // Take max dimensions of arrow canvas so that when canvas is rotated - // the arrow doesn't get clipped - const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); - tempCanvas.width = - maxDim * window.devicePixelRatio * zoom + - padding * elementWithCanvas.scale * 10; - tempCanvas.height = - maxDim * window.devicePixelRatio * zoom + - padding * elementWithCanvas.scale * 10; - const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2; - const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2; - - tempCanvasContext.translate(tempCanvas.width / 2, tempCanvas.height / 2); - tempCanvasContext.rotate(element.angle); - - tempCanvasContext.drawImage( - elementWithCanvas.canvas!, - -elementWithCanvas.canvas.width / 2, - -elementWithCanvas.canvas.height / 2, - elementWithCanvas.canvas.width, - elementWithCanvas.canvas.height, - ); - - const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( - boundTextElement, - allElementsMap, - ); - - tempCanvasContext.rotate(-element.angle); - - // Shift the canvas to the center of the bound text element - const shiftX = - tempCanvas.width / 2 - - (boundTextCx - x1) * window.devicePixelRatio * zoom - - offsetX - - padding * zoom; - - const shiftY = - tempCanvas.height / 2 - - (boundTextCy - y1) * window.devicePixelRatio * zoom - - offsetY - - padding * zoom; - tempCanvasContext.translate(-shiftX, -shiftY); - // Clear the bound text area - tempCanvasContext.clearRect( - -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * - window.devicePixelRatio * - zoom, - -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * - window.devicePixelRatio * - zoom, - (boundTextElement.width + BOUND_TEXT_PADDING * 2) * - window.devicePixelRatio * - zoom, - (boundTextElement.height + BOUND_TEXT_PADDING * 2) * - window.devicePixelRatio * - zoom, - ); - + const offsetX = + (elementWithCanvas.boundTextCanvas.width - + elementWithCanvas.canvas!.width) / + 2; + const offsetY = + (elementWithCanvas.boundTextCanvas.height - + elementWithCanvas.canvas!.height) / + 2; context.translate(cx, cy); context.drawImage( - tempCanvas, + elementWithCanvas.boundTextCanvas, (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding, (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding, - tempCanvas.width / zoom, - tempCanvas.height / zoom, + elementWithCanvas.boundTextCanvas.width / zoom, + elementWithCanvas.boundTextCanvas.height / zoom, ); } else { // we translate context to element center so that rotation and scale @@ -705,7 +726,7 @@ export const renderElement = ( } else { const elementWithCanvas = generateElementWithCanvas( element, - elementsMap, + allElementsMap, renderConfig, appState, ); @@ -843,7 +864,7 @@ export const renderElement = ( } else { const elementWithCanvas = generateElementWithCanvas( element, - elementsMap, + allElementsMap, renderConfig, appState, ); From 4c5408263cd8bc8e110c609087a0228f5181ddd1 Mon Sep 17 00:00:00 2001 From: DDDDD12138 <43703884+DDDDD12138@users.noreply.github.com> Date: Tue, 23 Jul 2024 16:56:55 +0800 Subject: [PATCH 092/174] chore: Correct Typos in Code Comments (#8268) chore: correct typos Co-authored-by: wuzhiqing --- excalidraw-app/app-language/language-detector.ts | 2 +- packages/excalidraw/fractionalIndex.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/excalidraw-app/app-language/language-detector.ts b/excalidraw-app/app-language/language-detector.ts index acf77d631..76dd1149c 100644 --- a/excalidraw-app/app-language/language-detector.ts +++ b/excalidraw-app/app-language/language-detector.ts @@ -17,7 +17,7 @@ export const getPreferredLanguage = () => { const initialLanguage = (detectedLanguage ? // region code may not be defined if user uses generic preferred language - // (e.g. chinese vs instead of chienese-simplified) + // (e.g. chinese vs instead of chinese-simplified) languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code : null) || defaultLang.code; diff --git a/packages/excalidraw/fractionalIndex.ts b/packages/excalidraw/fractionalIndex.ts index d66a23f59..01b6d7015 100644 --- a/packages/excalidraw/fractionalIndex.ts +++ b/packages/excalidraw/fractionalIndex.ts @@ -11,15 +11,15 @@ import { InvalidFractionalIndexError } from "./errors"; * Envisioned relation between array order and fractional indices: * * 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation. - * - it's undesirable to to perform reorder for each related operation, thefeore it's necessary to cache the order defined by fractional indices into an ordered data structure + * - it's undesirable to perform reorder for each related operation, therefore it's necessary to cache the order defined by fractional indices into an ordered data structure * - it's easy enough to define the order of the elements from the outside (boundaries), without worrying about the underlying structure of fractional indices (especially for the host apps) * - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc. * - it's necessary to always keep the fractional indices in sync with the array order * - elements with invalid indices should be detected and synced, without altering the already valid indices * * 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated. - * - as the fractional indices are encoded as part of the elements, it opens up possibilties for incremental-like APIs - * - re-order based on fractional indices should be part of (multiplayer) operations such as reconcillitation & undo/redo + * - as the fractional indices are encoded as part of the elements, it opens up possibilities for incremental-like APIs + * - re-order based on fractional indices should be part of (multiplayer) operations such as reconciliation & undo/redo * - technically all the z-index actions could perform also re-order based on fractional indices,but in current state it would not bring much benefits, * as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order */ From 62228e0bbb780d1070a8cf206caa32132d22f19e Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Thu, 25 Jul 2024 18:55:55 +0200 Subject: [PATCH 093/174] feat: introduce font picker (#8012) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- dev-docs/src/css/custom.scss | 2 +- examples/excalidraw/components/App.tsx | 4 +- examples/excalidraw/initialData.tsx | 2 +- examples/excalidraw/with-nextjs/.gitignore | 3 + examples/excalidraw/with-nextjs/package.json | 3 +- .../excalidraw/with-nextjs/src/app/page.tsx | 5 +- .../excalidraw/with-nextjs/src/common.scss | 2 +- .../with-script-in-browser/.gitignore | 2 + .../with-script-in-browser/index.html | 1 + .../with-script-in-browser/package.json | 6 +- excalidraw-app/index.html | 67 ++- excalidraw-app/package.json | 3 +- .../__snapshots__/MobileMenu.test.tsx.snap | 4 +- excalidraw-app/vite.config.mts | 12 +- packages/excalidraw/CHANGELOG.md | 2 + .../actions/actionProperties.test.tsx | 6 +- .../excalidraw/actions/actionProperties.tsx | 451 ++++++++++++++---- packages/excalidraw/actions/actionStyles.ts | 8 +- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/Actions.tsx | 4 +- packages/excalidraw/components/App.tsx | 64 ++- .../excalidraw/components/ButtonIcon.scss | 12 + packages/excalidraw/components/ButtonIcon.tsx | 36 ++ .../components/ButtonIconSelect.tsx | 19 +- .../excalidraw/components/ButtonSeparator.tsx | 10 + .../components/ColorPicker/ColorPicker.scss | 2 +- .../components/ColorPicker/ColorPicker.tsx | 193 +++----- .../components/ColorPicker/Picker.tsx | 2 +- .../components/FontPicker/FontPicker.scss | 15 + .../components/FontPicker/FontPicker.tsx | 110 +++++ .../components/FontPicker/FontPickerList.tsx | 268 +++++++++++ .../FontPicker/FontPickerTrigger.tsx | 38 ++ .../FontPicker/keyboardNavHandlers.ts | 66 +++ .../excalidraw/components/HelpDialog.scss | 4 +- packages/excalidraw/components/HelpDialog.tsx | 4 + .../excalidraw/components/LibraryMenu.scss | 2 +- .../components/LibraryMenuItems.scss | 4 +- .../components/PropertiesPopover.tsx | 96 ++++ .../excalidraw/components/PublishLibrary.scss | 2 +- .../excalidraw/components/QuickSearch.scss | 48 ++ .../excalidraw/components/QuickSearch.tsx | 28 ++ .../excalidraw/components/ScrollableList.scss | 21 + .../excalidraw/components/ScrollableList.tsx | 24 + .../components/TTDDialog/TTDDialog.scss | 2 +- packages/excalidraw/components/UserList.scss | 66 +-- packages/excalidraw/components/UserList.tsx | 93 ++-- .../components/canvases/StaticCanvas.tsx | 1 + .../components/dropdownMenu/DropdownMenu.scss | 66 ++- .../dropdownMenu/DropdownMenuItem.tsx | 90 +++- .../dropdownMenu/DropdownMenuItemContent.tsx | 8 +- .../components/dropdownMenu/common.ts | 6 +- packages/excalidraw/components/icons.tsx | 21 + .../welcome-screen/WelcomeScreen.Center.tsx | 4 +- .../welcome-screen/WelcomeScreen.Hints.tsx | 6 +- .../welcome-screen/WelcomeScreen.scss | 4 +- packages/excalidraw/constants.ts | 18 +- packages/excalidraw/css/styles.scss | 21 +- packages/excalidraw/css/theme.scss | 3 + packages/excalidraw/css/variables.module.scss | 12 +- .../data/__snapshots__/transform.test.ts.snap | 50 +- packages/excalidraw/data/restore.ts | 9 +- packages/excalidraw/data/transform.ts | 10 +- packages/excalidraw/element/mutateElement.ts | 4 +- packages/excalidraw/element/newElement.ts | 6 +- .../excalidraw/element/textElement.test.ts | 8 +- packages/excalidraw/element/textElement.ts | 224 ++++----- .../excalidraw/element/textWysiwyg.test.tsx | 20 +- packages/excalidraw/element/textWysiwyg.tsx | 110 ++--- packages/excalidraw/fonts/ExcalidrawFont.ts | 78 +++ .../fonts/assets}/Assistant-Bold.woff2 | Bin .../fonts/assets}/Assistant-Medium.woff2 | Bin .../fonts/assets}/Assistant-Regular.woff2 | Bin .../fonts/assets}/Assistant-SemiBold.woff2 | Bin .../fonts/assets/CascadiaMono-Regular.woff2 | Bin 0 -> 74128 bytes .../fonts/assets/ComicShanns-Regular.woff2 | Bin 0 -> 16856 bytes .../fonts/assets/Excalifont-Regular.woff2 | Bin 0 -> 52296 bytes .../fonts/assets/LiberationSans-Regular.woff2 | Bin 0 -> 70668 bytes .../fonts/assets/Virgil-Regular.woff2 | Bin 0 -> 56156 bytes packages/excalidraw/fonts/assets/fonts.css | 34 ++ packages/excalidraw/fonts/index.ts | 308 ++++++++++++ packages/excalidraw/fonts/metadata.ts | 125 +++++ packages/excalidraw/index.tsx | 4 +- packages/excalidraw/locales/en.json | 17 +- packages/excalidraw/renderer/renderElement.ts | 16 +- .../excalidraw/renderer/staticSvgScene.ts | 2 +- packages/excalidraw/scene/Fonts.ts | 90 ---- packages/excalidraw/scene/export.ts | 88 ++-- .../__snapshots__/contextmenu.test.tsx.snap | 51 +- .../__snapshots__/excalidraw.test.tsx.snap | 18 +- .../tests/__snapshots__/export.test.tsx.snap | 13 +- .../tests/__snapshots__/history.test.tsx.snap | 221 +++++---- .../linearElementEditor.test.tsx.snap | 2 +- .../regressionTests.test.tsx.snap | 156 ++++-- packages/excalidraw/tests/clipboard.test.tsx | 10 +- .../data/__snapshots__/restore.test.ts.snap | 2 +- .../tests/fixtures/elementFixture.ts | 15 + .../excalidraw/tests/helpers/polyfills.ts | 4 + .../excalidraw/tests/regressionTests.test.tsx | 4 +- .../scene/__snapshots__/export.test.ts.snap | 121 +++-- .../excalidraw/tests/scene/export.test.ts | 17 +- packages/excalidraw/types.ts | 11 +- packages/excalidraw/utils.ts | 35 +- .../utils/__snapshots__/export.test.ts.snap | 3 +- packages/utils/export.ts | 3 + packages/utils/index.ts | 1 + packages/utils/package.json | 6 +- public/fonts/Cascadia.ttf | Bin 213476 -> 0 bytes public/fonts/Cascadia.woff2 | Bin 86812 -> 0 bytes public/fonts/FG_Virgil.ttf | Bin 236876 -> 0 bytes public/fonts/FG_Virgil.woff2 | Bin 119508 -> 0 bytes public/fonts/Virgil.woff2 | Bin 61248 -> 0 bytes public/fonts/fonts.css | 38 -- scripts/buildPackage.js | 14 +- scripts/buildUtils.js | 16 +- scripts/woff2/assets/NotoEmoji-Regular.ttf | Bin 0 -> 836652 bytes scripts/woff2/woff2-esbuild-plugins.js | 269 +++++++++++ scripts/woff2/woff2-vite-plugins.js | 46 ++ setupTests.ts | 60 +++ vitest.config.mts | 4 + yarn.lock | 75 +++ 120 files changed, 3390 insertions(+), 1106 deletions(-) create mode 100644 examples/excalidraw/with-script-in-browser/.gitignore create mode 100644 packages/excalidraw/components/ButtonIcon.scss create mode 100644 packages/excalidraw/components/ButtonIcon.tsx create mode 100644 packages/excalidraw/components/ButtonSeparator.tsx create mode 100644 packages/excalidraw/components/FontPicker/FontPicker.scss create mode 100644 packages/excalidraw/components/FontPicker/FontPicker.tsx create mode 100644 packages/excalidraw/components/FontPicker/FontPickerList.tsx create mode 100644 packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx create mode 100644 packages/excalidraw/components/FontPicker/keyboardNavHandlers.ts create mode 100644 packages/excalidraw/components/PropertiesPopover.tsx create mode 100644 packages/excalidraw/components/QuickSearch.scss create mode 100644 packages/excalidraw/components/QuickSearch.tsx create mode 100644 packages/excalidraw/components/ScrollableList.scss create mode 100644 packages/excalidraw/components/ScrollableList.tsx create mode 100644 packages/excalidraw/fonts/ExcalidrawFont.ts rename {public/fonts => packages/excalidraw/fonts/assets}/Assistant-Bold.woff2 (100%) rename {public/fonts => packages/excalidraw/fonts/assets}/Assistant-Medium.woff2 (100%) rename {public/fonts => packages/excalidraw/fonts/assets}/Assistant-Regular.woff2 (100%) rename {public/fonts => packages/excalidraw/fonts/assets}/Assistant-SemiBold.woff2 (100%) create mode 100644 packages/excalidraw/fonts/assets/CascadiaMono-Regular.woff2 create mode 100644 packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2 create mode 100644 packages/excalidraw/fonts/assets/Excalifont-Regular.woff2 create mode 100644 packages/excalidraw/fonts/assets/LiberationSans-Regular.woff2 create mode 100644 packages/excalidraw/fonts/assets/Virgil-Regular.woff2 create mode 100644 packages/excalidraw/fonts/assets/fonts.css create mode 100644 packages/excalidraw/fonts/index.ts create mode 100644 packages/excalidraw/fonts/metadata.ts delete mode 100644 packages/excalidraw/scene/Fonts.ts delete mode 100644 public/fonts/Cascadia.ttf delete mode 100644 public/fonts/Cascadia.woff2 delete mode 100644 public/fonts/FG_Virgil.ttf delete mode 100644 public/fonts/FG_Virgil.woff2 delete mode 100644 public/fonts/Virgil.woff2 delete mode 100644 public/fonts/fonts.css create mode 100644 scripts/woff2/assets/NotoEmoji-Regular.ttf create mode 100644 scripts/woff2/woff2-esbuild-plugins.js create mode 100644 scripts/woff2/woff2-vite-plugins.js diff --git a/dev-docs/src/css/custom.scss b/dev-docs/src/css/custom.scss index 93c7f90ab..0ab28c9bd 100644 --- a/dev-docs/src/css/custom.scss +++ b/dev-docs/src/css/custom.scss @@ -59,7 +59,7 @@ pre a { padding: 5px; background: #70b1ec; color: white; - font-weight: bold; + font-weight: 700; border: none; } diff --git a/examples/excalidraw/components/App.tsx b/examples/excalidraw/components/App.tsx index 3b553a453..7cfd8a05a 100644 --- a/examples/excalidraw/components/App.tsx +++ b/examples/excalidraw/components/App.tsx @@ -872,7 +872,7 @@ export default function App({ files: excalidrawAPI.getFiles(), }); const ctx = canvas.getContext("2d")!; - ctx.font = "30px Virgil"; + ctx.font = "30px Excalifont"; ctx.strokeText("My custom text", 50, 60); setCanvasUrl(canvas.toDataURL()); }} @@ -893,7 +893,7 @@ export default function App({ files: excalidrawAPI.getFiles(), }); const ctx = canvas.getContext("2d")!; - ctx.font = "30px Virgil"; + ctx.font = "30px Excalifont"; ctx.strokeText("My custom text", 50, 60); setCanvasUrl(canvas.toDataURL()); }} diff --git a/examples/excalidraw/initialData.tsx b/examples/excalidraw/initialData.tsx index 3cb5e7af4..0db23d5f2 100644 --- a/examples/excalidraw/initialData.tsx +++ b/examples/excalidraw/initialData.tsx @@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [ ]; export default { elements, - appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 }, + appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 }, scrollToContent: true, libraryItems: [ [ diff --git a/examples/excalidraw/with-nextjs/.gitignore b/examples/excalidraw/with-nextjs/.gitignore index fd3dbb571..2279431c5 100644 --- a/examples/excalidraw/with-nextjs/.gitignore +++ b/examples/excalidraw/with-nextjs/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# copied assets +public/*.woff2 \ No newline at end of file diff --git a/examples/excalidraw/with-nextjs/package.json b/examples/excalidraw/with-nextjs/package.json index 177952407..5b4590ac5 100644 --- a/examples/excalidraw/with-nextjs/package.json +++ b/examples/excalidraw/with-nextjs/package.json @@ -3,7 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm", + "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", + "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public", "dev": "yarn build:workspace && next dev -p 3005", "build": "yarn build:workspace && next build", "start": "next start -p 3006", diff --git a/examples/excalidraw/with-nextjs/src/app/page.tsx b/examples/excalidraw/with-nextjs/src/app/page.tsx index bc8c98fcf..191aca120 100644 --- a/examples/excalidraw/with-nextjs/src/app/page.tsx +++ b/examples/excalidraw/with-nextjs/src/app/page.tsx @@ -1,4 +1,5 @@ import dynamic from "next/dynamic"; +import Script from "next/script"; import "../common.scss"; // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically @@ -15,7 +16,9 @@ export default function Page() { <> Switch to Pages router

App Router

- + {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */} diff --git a/examples/excalidraw/with-nextjs/src/common.scss b/examples/excalidraw/with-nextjs/src/common.scss index 1a77600a9..456bc7635 100644 --- a/examples/excalidraw/with-nextjs/src/common.scss +++ b/examples/excalidraw/with-nextjs/src/common.scss @@ -7,7 +7,7 @@ a { color: #1c7ed6; font-size: 20px; text-decoration: none; - font-weight: 550; + font-weight: 500; } .page-title { diff --git a/examples/excalidraw/with-script-in-browser/.gitignore b/examples/excalidraw/with-script-in-browser/.gitignore new file mode 100644 index 000000000..215fc2008 --- /dev/null +++ b/examples/excalidraw/with-script-in-browser/.gitignore @@ -0,0 +1,2 @@ +# copied assets +public/*.woff2 \ No newline at end of file diff --git a/examples/excalidraw/with-script-in-browser/index.html b/examples/excalidraw/with-script-in-browser/index.html index a56d7f421..8e29a1d8a 100644 --- a/examples/excalidraw/with-script-in-browser/index.html +++ b/examples/excalidraw/with-script-in-browser/index.html @@ -11,6 +11,7 @@ React App diff --git a/examples/excalidraw/with-script-in-browser/package.json b/examples/excalidraw/with-script-in-browser/package.json index d721ac162..e1c8ac37a 100644 --- a/examples/excalidraw/with-script-in-browser/package.json +++ b/examples/excalidraw/with-script-in-browser/package.json @@ -12,8 +12,10 @@ "typescript": "^5" }, "scripts": { - "start": "yarn workspace @excalidraw/excalidraw run build:esm && vite", - "build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build", + "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", + "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public", + "start": "yarn build:workspace && vite", + "build": "yarn build:workspace && vite build", "build:preview": "yarn build && vite preview --port 5002" } } diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 2fd21f722..a2919e512 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -114,6 +114,14 @@ ) { window.location.href = "https://app.excalidraw.com"; } + + // point into our CDN in prod + window.EXCALIDRAW_ASSET_PATH = + "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/"; + + <% } else { %> + <% } %> @@ -124,22 +132,74 @@ + + + + + + <% if (typeof PROD != 'undefined' && PROD == true) { %> + + <% } else { %> + + + + + <% } %> + + + - + + + <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %> <% } %> diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json index f066cebc7..d0a30b6d9 100644 --- a/excalidraw-app/package.json +++ b/excalidraw-app/package.json @@ -36,7 +36,8 @@ "build:version": "node ../scripts/build-version.js", "build": "yarn build:app && yarn build:version", "start": "yarn && vite", - "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", + "start:production": "yarn build && yarn serve", + "serve": "npx http-server build -a localhost -p 5001 -o", "build:preview": "yarn build && vite preview --port 5000" } } diff --git a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap index 4e526a998..77fc14757 100644 --- a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap +++ b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u class="welcome-screen-center" >
All your data is saved locally in your browser.
diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index 39417de36..ee1256263 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs"; import { VitePWA } from "vite-plugin-pwa"; import checker from "vite-plugin-checker"; import { createHtmlPlugin } from "vite-plugin-html"; +import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins"; // To load .env.local variables const envVars = loadEnv("", `../`); @@ -22,6 +23,14 @@ export default defineConfig({ outDir: "build", rollupOptions: { output: { + assetFileNames(chunkInfo) { + if (chunkInfo?.name?.endsWith(".woff2")) { + // put on root so we are flexible about the CDN path + return '[name]-[hash][extname]'; + } + + return 'assets/[name]-[hash][extname]'; + }, // Creating separate chunk for locales except for en and percentages.json so they // can be cached at runtime and not merged with // app precache. en.json and percentages.json are needed for first load @@ -35,12 +44,13 @@ export default defineConfig({ // Taking the substring after "locales/" return `locales/${id.substring(index + 8)}`; } - }, + } }, }, sourcemap: true, }, plugins: [ + woff2BrowserPlugin(), react(), checker({ typescript: true, diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index cb58d6ab6..c5e633ad6 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -19,6 +19,8 @@ Please add the latest change on the top under the correct section. - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) +- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`. + - `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853) - Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) diff --git a/packages/excalidraw/actions/actionProperties.test.tsx b/packages/excalidraw/actions/actionProperties.test.tsx index 2e1690107..a7c90e303 100644 --- a/packages/excalidraw/actions/actionProperties.test.tsx +++ b/packages/excalidraw/actions/actionProperties.test.tsx @@ -155,13 +155,15 @@ describe("element locking", () => { }); const text = API.createElement({ type: "text", - fontFamily: FONT_FAMILY.Cascadia, + fontFamily: FONT_FAMILY["Comic Shanns"], }); h.elements = [rect, text]; API.setSelectedElements([rect, text]); expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked(); - expect(queryByTestId(document.body, `font-family-code`)).toBeChecked(); + expect(queryByTestId(document.body, `font-family-code`)).toHaveClass( + "active", + ); }); }); }); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index d48f78ba4..e0cc825c9 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,4 +1,6 @@ +import { useEffect, useMemo, useRef, useState } from "react"; import type { AppClassProperties, AppState, Primitive } from "../types"; +import type { StoreActionType } from "../store"; import { DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS, @@ -9,6 +11,7 @@ import { trackEvent } from "../analytics"; import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { IconPicker } from "../components/IconPicker"; +import { FontPicker } from "../components/FontPicker/FontPicker"; // TODO barnabasmolnar/editor-redesign // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon, // ArrowHead icons @@ -38,9 +41,6 @@ import { FontSizeExtraLargeIcon, EdgeSharpIcon, EdgeRoundIcon, - FreedrawIcon, - FontFamilyNormalIcon, - FontFamilyCodeIcon, TextAlignLeftIcon, TextAlignCenterIcon, TextAlignRightIcon, @@ -65,10 +65,7 @@ import { redrawTextBoundingBox, } from "../element"; import { mutateElement, newElementWith } from "../element/mutateElement"; -import { - getBoundTextElement, - getDefaultLineHeight, -} from "../element/textElement"; +import { getBoundTextElement } from "../element/textElement"; import { isBoundToContainer, isLinearElement, @@ -94,9 +91,10 @@ import { isSomeElementSelected, } from "../scene"; import { hasStrokeColor } from "../scene/comparisons"; -import { arrayToMap, getShortcutKey } from "../utils"; +import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils"; import { register } from "./register"; import { StoreAction } from "../store"; +import { Fonts, getLineHeight } from "../fonts"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -729,104 +727,391 @@ export const actionIncreaseFontSize = register({ }, }); +type ChangeFontFamilyData = Partial< + Pick< + AppState, + "openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily" + > +> & { + /** cache of selected & editing elements populated on opened popup */ + cachedElements?: Map; + /** flag to reset all elements to their cached versions */ + resetAll?: true; + /** flag to reset all containers to their cached versions */ + resetContainers?: true; +}; + export const actionChangeFontFamily = register({ name: "changeFontFamily", label: "labels.fontFamily", trackEvent: false, perform: (elements, appState, value, app) => { - return { - elements: changeProperty( + const { cachedElements, resetAll, resetContainers, ...nextAppState } = + value as ChangeFontFamilyData; + + if (resetAll) { + const nextElements = changeProperty( elements, appState, - (oldElement) => { - if (isTextElement(oldElement)) { - const newElement: ExcalidrawTextElement = newElementWith( - oldElement, - { - fontFamily: value, - lineHeight: getDefaultLineHeight(value), - }, - ); - redrawTextBoundingBox( - newElement, - app.scene.getContainerElement(oldElement), - app.scene.getNonDeletedElementsMap(), - ); + (element) => { + const cachedElement = cachedElements?.get(element.id); + if (cachedElement) { + const newElement = newElementWith(element, { + ...cachedElement, + }); + return newElement; } - return oldElement; + return element; }, true, - ), + ); + + return { + elements: nextElements, + appState: { + ...appState, + ...nextAppState, + }, + storeAction: StoreAction.UPDATE, + }; + } + + const { currentItemFontFamily, currentHoveredFontFamily } = value; + + let nexStoreAction: StoreActionType = StoreAction.NONE; + let nextFontFamily: FontFamilyValues | undefined; + let skipOnHoverRender = false; + + if (currentItemFontFamily) { + nextFontFamily = currentItemFontFamily; + nexStoreAction = StoreAction.CAPTURE; + } else if (currentHoveredFontFamily) { + nextFontFamily = currentHoveredFontFamily; + nexStoreAction = StoreAction.NONE; + + const selectedTextElements = getSelectedElements(elements, appState, { + includeBoundTextElement: true, + }).filter((element) => isTextElement(element)); + + // skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined + if (selectedTextElements.length > 200) { + skipOnHoverRender = true; + } else { + let i = 0; + let textLengthAccumulator = 0; + + while ( + i < selectedTextElements.length && + textLengthAccumulator < 5000 + ) { + const textElement = selectedTextElements[i] as ExcalidrawTextElement; + textLengthAccumulator += textElement?.originalText.length || 0; + i++; + } + + if (textLengthAccumulator > 5000) { + skipOnHoverRender = true; + } + } + } + + const result = { appState: { ...appState, - currentItemFontFamily: value, + ...nextAppState, }, - storeAction: StoreAction.CAPTURE, + storeAction: nexStoreAction, }; + + if (nextFontFamily && !skipOnHoverRender) { + const elementContainerMapping = new Map< + ExcalidrawTextElement, + ExcalidrawElement | null + >(); + let uniqueGlyphs = new Set(); + let skipFontFaceCheck = false; + + const fontsCache = Array.from(Fonts.loadedFontsCache.values()); + const fontFamily = Object.entries(FONT_FAMILY).find( + ([_, value]) => value === nextFontFamily, + )?.[0]; + + // skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine) + if ( + currentHoveredFontFamily && + fontFamily && + fontsCache.some((sig) => sig.startsWith(fontFamily)) + ) { + skipFontFaceCheck = true; + } + + // following causes re-render so make sure we changed the family + // otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg + Object.assign(result, { + elements: changeProperty( + elements, + appState, + (oldElement) => { + if ( + isTextElement(oldElement) && + (oldElement.fontFamily !== nextFontFamily || + currentItemFontFamily) // force update on selection + ) { + const newElement: ExcalidrawTextElement = newElementWith( + oldElement, + { + fontFamily: nextFontFamily, + lineHeight: getLineHeight(nextFontFamily!), + }, + ); + + const cachedContainer = + cachedElements?.get(oldElement.containerId || "") || {}; + + const container = app.scene.getContainerElement(oldElement); + + if (resetContainers && container && cachedContainer) { + // reset the container back to it's cached version + mutateElement(container, { ...cachedContainer }, false); + } + + if (!skipFontFaceCheck) { + uniqueGlyphs = new Set([ + ...uniqueGlyphs, + ...Array.from(newElement.originalText), + ]); + } + + elementContainerMapping.set(newElement, container); + + return newElement; + } + + return oldElement; + }, + true, + ), + }); + + // size is irrelevant, but necessary + const fontString = `10px ${getFontFamilyString({ + fontFamily: nextFontFamily, + })}`; + const glyphs = Array.from(uniqueGlyphs.values()).join(); + + if ( + skipFontFaceCheck || + window.document.fonts.check(fontString, glyphs) + ) { + // we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded + for (const [element, container] of elementContainerMapping) { + // trigger synchronous redraw + redrawTextBoundingBox( + element, + container, + app.scene.getNonDeletedElementsMap(), + false, + ); + } + } else { + // otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded + window.document.fonts.load(fontString, glyphs).then((fontFaces) => { + for (const [element, container] of elementContainerMapping) { + // use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts) + const latestElement = app.scene.getElement(element.id); + const latestContainer = container + ? app.scene.getElement(container.id) + : null; + + if (latestElement) { + // trigger async redraw + redrawTextBoundingBox( + latestElement as ExcalidrawTextElement, + latestContainer, + app.scene.getNonDeletedElementsMap(), + false, + ); + } + } + + // trigger update once we've mutated all the elements, which also updates our cache + app.fonts.onLoaded(fontFaces); + }); + } + } + + return result; }, - PanelComponent: ({ elements, appState, updateData, app }) => { - const options: { - value: FontFamilyValues; - text: string; - icon: JSX.Element; - testId: string; - }[] = [ - { - value: FONT_FAMILY.Virgil, - text: t("labels.handDrawn"), - icon: FreedrawIcon, - testId: "font-family-virgil", - }, - { - value: FONT_FAMILY.Helvetica, - text: t("labels.normal"), - icon: FontFamilyNormalIcon, - testId: "font-family-normal", - }, - { - value: FONT_FAMILY.Cascadia, - text: t("labels.code"), - icon: FontFamilyCodeIcon, - testId: "font-family-code", - }, - ]; + PanelComponent: ({ elements, appState, app, updateData }) => { + const cachedElementsRef = useRef>(new Map()); + const prevSelectedFontFamilyRef = useRef(null); + // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them + const [batchedData, setBatchedData] = useState({}); + const isUnmounted = useRef(true); + + const selectedFontFamily = useMemo(() => { + const getFontFamily = ( + elementsArray: readonly ExcalidrawElement[], + elementsMap: Map, + ) => + getFormValue( + elementsArray, + appState, + (element) => { + if (isTextElement(element)) { + return element.fontFamily; + } + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + return boundTextElement.fontFamily; + } + return null; + }, + (element) => + isTextElement(element) || + getBoundTextElement(element, elementsMap) !== null, + (hasSelection) => + hasSelection + ? null + : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, + ); + + // popup opened, use cached elements + if ( + batchedData.openPopup === "fontFamily" && + appState.openPopup === "fontFamily" + ) { + return getFontFamily( + Array.from(cachedElementsRef.current?.values() ?? []), + cachedElementsRef.current, + ); + } + + // popup closed, use all elements + if (!batchedData.openPopup && appState.openPopup !== "fontFamily") { + return getFontFamily(elements, app.scene.getNonDeletedElementsMap()); + } + + // popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had + return prevSelectedFontFamilyRef.current; + }, [batchedData.openPopup, appState, elements, app.scene]); + + useEffect(() => { + prevSelectedFontFamilyRef.current = selectedFontFamily; + }, [selectedFontFamily]); + + useEffect(() => { + if (Object.keys(batchedData).length) { + updateData(batchedData); + // reset the data after we've used the data + setBatchedData({}); + } + // call update only on internal state changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [batchedData]); + + useEffect(() => { + isUnmounted.current = false; + + return () => { + isUnmounted.current = true; + }; + }, []); return (
{t("labels.fontFamily")} - - group="font-family" - options={options} - value={getFormValue( - elements, - appState, - (element) => { - if (isTextElement(element)) { - return element.fontFamily; + { + setBatchedData({ + openPopup: null, + currentHoveredFontFamily: null, + currentItemFontFamily: fontFamily, + }); + + // defensive clear so immediate close won't abuse the cached elements + cachedElementsRef.current.clear(); + }} + onHover={(fontFamily) => { + setBatchedData({ + currentHoveredFontFamily: fontFamily, + cachedElements: new Map(cachedElementsRef.current), + resetContainers: true, + }); + }} + onLeave={() => { + setBatchedData({ + currentHoveredFontFamily: null, + cachedElements: new Map(cachedElementsRef.current), + resetAll: true, + }); + }} + onPopupChange={(open) => { + if (open) { + // open, populate the cache from scratch + cachedElementsRef.current.clear(); + + const { editingElement } = appState; + + if (editingElement?.type === "text") { + // retrieve the latest version from the scene, as `editingElement` isn't mutated + const latestEditingElement = app.scene.getElement( + editingElement.id, + ); + + // inside the wysiwyg editor + cachedElementsRef.current.set( + editingElement.id, + newElementWith( + latestEditingElement || editingElement, + {}, + true, + ), + ); + } else { + const selectedElements = getSelectedElements( + elements, + appState, + { + includeBoundTextElement: true, + }, + ); + + for (const element of selectedElements) { + cachedElementsRef.current.set( + element.id, + newElementWith(element, {}, true), + ); + } } - const boundTextElement = getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), - ); - if (boundTextElement) { - return boundTextElement.fontFamily; + + setBatchedData({ + openPopup: "fontFamily", + }); + } else { + // close, use the cache and clear it afterwards + const data = { + openPopup: null, + currentHoveredFontFamily: null, + cachedElements: new Map(cachedElementsRef.current), + resetAll: true, + } as ChangeFontFamilyData; + + if (isUnmounted.current) { + // in case the component was unmounted by the parent, trigger the update directly + updateData({ ...batchedData, ...data }); + } else { + setBatchedData(data); } - return null; - }, - (element) => - isTextElement(element) || - getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), - ) !== null, - (hasSelection) => - hasSelection - ? null - : appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, - )} - onChange={(value) => updateData(value)} + + cachedElementsRef.current.clear(); + } + }} />
); diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 9483476f8..1a17bf9de 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -12,10 +12,7 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_TEXT_ALIGN, } from "../constants"; -import { - getBoundTextElement, - getDefaultLineHeight, -} from "../element/textElement"; +import { getBoundTextElement } from "../element/textElement"; import { hasBoundTextElement, canApplyRoundnessTypeToElement, @@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene"; import type { ExcalidrawTextElement } from "../element/types"; import { paintIcon } from "../components/icons"; import { StoreAction } from "../store"; +import { getLineHeight } from "../fonts"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; @@ -122,7 +120,7 @@ export const actionPasteStyles = register({ DEFAULT_TEXT_ALIGN, lineHeight: (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight || - getDefaultLineHeight(fontFamily), + getLineHeight(fontFamily), }); let container = null; if (newElement.containerId) { diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 677c0a077..2e490a908 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -36,6 +36,7 @@ export const getDefaultAppState = (): Omit< currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemTextAlign: DEFAULT_TEXT_ALIGN, + currentHoveredFontFamily: null, cursorButton: "up", activeEmbeddable: null, draggingElement: null, @@ -149,6 +150,7 @@ const APP_STATE_STORAGE_CONF = (< currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false }, + currentHoveredFontFamily: { browser: false, export: false, server: false }, cursorButton: { browser: true, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false }, draggingElement: { browser: false, export: false, server: false }, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index c49b4a5f0..2be642f79 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -158,10 +158,8 @@ export const SelectedShapeActions = ({ {(appState.activeTool.type === "text" || targetElements.some(isTextElement)) && ( <> - {renderAction("changeFontSize")} - {renderAction("changeFontFamily")} - + {renderAction("changeFontSize")} {(appState.activeTool.type === "text" || suppportsHorizontalAlign(targetElements, elementsMap)) && renderAction("changeTextAlign")} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d84a9febd..6f1ac7ffd 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -321,7 +321,6 @@ import { getBoundTextElement, getContainerCenter, getContainerElement, - getDefaultLineHeight, getLineHeightInPx, getMinTextElementWidth, isMeasureTextSupported, @@ -337,7 +336,7 @@ import { import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; import { shouldShowBoundingBox } from "../element/transformHandles"; import { actionUnlockAllElements } from "../actions/actionElementLock"; -import { Fonts } from "../scene/Fonts"; +import { Fonts, getLineHeight } from "../fonts"; import { getFrameChildren, isCursorInFrame, @@ -532,8 +531,8 @@ class App extends React.Component { private excalidrawContainerRef = React.createRef(); public scene: Scene; + public fonts: Fonts; public renderer: Renderer; - private fonts: Fonts; private resizeObserver: ResizeObserver | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; @@ -2335,11 +2334,6 @@ class App extends React.Component { }), }; } - // FontFaceSet loadingdone event we listen on may not always fire - // (looking at you Safari), so on init we manually load fonts for current - // text elements on canvas, and rerender them once done. This also - // seems faster even in browsers that do fire the loadingdone event. - this.fonts.loadFontsForElements(scene.elements); this.resetStore(); this.resetHistory(); @@ -2347,6 +2341,12 @@ class App extends React.Component { ...scene, storeAction: StoreAction.UPDATE, }); + + // FontFaceSet loadingdone event we listen on may not always + // fire (looking at you Safari), so on init we manually load all + // fonts and rerender scene text elements once done. This also + // seems faster even in browsers that do fire the loadingdone event. + this.fonts.load(); }; private isMobileBreakpoint = (width: number, height: number) => { @@ -2439,6 +2439,10 @@ class App extends React.Component { configurable: true, value: this.store, }, + fonts: { + configurable: true, + value: this.fonts, + }, }); } @@ -2576,7 +2580,7 @@ class App extends React.Component { // rerender text elements on font load to fix #637 && #1553 addEventListener(document.fonts, "loadingdone", (event) => { const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; - this.fonts.onFontsLoaded(loadedFontFaces); + this.fonts.onLoaded(loadedFontFaces); }), // Safari-only desktop pinch zoom addEventListener( @@ -3379,7 +3383,7 @@ class App extends React.Component { fontSize: textElementProps.fontSize, fontFamily: textElementProps.fontFamily, }); - const lineHeight = getDefaultLineHeight(textElementProps.fontFamily); + const lineHeight = getLineHeight(textElementProps.fontFamily); const [x1, , x2] = getVisibleSceneBounds(this.state); // long texts should not go beyond 800 pixels in width nor should it go below 200 px const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200); @@ -3397,13 +3401,13 @@ class App extends React.Component { }); let metrics = measureText(originalText, fontString, lineHeight); - const isTextWrapped = metrics.width > maxTextWidth; + const isTextUnwrapped = metrics.width > maxTextWidth; - const text = isTextWrapped + const text = isTextUnwrapped ? wrapText(originalText, fontString, maxTextWidth) : originalText; - metrics = isTextWrapped + metrics = isTextUnwrapped ? measureText(text, fontString, lineHeight) : metrics; @@ -3417,7 +3421,7 @@ class App extends React.Component { text, originalText, lineHeight, - autoResize: !isTextWrapped, + autoResize: !isTextUnwrapped, frameId: topLayerFrame ? topLayerFrame.id : null, }); acc.push(element); @@ -4107,6 +4111,36 @@ class App extends React.Component { } } + if ( + !event[KEYS.CTRL_OR_CMD] && + event.shiftKey && + event.key.toLowerCase() === KEYS.F + ) { + const selectedElements = this.scene.getSelectedElements(this.state); + + if ( + this.state.activeTool.type === "selection" && + !selectedElements.length + ) { + return; + } + + if ( + this.state.activeTool.type === "text" || + selectedElements.find( + (element) => + isTextElement(element) || + getBoundTextElement( + element, + this.scene.getNonDeletedElementsMap(), + ), + ) + ) { + event.preventDefault(); + this.setState({ openPopup: "fontFamily" }); + } + } + if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { if (this.state.activeTool.type === "laser") { this.setActiveTool({ type: "selection" }); @@ -4761,7 +4795,7 @@ class App extends React.Component { existingTextElement?.fontFamily || this.state.currentItemFontFamily; const lineHeight = - existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily); + existingTextElement?.lineHeight || getLineHeight(fontFamily); const fontSize = this.state.currentItemFontSize; if ( diff --git a/packages/excalidraw/components/ButtonIcon.scss b/packages/excalidraw/components/ButtonIcon.scss new file mode 100644 index 000000000..e435b69e4 --- /dev/null +++ b/packages/excalidraw/components/ButtonIcon.scss @@ -0,0 +1,12 @@ +@import "../css/theme"; + +.excalidraw { + button.standalone { + @include outlineButtonIconStyles; + + & > * { + // dissalow pointer events on children, so we always have event.target on the button itself + pointer-events: none; + } + } +} diff --git a/packages/excalidraw/components/ButtonIcon.tsx b/packages/excalidraw/components/ButtonIcon.tsx new file mode 100644 index 000000000..5421f4c3a --- /dev/null +++ b/packages/excalidraw/components/ButtonIcon.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from "react"; +import clsx from "clsx"; + +import "./ButtonIcon.scss"; + +interface ButtonIconProps { + icon: JSX.Element; + title: string; + className?: string; + testId?: string; + /** if not supplied, defaults to value identity check */ + active?: boolean; + /** include standalone style (could interfere with parent styles) */ + standalone?: boolean; + onClick: (event: React.MouseEvent) => void; +} + +export const ButtonIcon = forwardRef( + (props, ref) => { + const { title, className, testId, active, standalone, icon, onClick } = + props; + return ( + + ); + }, +); diff --git a/packages/excalidraw/components/ButtonIconSelect.tsx b/packages/excalidraw/components/ButtonIconSelect.tsx index 6933f0304..c3a390257 100644 --- a/packages/excalidraw/components/ButtonIconSelect.tsx +++ b/packages/excalidraw/components/ButtonIconSelect.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import { ButtonIcon } from "./ButtonIcon"; // TODO: It might be "clever" to add option.icon to the existing component export const ButtonIconSelect = ( @@ -24,21 +25,17 @@ export const ButtonIconSelect = ( } ), ) => ( -
+
{props.options.map((option) => props.type === "button" ? ( - + testId={option.testId} + active={option.active ?? props.value === option.value} + onClick={(event) => props.onClick(option.value, event)} + /> ) : (