Merge branch 'master' into arnost/scroll-in-read-only-links

# Conflicts:
#	excalidraw-app/App.tsx
#	packages/excalidraw/components/App.tsx
#	packages/excalidraw/components/MobileMenu.tsx
This commit is contained in:
dwelle
2025-11-18 20:18:05 +01:00
143 changed files with 15312 additions and 5308 deletions

View File

@@ -5,6 +5,7 @@ import {
CaptureUpdateAction,
reconcileElements,
getCommonBounds,
useEditorInterface,
} from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -151,6 +152,9 @@ import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import "./index.scss";
import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
import { AppSidebar } from "./components/AppSidebar";
import type { CollabAPI } from "./collab/Collab";
polyfill();
@@ -634,6 +638,8 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAppLangCode();
const editorInterface = useEditorInterface();
// initial state
// ---------------------------------------------------------------------------
@@ -955,8 +961,8 @@ const ExcalidrawWrapper = () => {
debugRenderer(
debugCanvasRef.current,
appState,
elements,
window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
);
}
};
@@ -1166,14 +1172,22 @@ const ExcalidrawWrapper = () => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
}
return (
<div className="top-right-ui">
<div className="excalidraw-ui-top-right">
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
<ExcalidrawPlusPromoBanner
isSignedIn={isExcalidrawPlusSignedUser}
/>
)}
{collabError.message && <CollabError collabError={collabError} />}
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" })
}
editorInterface={editorInterface}
/>
</div>
);
@@ -1266,6 +1280,8 @@ const ExcalidrawWrapper = () => {
}}
/>
<AppSidebar />
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}

View File

@@ -5,7 +5,6 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => {
@@ -19,11 +18,7 @@ export const AppFooter = React.memo(
}}
>
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
</div>
</Footer>
);

View File

@@ -0,0 +1,36 @@
.excalidraw {
.app-sidebar-promo-container {
padding: 0.75rem;
display: flex;
flex-direction: column;
text-align: center;
gap: 1rem;
flex: 1 1 auto;
}
.app-sidebar-promo-image {
margin: 1rem 0;
height: 16.25rem;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
background-image: radial-gradient(
circle,
transparent 60%,
var(--sidebar-bg-color) 100%
),
var(--image-source);
display: flex;
}
.app-sidebar-promo-text {
padding: 0 2rem;
}
.link-button {
margin: 0 auto;
}
}

View File

@@ -0,0 +1,79 @@
import { DefaultSidebar, Sidebar, THEME } from "@excalidraw/excalidraw";
import {
messageCircleIcon,
presentationIcon,
} from "@excalidraw/excalidraw/components/icons";
import { LinkButton } from "@excalidraw/excalidraw/components/LinkButton";
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import "./AppSidebar.scss";
export const AppSidebar = () => {
const { theme, openSidebar } = useUIAppState();
return (
<DefaultSidebar>
<DefaultSidebar.TabTriggers>
<Sidebar.TabTrigger
tab="comments"
style={{ opacity: openSidebar?.tab === "comments" ? 1 : 0.4 }}
>
{messageCircleIcon}
</Sidebar.TabTrigger>
<Sidebar.TabTrigger
tab="presentation"
style={{ opacity: openSidebar?.tab === "presentation" ? 1 : 0.4 }}
>
{presentationIcon}
</Sidebar.TabTrigger>
</DefaultSidebar.TabTriggers>
<Sidebar.Tab tab="comments">
<div className="app-sidebar-promo-container">
<div
className="app-sidebar-promo-image"
style={{
["--image-source" as any]: `url(/oss_promo_comments_${
theme === THEME.DARK ? "dark" : "light"
}.jpg)`,
opacity: 0.7,
}}
/>
<div className="app-sidebar-promo-text">
Make comments with Excalidraw+
</div>
<LinkButton
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=comments_promo#excalidraw-redirect`}
>
Sign up now
</LinkButton>
</div>
</Sidebar.Tab>
<Sidebar.Tab tab="presentation" className="px-3">
<div className="app-sidebar-promo-container">
<div
className="app-sidebar-promo-image"
style={{
["--image-source" as any]: `url(/oss_promo_presentations_${
theme === THEME.DARK ? "dark" : "light"
}.svg)`,
backgroundSize: "60%",
opacity: 0.4,
}}
/>
<div className="app-sidebar-promo-text">
Create presentations with Excalidraw+
</div>
<LinkButton
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=presentations_promo#excalidraw-redirect`}
>
Sign up now
</LinkButton>
</div>
</Sidebar.Tab>
</DefaultSidebar>
);
};

View File

@@ -8,9 +8,16 @@ import {
getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common";
import { arrayToMap, throttleRAF } from "@excalidraw/common";
import { useCallback } from "react";
import {
getGlobalFixedPointForBindableElement,
isArrowElement,
isBindableElement,
isFixedPointBinding,
} from "@excalidraw/element";
import {
isLineSegment,
type GlobalPoint,
@@ -21,8 +28,15 @@ import { isCurve } from "@excalidraw/math/curve";
import React from "react";
import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/utils/visualdebug";
import type { DebugElement } from "@excalidraw/common";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
FixedPointBinding,
OrderedExcalidrawElement,
PointBinding,
} from "@excalidraw/element/types";
import { STORAGE_KEYS } from "../app_constants";
@@ -75,6 +89,180 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.save();
};
const _renderBinding = (
context: CanvasRenderingContext2D,
binding: FixedPointBinding | PointBinding,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
if (isFixedPointBinding(binding)) {
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
}
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom - width,
y * zoom - height,
x * zoom - width,
y * zoom + height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
}
};
const _renderBindableBinding = (
binding: FixedPointBinding | PointBinding,
context: CanvasRenderingContext2D,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
if (isFixedPointBinding(binding)) {
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
}
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom + width,
y * zoom + height,
x * zoom + width,
y * zoom - height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
}
};
const renderBindings = (
context: CanvasRenderingContext2D,
elements: readonly OrderedExcalidrawElement[],
zoom: number,
) => {
const elementsMap = arrayToMap(elements);
const dim = 16;
elements.forEach((element) => {
if (element.isDeleted) {
return;
}
if (isArrowElement(element)) {
if (element.startBinding) {
if (
!elementsMap
.get(element.startBinding.elementId)
?.boundElements?.find((e) => e.id === element.id)
) {
return;
}
_renderBinding(
context,
element.startBinding as FixedPointBinding,
elementsMap,
zoom,
dim,
dim,
"red",
);
}
if (element.endBinding) {
if (
!elementsMap
.get(element.endBinding.elementId)
?.boundElements?.find((e) => e.id === element.id)
) {
return;
}
_renderBinding(
context,
element.endBinding,
elementsMap,
zoom,
dim,
dim,
"red",
);
}
}
if (isBindableElement(element) && element.boundElements?.length) {
element.boundElements.forEach((boundElement) => {
if (boundElement.type !== "arrow") {
return;
}
const arrow = elementsMap.get(
boundElement.id,
) as ExcalidrawArrowElement;
if (arrow && arrow.startBinding?.elementId === element.id) {
_renderBindableBinding(
arrow.startBinding,
context,
elementsMap,
zoom,
dim,
dim,
"green",
);
}
if (arrow && arrow.endBinding?.elementId === element.id) {
_renderBindableBinding(
arrow.endBinding,
context,
elementsMap,
zoom,
dim,
dim,
"green",
);
}
});
}
});
};
const render = (
frame: DebugElement[],
context: CanvasRenderingContext2D,
@@ -107,8 +295,8 @@ const render = (
const _debugRenderer = (
canvas: HTMLCanvasElement,
appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number,
refresh: () => void,
) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
@@ -131,6 +319,7 @@ const _debugRenderer = (
);
renderOrigin(context, appState.zoom.value);
renderBindings(context, elements, appState.zoom.value);
if (
window.visualDebug?.currentFrame &&
@@ -182,10 +371,10 @@ export const debugRenderer = throttleRAF(
(
canvas: HTMLCanvasElement,
appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number,
refresh: () => void,
) => {
_debugRenderer(canvas, appState, scale, refresh);
_debugRenderer(canvas, appState, elements, scale);
},
{ trailing: true },
);

View File

@@ -1,19 +0,0 @@
import { isExcalidrawPlusSignedUser } from "../app_constants";
export const ExcalidrawPlusAppLink = () => {
if (!isExcalidrawPlusSignedUser) {
return null;
}
return (
<a
href={`${
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank"
rel="noopener"
className="plus-button"
>
Go to Excalidraw+
</a>
);
};

View File

@@ -0,0 +1,22 @@
export const ExcalidrawPlusPromoBanner = ({
isSignedIn,
}: {
isSignedIn: boolean;
}) => {
return (
<a
href={
isSignedIn
? import.meta.env.VITE_APP_PLUS_APP
: `${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=guestBanner#excalidraw-redirect`
}
target="_blank"
rel="noopener"
className="plus-banner"
>
Excalidraw+
</a>
);
};

View File

@@ -1,3 +1,5 @@
@import "../packages/excalidraw/css/variables.module.scss";
.excalidraw {
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
@@ -5,12 +7,6 @@
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
}
.top-right-ui {
display: flex;
justify-content: center;
align-items: flex-start;
}
.footer-center {
justify-content: flex-end;
margin-top: auto;
@@ -90,22 +86,31 @@
}
}
.plus-button {
.plus-banner {
display: flex;
justify-content: center;
cursor: pointer;
align-items: center;
border: 1px solid var(--color-primary);
padding: 0.5rem 0.75rem;
padding: 0.5rem 0.875rem;
border-radius: var(--border-radius-lg);
background-color: var(--island-bg-color);
color: var(--color-primary) !important;
text-decoration: none !important;
font-size: 0.75rem;
font-family: var(--ui-font);
font-size: 0.8333rem;
box-sizing: border-box;
height: var(--lg-button-size);
border: none;
box-shadow: 0 0 0 1px var(--color-surface-lowest);
background-color: var(--color-surface-low);
color: var(--button-color, var(--color-on-surface)) !important;
&:active {
box-shadow: 0 0 0 1px var(--color-brand-active);
}
&:hover {
background-color: var(--color-primary);
color: white !important;
@@ -117,7 +122,7 @@
}
.theme--dark {
.plus-button {
.plus-banner {
&:hover {
color: black !important;
}

View File

@@ -23,7 +23,7 @@
]
},
"engines": {
"node": "18.0.0 - 22.x.x"
"node": ">=18.0.0"
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",

View File

@@ -17,30 +17,15 @@ describe("Test MobileMenu", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
// @ts-ignore
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
h.app.refreshEditorInterface();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should set device correctly", () => {
expect(h.app.device).toMatchInlineSnapshot(`
{
"editor": {
"canFitSidebar": false,
"isMobile": true,
},
"isTouchScreen": false,
"viewport": {
"isLandscape": true,
"isMobile": true,
},
}
`);
it("should set editor interface correctly", () => {
expect(h.app.editorInterface.formFactor).toBe("phone");
});
it("should initialize with welcome screen and hide once user interacts", async () => {