feat(packages/excalidraw): [ui] rewrite ui regions

This commit is contained in:
dwelle
2026-07-30 09:38:58 +02:00
parent b2e81e38a6
commit ca65177e6c
14 changed files with 598 additions and 416 deletions
+2 -2
View File
@@ -958,7 +958,7 @@ const ExcalidrawWrapper = () => {
}
return (
<div className="excalidraw-ui-top-right">
<>
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
<ExcalidrawPlusPromoBanner
isSignedIn={isExcalidrawPlusSignedUser}
@@ -973,7 +973,7 @@ const ExcalidrawWrapper = () => {
}
editorInterface={editorInterface}
/>
</div>
</>
);
}}
onLinkOpen={(element, event) => {
+34
View File
@@ -15,6 +15,40 @@ Please add the latest change on the top under the correct section.
## Excalidraw API
### UI layout regions (2026-07-27)
The editor UI is now laid out as a grid of nine logical regions, replacing the top bar (`FixedSideContainer` > `.App-menu_top`) plus absolutely-positioned footer:
```
.exc-regions
|_ .exc-region-top-left .exc-region-top-center .exc-region-top-right
|_ .exc-region-center-left .exc-region-center-center .exc-region-center-right
|_ .exc-region-bottom
|_ .exc-region-bottom-left .exc-region-bottom-center .exc-region-bottom-right
```
Regions at the same height negotiate width with each other — the toolbar's space is exactly what's left between the top-left and the top-right UI, and at widths where nothing fits the top row overflows to the right rather than overlapping. Regions in the same column stack independently of the other columns — the styles panel hangs right below the top-left UI regardless of how far the toolbar or a tall top-right UI reach down, while the stats panel clears the top-left/top-right UI (but not the top-center toolbar). The full-width bottom wrapper uses an independent three-column grid, so its left/right controls cannot size the outer columns or displace the toolbar, while its center track prevents the footer regions from overlapping. Regions stretch to their cell(s) and stay `pointer-events: none`, with only their content interactive.
#### Breaking changes
If you style or query the editor DOM:
- Removed class names: `.App-menu`, `.App-menu_top`, `.App-menu_top__left`, `.App-menu_bottom`, `.App-menu_left`, `.App-menu_right`, `.shapes-section`, `.layer-ui__wrapper__top-right` (+ `--compact`), `.layer-ui__wrapper__footer`, `.layer-ui__wrapper__footer-left` (+ `--transition-left`), `.layer-ui__wrapper__footer-right`. Target the region classes above instead — e.g. `.App-menu_top__left``.exc-region-top-left`, `.layer-ui__wrapper__top-right``.exc-region-top-right`, `.layer-ui__wrapper__footer-left``.exc-region-bottom-left`.
- `.FixedSideContainer` (and `.FixedSideContainer_side_top`) is now only rendered by the mobile UI. The desktop UI area is `.exc-regions`.
- The zen-mode modifier `.layer-ui__wrapper__footer-left--transition-bottom` is now `.transition-bottom`, alongside the existing `.transition-left` / `.transition-right`.
- The styles panel (`.App-menu__left`) and the stats panel (`.exc-stats`) are no longer absolutely positioned — they are in flow inside `.exc-region-center-left` / `.exc-region-center-right`.
- `<Footer>` content renders in the flexible `.exc-region-bottom-center` track between the zoom/undo controls and help button. `.footer-center` remains full-width flex content, so hosts can keep content beside the left controls, beside the right controls, or at both edges using flex alignment and auto margins.
- The host-content containers `.excalidraw-ui-top-left` / `.excalidraw-ui-top-right` were split into two concepts: `.exc-region-top-left-content` / `.exc-region-top-right-content` are the mixed rows of editor + host content (shared with the mobile top bar), and the new `.exc-region-top-left-host-slot` / `.exc-region-top-right-host-slot` wrap _only_ the host-rendered output (`renderTopLeftUI` / `renderTopRightUI`). Slots are flex rows (`gap: 0.5rem`) and hide themselves when empty, so host content no longer needs its own flex wrapper — returning a fragment is enough.
#### Behavior changes
- The styles panel is bounded by its region — from below the top-left UI down to the footer band — and scrolls past it, instead of the hardcoded `height - 166px` max-height. Its default position is unchanged; it no longer shifts down when a host renders a tall top-right UI, and it is correctly bounded when the top-left UI is taller than the default menu button.
- The styles panel's width no longer depends on the top-left UI's width — a wide `renderTopLeftUI` (e.g. a scene-name field) previously stretched the shared left column, inflating the panel's container and swallowing canvas clicks next to it.
- The stats panel starts below the top-right UI (default position unchanged in full mode; in compact mode it aligns with the styles panel, 8px higher than before), so tall host UI in the top-right region pushes it down instead of overlapping it — while the toolbar's height doesn't affect it. (A top-left UI taller than the top-right also pushes it down, by design.)
- In compact mode, the pen-mode button centers over the styles panel rather than over the top-left UI.
- The toast and the scroll-back-to-content button sit in a separate full-width overlay on the bottom-row baseline (14px lower than the previous `bottom: 30px`), so they remain centered on the canvas rather than on the footer's asymmetric middle track.
- At narrow widths the toolbar keeps a 1rem clearance from the top-left UI and pushes the top-right UI off-screen (as before), rather than sliding under its neighbors.
### Host-controlled active tool (2026-07-14) [#11665](https://github.com/excalidraw/excalidraw/pull/11665)
- Added `activeTool` prop (`{ type: ToolType } | { type: "custom"; customType: string }`) for forcing the active editor tool (controlled). While set, user- and API-driven tool switching is ignored — `setActiveTool` refuses non-matching activations with a console warning, non-forced toolbar buttons render disabled, and the tool-lock toggle (`Q`) is inert — and the editor snaps back if internal flows reset the tool (e.g. `restore()` on scene load). The forced tool behaves as if locked — it doesn't revert to selection after use and drawn elements aren't auto-selected — without mutating `appState.activeTool.locked`, so the user's persisted padlock preference stays untouched. Unset the prop to return tool control to the editor (the current tool stays active). The forced tool must be activatable to take effect — not disabled via `UIOptions.tools`, and (while non-interactive) allowed via `interaction.enabled.tools`; otherwise the editor stays on the `selection` tool and applies the forced tool once it becomes activatable. `image` cannot be forced (its activation opens the file picker). Composes with `interaction.enabled.tools` for presentation-style hosts: force `laser` for the presenter, `selection` + `interaction={false}` for viewers.
@@ -181,6 +181,8 @@
.compact-shape-actions-island {
width: fit-content;
overflow-x: hidden;
// may shrink (and scroll) to fit its region
min-height: 0;
}
.mobile-shape-actions {
+5 -37
View File
@@ -16,31 +16,6 @@
pointer-events: none;
z-index: var(--zIndex-layerUI);
&__top-right {
display: flex;
width: 100%;
justify-content: flex-end;
gap: 0.75rem;
pointer-events: none !important;
&--compact {
gap: 0.5rem;
}
& > * {
pointer-events: var(--ui-pointerEvents);
}
}
&__footer {
width: 100%;
&-right {
z-index: 100;
display: flex;
}
}
.zen-mode-transition {
transition: transform 0.5s ease-in-out;
@@ -60,7 +35,7 @@
transform: translate(-999px, 0);
}
&.layer-ui__wrapper__footer-left--transition-bottom {
&.transition-bottom {
transform: translate(0, 92px);
}
}
@@ -70,10 +45,10 @@
position: absolute;
bottom: 0;
[dir="ltr"] & {
right: 1rem;
right: 0;
}
[dir="rtl"] & {
left: 1rem;
left: 0;
}
opacity: 0;
visibility: hidden;
@@ -106,23 +81,16 @@
}
}
.layer-ui__wrapper__footer-left,
.footer-center,
.layer-ui__wrapper__footer-right {
.footer-center {
& > * {
pointer-events: var(--ui-pointerEvents);
}
}
.layer-ui__wrapper__footer-right {
margin-top: auto;
margin-bottom: auto;
}
}
// on tablet, the pen mode button is rendered as a separate floating button
// below the compact actions menu (see LayerUI.tsx)
.App-menu_top__left > .ToolIcon__penMode {
.exc-region-center-left > .ToolIcon__penMode {
justify-self: center;
.ToolIcon__icon {
+234 -171
View File
@@ -18,7 +18,7 @@ import { ShapeCache } from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { actionToggleStats } from "../actions";
import { actionShortcuts, actionToggleStats } from "../actions";
import { trackEvent } from "../analytics";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
import { UIAppStateContext } from "../context/ui-appState";
@@ -27,7 +27,13 @@ import { useAtom, useAtomValue } from "../editor-jotai";
import { t } from "../i18n";
import { getScrollToContentState } from "../scene";
import { SelectedShapeActions, CompactShapeActions } from "./Actions";
import {
SelectedShapeActions,
CompactShapeActions,
ExitZenModeButton,
UndoRedoActions,
ZoomActions,
} from "./Actions";
import { LoadingMessage } from "./LoadingMessage";
import { MobileMenu } from "./MobileMenu";
import { PasteChartDialog } from "./PasteChartDialog";
@@ -35,7 +41,6 @@ import { Section } from "./Section";
import Stack from "./Stack";
import { UserList } from "./UserList";
import { PenModeButton } from "./PenModeButton";
import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
@@ -48,7 +53,7 @@ import { Stats } from "./Stats";
import ElementLinkDialog from "./ElementLinkDialog";
import { ErrorDialog } from "./ErrorDialog";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import { FixedSideContainer } from "./FixedSideContainer";
import { HelpButton } from "./HelpButton";
import { HelpDialog } from "./HelpDialog";
import { ImageExportDialog } from "./ImageExportDialog";
import { Island } from "./Island";
@@ -226,8 +231,10 @@ const LayerUI = ({
const renderCanvasActions = () => (
<div style={{ position: "relative" }}>
<div className="excalidraw-ui-top-left">
{renderTopLeftUI?.(false, appState)}
<div className="exc-region-top-left-content">
<div className="exc-region-top-left-host-slot">
{renderTopLeftUI?.(false, appState)}
</div>
<tunnels.MainMenuTunnel.Out />
</div>
{renderWelcomeScreen && <tunnels.WelcomeScreenMenuHintTunnel.Out />}
@@ -248,11 +255,6 @@ const LayerUI = ({
padding={0}
data-viewport-ui="side"
data-viewport-ui-name="stylesPanel"
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
>
<CompactShapeActions
appState={appState}
@@ -266,11 +268,6 @@ const LayerUI = ({
<Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
data-viewport-ui="side"
data-viewport-ui-name="stylesPanel"
>
@@ -286,7 +283,15 @@ const LayerUI = ({
);
};
const renderFixedSideContainer = () => {
/**
* The whole editor UI is laid out as a single grid of nine regions (see
* `.exc-regions` in styles.scss for the how and why):
*
* top-left top-center top-right
* center-left center-center center-right
* bottom-left bottom-center bottom-right
*/
const renderUIRegions = () => {
const shouldRenderSelectedShapeActions =
defaultUIEnabled && showSelectedShapeActions(appState, elements);
@@ -297,132 +302,226 @@ const LayerUI = ({
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector";
const shouldRenderToolbar =
defaultUIEnabled &&
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector";
const shouldRenderCanvasActions =
defaultUIEnabled || (zoomUIEnabled && app.isNavigationEnabled());
return (
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
<Stack.Col
gap={spacing.menuTopGap}
className={clsx("App-menu_top__left")}
>
{renderCanvasActions()}
{defaultUIEnabled && (
<div
className={clsx("selected-shape-actions-container", {
"selected-shape-actions-container--compact":
isCompactStylesPanel,
})}
>
{shouldRenderSelectedShapeActions &&
renderSelectedShapeActions()}
</div>
)}
{/* in compact UI the pen mode button lives outside the toolbar, as
a separate floating button below the compact actions menu
(same as we render it on mobile); shown alongside the compact
actions island, i.e. when a drawing tool or elements are
selected */}
{defaultUIEnabled &&
isCompactStylesPanel &&
!appState.viewModeEnabled &&
shouldRenderSelectedShapeActions && (
<PenModeButton
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
<div
className="exc-regions"
style={
{
// gap between vertically stacked regions, e.g. between the
// top-left menu and the styles panel below it
"--exc-regions-row-gap": `calc(var(--space-factor) * ${spacing.menuTopGap})`,
} as React.CSSProperties
}
>
<div className="exc-region-top-left">{renderCanvasActions()}</div>
<div className="exc-region-top-center">
{shouldRenderToolbar && (
<Section heading="shapes">
{(heading: React.ReactNode) => (
<div style={{ position: "relative" }}>
{renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={spacing.toolbarColGap} align="start">
<Stack.Row
gap={spacing.toolbarRowGap}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
>
<Toolbar
app={app}
appState={appState}
setAppState={setAppState}
UIOptions={UIOptions}
onPenModeToggle={onPenModeToggle}
onLockToggle={onLockToggle}
heading={heading}
/>
{isCollaborating && (
<Island
style={{
marginLeft: spacing.collabMarginLeft,
alignSelf: "center",
height: "fit-content",
}}
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() =>
app.setActiveTool({ type: TOOL_TYPE.laser })
}
isMobile
/>
</Island>
)}
</Stack.Row>
</Stack.Col>
</div>
)}
</Stack.Col>
{defaultUIEnabled &&
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
<Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => (
<div style={{ position: "relative" }}>
{renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={spacing.toolbarColGap} align="start">
<Stack.Row
gap={spacing.toolbarRowGap}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
>
<Toolbar
app={app}
appState={appState}
setAppState={setAppState}
UIOptions={UIOptions}
onPenModeToggle={onPenModeToggle}
onLockToggle={onLockToggle}
heading={heading}
/>
{isCollaborating && (
<Island
style={{
marginLeft: spacing.collabMarginLeft,
alignSelf: "center",
height: "fit-content",
}}
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() =>
app.setActiveTool({ type: TOOL_TYPE.laser })
}
isMobile
/>
</Island>
)}
</Stack.Row>
</Stack.Col>
</div>
)}
</Section>
)}
<div
className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition",
{
"transition-right": appState.zenModeEnabled,
"layer-ui__wrapper__top-right--compact": isCompactStylesPanel,
},
)}
>
{defaultUIEnabled && appState.collaborators.size > 0 && (
<UserList
collaborators={appState.collaborators}
userToFollow={appState.userToFollow?.socketId || null}
/>
)}
</Section>
)}
</div>
<div
className={clsx("exc-region-top-right zen-mode-transition", {
"transition-right": appState.zenModeEnabled,
"exc-region-top-right--compact": isCompactStylesPanel,
})}
>
{defaultUIEnabled && appState.collaborators.size > 0 && (
<UserList
collaborators={appState.collaborators}
userToFollow={appState.userToFollow?.socketId || null}
/>
)}
<div className="exc-region-top-right-host-slot">
{renderTopRightUI?.(
editorInterface.formFactor === "phone",
appState,
)}
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
// hide button when sidebar docked
(!isSidebarDocked ||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
<tunnels.DefaultSidebarTriggerTunnel.Out />
)}
{shouldShowStats && (
<Stats
app={app}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
</div>
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
// hide button when sidebar docked
(!isSidebarDocked ||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
<tunnels.DefaultSidebarTriggerTunnel.Out />
)}
</div>
<div className="exc-region-center-left">
{defaultUIEnabled && (
<div
className={clsx("selected-shape-actions-container", {
"selected-shape-actions-container--compact":
isCompactStylesPanel,
})}
>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div>
)}
{/* in compact UI the pen mode button lives outside the toolbar, as
a separate floating button below the compact actions menu
(same as we render it on mobile); shown alongside the compact
actions island, i.e. when a drawing tool or elements are
selected */}
{defaultUIEnabled &&
isCompactStylesPanel &&
!appState.viewModeEnabled &&
shouldRenderSelectedShapeActions && (
<PenModeButton
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
)}
</div>
</div>
</FixedSideContainer>
{/* reserved for canvas-centered UI (host content, empty states) */}
<div className="exc-region-center-center" />
<div className="exc-region-center-right">
{shouldShowStats && (
<Stats
app={app}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
</div>
<footer className="exc-region-bottom">
<div className="exc-region-bottom-left">
{shouldRenderCanvasActions && (
<Section heading="canvasActions">
{zoomUIEnabled && app.isNavigationEnabled() && (
<ZoomActions renderAction={actionManager.renderAction} />
)}
{defaultUIEnabled && !appState.viewModeEnabled && (
<UndoRedoActions
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"transition-bottom": appState.zenModeEnabled,
})}
/>
)}
</Section>
)}
</div>
<div className="exc-region-bottom-center">
<tunnels.FooterCenterTunnel.Out />
</div>
<div
className={clsx("exc-region-bottom-right zen-mode-transition", {
"transition-right": appState.zenModeEnabled,
})}
>
{(defaultUIEnabled || renderWelcomeScreen) && (
<div style={{ position: "relative" }}>
{renderWelcomeScreen && (
<tunnels.WelcomeScreenHelpHintTunnel.Out />
)}
{defaultUIEnabled && (
<HelpButton
onClick={() => actionManager.executeAction(actionShortcuts)}
/>
)}
</div>
)}
</div>
</footer>
{(appState.toast ||
(scrollBackToContentUIEnabled && appState.scrolledOutside)) && (
<div className="floating-status-stack">
{appState.toast && (
<Toast
message={appState.toast.message}
onClose={() => setAppState({ toast: null })}
duration={appState.toast.duration}
closable={appState.toast.closable}
/>
)}
{!appState.toast &&
scrollBackToContentUIEnabled &&
appState.scrolledOutside && (
<button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...getScrollToContentState(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
)}
{/* pinned to the UI area rather than docked into a region, so that it
doesn't slide out with the bottom-right region in zen mode */}
{defaultUIEnabled && (
<ExitZenModeButton
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
/>
)}
</div>
);
};
@@ -610,43 +709,7 @@ const LayerUI = ({
}
>
{renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
{renderFixedSideContainer()}
<Footer
appState={appState}
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen}
defaultUIEnabled={defaultUIEnabled}
zoomUIEnabled={zoomUIEnabled}
/>
{(appState.toast ||
(scrollBackToContentUIEnabled && appState.scrolledOutside)) && (
<div className="floating-status-stack">
{appState.toast && (
<Toast
message={appState.toast.message}
onClose={() => setAppState({ toast: null })}
duration={appState.toast.duration}
closable={appState.toast.closable}
/>
)}
{!appState.toast &&
scrollBackToContentUIEnabled &&
appState.scrolledOutside && (
<button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...getScrollToContentState(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
)}
{renderUIRegions()}
</div>
{renderSidebars()}
</>
+12 -6
View File
@@ -67,10 +67,13 @@ export const MobileMenu = ({
return null;
}
const topRightHostUI = renderTopRightUI?.(true, appState);
const topRightUI = (
<div className="excalidraw-ui-top-right">
{renderTopRightUI?.(true, appState) ??
(!appState.viewModeEnabled && (
<div className="exc-region-top-right-content">
{topRightHostUI != null ? (
<div className="exc-region-top-right-host-slot">{topRightHostUI}</div>
) : (
!appState.viewModeEnabled && (
<>
{defaultUIEnabled && (
<PenModeButton
@@ -83,7 +86,8 @@ export const MobileMenu = ({
)}
<DefaultSidebarTriggerTunnel.Out />
</>
))}
)
)}
{defaultUIEnabled &&
appState.viewModeEnabled &&
app.isInteractionEnabled() && (
@@ -93,8 +97,10 @@ export const MobileMenu = ({
);
const topLeftUI = (
<div className="excalidraw-ui-top-left">
{renderTopLeftUI?.(true, appState)}
<div className="exc-region-top-left-content">
<div className="exc-region-top-left-host-slot">
{renderTopLeftUI?.(true, appState)}
</div>
<MainMenuTunnel.Out />
</div>
);
@@ -1,16 +1,9 @@
.exc-stats {
width: 204px;
position: absolute;
top: 60px;
font-size: 12px;
z-index: var(--zIndex-layerUI);
pointer-events: var(--ui-pointerEvents);
:root[dir="rtl"] & {
left: 12px;
right: initial;
}
h2 {
font-size: 1.5em;
margin-block-start: 0.83em;
@@ -4,6 +4,8 @@
$closeButtonPadding: 0.4rem;
animation: Toast-fade-in 0.5s;
// containing block for the close button
position: relative;
min-width: 220px;
max-width: min(360px, calc(100vw - 32px));
border-radius: var(--border-radius-lg);
@@ -1,97 +0,0 @@
import clsx from "clsx";
import { actionShortcuts } from "../../actions";
import { useTunnels } from "../../context/tunnels";
import { ExitZenModeButton, UndoRedoActions, ZoomActions } from "../Actions";
import { useApp } from "../App";
import { HelpButton } from "../HelpButton";
import { Section } from "../Section";
import Stack from "../Stack";
import type { ActionManager } from "../../actions/manager";
import type { UIAppState } from "../../types";
const Footer = ({
appState,
actionManager,
showExitZenModeBtn,
renderWelcomeScreen,
defaultUIEnabled,
zoomUIEnabled,
}: {
appState: UIAppState;
actionManager: ActionManager;
showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean;
defaultUIEnabled: boolean;
zoomUIEnabled: boolean;
}) => {
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
const app = useApp();
return (
<footer
role="contentinfo"
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
>
{(defaultUIEnabled || (zoomUIEnabled && app.isNavigationEnabled())) && (
<div
className={clsx(
"layer-ui__wrapper__footer-left zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
},
)}
>
<Stack.Col gap={2}>
<Section heading="canvasActions">
{zoomUIEnabled && app.isNavigationEnabled() && (
<ZoomActions renderAction={actionManager.renderAction} />
)}
{defaultUIEnabled && !appState.viewModeEnabled && (
<UndoRedoActions
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
/>
)}
</Section>
</Stack.Col>
</div>
)}
<FooterCenterTunnel.Out />
{(defaultUIEnabled || renderWelcomeScreen) && (
<div
className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition",
{
"transition-right": appState.zenModeEnabled,
},
)}
>
<div style={{ position: "relative" }}>
{renderWelcomeScreen && <WelcomeScreenHelpHintTunnel.Out />}
{defaultUIEnabled && (
<HelpButton
onClick={() => actionManager.executeAction(actionShortcuts)}
/>
)}
</div>
</div>
)}
{defaultUIEnabled && (
<ExitZenModeButton
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
/>
)}
</footer>
);
};
export default Footer;
Footer.displayName = "Footer";
@@ -1,8 +1,12 @@
// Host-supplied footer content owns the flexible space between the editor's
// left and right footer controls. Hosts can position their content at either
// edge (or both) with flex alignment and auto margins.
.footer-center {
pointer-events: none;
display: flex;
width: 100%;
align-items: center;
justify-content: flex-start;
margin-inline-end: 0.6rem;
@@ -12,8 +12,7 @@ const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
<FooterCenterTunnel.In>
<div
className={clsx("footer-center zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
"transition-bottom": appState.zenModeEnabled,
})}
>
{children}
+294 -93
View File
@@ -322,13 +322,31 @@ body.excalidraw-cursor-resize * {
}
}
.excalidraw-ui-top-left,
.excalidraw-ui-top-right {
// content clusters — the editor-owned rows of a region's content (menu
// trigger, user list, …), shared between the desktop regions and the
// mobile top bar
.exc-region-top-left-content,
.exc-region-top-right-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
// host-app slots — wrap ONLY the host-rendered content
// (`renderTopLeftUI` / `renderTopRightUI` output), nested inside the
// clusters/regions alongside the editor-owned content
.exc-region-top-left-host-slot,
.exc-region-top-right-host-slot {
display: flex;
align-items: center;
gap: 0.5rem;
// don't leave a stray flex/grid gap behind when the host renders nothing
&:empty {
display: none;
}
}
.App-toolbar-content {
display: flex;
flex-direction: column;
@@ -352,98 +370,299 @@ body.excalidraw-cursor-resize * {
}
}
.App-menu {
// ---------------------------------------------------------------------------
// UI regions
//
// The editor UI has 3×3 logical regions:
//
// .exc-region-top-left .exc-region-top-center .exc-region-top-right
// .exc-region-center-left .exc-region-center-center .exc-region-center-right
// .exc-region-bottom-left .exc-region-bottom-center .exc-region-bottom-right
//
// The top and center regions are built from one outer grid of 3 COLUMNS ×
// 5 ROWS. The extra rows let the center regions start right below whatever
// they need to clear, instead of all starting below the tallest thing in
// the top row (see "why 5 rows", below). The bottom row is one spanning
// `.exc-region-bottom` whose three children use an independent inner grid.
//
// c1 c2 c3
// minmax(auto,1fr) auto minmax(auto,1fr)
// ┌─────────┬───────────────────────┬─────────┐
// r1 │TOP-LEFT │ │ │
// ├─────────┤ TOP-CENTER │TOP-RIGHT│
// r2 │ │ (toolbar) ├─────────┤
// │ CENTER │ │ │
// r3 │ -LEFT ├───────────────────────┤ CENTER │
// │ │ │ -RIGHT │
// r4 │ │ CENTER-CENTER │ │
// ├─────────┴───────────────────────┴─────────┤
// r5 │ BOTTOM { LEFT | CENTER | RIGHT } │
// └───────────────────────────────────────────┘
//
// Column tracks:
// c1 TOP-LEFT's own minimum, then an EQUAL share of the leftover space
// c2 sized only by the toolbar (TOP-CENTER, the only region in c2 alone)
// c3 TOP-RIGHT's own minimum, then an EQUAL share of the leftover space
//
// c1 and c3 share the SAME flex factor (both `1fr`), so whatever space is
// left after c2 gets split evenly between them — which is what keeps the
// toolbar exactly viewport-centered even when TOP-LEFT and TOP-RIGHT are
// different widths (e.g. a bare menu button vs. a user-list + share
// button cluster). Only once one side's own content outgrows its EQUAL
// share does that side pin to its own size and the split stop being even.
//
// Row tracks (`auto auto auto minmax(0, 1fr) auto`):
// r1 sized only by TOP-LEFT
// r2 TOP-RIGHT's height, MINUS whatever r1 already covers
// r3 the toolbar's height, MINUS whatever r1+r2 already cover
// r4 everything left over
// r5 the bottom band
//
// WHY 5 ROWS (INSTEAD OF 3 NESTED COLUMNS)
// ------------------------------------------
// We could put each top/center pair in its own nested column. That would
// give each center region a fully independent vertical stack, but it would
// also make CENTER-RIGHT follow TOP-RIGHT only. We deliberately need an
// asymmetric cross-column dependency:
//
// line 2 (CENTER-LEFT start) = height(TOP-LEFT)
// line 3 (CENTER-RIGHT start) = max(TOP-LEFT, TOP-RIGHT)
// line 4 (CENTER-CENTER start) = max(TOP-LEFT, TOP-RIGHT, TOP-CENTER)
//
// Thus stats clears both top-left and top-right, while styles clears only
// top-left. Neither side waits for a taller toolbar. Independent nested
// columns cannot express that shared `max(TOP-LEFT, TOP-RIGHT)` boundary;
// the ordered outer rows can.
//
// If we eveer want to drop this constraint, we can reduce the columns to 3
// with independent nested grids.
// ------------------------------------------
//
// A grid track's size is set only by the items placed ENTIRELY inside it —
// an item that spans across other tracks has its size "absorbed" by
// whichever spanned track is still growable, instead of inflating the
// tracks it merely passes through. Rows 2 and 3 exist to use that rule on
// purpose, in two steps:
//
// 1. TOP-LEFT alone sizes r1 (nothing else is placed in r1 only).
// 2. TOP-RIGHT spans r1r2. Since r1 is already fixed by TOP-LEFT,
// the part of TOP-RIGHT that's TALLER than r1 spills into r2,
// not affecting r1 height.
// 3. The toolbar (TOP-CENTER) spans r1r3. Since r1+r2 are already
// fixed, the part of the toolbar taller than r1+r2 spills into
// r3, not affecting r1+r2 height.
//
// Each center region then starts on the row line that matches exactly what
// it needs to clear, and nothing else:
// - CENTER-LEFT starts at line 2 (below TOP-LEFT only) — so it hangs
// right below the menu, unmoved by the toolbar's height or a tall
// host UI in TOP-RIGHT (e.g. a call tray).
// - CENTER-RIGHT starts at line 3 (below the taller of TOP-LEFT and
// TOP-RIGHT) — a tall call tray pushes it down; the toolbar does not.
// - CENTER-CENTER starts at line 4 (below the whole top band).
//
// The result is intentional: a taller TOP-RIGHT moves stats but not styles;
// a taller TOP-LEFT moves both; and a taller TOP-CENTER moves neither.
//
// OTHER RULES
// ------------
// - Gaps between tracks are 0. r2/r3 are 0px in the common case (nothing
// needs the extra room), and any gap would double up whenever that
// happens. Spacing between regions is done with margins on the
// regions themselves instead.
// - Outer region boxes overlap on purpose — that's how a region's excess
// size gets absorbed into a shared track instead of pushing a boundary.
// Content is pushed into a corner with `place-content`, and every
// region is `pointer-events: none`, with only its content opting back
// in via `pointer-events: var(--ui-pointerEvents)`. Never set
// `pointer-events` on a region directly — its box overlaps its
// neighbors, so it would swallow clicks meant for the canvas or for
// another region.
// - When touching the outer grid: TOP-LEFT / TOP-CENTER / TOP-RIGHT must
// stay the only regions occupying a single column (c1 / c2 / c3
// respectively) — every other outer item must keep spanning across c2
// (and usually r4 too). In particular, the whole bottom wrapper spans
// c1c3; placing its children directly into those outer columns would
// let their widths offset the toolbar.
// ---------------------------------------------------------------------------
.exc-regions {
position: absolute;
inset: var(--editor-container-padding);
padding-top: var(--sat, 0);
padding-right: var(--sar, 0);
padding-bottom: var(--sab, 0);
padding-left: var(--sal, 0);
display: grid;
// c1/c3: EQUAL flex factor, so the leftover space (after c2) always
// splits evenly between them — that's what keeps the toolbar centered
// regardless of how wide TOP-LEFT/TOP-RIGHT's own content is · c2: sized
// only by the toolbar
grid-template-columns: minmax(auto, 1fr) auto minmax(auto, 1fr);
// r1: top-left anchor · r2: top-right overhang · r3: toolbar overhang ·
// r4: leftover · r5: bottom band
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
color: var(--icon-fill-color);
}
.shapes-section {
display: flex;
justify-content: center;
pointer-events: none !important;
& > * {
pointer-events: var(--ui-pointerEvents);
}
}
.App-menu_top {
grid-template-columns: 1fr 2fr 1fr;
grid-gap: 1rem;
align-items: flex-start;
cursor: default;
pointer-events: none !important;
pointer-events: none;
}
.exc-region-top-left,
.exc-region-top-center,
.exc-region-top-right,
.exc-region-center-left,
.exc-region-center-center,
.exc-region-center-right {
display: grid;
min-height: 0;
pointer-events: none;
& > * {
pointer-events: var(--ui-pointerEvents);
}
}
.exc-region-bottom,
.exc-region-bottom-left,
.exc-region-bottom-center,
.exc-region-bottom-right {
display: grid;
min-height: 0;
pointer-events: none;
}
.exc-region-bottom-left,
.exc-region-bottom-right {
& > * {
pointer-events: var(--ui-pointerEvents);
}
}
// `place-content` (not `place-items`) anchors the content inside the region,
// so that the region itself can stretch to its cell(s)
.exc-region-top-left {
grid-area: 1 / 1 / 2 / 2;
grid-auto-flow: column;
place-content: start start;
}
// single-track on the column axis on purpose: a non-spanning item's
// min-content floors its track, so at narrow widths the toolbar pushes the
// top-right UI (row overflow) instead of sliding under its neighbors
.exc-region-top-center {
grid-area: 1 / 2 / 4 / 3;
grid-auto-flow: column;
place-content: start center;
// clearance from the top-left/top-right anchors (in place of column gaps)
margin-inline: 1rem;
@media (min-width: 1536px) {
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 3rem;
margin-inline: 3rem;
}
}
.App-menu_top > .App-menu_top__left {
grid-column: 1;
justify-self: start;
}
.App-menu_top > .shapes-section {
grid-column: 2;
}
.selected-shape-actions-container {
width: fit-content;
.exc-region-top-right {
grid-area: 1 / 3 / 3 / 4;
grid-auto-flow: column;
place-content: start end;
column-gap: 0.75rem;
&--compact {
min-width: 48px;
column-gap: 0.5rem;
}
}
.App-menu_top > .layer-ui__wrapper__top-right {
grid-column: 3;
justify-self: end;
.exc-region-center-left {
grid-area: 2 / 1 / 5 / 3;
place-content: start start;
row-gap: var(--exc-regions-row-gap, 1rem);
margin-top: var(--exc-regions-row-gap, 1rem);
margin-bottom: 1rem;
}
.App-menu_bottom {
position: absolute;
bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
cursor: default;
pointer-events: none !important;
box-sizing: border-box;
padding: 0 1rem;
.exc-region-center-center {
grid-area: 4 / 2 / 5 / 3;
place-content: center center;
}
&--transition-left {
section {
width: 185px;
}
}
.exc-region-center-right {
grid-area: 3 / 2 / 5 / 4;
place-content: start end;
row-gap: var(--exc-regions-row-gap, 1rem);
margin-top: var(--exc-regions-row-gap, 1rem);
margin-bottom: 1rem;
}
section {
// The bottom band is one outer-grid item so none of its occupants can size
// c1/c3 and offset the toolbar. Its independent inner grid recreates the
// footer contract: natural-width editor controls on either side and a host
// slot that owns all remaining space between them.
.exc-region-bottom {
grid-area: 5 / 1 / 6 / 4;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
min-width: 0;
}
.exc-region-bottom-left {
grid-column: 1;
grid-auto-flow: column;
place-content: end start;
// the canvas actions (zoom, undo/redo)
& > section {
display: flex;
}
}
.App-menu_bottom > *:first-child {
justify-self: flex-start;
.exc-region-bottom-center {
grid-column: 2;
align-items: end;
min-width: 0;
}
.App-menu_bottom > *:last-child {
justify-self: flex-end;
.exc-region-bottom-right {
grid-column: 3;
grid-auto-flow: column;
place-content: end end;
}
.App-menu_left {
grid-template-rows: 1fr auto 1fr;
height: 100%;
// Status UI stays tied to the canvas center instead of the center track of
// the inner footer grid, which shifts when the side controls have different
// widths. It deliberately overlays the bottom band just like the old
// absolutely positioned status stack.
.exc-regions > .floating-status-stack {
// note: must not span only the last row (region-bottom) else content
// of this container would expand height of the last row, which is currently
// vertically centered.
grid-area: 4 / 1 / 6 / 4;
place-self: end center;
margin-bottom: 1rem;
// the toast is click-through (only its close button is interactive)
& > .Toast {
pointer-events: none;
}
}
.App-menu_right {
grid-template-rows: 1fr;
height: 100%;
// the styles panel is bounded by its region, and scrolls past it (the
// `min-height: 0`s let the panel shrink below its content height)
.selected-shape-actions-container {
display: flex;
flex-direction: column;
width: fit-content;
max-height: 100%;
min-height: 0;
&--compact {
min-width: 48px;
}
& > section {
min-height: 0;
}
}
.App-menu__left {
@@ -451,7 +670,7 @@ body.excalidraw-cursor-resize * {
padding: 0.75rem;
width: 12.5rem;
box-sizing: border-box;
position: absolute;
min-height: 0;
}
.dropdown-select {
@@ -508,10 +727,6 @@ body.excalidraw-cursor-resize * {
border: 1px solid var(--default-border-color);
padding: 10px 20px;
position: absolute;
left: 50%;
bottom: 30px;
transform: translateX(-50%);
pointer-events: var(--ui-pointerEvents);
font-family: inherit;
@@ -524,24 +739,14 @@ body.excalidraw-cursor-resize * {
}
}
// The desktop stack is placed by `.exc-regions` above; mobile positioning
// is applied in the mobile block below.
.floating-status-stack {
position: absolute;
left: 50%;
bottom: 30px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
pointer-events: none;
.scroll-back-to-content {
position: static;
left: auto;
bottom: auto;
transform: none;
pointer-events: var(--ui-pointerEvents);
}
}
.help-icon {
@@ -585,19 +790,22 @@ body.excalidraw-cursor-resize * {
}
.floating-status-stack {
position: absolute;
left: 50%;
bottom: calc(30px + var(--sab, 0));
transform: translateX(-50%);
z-index: var(--zIndex-ui-bottom);
}
.scroll-back-to-content {
// rendered inside the mobile toolbar island (see MobileMenu), and floats
// above it
.App-toolbar > .scroll-back-to-content {
position: absolute;
left: 50%;
bottom: calc(100px + var(--sab, 0));
transform: translateX(-50%);
z-index: -1;
}
.floating-status-stack .scroll-back-to-content {
bottom: auto;
z-index: auto;
}
}
.rtl-mirror {
@@ -626,13 +834,6 @@ body.excalidraw-cursor-resize * {
pointer-events: none !important;
}
&.excalidraw--view-mode {
.App-menu {
display: flex;
justify-content: space-between;
}
}
&.excalidraw--non-interactive {
user-select: none;
@@ -85,7 +85,10 @@ describe("<Excalidraw/>", () => {
</Footer>
</Excalidraw>,
));
expect(container.querySelector(".footer-center")).toMatchInlineSnapshot(
const footerCenter = container.querySelector(".footer-center");
expect(footerCenter?.parentElement).toHaveClass("exc-region-bottom-center");
expect(footerCenter?.closest("footer.exc-region-bottom")).not.toBe(null);
expect(footerCenter).toMatchInlineSnapshot(
`
<div
class="footer-center zen-mode-transition"
@@ -732,6 +732,10 @@ describe("ui={{ enabled: ... }}", () => {
const scrollBackButton = queryContainer(".scroll-back-to-content");
expect(scrollBackButton).not.toBe(null);
expect(scrollBackButton!.parentElement).toHaveClass(
"floating-status-stack",
);
expect(scrollBackButton!.closest(".exc-region-bottom")).toBe(null);
fireEvent.click(scrollBackButton!);
await waitFor(() => expect(h.state.scrolledOutside).toBe(false));
});