snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
.zoom-actions,
.undo-redo-buttons {
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px var(--color-surface-lowest);
}
.zoom-button,
.undo-redo-buttons button {
border-radius: 0 !important;
background-color: var(--color-surface-low) !important;
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;
}
.ToolIcon__icon {
width: 100%;
height: 100%;
}
}
.reset-zoom-button {
border-left: 0 !important;
border-right: 0 !important;
padding: 0 0.625rem !important;
width: 3.75rem !important;
justify-content: center;
color: var(--text-primary-color);
}
.zoom-out-button {
border-top-left-radius: var(--border-radius-lg) !important;
border-bottom-left-radius: var(--border-radius-lg) !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
}
.zoom-in-button {
border-top-right-radius: var(--border-radius-lg) !important;
border-bottom-right-radius: var(--border-radius-lg) !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
}
.undo-redo-buttons {
.undo-button-container button {
border-top-left-radius: var(--border-radius-lg) !important;
border-bottom-left-radius: var(--border-radius-lg) !important;
border-right: 0 !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
}
.redo-button-container button {
border-top-right-radius: var(--border-radius-lg) !important;
border-bottom-right-radius: var(--border-radius-lg) !important;
:root[dir="rtl"] & {
transform: scaleX(-1);
}
.ToolIcon__icon {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
}
}
.compact-shape-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: calc(100vh - 200px);
overflow-y: auto;
padding: 0.5rem;
.compact-action-item {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 2.5rem;
pointer-events: auto;
--default-button-size: 2rem;
.compact-action-button {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
border: none;
border-radius: var(--border-radius-lg);
color: var(--color-on-surface);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
background: var(--mobile-action-button-bg);
svg {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
&.active {
background: var(
--color-surface-primary-container,
var(--mobile-action-button-bg)
);
}
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
}
.ToolIcon {
.ToolIcon__icon {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
background: var(--mobile-action-button-bg);
&:hover {
background-color: transparent;
}
}
}
}
.compact-shape-actions-island {
width: fit-content;
overflow-x: hidden;
}
.mobile-shape-actions {
z-index: 999;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
background: transparent;
border-radius: var(--border-radius-lg);
box-shadow: none;
overflow: none;
scrollbar-width: none;
-ms-overflow-style: none;
}
.shape-actions-theme-scope {
--button-border: transparent;
--button-bg: var(--color-surface-mid);
}
:root.theme--dark .shape-actions-theme-scope {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}

View File

@@ -0,0 +1,945 @@
import clsx from "clsx";
import { useRef } from "react";
import { Popover } from "radix-ui";
import { CLASSES } from "@excalidraw/common";
import { isArrowElement } from "@excalidraw/element";
import type {
ExcalidrawElement,
NonDeletedElementsMap,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import { actionToggleZenMode } from "../actions";
import { t } from "../i18n";
import { getTargetElements } from "../scene";
import { getFormValue } from "../actions/actionProperties";
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import "./Actions.scss";
import { useExcalidrawContainer } from "./App";
import Stack from "./Stack";
import { Tooltip } from "./Tooltip";
import { PropertiesPopover } from "./PropertiesPopover";
import {
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
TextSizeIcon,
adjustmentsIcon,
DotsHorizontalIcon,
pencilIcon,
} from "./icons";
import { Island } from "./Island";
import { getShapeActionPredicates } from "./shapeActionPredicates";
import type { ShapeActionPredicates } from "./shapeActionPredicates";
import type { AppClassProperties, UIAppState, AppState } from "../types";
import type { ActionManager } from "../actions/manager";
// re-exported for consumers outside the styles panel (e.g. CommandPalette)
export {
canChangeStrokeColor,
canChangeBackgroundColor,
} from "./shapeActionPredicates";
// Common CSS class combinations
const PROPERTIES_CLASSES = clsx([
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
"properties-content",
]);
/**
* The "arrange" (z-order) fieldset, identical across every styles-panel layout.
*/
const LayersFieldset = ({
renderAction,
}: {
renderAction: ActionManager["renderAction"];
}) => (
<fieldset>
<legend>{t("labels.layers")}</legend>
<div className="buttonList">
{renderAction("sendToBack")}
{renderAction("sendBackward")}
{renderAction("bringForward")}
{renderAction("bringToFront")}
</div>
</fieldset>
);
/**
* The align + distribute fieldset, identical across every styles-panel layout.
* Button order is mirrored for RTL so the leftmost button always aligns left.
*/
const AlignFieldset = ({
renderAction,
showDistribute,
}: {
renderAction: ActionManager["renderAction"];
showDistribute: boolean;
}) => {
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
return (
<fieldset>
<legend>{t("labels.align")}</legend>
<div className="buttonList">
{isRTL ? (
<>
{renderAction("alignRight")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignLeft")}
</>
) : (
<>
{renderAction("alignLeft")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignRight")}
</>
)}
{showDistribute && renderAction("distributeHorizontally")}
{/* breaks the row ˇˇ */}
<div style={{ flexBasis: "100%", height: 0 }} />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: ".5rem",
marginTop: "-0.5rem",
}}
>
{renderAction("alignTop")}
{renderAction("alignVerticallyCentered")}
{renderAction("alignBottom")}
{showDistribute && renderAction("distributeVertically")}
</div>
</div>
</fieldset>
);
};
/**
* Full styles panel: the wide, always-expanded layout used on desktop when the
* UI is in "full" mode.
*/
export const SelectedShapeActions = ({
appState,
elementsMap,
renderAction,
app,
}: {
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
}) => {
const targetElements = getTargetElements(elementsMap, appState);
const predicates = getShapeActionPredicates(
appState,
targetElements,
elementsMap,
app,
);
// the bucket fill tool configures only the fill it creates: color, fill
// style, and opacity (shared `currentItem*` values; no stroke properties)
if (appState.activeTool.type === "bucketfill") {
return (
<div className="selected-shape-actions">
<div>{renderAction("changeBucketFillBackgroundColor")}</div>
{renderAction("changeFillStyle")}
{renderAction("changeOpacity")}
</div>
);
}
return (
<div className="selected-shape-actions">
<div>{predicates.strokeColor && renderAction("changeStrokeColor")}</div>
{predicates.backgroundColor && (
<div>{renderAction("changeBackgroundColor")}</div>
)}
{predicates.fill && renderAction("changeFillStyle")}
{predicates.strokeWidth && renderAction("changeStrokeWidth")}
{predicates.strokeStyle && <>{renderAction("changeStrokeStyle")}</>}
{predicates.freedrawMode && renderAction("changeFreedrawMode")}
{predicates.sloppiness && <>{renderAction("changeSloppiness")}</>}
{predicates.roundness && <>{renderAction("changeRoundness")}</>}
{predicates.arrowType && <>{renderAction("changeArrowType")}</>}
{predicates.text && (
<>
<fieldset>{renderAction("changeFontFamily")}</fieldset>
{renderAction("changeFontSize")}
{predicates.textAlign && renderAction("changeTextAlign")}
</>
)}
{predicates.verticalAlign && renderAction("changeVerticalAlign")}
{predicates.arrowheads && <>{renderAction("changeArrowhead")}</>}
{predicates.opacity && renderAction("changeOpacity")}
{predicates.layers && <LayersFieldset renderAction={renderAction} />}
{predicates.align && (
<AlignFieldset
renderAction={renderAction}
showDistribute={predicates.distribute}
/>
)}
{predicates.showExtraActions && (
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{renderAction("duplicateSelection")}
{renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{predicates.link && renderAction("hyperlink")}
{predicates.cropEditor && renderAction("cropEditor")}
{predicates.lineEditor && renderAction("toggleLinearEditor")}
</div>
</fieldset>
)}
</div>
);
};
const CombinedShapeProperties = ({
appState,
renderAction,
setAppState,
predicates,
container,
}: {
appState: UIAppState;
renderAction: ActionManager["renderAction"];
setAppState: React.Component<any, AppState>["setState"];
predicates: ShapeActionPredicates;
container: HTMLDivElement | null;
}) => {
const shouldShowCombinedProperties =
predicates.hasSelection ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand" &&
appState.activeTool.type !== "laser" &&
appState.activeTool.type !== "lasso");
const isOpen = appState.openPopup === "compactStrokeStyles";
if (!shouldShowCombinedProperties) {
return null;
}
return (
<div className="compact-action-item">
<Popover.Root
open={isOpen}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactStrokeStyles" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className={clsx("compact-action-button properties-trigger", {
active: isOpen,
})}
title={t("labels.stroke")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup: isOpen ? null : "compactStrokeStyles",
});
}}
>
{adjustmentsIcon}
</button>
</Popover.Trigger>
{isOpen && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{ maxWidth: "13rem" }}
onClose={() => {}}
>
<div className="selected-shape-actions">
{predicates.fill && renderAction("changeFillStyle")}
{predicates.strokeWidth && renderAction("changeStrokeWidth")}
{
/* in compact UI the freedraw pressure setting is rendered as a
standalone cycle button in the compact actions list; we render
it in the combined properties popup as well for clarity
*/
predicates.freedrawMode && renderAction("changeFreedrawMode")
}
{predicates.strokeStyle && (
<>{renderAction("changeStrokeStyle")}</>
)}
{predicates.sloppiness && <>{renderAction("changeSloppiness")}</>}
{predicates.roundness && renderAction("changeRoundness")}
{predicates.opacity && renderAction("changeOpacity")}
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
);
};
const CombinedArrowProperties = ({
appState,
renderAction,
setAppState,
targetElements,
predicates,
container,
app,
}: {
appState: UIAppState;
renderAction: ActionManager["renderAction"];
setAppState: React.Component<any, AppState>["setState"];
targetElements: ExcalidrawElement[];
predicates: ShapeActionPredicates;
container: HTMLDivElement | null;
app: AppClassProperties;
}) => {
if (!predicates.arrowType) {
return null;
}
const isOpen = appState.openPopup === "compactArrowProperties";
return (
<div className="compact-action-item">
<Popover.Root
open={isOpen}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactArrowProperties" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className={clsx("compact-action-button properties-trigger", {
active: isOpen,
})}
title={t("labels.arrowtypes")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup: isOpen ? null : "compactArrowProperties",
});
}}
>
{(() => {
// Show an icon based on the current arrow type
const arrowType = getFormValue(
targetElements,
app,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? "elbow"
: element.roundness
? "round"
: "sharp";
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
);
if (arrowType === "elbow") {
return elbowArrowIcon;
}
if (arrowType === "round") {
return roundArrowIcon;
}
return sharpArrowIcon;
})()}
</button>
</Popover.Trigger>
{isOpen && (
<PropertiesPopover
container={container}
className="properties-content"
style={{ maxWidth: "13rem" }}
onClose={() => {}}
>
{renderAction("changeArrowProperties")}
</PropertiesPopover>
)}
</Popover.Root>
</div>
);
};
const CombinedTextProperties = ({
appState,
renderAction,
setAppState,
predicates,
container,
}: {
appState: UIAppState;
renderAction: ActionManager["renderAction"];
setAppState: React.Component<any, AppState>["setState"];
predicates: ShapeActionPredicates;
container: HTMLDivElement | null;
}) => {
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(
container?.ownerDocument,
);
const isOpen = appState.openPopup === "compactTextProperties";
return (
<div className="compact-action-item">
<Popover.Root
open={isOpen}
onOpenChange={(open) => {
if (open) {
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "compactTextProperties" });
} else {
setAppState({ openPopup: null });
if (appState.editingTextElement) {
restoreCaretPosition();
}
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className={clsx("compact-action-button properties-trigger", {
active: isOpen,
})}
title={t("labels.textAlign")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isOpen) {
setAppState({ openPopup: null });
} else {
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "compactTextProperties" });
}
}}
>
{TextSizeIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactTextProperties" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onClose={() => {
// Refocus text editor when popover closes with caret restoration
if (appState.editingTextElement) {
restoreCaretPosition();
}
}}
>
<div className="selected-shape-actions">
{predicates.text && renderAction("changeFontSize")}
{predicates.textAlign && renderAction("changeTextAlign")}
{predicates.verticalAlign && renderAction("changeVerticalAlign")}
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
);
};
const CombinedExtraActions = ({
appState,
renderAction,
predicates,
setAppState,
container,
showDuplicate,
showDelete,
}: {
appState: UIAppState;
renderAction: ActionManager["renderAction"];
predicates: ShapeActionPredicates;
setAppState: React.Component<any, AppState>["setState"];
container: HTMLDivElement | null;
showDuplicate?: boolean;
showDelete?: boolean;
}) => {
const isOpen = appState.openPopup === "compactOtherProperties";
if (!predicates.showExtraActions) {
return null;
}
return (
<div className="compact-action-item">
<Popover.Root
open={isOpen}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactOtherProperties" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className={clsx("compact-action-button properties-trigger", {
active: isOpen,
})}
title={t("labels.actions")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup: isOpen ? null : "compactOtherProperties",
});
}}
>
{DotsHorizontalIcon}
</button>
</Popover.Trigger>
{isOpen && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{
maxWidth: "12rem",
justifyContent: "center",
alignItems: "center",
}}
onClose={() => {}}
>
<div className="selected-shape-actions">
{predicates.layers && (
<LayersFieldset renderAction={renderAction} />
)}
{predicates.align && (
<AlignFieldset
renderAction={renderAction}
showDistribute={predicates.distribute}
/>
)}
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{renderAction("group")}
{renderAction("ungroup")}
{predicates.linkSingleOnly && renderAction("hyperlink")}
{predicates.cropEditor && renderAction("cropEditor")}
{showDuplicate && renderAction("duplicateSelection")}
{showDelete && renderAction("deleteSelectedElements")}
</div>
</fieldset>
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
);
};
const LinearEditorAction = ({
renderAction,
predicates,
}: {
renderAction: ActionManager["renderAction"];
predicates: ShapeActionPredicates;
}) => {
if (!predicates.lineEditor) {
return null;
}
return (
<div className="compact-action-item">
{renderAction("toggleLinearEditor")}
</div>
);
};
/**
* Compact styles panel — the collapsed, popover-driven layout used on tablets
* and on desktop when the UI is in "compact" mode.
*/
export const CompactShapeActions = ({
appState,
elementsMap,
renderAction,
app,
setAppState,
}: {
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const targetElements = getTargetElements(elementsMap, appState);
const predicates = getShapeActionPredicates(
appState,
targetElements,
elementsMap,
app,
);
const { container } = useExcalidrawContainer();
return (
<div className="compact-shape-actions">
{/* Stroke Color */}
{predicates.strokeColor && (
<div className={clsx("compact-action-item")}>
{renderAction("changeStrokeColor")}
</div>
)}
{/* Background Color (the bucket fill variant excludes `transparent`) */}
{predicates.backgroundColor && (
<div className="compact-action-item">
{renderAction(
appState.activeTool.type === "bucketfill"
? "changeBucketFillBackgroundColor"
: "changeBackgroundColor",
)}
</div>
)}
{/* Freedraw pressure: standalone button cycling the variability mode */}
{predicates.freedrawMode && (
<div className="compact-action-item">
{renderAction("changeFreedrawMode", { cycle: true })}
</div>
)}
<CombinedShapeProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
predicates={predicates}
container={container}
/>
<CombinedArrowProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
targetElements={targetElements}
predicates={predicates}
container={container}
app={app}
/>
{/* Linear Editor */}
{predicates.lineEditor && (
<div className="compact-action-item">
{renderAction("toggleLinearEditor")}
</div>
)}
{/* Text Properties */}
{predicates.text && (
<>
<div className="compact-action-item">
{renderAction("changeFontFamily")}
</div>
<CombinedTextProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
predicates={predicates}
container={container}
/>
</>
)}
{/* Dedicated Copy Button */}
{predicates.showExtraActions && (
<div className="compact-action-item">
{renderAction("duplicateSelection")}
</div>
)}
{/* Dedicated Delete Button */}
{predicates.showExtraActions && (
<div className="compact-action-item">
{renderAction("deleteSelectedElements")}
</div>
)}
<CombinedExtraActions
appState={appState}
renderAction={renderAction}
predicates={predicates}
setAppState={setAppState}
container={container}
/>
</div>
);
};
/**
* Mobile styles panel — the horizontal action bar used on phones, with an
* overflow measurement that promotes duplicate/delete out of the popover when
* there is room.
*/
export const MobileShapeActions = ({
appState,
elementsMap,
renderAction,
app,
setAppState,
}: {
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const targetElements = getTargetElements(elementsMap, appState);
const predicates = getShapeActionPredicates(
appState,
targetElements,
elementsMap,
app,
);
const { container } = useExcalidrawContainer();
const mobileActionsRef = useRef<HTMLDivElement>(null);
const ACTIONS_WIDTH =
mobileActionsRef.current?.getBoundingClientRect()?.width ?? 0;
// 7 actions + 2 for undo/redo
const MIN_ACTIONS = 9;
const GAP = 6;
const WIDTH = 32;
const MIN_WIDTH = MIN_ACTIONS * WIDTH + (MIN_ACTIONS - 1) * GAP;
const ADDITIONAL_WIDTH = WIDTH + GAP;
const showDeleteOutside = ACTIONS_WIDTH >= MIN_WIDTH + ADDITIONAL_WIDTH;
const showDuplicateOutside =
ACTIONS_WIDTH >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH;
return (
<Island
className="compact-shape-actions mobile-shape-actions"
style={{
flexDirection: "row",
boxShadow: "none",
padding: 0,
zIndex: 2,
backgroundColor: "transparent",
height: WIDTH * 1.35,
marginBottom: 4,
alignItems: "center",
gap: GAP,
pointerEvents: "none",
}}
ref={mobileActionsRef}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: GAP,
flex: 1,
}}
>
{predicates.strokeColor && (
<div className={clsx("compact-action-item")}>
{renderAction("changeStrokeColor")}
</div>
)}
{/* Background Color (the bucket fill variant excludes `transparent`) */}
{predicates.backgroundColor && (
<div className="compact-action-item">
{renderAction(
appState.activeTool.type === "bucketfill"
? "changeBucketFillBackgroundColor"
: "changeBackgroundColor",
)}
</div>
)}
<CombinedShapeProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
predicates={predicates}
container={container}
/>
{/* Combined Arrow Properties */}
<CombinedArrowProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
targetElements={targetElements}
predicates={predicates}
container={container}
app={app}
/>
{/* Linear Editor */}
<LinearEditorAction
renderAction={renderAction}
predicates={predicates}
/>
{/* Text Properties */}
{predicates.text && (
<>
<div className="compact-action-item">
{renderAction("changeFontFamily")}
</div>
<CombinedTextProperties
appState={appState}
renderAction={renderAction}
setAppState={setAppState}
predicates={predicates}
container={container}
/>
</>
)}
{/* Combined Other Actions */}
<CombinedExtraActions
appState={appState}
renderAction={renderAction}
predicates={predicates}
setAppState={setAppState}
container={container}
showDuplicate={!showDuplicateOutside}
showDelete={!showDeleteOutside}
/>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
gap: GAP,
}}
>
<div className="compact-action-item">{renderAction("undo")}</div>
<div className="compact-action-item">{renderAction("redo")}</div>
{showDuplicateOutside && (
<div className="compact-action-item">
{renderAction("duplicateSelection")}
</div>
)}
{showDeleteOutside && (
<div className="compact-action-item">
{renderAction("deleteSelectedElements")}
</div>
)}
</div>
</Island>
);
};
export const ZoomActions = ({
renderAction,
}: {
renderAction: ActionManager["renderAction"];
}) => (
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
<Stack.Row align="center">
{renderAction("zoomOut")}
{renderAction("resetZoom")}
{renderAction("zoomIn")}
</Stack.Row>
</Stack.Col>
);
export const UndoRedoActions = ({
renderAction,
className,
}: {
renderAction: ActionManager["renderAction"];
className?: string;
}) => (
<div className={`undo-redo-buttons ${className}`}>
<div className="undo-button-container">
<Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip>
</div>
<div className="redo-button-container">
<Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip>
</div>
</div>
);
export const ExitZenModeButton = ({
actionManager,
showExitZenModeBtn,
}: {
actionManager: ActionManager;
showExitZenModeBtn: boolean;
}) => (
<button
type="button"
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={() => actionManager.executeAction(actionToggleZenMode)}
>
{t("buttons.exitZenMode")}
</button>
);
export const ExitViewModeButton = ({
actionManager,
}: {
actionManager: ActionManager;
}) => (
<button
type="button"
className="disable-view-mode"
onClick={() => actionManager.executeAction(actionToggleViewMode)}
>
{pencilIcon}
</button>
);

View File

@@ -0,0 +1,36 @@
import { actionClearCanvas } from "../actions";
import { atom, useAtom } from "../editor-jotai";
import { t } from "../i18n";
import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog";
export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom,
);
const actionManager = useExcalidrawActionManager();
if (!activeConfirmDialog) {
return null;
}
if (activeConfirmDialog === "clearCanvas") {
return (
<ConfirmDialog
onConfirm={() => {
actionManager.executeAction(actionClearCanvas);
setActiveConfirmDialog(null);
}}
onCancel={() => setActiveConfirmDialog(null)}
title={t("clearCanvasDialog.title")}
>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
</ConfirmDialog>
);
}
return null;
};

View File

@@ -0,0 +1,352 @@
import { CURSOR_TYPE, DRAGGING_THRESHOLD } from "@excalidraw/common";
import {
bindBindingElementToFixedPoint,
dragNewTextElement,
getBoundTextElement,
getEndpointBoundTextDragAnchor,
getTextBindingForArrowEndpoint,
getUnboundArrowEndpointAtPoint,
hitElementBoundText,
isArrowElement,
isBindingEnabled,
isEndpointBoundText,
isTextElement,
LinearElementEditor,
} from "@excalidraw/element";
import { pointDistance, pointFrom } from "@excalidraw/math";
import type { ArrowEndpoint } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
FixedPoint,
NonDeleted,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type App from "./App";
import type { AppState, PointerDownState } from "../types";
/**
* Text ↔ arrow interactions.
*
* With the text tool: the hover affordance showing where a click would attach
* text to an arrow — a free endpoint (binds the arrow to a new text element
* positioned against that endpoint) or the arrow's midpoint (adds a label
* bound to the arrow) — and the endpoint-bound flavor of drag-sizing a new
* text.
*
* With the selection tool: dragging an arrow's existing label along the arrow
* — the grab affordance, the drag itself, and where a double-click starts
* editing a label the drag has moved.
*
* The scene-level logic lives in `@excalidraw/element`'s
* `arrowEndpointText.ts` and `linearElementEditor.ts`.
*/
export class AppArrowText {
constructor(private app: App) {}
/**
* With the text tool active, an arrow under the cursor is a target for
* attaching text. Returns where the text would land (if anywhere), and keeps
* `appState.hoveredArrowTextAnchor` — which drives the highlight — in sync.
*/
updateHoveredAnchor = (scenePointer: {
x: number;
y: number;
}): AppState["hoveredArrowTextAnchor"] => {
const hovered =
this.app.state.activeTool.type === "text" &&
!this.app.state.editingTextElement &&
!this.app.state.newElement
? this.getAnchorAtPosition(scenePointer.x, scenePointer.y)
: null;
const previous = this.app.state.hoveredArrowTextAnchor;
if (
previous?.elementId !== hovered?.elementId ||
previous?.anchor !== hovered?.anchor
) {
this.app.setState({ hoveredArrowTextAnchor: hovered });
}
return hovered;
};
/**
* Re-evaluates the hovered anchor at the last known pointer position — for
* events that change what a click would do without the pointer moving,
* i.e. the ctrl/cmd binding toggle. (Events that invalidate the anchor
* wholesale — tool switches, finalize, deselect — clear it directly
* instead.)
*/
refresh = (): void => {
if (this.app.lastPointerMoveCoords) {
this.updateHoveredAnchor(this.app.lastPointerMoveCoords);
}
};
/**
* A free arrow endpoint a new text could be bound to. Binding an endpoint is
* an arrow binding, so it follows the binding toggle (ctrl/cmd) like every
* other one — holding it makes the text tool drop plain text instead.
*/
getBindableEndpointAtPosition(x: number, y: number): ArrowEndpoint | null {
if (!isBindingEnabled(this.app.state)) {
return null;
}
const endpoint = getUnboundArrowEndpointAtPoint(
pointFrom(x, y),
this.app.scene.getNonDeletedElements(),
this.app.scene.getNonDeletedElementsMap(),
this.app.state.zoom,
);
if (!endpoint) {
return null;
}
// The text tool edits before it creates: when a text element is the
// top-most hit at this position, a click edits that text, so a nearby
// endpoint must not be offered over it.
if (this.app.getTextElementAtPosition(x, y)) {
return null;
}
// The endpoint scan only knows about arrows, so it happily reaches through
// whatever is drawn on top of them. An element stacked above the arrow that
// the pointer actually hits owns the click — the text tool should label
// that element rather than bind the endpoint hidden behind it.
const hitElement = this.app.getElementAtPosition(x, y, {
includeLockedElements: true,
});
if (
hitElement &&
hitElement.id !== endpoint.arrow.id &&
this.app.scene.getElementIndex(hitElement.id) >
this.app.scene.getElementIndex(endpoint.arrow.id)
) {
return null;
}
return endpoint;
}
/**
* Mirrors what `handleTextOnPointerDown` would do at this position, so the
* highlight can't promise something the click won't deliver.
*/
private getAnchorAtPosition(
x: number,
y: number,
): AppState["hoveredArrowTextAnchor"] {
const endpoint = this.getBindableEndpointAtPosition(x, y);
if (endpoint) {
return { elementId: endpoint.arrow.id, anchor: endpoint.startOrEnd };
}
const container = this.app.getTextBindableContainerAtPosition(x, y);
// Only arrows get a midpoint label anchor worth pointing at; other
// containers center the text in themselves, which needs no affordance.
// An arrow that already has a label is edited in place, not re-anchored.
if (
!isArrowElement(container) ||
getBoundTextElement(container, this.app.scene.getNonDeletedElementsMap())
) {
return null;
}
// `getTextBindableContainerAtPosition` resolves an arrow anywhere in its
// bounding box, but a click only becomes a *label* when it also snaps to
// the arrow's center — off-center clicks drop a free-floating text
// instead. Gate on the same check so the highlight can't promise a label
// the click won't deliver.
const snappedToCenter = this.app.getTextWysiwygSnappedToCenterPosition(
x,
y,
this.app.state,
container,
);
return snappedToCenter
? { elementId: container.id, anchor: "label" }
: null;
}
/**
* How a text should be created to read as a label for this endpoint — the
* side midpoint to bind, the alignment that pins it, and the scene position
* it must sit at. `targetStrokeWidth` is the caller's to provide so it can
* guarantee it matches the stroke width the text is then created with — the
* binding gap derives from it (see `getTextBindingForArrowEndpoint`).
*/
getTextBinding(
{ arrow, startOrEnd }: ArrowEndpoint,
targetStrokeWidth: number,
) {
return getTextBindingForArrowEndpoint(
arrow,
startOrEnd,
this.app.scene.getNonDeletedElementsMap(),
targetStrokeWidth,
);
}
/**
* Binds the arrow endpoint to the created text, at the side midpoint the
* placement resolved (`getTextBinding`'s `fixedPoint`).
*/
bindText(
{ arrow, startOrEnd }: ArrowEndpoint,
text: NonDeleted<ExcalidrawTextElement>,
fixedPoint: FixedPoint,
): void {
bindBindingElementToFixedPoint(
arrow,
text,
startOrEnd,
fixedPoint,
this.app.scene,
);
}
/**
* A text bound to an arrow endpoint can't be positioned by the drag — the
* binding already placed it — so only its width is dragged out. Returns
* whether it owned the drag.
*/
maybeDragNewText(
newElement: ExcalidrawElement,
pointerCoords: { x: number; y: number },
): boolean {
if (
!isTextElement(newElement) ||
!isEndpointBoundText(
newElement,
this.app.scene.getNonDeletedElementsMap(),
)
) {
return false;
}
dragNewTextElement({
newElement,
...getEndpointBoundTextDragAnchor(newElement),
pointerX: pointerCoords.x,
zoom: this.app.state.zoom.value,
scene: this.app.scene,
});
return true;
}
/**
* Whether the arrow's label is what a grab at this position would pick up:
* the pointer is over the label, and no element stacked above the arrow
* owns the hit instead.
*/
isBoundTextGrabbable(
element: NonDeletedExcalidrawElement,
x: number,
y: number,
): boolean {
if (
!isArrowElement(element) ||
!hitElementBoundText(
pointFrom(x, y),
element,
this.app.scene.getNonDeletedElementsMap(),
)
) {
return false;
}
const hitElements = this.app.getElementsAtPosition(x, y);
const arrowIndex = hitElements.findIndex((el) => el.id === element.id);
return (
arrowIndex !== -1 &&
!hitElements
.slice(arrowIndex + 1)
.some((el) => this.app.hitElement(x, y, el, false))
);
}
/**
* The pointer-move half of dragging a label along its arrow. Owns the move
* whenever the gesture started on the label (`pointerDownState.hit.arrowLabel`),
* dragging only once past the threshold. Returns whether it owned it.
*/
maybeDragLabel(
linearElementEditor: LinearElementEditor,
pointerDownState: PointerDownState,
pointerCoords: { x: number; y: number },
): boolean {
if (!pointerDownState.hit.arrowLabel) {
return false;
}
this.app.cursor.set(CURSOR_TYPE.GRABBING);
if (
linearElementEditor.isDragging ||
pointDistance(
pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
pointFrom(pointerCoords.x, pointerCoords.y),
) >=
DRAGGING_THRESHOLD / this.app.state.zoom.value
) {
const updatedEditor = LinearElementEditor.handleBoundTextDragging(
linearElementEditor,
this.app.scene,
pointerCoords.x,
pointerCoords.y,
);
if (updatedEditor) {
pointerDownState.drag.hasOccurred = true;
this.app.setState({
selectedLinearElement: updatedEditor,
});
}
}
return true;
}
/**
* Where a double-click on a labeled arrow should start text editing: the
* center of the label itself, which a label drag may have moved away from
* the arrow's midpoint. Null when the element is not a labeled arrow.
*/
getLabelCenter(element: ExcalidrawElement): { x: number; y: number } | null {
const elementsMap = this.app.scene.getNonDeletedElementsMap();
if (!isArrowElement(element)) {
return null;
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (!boundTextElement) {
return null;
}
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
return {
x: x + boundTextElement.width / 2,
y: y + boundTextElement.height / 2,
};
}
}

View File

@@ -0,0 +1,301 @@
import {
applyDarkModeFilter,
BUCKET_FILL_BACKGROUND_PICKS,
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
getSizeFromPoints,
isTransparent,
THEME,
} from "@excalidraw/common";
import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
import {
computeBucketFillPolygon,
isBucketFillCompatible,
isFrameLikeElement,
isRestylableFill,
newLinearElement,
ShapeCache,
} from "@excalidraw/element";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/element/types";
import { actionChangeBucketFillBackgroundColor } from "../actions";
import { editorJotaiStore } from "../editor-jotai";
import { t } from "../i18n";
import { activeEyeDropperAtom, type EyeDropperProperties } from "./EyeDropper";
import type App from "./App";
type ScenePoint = {
x: number;
y: number;
};
const BUCKET_FILL_KEYBOARD_COLOR_PICKS = BUCKET_FILL_BACKGROUND_PICKS.filter(
(color) => color !== COLOR_PALETTE.white,
);
/** Owns bucket-fill interaction and App-bound scene mutations. */
export class AppBucketFill {
constructor(private app: App) {}
private temporaryEyeDropper: EyeDropperProperties | null = null;
/**
* The click armed on pointer down, committed on pointer up. Deferring the
* scene mutation to pointer up is what lets a pinch/pan second finger, a
* long-press context menu, or a pointercancel abort the fill — on pointer
* down the editor cannot yet know the gesture will stay a single-pointer
* click.
*/
private pending: ScenePoint | null = null;
/** Arm a fill at the pointer-down position; nothing mutates yet. */
handlePointerDown = (scenePointer: ScenePoint) => {
this.pending = scenePointer;
};
/**
* Commit the armed fill, at the pointer-DOWN position (what the user
* aimed at). No-op when the gesture was aborted in the meantime.
*/
handlePointerUp = () => {
const scenePointer = this.pending;
this.pending = null;
if (scenePointer) {
this.fill(scenePointer);
}
};
/** Abort the armed fill (second finger, context menu, pointercancel). */
cancel = () => {
this.pending = null;
};
cycleBackgroundColor = () => {
const currentColor = this.getBucketFillBackgroundColor(
this.app.state.currentItemBackgroundColor,
);
const currentIndex = BUCKET_FILL_KEYBOARD_COLOR_PICKS.indexOf(currentColor);
const nextColor =
BUCKET_FILL_KEYBOARD_COLOR_PICKS[
(currentIndex + 1) % BUCKET_FILL_KEYBOARD_COLOR_PICKS.length
];
this.app.actionManager.executeAction(
actionChangeBucketFillBackgroundColor,
"keyboard",
{ currentItemBackgroundColor: nextColor },
);
};
openTemporaryEyeDropper = () => {
if (editorJotaiStore.get(activeEyeDropperAtom)) {
return;
}
const eyeDropper: EyeDropperProperties = {
colorPickerType: "elementBackground",
keepOpenOnAlt: true,
onSelect: (color) => {
this.app.actionManager.executeAction(
actionChangeBucketFillBackgroundColor,
"ui",
{ currentItemBackgroundColor: color },
);
},
};
this.temporaryEyeDropper = eyeDropper;
this.app.updateEditorAtom(activeEyeDropperAtom, eyeDropper);
};
closeTemporaryEyeDropper = () => {
const eyeDropper = this.temporaryEyeDropper;
this.temporaryEyeDropper = null;
if (
eyeDropper &&
editorJotaiStore.get(activeEyeDropperAtom) === eyeDropper
) {
this.app.updateEditorAtom(activeEyeDropperAtom, null);
}
};
/**
* Apply the current bucket fill settings to an existing fill-compatible
* element (no-op when nothing would change). One undoable step.
*/
private restyle = (element: NonDeletedExcalidrawElement) => {
const backgroundColor = this.getBucketFillBackgroundColor(
this.app.state.currentItemBackgroundColor,
);
if (
element.backgroundColor === backgroundColor &&
element.fillStyle === this.app.state.currentItemFillStyle &&
element.opacity === this.app.state.currentItemOpacity
) {
return;
}
this.app.scene.mutateElement(element, {
backgroundColor,
fillStyle: this.app.state.currentItemFillStyle,
opacity: this.app.state.currentItemOpacity,
});
// the roughjs drawable is cached by element reference; in-place
// mutation keeps the reference, so evict it or the canvas won't
// reflect the new style
ShapeCache.delete(element);
this.app.scheduleCapture();
};
private fill = (scenePointer: ScenePoint) => {
// shared with the generic shape background, but a transparent fill would
// be invisible, so fall back to a real color (appState is not mutated)
const backgroundColor = this.getBucketFillBackgroundColor(
this.app.state.currentItemBackgroundColor,
);
const elements = this.app.scene.getNonDeletedElements();
const elementsMap = this.app.scene.getNonDeletedElementsMap();
const point = pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y);
const hitElement = this.app.getElementAtPosition(point[0], point[1]);
// gap bridging is covered by the default gapTolerance (a bucket-specific
// constant): connector edges close visual gaps without distorting the
// shape, so no zoom-dependent tuning is needed
const result = computeBucketFillPolygon({ point, elements, elementsMap });
if (!result.ok) {
// no region could be derived, but the click still landed on existing
// paint (e.g. an orphaned fill whose boundaries were deleted, or a
// hand-drawn strokeless bg polygon on empty canvas) — restyling it is
// the sensible outcome, and beats a toast
if (hitElement && isBucketFillCompatible(hitElement)) {
this.restyle(hitElement);
return;
}
if (result.reason === "too_complex") {
this.app.setToast({
message: t("bucketfill.tooComplex"),
duration: 3000,
});
} else if (
result.reason === "open_region" ||
result.reason === "too_small" ||
result.reason === "invalid_polygon"
) {
this.app.setToast({
message: t("bucketfill.noRegion"),
duration: 3000,
});
}
// "no_owner" stays silent — clicking empty canvas shouldn't nag
return;
}
// re-clicking an already-filled region shouldn't stack a duplicate:
// restyle the existing fill with the current settings, or no-op when
// nothing would change
if (
hitElement &&
isRestylableFill({
hitElement,
scenePoints: result.scenePoints,
elementsMap,
})
) {
this.restyle(hitElement);
return;
}
const { width, height } = getSizeFromPoints(result.scenePoints);
// linear elements are normalized so points[0] must be [0, 0]; use the
// first scene point as the element origin
const [originX, originY] = result.scenePoints[0];
const points = result.scenePoints.map((p) =>
pointFrom<LocalPoint>(p[0] - originX, p[1] - originY),
);
const owner = result.ownerId ? elementsMap.get(result.ownerId) : null;
// owner-less fills (regions formed by open lines) take their frame from
// the click position and only inherit a group shared by every boundary
const frameId = owner
? isFrameLikeElement(owner)
? owner.id
: owner.frameId
: this.app.getTopLayerFrameAtSceneCoords(scenePointer)?.id ?? null;
const groupIds = owner
? owner.groupIds
: result.boundaryElementIds
.map((id) => elementsMap.get(id)?.groupIds)
.filter((groupIds): groupIds is string[] => !!groupIds)
.reduce(
(common, groupIds) =>
common === null
? groupIds
: common.filter((groupId) => groupIds.includes(groupId)),
null as string[] | null,
) ?? [];
const fill = newLinearElement({
type: "line",
x: originX,
y: originY,
width,
height,
points,
polygon: true,
strokeColor: "transparent",
backgroundColor,
fillStyle: this.app.state.currentItemFillStyle,
strokeWidth: 1,
strokeStyle: "solid",
roughness: 0,
roundness: null,
opacity: this.app.state.currentItemOpacity,
frameId,
groupIds,
// no marker: fills are recognized by shape (`isBucketFillCompatible`),
// since metadata would go stale the moment the user restyles the fill
});
// resolve the geometry's relative placement against the
// deleted-inclusive array that `insertElementsAtIndex` operates on
const anchorIndex = this.app.scene
.getElementsIncludingDeleted()
.findIndex((el) => el.id === result.insertion.elementId);
this.app.scene.insertElementsAtIndex(
[fill],
anchorIndex < 0
? null
: result.insertion.placement === "above"
? anchorIndex + 1
: anchorIndex,
);
// Keep the bucket fill tool active and do NOT select the new fill, so the
// user can keep filling regions back-to-back without re-selecting the tool
// (even when the tool isn't locked).
this.app.scheduleCapture();
};
/**
* The color the bucket fill tool actually fills with: the shared
* `currentItemBackgroundColor`, falling back to green when that is
* transparent (the tool's picker doesn't offer transparent, but the shared
* state can hold it from the generic shape picker).
*/
public getBucketFillBackgroundColor = (
backgroundColor: string,
/** supply only for display purposes such as when applying to the cursor */
theme?: Theme,
) =>
isTransparent(backgroundColor)
? COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]
: applyDarkModeFilter(backgroundColor, theme === THEME.DARK);
}

View File

@@ -0,0 +1,238 @@
import { CURSOR_TYPE, MIME_TYPES, THEME } from "@excalidraw/common";
import { isHandToolActive, isEraserActive } from "../appState";
import { bucketFillIconSvgPaths } from "./icons";
import type { AppState, DataURL } from "../types";
import type App from "./App";
const laserPointerCursorSVG_tag = `<svg viewBox="0 0 24 24" stroke-width="1" width="28" height="28" xmlns="http://www.w3.org/2000/svg">`;
const laserPointerCursorBackgroundSVG = `<path d="M6.164 11.755a5.314 5.314 0 0 1-4.932-5.298 5.314 5.314 0 0 1 5.311-5.311 5.314 5.314 0 0 1 5.307 5.113l8.773 8.773a3.322 3.322 0 0 1 0 4.696l-.895.895a3.322 3.322 0 0 1-4.696 0l-8.868-8.868Z" style="fill:#fff"/>`;
const laserPointerCursorIconSVG = `<path stroke="#1b1b1f" fill="#fff" d="m7.868 11.113 7.773 7.774a2.359 2.359 0 0 0 1.667.691 2.368 2.368 0 0 0 2.357-2.358c0-.625-.248-1.225-.69-1.667L11.201 7.78 9.558 9.469l-1.69 1.643v.001Zm10.273 3.606-3.333 3.333m-3.25-6.583 2 2m-7-7 3 3M3.664 3.625l1 1M2.529 6.922l1.407-.144m5.735-2.932-1.118.866M4.285 9.823l.758-1.194m1.863-6.207-.13 1.408"/>`;
const laserPointerCursorDataURL_lightMode = `data:${
MIME_TYPES.svg
},${encodeURIComponent(
`${laserPointerCursorSVG_tag}${laserPointerCursorIconSVG}</svg>`,
)}`;
const laserPointerCursorDataURL_darkMode = `data:${
MIME_TYPES.svg
},${encodeURIComponent(
`${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}</svg>`,
)}`;
const createBucketFillCursorDataURL = (color: string) => {
const paths = bucketFillIconSvgPaths
.map(
(path, index) =>
`<path d="${path}" ${index < 2 ? `fill="${color}"` : ""} />`,
)
.join("");
return `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke-linecap="round" stroke-linejoin="round"><g stroke="#fff" fill="#fff" stroke-width="5">${paths}</g><g stroke="#1b1b1f" stroke-width="1.25">${paths}</g></svg>`,
)}`;
};
/**
* Captures all cursor management for the interactive canvas.
*
* The canvas cursor is set exclusively imperatively, through this class —
* never via inline styles or React-rendered `style.cursor`, which React
* would rewrite on rerender whenever the computed value changes, clobbering
* cursors set here.
*/
export class AppCursor {
private toolCursorMemo: {
canvas: HTMLCanvasElement;
type: AppState["activeTool"]["type"];
bucketFillColor?: string;
theme?: AppState["theme"];
} | null = null;
private eraserCanvasCache:
| (HTMLCanvasElement & { theme?: AppState["theme"] })
| null = null;
private eraserPreviewDataURL: DataURL | null = null;
constructor(private app: App) {}
private get canvas() {
return this.app.interactiveCanvas;
}
set = (cursor: string) => {
// Hover and interaction cursors override the resting tool cursor. The
// next applyForTool() must restore it even if the tool itself is unchanged.
this.toolCursorMemo = null;
if (this.canvas) {
this.canvas.style.cursor = cursor;
}
};
/**
* Resets to the resting cursor — the cursor shown when no interaction or
* hover affordance overrides it: the view-mode grab cursor when
* drag-to-pan applies, the active tool's cursor otherwise.
*/
reset = () => {
if (
this.app.state.viewModeEnabled &&
!this.app.isActiveToolPointerCapturing()
) {
if (this.app.isNavigationEnabled()) {
this.set(CURSOR_TYPE.GRAB);
} else {
this.clear();
}
} else {
this.applyForTool();
}
};
/** applies the given tool's cursor (defaults to the active tool) */
applyForTool = (
activeTool: AppState["activeTool"] = this.app.state.activeTool,
) => {
const canvas = this.canvas;
if (!canvas) {
return;
}
const theme =
activeTool.type === "eraser" ||
activeTool.type === "laser" ||
activeTool.type === "bucketfill"
? this.app.state.theme
: undefined;
const bucketFillColor =
activeTool.type === "bucketfill"
? this.app.bucketFill.getBucketFillBackgroundColor(
this.app.state.currentItemBackgroundColor,
theme,
)
: undefined;
const memo = this.toolCursorMemo;
if (
memo?.canvas === canvas &&
memo.type === activeTool.type &&
memo.bucketFillColor === bucketFillColor &&
memo.theme === theme
) {
return;
}
if (activeTool.type === "selection") {
this.clear();
} else if (isHandToolActive({ activeTool })) {
this.set(CURSOR_TYPE.GRAB);
} else if (isEraserActive({ activeTool })) {
this.applyEraser();
} else if (activeTool.type === "autoshape") {
this.set(CURSOR_TYPE.CROSSHAIR);
} else if (activeTool.type === "bucketfill") {
// The hotspot is the center of the paint droplet in the icon.
this.set(
`url(${createBucketFillCursorDataURL(bucketFillColor!)}) 5 18, auto`,
);
} else if (activeTool.type === "laser") {
const url =
this.app.state.theme === THEME.LIGHT
? laserPointerCursorDataURL_lightMode
: laserPointerCursorDataURL_darkMode;
this.set(`url(${url}), auto`);
} else if (!["image", "custom"].includes(activeTool.type)) {
this.set(CURSOR_TYPE.CROSSHAIR);
// do nothing if image tool is selected which suggests there's
// an image-preview set as the cursor
// Ignore custom type as well and let host decide
} else if (activeTool.type !== "image") {
this.set(CURSOR_TYPE.AUTO);
}
this.toolCursorMemo = {
canvas,
type: activeTool.type,
bucketFillColor,
theme,
};
};
/**
* Re-runs the hover cursor logic at the last known pointer position by
* re-dispatching a synthetic pointermove — for state changes that alter
* what hovering means without the pointer moving (e.g. the tool reverting
* to selection after an element is created). Mouse only: touch and pen
* have no resting hover to restore.
*/
refreshHover = () => {
const lastEvent =
this.app.lastPointerMoveEvent ?? this.app.lastPointerUpEvent;
if (!lastEvent || !this.canvas) {
return;
}
const pointerType = (lastEvent as PointerEvent).pointerType;
if (pointerType && pointerType !== "mouse") {
return;
}
// jsdom has no PointerEvent; a MouseEvent of type "pointermove" walks
// the identical dispatch path
const EventConstructor =
typeof PointerEvent !== "undefined" ? PointerEvent : MouseEvent;
this.canvas.dispatchEvent(
new EventConstructor("pointermove", {
bubbles: true,
cancelable: true,
clientX: lastEvent.clientX,
clientY: lastEvent.clientY,
}),
);
};
/** clears the inline cursor so the environment default (CSS) applies */
private clear = () => {
this.set("");
};
private applyEraser = () => {
const cursorImageSizePx = 20;
const theme = this.app.state.theme;
if (!this.eraserCanvasCache || this.eraserCanvasCache.theme !== theme) {
const isDarkTheme = theme === THEME.DARK;
this.eraserCanvasCache = document.createElement("canvas");
this.eraserCanvasCache.theme = theme;
this.eraserCanvasCache.height = cursorImageSizePx;
this.eraserCanvasCache.width = cursorImageSizePx;
const context = this.eraserCanvasCache.getContext("2d")!;
context.lineWidth = 1;
context.beginPath();
context.arc(
this.eraserCanvasCache.width / 2,
this.eraserCanvasCache.height / 2,
5,
0,
2 * Math.PI,
);
context.fillStyle = isDarkTheme ? "#000" : "#fff";
context.fill();
context.strokeStyle = isDarkTheme ? "#fff" : "#000";
context.stroke();
this.eraserPreviewDataURL = this.eraserCanvasCache.toDataURL(
MIME_TYPES.svg,
) as DataURL;
}
this.set(
`url(${this.eraserPreviewDataURL}) ${cursorImageSizePx / 2} ${
cursorImageSizePx / 2
}, auto`,
);
};
}

View File

@@ -0,0 +1,265 @@
import { randomInteger } from "@excalidraw/common";
import {
bindBindingElement,
convertToShape,
convertToShapeHandlePointerMoveFromPointerDown,
getHoveredElementForBinding,
isBindingElement,
isLineElement,
LinearElementEditor,
maxBindingDistance_simple,
newArrowElement,
updateBoundPoint,
} from "@excalidraw/element";
import type {
ExcalidrawArrowElement,
ExcalidrawLineElement,
NonDeleted,
NonDeletedExcalidrawElement,
PointsPositionUpdates,
} from "@excalidraw/element/types";
import { actionFinalize } from "../actions";
import { DrawShapeTrail } from "../drawShapeTrail";
import type App from "./App";
import type { PointerDownState } from "../types";
/**
* Captures the App state management for the drawShape tool (free-form
* sketch converted into a recognized excalidraw element). Pointer events
* are piped here from App.
*
* The gesture is committed by `finalize()` — recognize → (maybe upgrade
* line to arrow) → insert → bind endpoints — which is invoked from within
* `actionFinalize`, the editor's single finalization funnel. Whatever
* triggers a finalize (the gesture's own pointerup, a mid-gesture paste
* switching tools, …), the pending sketch resolves there exactly once.
*/
export class AppDrawShape {
public trail: DrawShapeTrail;
/** a pointerdown started a sketch that `finalize()` hasn't resolved yet */
private gestureInProgress = false;
constructor(private app: App) {
this.trail = new DrawShapeTrail(app);
}
stop = () => {
this.trail.stop();
};
hasPendingGesture = () => {
return this.gestureInProgress;
};
handlePointerDown = (pointerDownState: PointerDownState) => {
if (this.gestureInProgress) {
// a stale gesture (e.g. after a missed pointerup) — resolve it before
// starting a new one
this.app.actionManager.executeAction(actionFinalize);
}
this.gestureInProgress = true;
this.trail.startPath(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
);
};
/** returns true when the pointermove was consumed by the shape preview */
handlePointerMove = (pointerCoords: { x: number; y: number }): boolean => {
return convertToShapeHandlePointerMoveFromPointerDown(
this.app,
pointerCoords,
);
};
/**
* Binds both endpoints of a freshly inserted drawShape arrow to the
* bindable elements they hover, if any.
*
* Endpoints bind in "orbit" mode — the arrow starts/ends at the target's
* outline — even when the stroke was started or released inside the shape
* (a sketched connector aims at the shape, not at a point inside it). The
* exception is a sketch contained in a single shape: both ends then bind
* "inside" and stay where they were drawn, like the interactive
* inside→inside flow.
*/
private bindRecognizedArrow = (arrow: NonDeleted<ExcalidrawArrowElement>) => {
const { app } = this;
const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements();
const bindingDistance = maxBindingDistance_simple(app.state.zoom);
const endpoints = (["start", "end"] as const).map((startOrEnd) => {
const globalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
return {
startOrEnd,
globalPoint,
target: getHoveredElementForBinding(
globalPoint,
elements,
elementsMap,
bindingDistance,
),
};
});
const sameTarget =
!!endpoints[0].target && endpoints[0].target === endpoints[1].target;
const isMidpointSnappingEnabled =
app.state.isMidpointSnappingEnabled && !app.state.gridModeEnabled;
for (const { startOrEnd, globalPoint, target } of endpoints) {
if (target) {
bindBindingElement(
arrow,
target,
sameTarget ? "inside" : "orbit",
startOrEnd,
app.scene,
globalPoint,
app.state.isBindingEnabled,
isMidpointSnappingEnabled,
);
}
}
if (sameTarget) {
return;
}
// pull the orbit-bound endpoints onto the target outlines; both bindings
// exist by now, so each endpoint clips the segment between the two focus
// points (mirrors the focusPoint branch of bindOrUnbindBindingElement)
const updates: PointsPositionUpdates = new Map();
for (const { startOrEnd, target } of endpoints) {
if (!target) {
continue;
}
const point = updateBoundPoint(
arrow,
startOrEnd === "start" ? "startBinding" : "endBinding",
startOrEnd === "start" ? arrow.startBinding : arrow.endBinding,
target,
elementsMap,
);
if (point) {
updates.set(startOrEnd === "start" ? 0 : arrow.points.length - 1, {
point,
});
}
}
if (updates.size) {
LinearElementEditor.movePoints(arrow, app.scene, updates);
}
};
/**
* A sketched line that connects a bindable element with something else —
* another element or blank canvas — was meant as a connector: upgrade it
* to an arrow (called before insertion). A line touching no shape, or
* contained in a single shape (an annotation, not a connector), stays a
* line.
*/
private maybeUpgradeLineToArrow = (
line: NonDeleted<ExcalidrawLineElement>,
): NonDeleted<ExcalidrawArrowElement> | null => {
const { app } = this;
const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements();
const bindingDistance = maxBindingDistance_simple(app.state.zoom);
const [startTarget, endTarget] = ([0, -1] as const).map((index) =>
getHoveredElementForBinding(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
line,
index,
elementsMap,
),
elements,
elementsMap,
bindingDistance,
),
);
if ((!startTarget && !endTarget) || startTarget === endTarget) {
return null;
}
return newArrowElement({
type: "arrow",
x: line.x,
y: line.y,
width: line.width,
height: line.height,
startArrowhead: null,
endArrowhead: app.state.currentItemEndArrowhead,
points: line.points,
groupIds: line.groupIds,
frameId: line.frameId,
locked: false,
angle: line.angle,
strokeColor: line.strokeColor,
backgroundColor: line.backgroundColor,
fillStyle: line.fillStyle,
roughness: line.roughness,
opacity: line.opacity,
strokeStyle: line.strokeStyle,
strokeWidth: line.strokeWidth,
});
};
/**
* Commits the pending sketch, if any, and clears the trail. Invoked from
* within `actionFinalize` — never call directly, execute `actionFinalize`
* instead so element insertion flows into the action's returned elements.
*/
finalize = () => {
const { app } = this;
// when no gesture is in progress this only clears residual trail state
const points = this.gestureInProgress ? this.trail.getCurrentPoints() : [];
this.gestureInProgress = false;
this.trail.endPath();
// note: size gate inside recognizeShape
if (points.length >= 3) {
const detectedElement =
app.state.newElement ||
convertToShape(
points,
app.state,
app.scene.getNonDeletedElementsMap(),
app.state.newElement,
app.scene.getNonDeletedFramesLikes(),
);
if (detectedElement) {
let element: NonDeletedExcalidrawElement = {
...detectedElement,
seed: randomInteger(),
opacity: app.state.currentItemOpacity,
};
if (app.state.isBindingEnabled && isLineElement(element)) {
element = this.maybeUpgradeLineToArrow(element) ?? element;
}
app.insertNewElement(element);
if (app.state.isBindingEnabled && isBindingElement(element)) {
this.bindRecognizedArrow(element);
}
}
}
this.trail.clearTrails();
};
}

View File

@@ -0,0 +1,198 @@
import { isArrowKey, KEYS } from "@excalidraw/common";
import {
makeNextSelectedElementIds,
CaptureUpdateAction,
FlowChartCreator,
FlowChartNavigator,
getSelectedElements,
isFlowchartNodeElement,
type LinkDirection,
} from "@excalidraw/element";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type React from "react";
import type App from "./App";
import type { PendingExcalidrawElements } from "../types";
type FlowchartOperation =
| { type: "none" }
| { type: "canceled" }
| { type: "creating"; pending: PendingExcalidrawElements }
| { type: "navigating"; nodeId: ExcalidrawElement["id"] | null }
| { type: "committed"; nodes: PendingExcalidrawElements }
| { type: "navigationEnded" };
/**
* Captures the App state management for the flowchart functionality.
*/
export class AppFlowchart {
private creator = new FlowChartCreator();
private navigator = new FlowChartNavigator();
constructor(private app: App) {}
get pendingNodes() {
return this.creator.pendingNodes;
}
get isCreatingChart() {
return this.creator.isCreatingChart;
}
/** ends any in-progress flowchart creation/navigation session */
clear = () => {
this.creator.clear();
this.navigator.clear();
};
handleKeyEvent = (event: React.KeyboardEvent | KeyboardEvent): boolean => {
const operation = this.resolveKeyboardEventToOperation(event);
switch (operation.type) {
case "none":
return false;
case "canceled":
this.app.triggerRender(true);
return true;
case "creating":
event.preventDefault();
if (operation.pending.length) {
this.app.revealIfHidden(operation.pending);
}
return true;
case "navigating": {
event.preventDefault();
const node =
operation.nodeId &&
this.app.scene.getNonDeletedElementsMap().get(operation.nodeId);
if (node) {
this.selectAndReveal(node);
}
return true;
}
case "committed": {
if (operation.nodes.length) {
this.app.insertNewElements(operation.nodes);
}
const firstNode = operation.nodes[0];
if (firstNode) {
this.selectAndReveal(firstNode);
}
this.captureUpdate();
return true;
}
case "navigationEnded":
this.captureUpdate();
return true;
}
};
private resolveKeyboardEventToOperation(
event: React.KeyboardEvent | KeyboardEvent,
): FlowchartOperation {
const { creator, navigator, app } = this;
if (event.type === "keydown") {
if (event.key === KEYS.ESCAPE && creator.isCreatingChart) {
creator.clear();
return { type: "canceled" };
}
if (!isArrowKey(event.key)) {
return { type: "none" };
}
if (event[KEYS.CTRL_OR_CMD] && !event.shiftKey) {
const selectedElements = getSelectedElements(
app.scene.getNonDeletedElementsMap(),
app.state,
);
if (
selectedElements.length === 1 &&
isFlowchartNodeElement(selectedElements[0])
) {
creator.createNodes(
selectedElements[0],
app.state,
AppFlowchart.getLinkDirectionFromKey(event.key),
app.scene,
);
}
return { type: "creating", pending: creator.pendingNodes ?? [] };
}
if (event.altKey) {
const elementsMap = app.scene.getNonDeletedElementsMap();
const selectedElements = getSelectedElements(elementsMap, app.state);
if (selectedElements.length === 1) {
return {
type: "navigating",
nodeId: navigator.exploreByDirection(
selectedElements[0],
elementsMap,
AppFlowchart.getLinkDirectionFromKey(event.key),
),
};
}
}
return { type: "none" };
}
// keyup: releasing a modifier finalizes the workflow it was driving;
// both can finalize on the same event
const navigationEnded = !event.altKey && navigator.isExploring;
if (navigationEnded) {
navigator.clear();
}
if (!event[KEYS.CTRL_OR_CMD] && creator.isCreatingChart) {
const nodes = creator.pendingNodes ?? [];
creator.clear();
return { type: "committed", nodes };
}
return navigationEnded ? { type: "navigationEnded" } : { type: "none" };
}
private selectAndReveal(node: NonDeletedExcalidrawElement) {
this.app.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds(
{ [node.id]: true },
prevState,
),
}));
this.app.revealIfHidden([node]);
}
private captureUpdate() {
this.app.syncActionResult({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
}
private static getLinkDirectionFromKey(key: string): LinkDirection {
switch (key) {
case KEYS.ARROW_UP:
return "up";
case KEYS.ARROW_DOWN:
return "down";
case KEYS.ARROW_RIGHT:
return "right";
case KEYS.ARROW_LEFT:
return "left";
default:
return "right";
}
}
}

View File

@@ -0,0 +1,246 @@
import {
DEFAULT_STICKY_NOTE_SIZE,
DRAGGING_THRESHOLD,
EVENT,
KEYS,
getGridPoint,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import { isStickyNoteElement, newStickyNoteElement } from "@excalidraw/element";
import type {
ExcalidrawNonSelectionElement,
NonDeleted,
} from "@excalidraw/element/types";
import { trackEvent } from "../analytics";
import type App from "./App";
import type { ToolType } from "../types";
type ScenePoint = { x: number; y: number };
/** what a drag places: any element the new-element canvas can preview */
type DraggedElement = NonDeleted<ExcalidrawNonSelectionElement>;
/**
* A tool the user can drag out of the toolbar to drop a default-sized element
* where the pointer is released.
*/
type DraggableTool = {
/** the element the drop creates, centered on the pointer */
createElement: (app: App, center: ScenePoint) => DraggedElement;
/** what the tool does once its element is on the canvas (e.g. start editing) */
onDrop?: (app: App, element: DraggedElement) => void;
};
const centered = (center: ScenePoint, width: number, height: number) => ({
x: center.x - width / 2,
y: center.y - height / 2,
width,
height,
});
/**
* The tools that can be dragged out of the toolbar. Add an entry to make a
* tool's button draggable — the gesture, preview and drop are shared.
*/
export const DRAGGABLE_TOOLS: Partial<Record<ToolType, DraggableTool>> = {
stickynote: {
createElement: (app, center) => {
const size = DEFAULT_STICKY_NOTE_SIZE;
const { state } = app;
return newStickyNoteElement({
type: "stickynote",
...centered(center, size, size),
baseHeight: size,
strokeColor: state.currentItemStickynoteStrokeColor,
backgroundColor: state.currentItemStickynoteBackgroundColor,
fillStyle: state.currentItemFillStyle,
strokeWidth: app.getCurrentItemStrokeWidth("stickynote"),
strokeStyle: state.currentItemStrokeStyle,
roughness: state.currentItemRoughness,
opacity: state.currentItemOpacity,
roundness: app.getCurrentItemRoundness("stickynote"),
locked: false,
frameId: app.getTopLayerFrameAtSceneCoords(center)?.id ?? null,
});
},
onDrop: (app, element) => {
if (isStickyNoteElement(element)) {
app.startTextEditing({
sceneX: element.x + element.width / 2,
sceneY: element.y + element.height / 2,
container: element,
});
}
},
},
};
/** CSS opacity of the preview canvas while dragging */
export const TOOL_DRAG_PREVIEW_OPACITY = 0.5;
/**
* Dragging a tool out of the toolbar. The preview element exists only here
* until the drop — it is not in the scene, the store or the history, so
* collaborators never see it and an aborted drag leaves nothing behind. It
* is painted by the `NewElementCanvas`, drawn exactly as it will land, with
* the canvas itself made translucent.
*
* The button arms on pointerdown; the drag starts once the pointer has
* moved `DRAGGING_THRESHOLD`, so a plain click still selects the tool. The
* gesture lives on the owner window (the pointer leaves the button at once),
* like the other pointer gestures in `App`.
*/
export class AppToolDrag {
constructor(private app: App) {}
/** the element under the pointer while dragging, `null` otherwise */
public preview: DraggedElement | null = null;
private armed: {
type: ToolType;
pointerId: number;
clientX: number;
clientY: number;
} | null = null;
private tool: DraggableTool | null = null;
private type: ToolType | null = null;
static isDraggable = (type: ToolType) => type in DRAGGABLE_TOOLS;
isDragging = () => this.preview !== null;
/**
* Arm a drag from a tool button's pointerdown. Returns whether the tool is
* draggable (the caller lets a non-draggable press fall through).
*/
handleButtonPointerDown = (type: ToolType, event: PointerEvent) => {
const tool = DRAGGABLE_TOOLS[type];
if (!tool || event.button !== 0 || !this.app.isInteractionEnabled()) {
return false;
}
this.cancel();
this.armed = {
type,
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
};
this.tool = tool;
this.type = type;
this.addListeners();
return true;
};
private onPointerMove = (event: PointerEvent) => {
if (!this.armed || event.pointerId !== this.armed.pointerId) {
return;
}
if (!this.preview) {
const moved = Math.hypot(
event.clientX - this.armed.clientX,
event.clientY - this.armed.clientY,
);
if (moved < DRAGGING_THRESHOLD) {
return;
}
trackEvent("toolbar", this.armed.type, "drag");
}
const preview = this.tool!.createElement(
this.app,
viewportCoordsToSceneCoords(event, this.app.state),
);
// Snap after the tool centers its element, just like click-to-place.
const [x, y] = getGridPoint(
preview.x,
preview.y,
event[KEYS.CTRL_OR_CMD] ? null : this.app.getEffectiveGridSize(),
);
this.preview = { ...preview, x, y };
this.app.triggerRender();
};
private onPointerUp = (event: PointerEvent) => {
if (!this.armed || event.pointerId !== this.armed.pointerId) {
return;
}
const { preview, tool, type } = this;
// the button's own click handles a press that never became a drag
if (!preview) {
this.cancel();
return;
}
this.cancel();
if (!this.isOverCanvas(event)) {
return;
}
const { app } = this;
app.insertNewElement(preview);
app.store.scheduleCapture();
app.setState({
selectedElementIds: { [preview.id]: true },
selectedGroupIds: {},
});
// the drop is a one-off placement: whatever tool was active (an arrow,
// say) must not stay armed under the dropped element or its editor
app.setActiveTool(
{ type: app.state.preferredSelectionTool.type },
{ keepSelection: true },
);
trackEvent("toolbar", type!, "drop");
tool!.onDrop?.(app, preview);
};
private onKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
event.stopPropagation();
this.cancel();
}
};
/** drop the gesture without a trace */
cancel = () => {
const wasDragging = this.preview !== null;
this.armed = null;
this.tool = null;
this.type = null;
this.preview = null;
this.removeListeners();
if (wasDragging) {
this.app.triggerRender();
}
};
private isOverCanvas = (event: PointerEvent) => {
// the element under the pointer. `elementFromPoint` sees through the
// implicit capture a touch gives the button; jsdom doesn't implement it,
// so fall back to the event's target
const target = (this.app.ownerDocument.elementFromPoint?.(
event.clientX,
event.clientY,
) ?? event.target) as Element | null;
return !!target?.closest?.(".excalidraw__canvas");
};
private addListeners = () => {
const { ownerWindow } = this.app;
ownerWindow.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
ownerWindow.addEventListener(EVENT.POINTER_UP, this.onPointerUp);
ownerWindow.addEventListener(EVENT.POINTER_CANCEL, this.cancel);
ownerWindow.addEventListener(EVENT.BLUR, this.cancel);
ownerWindow.addEventListener(EVENT.KEYDOWN, this.onKeyDown, true);
};
private removeListeners = () => {
const { ownerWindow } = this.app;
ownerWindow.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
ownerWindow.removeEventListener(EVENT.POINTER_UP, this.onPointerUp);
ownerWindow.removeEventListener(EVENT.POINTER_CANCEL, this.cancel);
ownerWindow.removeEventListener(EVENT.BLUR, this.cancel);
ownerWindow.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, true);
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,828 @@
import {
debounce,
easeOut,
isBounds,
type Bounds,
type StylesPanelMode,
} from "@excalidraw/common";
import { clamp } from "@excalidraw/math";
import {
getCommonBounds,
getElementsInGroup,
isElementLink,
isExcalidrawElement,
parseElementLinkFromURL,
} from "@excalidraw/element";
import type {
ExcalidrawElement,
NonDeleted,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import { getLanguage, t } from "../i18n";
import { AnimationController } from "../renderer/animation";
import {
constrainScrollState,
DEFAULT_OVERSCROLL,
type SetViewportOptions,
type SetViewportRect,
zoomToFitBounds,
} from "../viewport";
import type {
AppProps,
AppState,
NormalizedZoomValue,
Offsets,
ScrollConstraints,
ViewportOffsets,
ViewportOffsetsOptions,
ViewportUIDock,
ViewportUIName,
} from "../types";
import type App from "./App";
export const SCROLL_TO_CONTENT_ANIMATION_KEY = "animateScrollToContent";
export const SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY =
"animateScrollConstraintsSnapBack";
const DEFAULT_SCROLL_ANIMATION_DURATION = 500;
/** rubberband snap-back animation duration, in ms — kept snappier than the
* default scroll animation so releasing an overscroll feels responsive */
const SNAP_BACK_ANIMATION_DURATION = 250;
/** how long after the last pan/zoom we animate the rubberband back into the
* scroll constraints box */
const SCROLL_CONSTRAINTS_SNAP_BACK_DELAY = 200;
/** single source of truth for the `--right-sidebar-width` CSS variable */
export const RIGHT_SIDEBAR_WIDTH = 302;
/**
* Approximate styles panel footprints (panel width + editor edge inset),
* used by `getOffsets` to reserve space for the panel before it was ever
* rendered (= measured). Keep roughly in sync with the CSS
* (`.App-menu__left` width / `.compact-shape-actions-island` min-width +
* `--editor-container-padding`).
*/
const STYLES_PANEL_APPROX_WIDTH = { full: 216, compact: 64 };
type Viewport = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
const isSetViewportRect = (target: unknown): target is SetViewportRect => {
if (!target || typeof target !== "object" || Array.isArray(target)) {
return false;
}
const rect = target as Partial<SetViewportRect>;
return (
typeof rect.x === "number" &&
typeof rect.y === "number" &&
(rect.width == null || typeof rect.width === "number") &&
(rect.height == null || typeof rect.height === "number")
);
};
const getElementsFromId = (
id: string,
elementsMap: NonDeletedSceneElementsMap,
) => {
const element = elementsMap.get(id);
if (element) {
return [element];
}
return getElementsInGroup(elementsMap, id);
};
type ResolvedViewportTarget = {
/** null when the target couldn't be resolved (unknown id/link, or all
* supplied elements deleted) */
bounds: Bounds | null;
/** how the target was specified, so callers can react to unresolved
* targets themselves (e.g. toast on a broken element link) */
type: "element" | "area" | "link";
};
/** Resolves a `setViewport` target to a scene-coordinate box. */
const resolveViewportTarget = (
target: SetViewportOptions["target"],
elementsMap: NonDeletedSceneElementsMap,
appState: Pick<AppState, "width" | "height">,
): ResolvedViewportTarget => {
if (typeof target === "string") {
const isLink = isElementLink(target);
const type = isLink ? ("link" as const) : ("element" as const);
const id = isLink ? parseElementLinkFromURL(target) : target;
const resolved = id ? getElementsFromId(id, elementsMap) : [];
if (!resolved.length) {
return { bounds: null, type };
}
return { bounds: getCommonBounds(resolved, elementsMap), type };
}
if (isBounds(target)) {
return { bounds: target, type: "area" };
}
if (isSetViewportRect(target) && !isExcalidrawElement(target)) {
const width = target.width ?? appState.width;
const height = target.height ?? appState.height;
return {
bounds: [target.x, target.y, target.x + width, target.y + height],
type: "area",
};
}
// widening to null values in case the host app doesn't have
// noUncheckedIndexedAccess enabled
const targetElements: (ExcalidrawElement | undefined | null)[] =
Array.isArray(target) ? target : [target];
const elements = targetElements.reduce<NonDeleted<ExcalidrawElement>[]>(
(acc, element) => {
if (element && !element.isDeleted) {
const sceneElement = elementsMap.get(element.id);
if (sceneElement) {
acc.push(sceneElement);
}
}
return acc;
},
[],
);
const hasNoElements = !elements.length;
if (elements.length !== targetElements.length || hasNoElements) {
console.warn(
"supplied element target(s) for setViewport contain deleted or non-existent elements which have been filtered out",
);
}
if (hasNoElements) {
return { bounds: null, type: "element" };
}
return { bounds: getCommonBounds(elements, elementsMap), type: "element" };
};
const resolveOverscroll = (
overscroll: boolean | number | undefined,
): number => {
if (overscroll === false) {
return 0;
}
if (overscroll === true || overscroll == null) {
return DEFAULT_OVERSCROLL;
}
return Math.max(overscroll, 0);
};
/** Computes the viewport (scroll + zoom) that brings the target box into view,
* based on the requested fit behavior. */
const getTargetViewport = (
state: AppState,
bounds: Bounds,
fit: SetViewportOptions["fit"] = "scale-down",
offsets?: Offsets,
): Viewport => {
const { appState } = zoomToFitBounds({
bounds,
appState: state,
fit,
canvasOffsets: offsets,
steppedZoom: false,
});
return {
scrollX: appState.scrollX,
scrollY: appState.scrollY,
zoom: appState.zoom,
};
};
/** Computes the viewport patch for landing on `bounds`: the target
* scroll/zoom, plus the scroll lock to install — or `scrollConstraints: null`
* to clear a previous lock when none is requested. */
const getConstrainedTargetViewport = (
appState: AppState,
bounds: Bounds,
// NOTE offsets must be resolved (see `AppViewport.resolveOffsets`)
{
fit,
offsets,
lock,
}: Pick<SetViewportOptions, "fit" | "lock"> & { offsets?: Offsets },
): Viewport & { scrollConstraints: ScrollConstraints | null } => {
const viewport = getTargetViewport(appState, bounds, fit, offsets);
if (!lock?.scroll && !lock?.zoom) {
return { ...viewport, scrollConstraints: null };
}
const [x1, y1, x2, y2] = bounds;
const scrollConstraints: ScrollConstraints = {
x: x1,
y: y1,
width: x2 - x1,
height: y2 - y1,
lockScroll: !!lock.scroll,
lockZoom: !!lock.zoom,
zoom: viewport.zoom.value,
overscroll: resolveOverscroll(lock.overscroll),
offsets,
};
return {
...constrainScrollState({ ...appState, ...viewport, scrollConstraints }),
scrollConstraints,
};
};
/**
* Interpolates the viewport from `from` to `target` at the (already-eased)
* blend amount `factor` (0 = `from`, 1 = `target`).
*
* Zoom is interpolated geometrically (so it feels uniform), but the pan can't
* simply lerp alongside it: pairing a geometric zoom with a linear scroll (or
* a linearly-tweened focal point) makes scene points swoop along curved,
* non-monotone screen paths once the zoom ratio exceeds ~e (the destination
* visibly drifts away before converging). Instead we interpolate the view
* transform affinely — a scene point maps to screen as
* `(scenePt + scroll) * zoom`, and requiring every point to travel a straight
* screen line forces `zoom` to be a convex blend `(1-m)*z0 + m*z1` with
* `scroll * zoom` lerped by that same weight. Deriving `m` from the geometric
* zoom keeps its pacing while making all screen trajectories straight and
* monotone.
*/
const interpolateViewport = ({
from,
target,
factor,
}: {
from: Viewport;
target: Viewport;
factor: number;
}): Viewport => {
if (factor >= 1) {
// land bit-exactly on the target (`z0 * (z1/z0)^1` can be off by an ulp)
return { ...target };
}
const zoom = (from.zoom.value *
Math.pow(
target.zoom.value / from.zoom.value,
factor,
)) as NormalizedZoomValue;
// pan blend weight derived from the zoom blend (0/0 for pure pans, hence
// the `factor` fallback; near-equal zooms are fine — the ratio limits to
// `factor` smoothly)
const m =
target.zoom.value === from.zoom.value
? factor
: (zoom - from.zoom.value) / (target.zoom.value - from.zoom.value);
return {
scrollX:
((1 - m) * from.scrollX * from.zoom.value +
m * target.scrollX * target.zoom.value) /
zoom,
scrollY:
((1 - m) * from.scrollY * from.zoom.value +
m * target.scrollY * target.zoom.value) /
zoom,
zoom: { value: zoom },
};
};
type AppViewportDependencies = {
getContainer: () => HTMLDivElement | null;
getStylesPanelMode: () => StylesPanelMode;
isGestureActive: () => boolean;
};
const resolveAnimationDuration = (
animation: SetViewportOptions["animation"],
): number | null => {
if (animation === false) {
return null;
}
if (animation === true || animation == null) {
return DEFAULT_SCROLL_ANIMATION_DURATION;
}
return animation.duration ?? DEFAULT_SCROLL_ANIMATION_DURATION;
};
/** Eases the viewport from its current position to `target` over `duration`,
* driving the transition through the shared AnimationController so it doesn't
* slow down other processes. */
const animateToViewport = (
from: Viewport,
target: Viewport,
duration: number,
onFrame: (
state: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
>,
) => void,
onComplete: () => void,
) => {
AnimationController.start<{ elapsed: number }>(
SCROLL_TO_CONTENT_ANIMATION_KEY,
({ deltaTime, state }) => {
const elapsed = (state?.elapsed ?? 0) + deltaTime;
const progress = Math.min(elapsed / duration, 1);
const factor = easeOut(clamp(progress, 0, 1));
if (progress < 1) {
onFrame({
...interpolateViewport({ from, target, factor }),
shouldCacheIgnoreZoom: true,
});
return { elapsed };
}
onComplete();
return null;
},
);
};
/**
* Rubberband snap-back: animates the viewport from its current (possibly
* overscrolled) position back inside the lock box via the shared
* AnimationController. No-op when already within the hard bounds (or when
* there is no lock).
*/
export const snapBackToConstraints = (
state: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
>,
onFrame: (
updater: (
state: Pick<
AppState,
| "scrollX"
| "scrollY"
| "zoom"
| "width"
| "height"
| "scrollConstraints"
>,
) => Viewport | null,
) => void,
duration = SNAP_BACK_ANIMATION_DURATION,
) => {
const target = constrainScrollState(state);
if (
target.scrollX === state.scrollX &&
target.scrollY === state.scrollY &&
target.zoom.value === state.zoom.value
) {
return;
}
// A programmatic navigation owns the viewport until it settles. A stale
// rubberband debounce must not supersede it.
if (AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)) {
return;
}
// Keep the displacement zoom-independent. Each animation frame resolves
// the resting viewport from the latest state, allowing zoom to update the
// underlying viewport while the same on-screen rubberband distance decays.
const overscrollX = (state.scrollX - target.scrollX) * state.zoom.value;
const overscrollY = (state.scrollY - target.scrollY) * state.zoom.value;
AnimationController.start<{ elapsed: number }>(
SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY,
({ deltaTime, state: animationState }) => {
const elapsed = (animationState?.elapsed ?? 0) + deltaTime;
const progress = Math.min(elapsed / duration, 1);
const remaining = 1 - easeOut(clamp(progress, 0, 1));
onFrame((currentState) => {
if (!currentState.scrollConstraints) {
return null;
}
const restingViewport = constrainScrollState(currentState);
const zoom = restingViewport.zoom.value;
return {
scrollX: restingViewport.scrollX + (overscrollX * remaining) / zoom,
scrollY: restingViewport.scrollY + (overscrollY * remaining) / zoom,
zoom: restingViewport.zoom,
};
});
return progress < 1 ? { elapsed } : null;
},
);
};
/**
* Owns viewport state transitions and App-bound viewport concerns. Pure
* viewport geometry remains in `viewport.ts` so actions and public helpers can
* calculate state without an App instance.
*/
export class AppViewport {
public lastPosition = { x: 0, y: 0 };
private activeTransition: {
target: Viewport & Pick<AppState, "scrollConstraints">;
} | null = null;
private uiLastMeasured = new Map<
ViewportUIName,
{ side: "left" | "right"; offset: number }
>();
constructor(
private app: App,
private dependencies: AppViewportDependencies,
) {}
get isAnimating() {
return (
AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY) ||
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY)
);
}
/** Whether a programmatic transition into a locked viewport currently owns
* user pan/zoom input. */
get isLockedTransitionPending() {
return !!this.activeTransition?.target.scrollConstraints;
}
invalidateUIOffset = (name: ViewportUIName) => {
this.uiLastMeasured.delete(name);
};
/**
* Resolves user-supplied viewport offsets into concrete per-side pixel
* values. Static sides take precedence over UI-derived sides.
*/
resolveOffsets = (
offsets: ViewportOffsets | undefined,
): Offsets | undefined => {
if (!offsets) {
return offsets;
}
const { ui, ...staticOffsets } = offsets;
if (!ui) {
return staticOffsets;
}
const uiOffsets = this.getOffsets(ui === true ? undefined : ui);
return {
top: staticOffsets.top ?? uiOffsets.top,
right: staticOffsets.right ?? uiOffsets.right,
bottom: staticOffsets.bottom ?? uiOffsets.bottom,
left: staticOffsets.left ?? uiOffsets.left,
};
};
/**
* Measures the currently rendered editor UI and returns the usable viewport
* offsets, including optional padding and reserved hidden surfaces.
*/
getOffsets = (opts?: ViewportOffsetsOptions): Offsets => {
const excalidrawContainer = this.dependencies.getContainer();
const excalidrawContainerRect =
excalidrawContainer?.getBoundingClientRect();
const isRTL = getLanguage().rtl;
const measuredOffsets = { top: 0, right: 0, bottom: 0, left: 0 };
const renderedSurfaces = new Set<ViewportUIName>();
if (excalidrawContainer && excalidrawContainerRect) {
for (const node of excalidrawContainer.querySelectorAll<HTMLElement>(
"[data-viewport-ui]",
)) {
const domRect = node.getBoundingClientRect();
const rect = {
top: domRect.top - excalidrawContainerRect.top,
right: domRect.right - excalidrawContainerRect.left,
bottom: domRect.bottom - excalidrawContainerRect.top,
left: domRect.left - excalidrawContainerRect.left,
width: domRect.width,
};
switch (node.dataset.viewportUi as ViewportUIDock) {
case "top":
measuredOffsets.top = Math.max(measuredOffsets.top, rect.bottom);
break;
case "bottom":
measuredOffsets.bottom = Math.max(
measuredOffsets.bottom,
this.app.state.height - rect.top,
);
break;
case "side": {
const [side, offset] =
rect.left + rect.width / 2 < this.app.state.width / 2
? (["left", rect.right] as const)
: (["right", this.app.state.width - rect.left] as const);
measuredOffsets[side] = Math.max(measuredOffsets[side], offset);
const name = node.dataset.viewportUiName as
| ViewportUIName
| undefined;
if (name) {
renderedSurfaces.add(name);
if (offset > 0) {
this.uiLastMeasured.set(name, { side, offset });
}
}
break;
}
}
}
}
if (opts?.reserve && this.app.editorInterface.formFactor !== "phone") {
const reserveSurface = (
name: ViewportUIName,
fallback: { side: "left" | "right"; offset: number },
) => {
if (renderedSurfaces.has(name)) {
return;
}
const { side, offset } = this.uiLastMeasured.get(name) ?? fallback;
measuredOffsets[side] = Math.max(measuredOffsets[side], offset);
};
if (opts.reserve.stylesPanel) {
reserveSurface("stylesPanel", {
side: isRTL ? "right" : "left",
offset:
this.dependencies.getStylesPanelMode() === "compact"
? STYLES_PANEL_APPROX_WIDTH.compact
: STYLES_PANEL_APPROX_WIDTH.full,
});
}
if (opts.reserve.sidebar) {
reserveSurface("sidebar", {
side: isRTL ? "left" : "right",
offset: RIGHT_SIDEBAR_WIDTH,
});
}
}
const padding = opts?.padding ?? 24;
const topPadding = opts?.paddingTop ?? padding;
const rightPadding =
(isRTL ? opts?.paddingLeft : opts?.paddingRight) ?? padding;
const bottomPadding = opts?.paddingBottom ?? padding;
const leftPadding =
(isRTL ? opts?.paddingRight : opts?.paddingLeft) ?? padding;
const editorOffsets = {
top: measuredOffsets.top + topPadding,
right: measuredOffsets.right + rightPadding,
bottom: measuredOffsets.bottom + bottomPadding,
left: measuredOffsets.left + leftPadding,
};
return {
top: opts?.top ?? editorOffsets.top,
right: (isRTL ? opts?.left : opts?.right) ?? editorOffsets.right,
bottom: opts?.bottom ?? editorOffsets.bottom,
left: (isRTL ? opts?.right : opts?.left) ?? editorOffsets.left,
};
};
/** Resolves the host-supplied initial viewport against restored scene data. */
resolveInitialViewport = (
opts: Omit<SetViewportOptions, "animation">,
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
) => {
const { bounds } = resolveViewportTarget(
opts.target,
elementsMap,
appState,
);
if (!bounds) {
return null;
}
return getConstrainedTargetViewport(appState, bounds, {
...opts,
// Initial state is resolved post-mount so UI-derived offsets measure
// the actually-rendered editor UI.
offsets: this.resolveOffsets(opts.offsets),
});
};
/** Navigates to a target and optionally installs scroll/zoom constraints. */
setViewport = (opts: SetViewportOptions | null) => {
if (opts === null) {
this.cancelTransition();
AnimationController.cancel(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY);
this.snapBackDebounced.cancel();
this.app.setState({
scrollConstraints: null,
shouldCacheIgnoreZoom: false,
});
return;
}
const { target, fit, lock, animation } = opts;
const offsets = this.resolveOffsets(opts.offsets);
const { bounds, type } = resolveViewportTarget(
target,
this.app.scene.getNonDeletedElementsMap(),
this.app.state,
);
if (!bounds) {
if (type === "link") {
this.app.setState({
toast: {
message: t("elementLink.notFound"),
duration: 3000,
closable: true,
},
});
}
return;
}
const viewportUpdate = getConstrainedTargetViewport(
this.app.state,
bounds,
{ fit, offsets, lock },
);
const duration = resolveAnimationDuration(animation);
const from = {
scrollX: this.app.state.scrollX,
scrollY: this.app.state.scrollY,
zoom: this.app.state.zoom,
};
// A new programmatic navigation supersedes the previous one and starts
// from whatever viewport its last rendered frame reached.
this.cancelTransition();
AnimationController.cancel(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY);
this.snapBackDebounced.cancel();
if (duration === null) {
this.app.setState({
...viewportUpdate,
shouldCacheIgnoreZoom: false,
});
return;
}
const transition = {
target: viewportUpdate,
};
this.activeTransition = transition;
// The old lock has been superseded. Keep the new lock pending outside
// AppState so it cannot constrain animation frames back toward either
// endpoint, and install it together with the final viewport.
if (this.app.state.scrollConstraints) {
this.app.setState({ scrollConstraints: null });
}
animateToViewport(
from,
viewportUpdate,
duration,
(state) => {
if (this.activeTransition === transition) {
this.app.setState(state);
}
},
() => {
if (this.activeTransition !== transition) {
return;
}
this.activeTransition = null;
this.app.setState({
...transition.target,
shouldCacheIgnoreZoom: false,
});
},
);
};
/** Use when changing scrollX/scrollY/zoom based on user interaction. */
translate = <K extends keyof AppState>(
state:
| AppState
| Pick<AppState, K>
| null
| ((
prevState: Readonly<AppState>,
props: Readonly<AppProps>,
) => AppState | Pick<AppState, K> | null),
opts?: {
zoomPreConstrained?: boolean;
preserveScrollConstraintsSnapBack?: boolean;
},
) => {
if (this.isLockedTransitionPending) {
return false;
}
// a user gesture interrupting an animated `setViewport` takes over from
// frames that draw from zoom-scaled bitmaps, and the completion handler
// that would switch back to crisp ones no longer runs. Otherwise the
// flag is left alone: wheel and touch zoom set it and clear it on a
// debounce once the gesture is over, and a pan frame landing between two
// zoom ticks must not force every element to be re-rasterized at the
// in-progress zoom
if (this.activeTransition) {
this.app.setState({ shouldCacheIgnoreZoom: false });
}
this.cancelTransition();
if (!opts?.preserveScrollConstraintsSnapBack) {
AnimationController.cancel(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY);
}
this.app.requestUnfollow();
this.app.setState((prevState, props) => {
const update =
typeof state === "function" ? state(prevState, props) : state;
if (!update) {
return null;
}
const nextState = { ...prevState, ...update };
if (!nextState.scrollConstraints) {
return update;
}
const zoomed =
!opts?.zoomPreConstrained &&
nextState.zoom.value !== prevState.zoom.value;
const overscroll = zoomed ? 0 : nextState.scrollConstraints.overscroll;
if (overscroll > 0) {
this.snapBackDebounced();
}
return { ...nextState, ...constrainScrollState(nextState, overscroll) };
});
return true;
};
/** Clamps the viewport into the active scroll constraints. */
constrain = (overscroll = 0) => {
this.app.setState((prevState) =>
prevState.scrollConstraints
? constrainScrollState(prevState, overscroll)
: null,
);
};
/** Releases a held rubberband overscroll. */
releaseOverscroll = () => {
if (!this.app.state.scrollConstraints) {
return;
}
this.snapBackDebounced.cancel();
this.snapBack();
};
private snapBack = () => {
if (this.app.unmounted || this.dependencies.isGestureActive()) {
return;
}
snapBackToConstraints(this.app.state, (updater) =>
this.app.setState((state) => updater(state)),
);
};
private snapBackDebounced = debounce(
this.snapBack,
SCROLL_CONSTRAINTS_SNAP_BACK_DELAY,
);
private cancelTransition = () => {
this.activeTransition = null;
AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
};
destroy = () => {
this.cancelTransition();
AnimationController.cancel(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY);
this.snapBackDebounced.cancel();
};
}

View File

@@ -0,0 +1,184 @@
import { CLASSES, KEYS, MIN_ZOOM, ZOOM_STEP } from "@excalidraw/common";
import { resolveInputDevice } from "../appState";
import { withBatchedUpdates } from "../reactUtils";
import { getNormalizedZoom } from "../scene";
import { getViewportForZoomWithScrollConstraints } from "../viewport";
import type App from "./App";
/** `MouseEvent.buttons` bit of the wheel (middle) button */
const WHEEL_BUTTON_MASK = 4;
/**
* Wheel input over the editor: pans the canvas, or zooms it around the
* pointer (`viewport.lastPosition`) — on ctrl/cmd+wheel (which is also how
* a trackpad pinch is delivered), while the wheel button itself is held
* down, or on a plain wheel when the input device is a mouse
* (`appState.inputDevice`; ctrl/cmd+wheel pans instead then).
*/
export class AppWheel {
constructor(
private app: App,
private dependencies: {
/** whether a drag-pan (wheel button, space+drag, hand tool) is in
* progress — wheel input is ignored meanwhile */
isPanning: () => boolean;
/** applies the pointer movement the drag-pan is holding back for its
* next frame, if any */
flushPanMove: () => void;
},
) {}
/** the editor surfaces whose wheel input the editor consumes; everywhere
* else (menus, sidebars, …) the DOM keeps scrolling. The frame-name labels
* are DOM, but sit inside the container this listener is attached to */
private isOverEditorSurface = (event: WheelEvent) => {
const { ownerWindow } = this.app;
return (
event.target instanceof ownerWindow.HTMLCanvasElement ||
event.target instanceof ownerWindow.HTMLTextAreaElement ||
event.target instanceof ownerWindow.HTMLIFrameElement ||
(event.target instanceof ownerWindow.HTMLElement &&
event.target.classList.contains(CLASSES.FRAME_NAME))
);
};
handle = withBatchedUpdates((event: WheelEvent) => {
// NOTE no preventDefault so the page can scroll over the editor
if (!this.app.isNavigationEnabled()) {
return;
}
if (!this.isOverEditorSurface(event)) {
// prevent zooming the browser (but allow scrolling DOM)
if (event[KEYS.CTRL_OR_CMD]) {
event.preventDefault();
}
return;
}
event.preventDefault();
// scrolling with the wheel button held down zooms: the wheel sits under
// the same finger, so a wheel-button pan can be zoomed without reaching
// for a modifier — hence it wins over any modifier, and over the
// drag-pan the button itself started
const isWheelButtonHeld = !!(event.buttons & WHEEL_BUTTON_MASK);
if (this.dependencies.isPanning()) {
if (!isWheelButtonHeld) {
return;
}
// the drag-pan applies pointer movement once per frame; a move still
// waiting for its frame has to land before the zoom, or it lands after
// it — at the new zoom, on a viewport the zoom anchored without it —
// and the point grabbed by the pan drifts from under the cursor a
// little on every tick
this.dependencies.flushPanMove();
}
const { deltaX, deltaY } = event;
if (!deltaX && !deltaY) {
return;
}
// note that event.ctrlKey is necessary to handle pinch zooming
const hasZoomModifier = event.metaKey || event.ctrlKey;
const shouldZoom =
// a horizontal-only wheel (tilt wheel, sideways two-finger scroll)
// has nothing to zoom by; it pans sideways below instead
deltaY !== 0 &&
(isWheelButtonHeld ||
(resolveInputDevice(this.app.state.inputDevice) === "mouse"
? // a mouse has no pinch: a plain wheel zooms, and any modifier
// pans instead (ctrl/cmd vertically, shift horizontally)
!hasZoomModifier && !event.shiftKey
: hasZoomModifier));
if (shouldZoom) {
this.zoomBy(deltaY);
return;
}
// scroll horizontally when shift pressed
if (event.shiftKey) {
this.app.viewport.translate(({ zoom, scrollX }) => ({
// on Mac, shift+wheel tends to result in deltaX
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
}));
return;
}
this.app.viewport.translate(({ zoom, scrollX, scrollY }) => ({
scrollX: scrollX - deltaX / zoom.value,
scrollY: scrollY - deltaY / zoom.value,
}));
});
/**
* Prevents the browser's own zoom over the non-interactive editor while
* letting regular scroll through (trackpad pinch is delivered as
* ctrl+wheel).
*/
preventBrowserZoom = (event: WheelEvent) => {
if (event[KEYS.CTRL_OR_CMD]) {
event.preventDefault();
}
};
/** zooms around the pointer by a wheel delta (positive = zoom out) */
private zoomBy = (deltaY: number) => {
const { viewport } = this.app;
const sign = Math.sign(deltaY);
const MAX_STEP = ZOOM_STEP * 100;
const absDelta = Math.abs(deltaY);
let delta = deltaY;
if (absDelta > MAX_STEP) {
delta = MAX_STEP * sign;
}
// where the pointer is now — the updater below runs when React flushes,
// by which time the pointer may have moved on (`lastPosition` is live)
const { x: viewportX, y: viewportY } = viewport.lastPosition;
const didTranslate = viewport.translate(
// computed from the state the zoom applies to, not `app.state`: wheel
// ticks arriving before React has flushed the previous one would
// otherwise all start from the same zoom and collapse into one step
(state) => {
let newZoom = state.zoom.value - delta / 100;
// increase zoom steps the more zoomed-in we are (applies to >100% only)
newZoom +=
Math.log10(Math.max(1, state.zoom.value)) *
-sign *
// reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20);
const minZoom = state.scrollConstraints?.lockZoom
? state.scrollConstraints.zoom
: MIN_ZOOM;
const nextZoom = getNormalizedZoom(Math.max(newZoom, minZoom));
if (nextZoom === state.zoom.value) {
// at a zoom limit there is nothing to do; flipping the bitmap-cache
// flag would only redraw the scene on every tick
return null;
}
return {
...getViewportForZoomWithScrollConstraints(
{ viewportX, viewportY, nextZoom },
state,
),
shouldCacheIgnoreZoom: true,
};
},
{
zoomPreConstrained: true,
preserveScrollConstraintsSnapBack: true,
},
);
if (didTranslate) {
this.app.resetShouldCacheIgnoreZoomDebounced();
}
};
}

View File

@@ -0,0 +1,208 @@
import type { AppState, UnsubscribeCallback } from "../types";
type StateChangeSelector =
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown);
type StateChangePredicateOptions = {
predicate: (appState: AppState) => boolean;
callback?: (appState: AppState) => void;
once?: boolean;
};
type StateChangeArg = StateChangeSelector | StateChangePredicateOptions;
type StateChangeListener = {
predicate: (appState: AppState, prevState: AppState) => boolean;
getValue: (appState: AppState) => unknown;
callback: (value: any, appState: AppState) => void;
once: boolean;
};
type NormalizedStateChange = {
predicate: StateChangeListener["predicate"];
getValue: StateChangeListener["getValue"];
callback?: StateChangeListener["callback"];
once: boolean;
matchesImmediately: boolean;
};
export type OnStateChange = {
<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<K extends keyof AppState>(prop: K): Promise<AppState[K]>;
(
prop: (keyof AppState)[],
callback: (appState: AppState, appState2: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
(prop: (keyof AppState)[]): Promise<AppState>;
<T>(
prop: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<T>(prop: (appState: AppState) => T): Promise<T>;
(opts: {
predicate: (appState: AppState) => boolean;
callback: (appState: AppState) => void;
once?: boolean;
}): UnsubscribeCallback;
(opts: { predicate: (appState: AppState) => boolean }): Promise<AppState>;
(
selector: StateChangeSelector,
callback: (value: any, appState: AppState) => void,
): any;
};
export class AppStateObserver {
private listeners: StateChangeListener[] = [];
constructor(private readonly getState: () => AppState) {}
private isStateChangePredicateOptions(
propOrOpts: StateChangeArg,
): propOrOpts is StateChangePredicateOptions {
return (
typeof propOrOpts === "object" &&
!Array.isArray(propOrOpts) &&
"predicate" in propOrOpts
);
}
private subscribe(listener: StateChangeListener): UnsubscribeCallback {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(
(existingListener) => existingListener !== listener,
);
};
}
private normalize(
propOrOpts: StateChangeArg,
callback?: (value: any, appState: AppState) => void,
opts?: { once: boolean },
): NormalizedStateChange {
let predicate: StateChangeListener["predicate"];
let getValue: StateChangeListener["getValue"];
let normalizedCallback = callback;
let once = opts?.once ?? false;
let matchesImmediately = false;
if (this.isStateChangePredicateOptions(propOrOpts)) {
const {
predicate: predicateFn,
callback: callbackFromOpts,
once: onceFromOpts,
} = propOrOpts;
predicate = predicateFn;
getValue = (appState: AppState) => appState;
normalizedCallback = callbackFromOpts
? (_value: AppState, appState: AppState) => callbackFromOpts(appState)
: undefined;
once = onceFromOpts ?? false;
matchesImmediately = predicateFn(this.getState());
} else if (typeof propOrOpts === "function") {
const selector = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
selector(appState) !== selector(prevState);
getValue = (appState: AppState) => selector(appState);
} else if (Array.isArray(propOrOpts)) {
const keys = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
keys.some((key) => appState[key] !== prevState[key]);
getValue = (appState: AppState) => appState;
} else {
const key = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
appState[key] !== prevState[key];
getValue = (appState: AppState) => appState[key];
}
return {
predicate,
getValue,
callback: normalizedCallback,
once,
matchesImmediately,
};
}
public onStateChange: OnStateChange = ((
propOrOpts: StateChangeArg,
callback?: any,
opts?: { once: boolean },
) => {
const {
predicate,
getValue,
callback: stateChangeCallback,
once,
matchesImmediately,
} = this.normalize(propOrOpts, callback, opts);
if (stateChangeCallback) {
if (matchesImmediately) {
queueMicrotask(() => {
const state = this.getState();
stateChangeCallback(getValue(state), state);
});
if (once) {
return () => {};
}
}
return this.subscribe({
predicate,
getValue,
callback: stateChangeCallback,
once,
});
}
if (matchesImmediately) {
return Promise.resolve(getValue(this.getState()));
}
return new Promise<any>((resolve) => {
this.subscribe({
predicate,
getValue,
callback: (value) => resolve(value),
once: true,
});
});
}) as OnStateChange;
public flush(prevState: AppState) {
if (!this.listeners.length) {
return;
}
const state = this.getState();
const listenersToKeep: StateChangeListener[] = [];
for (const listener of this.listeners) {
if (listener.predicate(state, prevState)) {
listener.callback(listener.getValue(state), state);
if (!listener.once) {
listenersToKeep.push(listener);
}
} else {
listenersToKeep.push(listener);
}
}
this.listeners = listenersToKeep;
}
public clear() {
this.listeners = [];
}
}

View File

@@ -0,0 +1,7 @@
@use "../css/variables.module" as *;
.excalidraw {
.Avatar {
@include avatarStyles;
}
}

View File

@@ -0,0 +1,42 @@
import clsx from "clsx";
import React, { useState } from "react";
import { getNameInitial } from "../clients";
import "./Avatar.scss";
type AvatarProps = {
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string;
name: string;
src?: string;
className?: string;
};
export const Avatar = ({
color,
onClick,
name,
src,
className,
}: AvatarProps) => {
const shortName = getNameInitial(name);
const [error, setError] = useState(false);
const loadImg = !error && src;
const style = loadImg ? undefined : { background: color };
return (
<div className={clsx("Avatar", className)} style={style} onClick={onClick}>
{loadImg ? (
<img
className="Avatar-img"
src={src}
alt={shortName}
referrerPolicy="no-referrer"
onError={() => setError(true)}
/>
) : (
shortName
)}
</div>
);
};

View File

@@ -0,0 +1,43 @@
import Trans from "./Trans";
const BraveMeasureTextError = () => {
return (
<div data-testid="brave-measure-text-error">
<p>
<Trans
i18nKey="errors.brave_measure_text_error.line1"
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
/>
</p>
<p>
<Trans
i18nKey="errors.brave_measure_text_error.line2"
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
/>
</p>
<p>
<Trans
i18nKey="errors.brave_measure_text_error.line3"
link={(el) => (
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
{el}
</a>
)}
/>
</p>
<p>
<Trans
i18nKey="errors.brave_measure_text_error.line4"
issueLink={(el) => (
<a href="https://github.com/excalidraw/excalidraw/issues/new">
{el}
</a>
)}
discordLink={(el) => <a href="https://discord.gg/UexuTaE">{el}.</a>}
/>
</p>
</div>
);
};
export default BraveMeasureTextError;

View File

@@ -0,0 +1,7 @@
@use "../css/theme" as *;
.excalidraw {
.excalidraw-button {
@include outlineButtonStyles;
}
}

View File

@@ -0,0 +1,46 @@
import clsx from "clsx";
import React from "react";
import { composeEventHandlers } from "@excalidraw/common";
import "./Button.scss";
interface ButtonProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
type?: "button" | "submit" | "reset";
onSelect: () => any;
/** whether button is in active state */
selected?: boolean;
children: React.ReactNode;
className?: string;
}
/**
* A generic button component that follows Excalidraw's design system.
* Style can be customised using `className` or `style` prop.
* Accepts all props that a regular `button` element accepts.
*/
export const Button = ({
type = "button",
onSelect,
selected,
children,
className = "",
...rest
}: ButtonProps) => {
return (
<button
onClick={composeEventHandlers(rest.onClick, (event) => {
onSelect();
})}
type={type}
className={clsx("excalidraw-button", className, { selected })}
{...rest}
>
{children}
</button>
);
};

View File

@@ -0,0 +1,30 @@
import clsx from "clsx";
import type { JSX } from "react";
export const ButtonIconCycle = <T extends any>({
options,
value,
onChange,
group,
}: {
options: { value: T; text: string; icon: JSX.Element }[];
value: T | null;
onChange: (value: T) => void;
group: string;
}) => {
const current = options.find((op) => op.value === value);
const cycle = () => {
const index = options.indexOf(current!);
const next = (index + 1) % options.length;
onChange(options[next].value);
};
return (
<label key={group} className={clsx({ active: current!.value !== null })}>
<input type="button" name={group} onClick={cycle} />
{current!.icon}
</label>
);
};

View File

@@ -0,0 +1,10 @@
export const ButtonSeparator = () => (
<div
style={{
width: 1,
height: "1rem",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
);

View File

@@ -0,0 +1,57 @@
@use "../css/variables.module.scss" as *;
.excalidraw {
.Card {
display: flex;
flex-direction: column;
align-items: center;
max-width: 290px;
margin: 1em;
text-align: center;
.Card-icon {
font-size: 2.6em;
display: flex;
flex: 0 0 auto;
padding: 1.4rem;
border-radius: 50%;
background: var(--card-color);
color: #fff;
svg {
width: 2.8rem;
height: 2.8rem;
}
}
.Card-details {
font-size: 0.96em;
min-height: 90px;
padding: 0 1em;
margin-bottom: auto;
}
& .Card-button.ToolIcon_type_button {
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
background-color: var(--card-color);
&:hover {
background-color: var(--card-color-darker);
}
&:active {
background-color: var(--card-color-darkest);
}
.ToolIcon__label {
color: #fff;
}
.Spinner {
--spinner-color: #fff;
}
}
}
}

View File

@@ -0,0 +1,38 @@
import "./Card.scss";
// for open-color see https://github.com/yeun/open-color/blob/master/open-color.scss
const COLOR_MAP = {
primary: {
base: "var(--color-primary)",
darker: "var(--color-primary-darker)",
darkest: "var(--color-primary-darkest)",
},
lime: {
base: "#74b816", // open-color lime[7]
darker: "#66a80f", // open-color lime[8]
darkest: "#5c940d", // open-color lime[9]
},
pink: {
base: "#d6336c", // open-color pink[7]
darker: "#c2255c", // open-color pink[8]
darkest: "#a61e4d", // open-color pink[9]
},
};
export const Card: React.FC<{
color: "primary" | "lime" | "pink";
children?: React.ReactNode;
}> = ({ children, color }) => {
return (
<div
className="Card"
style={{
["--card-color" as any]: COLOR_MAP[color].base,
["--card-color-darker" as any]: COLOR_MAP[color].darker,
["--card-color-darkest" as any]: COLOR_MAP[color].darkest,
}}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,92 @@
@use "sass:color";
@use "../css/variables.module" as *;
.excalidraw {
.Checkbox {
margin: 4px 0.3em;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
box-shadow: 0 0 0 2px #{$color-blue-4};
}
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
svg {
display: block;
opacity: 0.3;
}
}
&:active {
.Checkbox-box {
box-shadow: 0 0 2px 1px inset #{$color-blue-7} !important;
}
}
&:hover {
.Checkbox-box {
background-color: color.adjust($color-blue-1, $alpha: -0.8);
}
}
&.is-checked {
.Checkbox-box {
background-color: #{$color-blue-1};
svg {
display: block;
}
}
&:hover .Checkbox-box {
background-color: #{$color-blue-2};
}
}
.Checkbox-box {
width: 22px;
height: 22px;
padding: 0;
flex: 0 0 auto;
margin: 0 1em;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 2px #{$color-blue-7};
background-color: transparent;
border-radius: 4px;
color: #{$color-blue-7};
border: 0;
&:focus {
box-shadow: 0 0 0 3px #{$color-blue-7};
}
svg {
display: none;
width: 16px;
height: 16px;
stroke-width: 3px;
}
}
.Checkbox-label {
display: flex;
align-items: center;
}
.excalidraw-tooltip-icon {
width: 1em;
height: 1em;
}
}
}

View File

@@ -0,0 +1,37 @@
import clsx from "clsx";
import React from "react";
import { checkIcon } from "./icons";
import "./CheckboxItem.scss";
export const CheckboxItem: React.FC<{
checked: boolean;
onChange: (checked: boolean, event: React.MouseEvent) => void;
className?: string;
children?: React.ReactNode;
}> = ({ children, checked, onChange, className }) => {
return (
<div
className={clsx("Checkbox", className, { "is-checked": checked })}
onClick={(event) => {
onChange(!checked, event);
(
(event.currentTarget as HTMLDivElement).querySelector(
".Checkbox-box",
) as HTMLButtonElement
).focus();
}}
>
<button
type="button"
className="Checkbox-box"
role="checkbox"
aria-checked={checked}
>
{checkIcon}
</button>
<div className="Checkbox-label">{children}</div>
</div>
);
};

View File

@@ -0,0 +1,155 @@
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS, normalizeInputColor } from "@excalidraw/common";
import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n";
import { useEditorInterface } from "../App";
import { activeEyeDropperAtom } from "../EyeDropper";
import { eyeDropperIcon } from "../icons";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils";
export const ColorInput = ({
color,
onChange,
label,
colorPickerType,
placeholder,
}: {
color: string;
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}) => {
const editorInterface = useEditorInterface();
const [innerValue, setInnerValue] = useState(color);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [activeSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
useEffect(() => {
setInnerValue(color);
}, [color]);
const changeColor = useCallback(
(inputValue: string) => {
const value = inputValue.toLowerCase().trim();
const color = normalizeInputColor(value);
if (color) {
onChange(color);
setErrorMessage(null);
} else if (value.length === 0) {
setErrorMessage(null);
} else if (/^#?[0-9a-f]+$/.test(value)) {
setErrorMessage(t("colorPicker.invalidHexLength"));
} else {
setErrorMessage(t("colorPicker.invalidColor"));
}
setInnerValue(value);
},
[onChange],
);
const inputRef = useRef<HTMLInputElement>(null);
const eyeDropperTriggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [activeSection]);
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
useEffect(() => {
return () => {
setEyeDropperState(null);
};
}, [setEyeDropperState]);
return (
<div className="color-picker__input-label-container">
<div
className={clsx("color-picker__input-label", {
"has-error": errorMessage,
})}
>
<div className="color-picker__input-hash">#</div>
<input
ref={activeSection === "hex" ? inputRef : undefined}
style={{ border: 0, padding: 0 }}
spellCheck={false}
className="color-picker-input"
aria-label={label}
aria-invalid={!!errorMessage}
onChange={(event) => {
changeColor(event.target.value);
}}
value={(innerValue || "").replace(/^#/, "")}
onBlur={() => {
setInnerValue(color);
setErrorMessage(null);
}}
tabIndex={-1}
onFocus={() => setActiveColorPickerSection("hex")}
onKeyDown={(event) => {
if (event.key === KEYS.TAB) {
return;
} else if (event.key === KEYS.ESCAPE) {
eyeDropperTriggerRef.current?.focus();
}
event.stopPropagation();
}}
placeholder={placeholder}
/>
{/* TODO reenable on mobile with a better UX */}
{editorInterface.formFactor !== "phone" && (
<>
<div
style={{
width: "1px",
height: "1.25rem",
backgroundColor: "var(--default-border-color)",
}}
/>
<div
ref={eyeDropperTriggerRef}
className={clsx("excalidraw-eye-dropper-trigger", {
selected: eyeDropperState,
})}
onClick={() =>
setEyeDropperState((s) =>
s
? null
: {
keepOpenOnAlt: false,
onSelect: (color) => onChange(color),
colorPickerType,
},
)
}
title={`${t(
"labels.eyeDropper",
)} — ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `}
>
{eyeDropperIcon}
</div>
</>
)}
</div>
{errorMessage && (
<div className="color-picker__error-message" role="alert">
{errorMessage}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,598 @@
@use "sass:color";
@use "../../css/variables.module" as *;
.excalidraw {
--color-outline: var(--color-gray-20);
&.theme--dark {
--color-outline: var(--color-gray-80);
}
.focus-visible-none {
&:focus-visible {
outline: none !important;
}
}
.color-picker__title {
padding: 0 0.5rem;
font-size: 0.875rem;
text-align: left;
}
.color-picker__heading {
padding: 0 0.5rem;
font-size: 0.75rem;
text-align: left;
}
.color-picker__context-menu {
background: var(--popup-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
padding: 0.25rem;
}
.color-picker__context-menu-item {
font-size: 0.75rem;
padding: 0.375rem 0.625rem;
border-radius: var(--border-radius-md);
color: var(--text-primary-color);
cursor: pointer;
outline: none;
user-select: none;
// :hover as well — radix only sets data-highlighted once the item gets
// focus via pointermove, which leaves dead frames (e.g. menu opening
// under a stationary cursor)
&[data-highlighted],
&:not([data-disabled]):hover {
background: var(--button-hover-bg);
}
&[data-disabled] {
opacity: 0.5;
pointer-events: none;
}
}
.color-picker__tip {
padding: 0 0.5rem;
font-size: 0.6875rem;
line-height: 1.4;
text-align: left;
color: var(--color-gray-60);
}
.color-picker-container {
display: grid;
grid-template-columns: 1fr 20px 1.625rem;
padding: 0.25rem 0px;
align-items: center;
@include isMobile {
max-width: 11rem;
}
&.color-picker-container--no-top-picks {
display: flex;
justify-content: center;
grid-template-columns: unset;
}
}
.color-picker__top-picks {
display: flex;
justify-content: space-between;
align-items: center;
}
.color-picker__button {
--radius: 4px;
--size: 1.375rem;
&.has-outline {
box-shadow: 0 0 0 1px var(--color-outline);
}
padding: 0;
margin: 0;
width: var(--size);
height: var(--size);
border: 0;
border-radius: var(--radius);
background-color: var(--swatch-color);
background-position: left center;
position: relative;
font-family: inherit;
box-sizing: border-box;
&:hover:not(.active).color-picker__button--large {
box-shadow: inset 0 0 0 1px var(--color-primary);
}
&:active {
box-shadow: 0 0 0 1px var(--color-primary) !important;
}
&:active.color-picker__button--large {
box-shadow: none !important;
&.has-outline {
box-shadow: 0 0 0 1px var(--color-outline) !important;
}
}
.color-picker__button-outline {
display: flex;
align-items: center;
justify-content: center;
svg {
color: var(--color-gray-60);
width: 1.25rem;
height: 1.25rem;
}
}
.color-picker__button-background {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
}
}
&.active:not(:active) {
.color-picker__button-outline {
position: absolute;
--offset: -1px;
top: var(--offset);
left: var(--offset);
right: var(--offset);
bottom: var(--offset);
box-shadow: 0 0 0 1px var(--color-primary-darkest);
z-index: 1; // due hover state so this has preference
border-radius: var(--radius);
}
}
&:focus-visible {
outline: none;
&::after {
content: "";
position: absolute;
top: -4px;
right: -4px;
bottom: -4px;
left: -4px;
border: 3px solid var(--focus-highlight-color);
border-radius: calc(var(--radius) + 1px);
}
&.active {
.color-picker__button-outline {
display: none;
}
}
}
&--large {
--radius: 6px;
width: 1.875rem;
height: 1.875rem;
}
&.is-transparent {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==");
}
&--no-focus-visible {
border: 0;
&::after {
display: none;
}
&:focus-visible {
outline: none !important;
}
}
&.active-color {
border-radius: calc(var(--radius) + 1px);
width: 1.625rem;
height: 1.625rem;
}
&.compact-sizing {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
}
&.mobile-border {
border: 1px solid var(--mobile-color-border);
}
}
.color-picker__button__hotkey-label {
position: absolute;
right: 4px;
bottom: 2px;
font-size: 10px;
font-family: monospace;
font-weight: 500;
}
.color-picker {
background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: color.adjust(#000, $alpha: -0.75) 0 1px 4px;
border-radius: 4px;
position: absolute;
:root[dir="ltr"] & {
left: -5.5px;
}
:root[dir="rtl"] & {
right: -5.5px;
}
}
.color-picker-control-container {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
column-gap: 0.5rem;
}
.color-picker-control-container + .popover {
position: static;
}
.color-picker-popover-container {
margin-top: -0.25rem;
:root[dir="ltr"] & {
margin-left: 0.5rem;
}
:root[dir="rtl"] & {
margin-left: -3rem;
}
}
.color-picker-triangle {
width: 0;
height: 0;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color);
position: absolute;
top: 10px;
:root[dir="ltr"] & {
transform: rotate(270deg);
left: -14px;
}
:root[dir="rtl"] & {
transform: rotate(90deg);
right: -14px;
}
}
.color-picker-triangle-shadow {
border-color: transparent transparent color.adjust(#000, $alpha: -0.9);
:root[dir="ltr"] & {
left: -14px;
}
:root[dir="rtl"] & {
right: -16px;
}
}
.color-picker-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
outline: none;
}
.color-picker-content--default {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(5, 1.875rem);
grid-gap: 0.25rem;
border-radius: 4px;
&:focus {
outline: none;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
}
.color-picker-content--canvas {
display: flex;
flex-direction: column;
padding: 0.25rem;
&-title {
color: $color-gray-6;
font-size: 12px;
padding: 0 0.25rem;
}
&-colors {
padding: 0.5rem 0;
}
}
.color-picker-content .color-input-container {
grid-column: 1 / span 5;
}
.color-picker-hash {
height: var(--default-button-size);
flex-shrink: 0;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border: 1px solid var(--default-border-color);
border-right: 0;
box-sizing: border-box;
:root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
}
:root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
}
color: var(--input-label-color);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.color-input-container {
display: flex;
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
}
}
.color-picker__input-label-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.color-picker__input-label {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 8px;
align-items: center;
border: 1px solid var(--default-border-color);
border-radius: 8px;
padding: 0 12px;
margin: 8px;
box-sizing: border-box;
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
}
&.has-error {
border-color: var(--color-danger, #e03131);
&:focus-within {
box-shadow: 0 0 0 1px var(--color-danger, #e03131);
}
}
}
.color-picker__input-hash {
padding: 0 0.25rem;
}
.color-picker__error-message {
color: var(--color-danger, #e03131);
font-size: 0.75rem;
padding: 0 8px 4px 8px;
text-align: left;
line-height: 1.3;
}
.color-picker-input {
box-sizing: border-box;
width: 100%;
margin: 0;
font-size: 0.875rem;
font-family: inherit;
background-color: transparent;
color: var(--text-primary-color);
border: 0;
outline: none;
height: var(--default-button-size);
border: 1px solid var(--default-border-color);
border-left: 0;
letter-spacing: 0.4px;
:root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
}
:root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-left: 1px solid var(--default-border-color);
border-right: 0;
}
padding: 0.5rem;
padding-left: 0.25rem;
appearance: none;
&:focus-visible {
box-shadow: none;
}
}
// top-picks drag & drop (pinning + reordering)
// ---------------------------------------------------------------------------
.color-picker__top-picks {
// anchor for the animated drop-target outline
position: relative;
}
.color-picker__top-picks .color-picker__button {
// the strip is tiny and never scrolls — allow touch-dragging picks
touch-action: none;
}
.color-picker__top-picks.is-dnd-active {
.color-picker__button {
// no hover/tooltips while a drag is in flight; hit-testing is
// coordinate-based so this doesn't affect dropping
pointer-events: none;
transition: transform 0.14s ease, opacity 0.14s ease,
box-shadow 0.14s ease;
}
}
// "marching ants" outline hinting that the strip accepts the drop —
// SVG because CSS dashed outlines/borders can't animate their dash offset
.color-picker__top-picks__dnd-outline {
position: absolute;
top: -5px;
left: -5px;
width: calc(100% + 10px);
height: calc(100% + 10px);
pointer-events: none;
overflow: visible;
rect {
x: 0.5px;
y: 0.5px;
rx: 6px;
width: calc(100% - 1px);
height: calc(100% - 1px);
fill: none;
stroke: var(--color-primary);
stroke-width: 1;
stroke-dasharray: 4 4;
animation: excalidraw-color-dnd-march 0.8s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
@keyframes excalidraw-color-dnd-march {
to {
// one full dash+gap period per cycle → seamless loop
stroke-dashoffset: -8;
}
}
// the pick being reordered — its ghost is following the pointer
.color-picker__button.is-dnd-source {
opacity: 0.3;
}
// slot a dragged swatch would replace
.color-picker__button.is-dnd-target {
transform: scale(1.2);
box-shadow: 0 0 0 1px var(--color-primary-darkest);
}
// this color is already pinned — drop not allowed
.color-picker__button.is-dnd-duplicate {
animation: excalidraw-color-dnd-duplicate 0.45s ease-in-out infinite
alternate;
}
@keyframes excalidraw-color-dnd-duplicate {
from {
box-shadow: 0 0 0 2px var(--color-danger, #e03131);
transform: scale(1);
}
to {
box-shadow: 0 0 0 3px var(--color-danger, #e03131);
transform: scale(1.12);
}
}
}
// drag & drop ghost — appended to document.body (outside the .excalidraw
// scope) so it can follow the pointer without overflow clipping
.excalidraw-color-dnd-ghost {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
pointer-events: none;
will-change: transform;
// NOTE transform is deliberately not transitioned here (tracks the pointer
// every frame); --dropping adds it for the fly-to-target animation
transition: width 0.15s ease, height 0.15s ease;
&__swatch {
width: 100%;
height: 100%;
border-radius: 4px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
transition: transform 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease;
&.is-transparent {
background-color: #fff;
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==");
background-position: left center;
}
}
&--lifted &__swatch {
transform: scale(1.2);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.08);
}
&--over &__swatch {
transform: scale(1.05);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.08);
}
&--blocked &__swatch {
opacity: 0.4;
transform: scale(0.85);
}
&--dropping {
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.3, 1), width 0.18s ease,
height 0.18s ease;
}
&--dropping &__swatch {
transform: scale(1);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
}
&--fade &__swatch {
opacity: 0;
}
}
body.excalidraw-color-dnd-active {
user-select: none !important;
-webkit-user-select: none !important;
&,
* {
cursor: grabbing !important;
}
}

View File

@@ -0,0 +1,539 @@
import { Popover } from "radix-ui";
import clsx from "clsx";
import { memo, useRef, useEffect } from "react";
import {
applyDarkModeFilter,
COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS,
isColorDark,
isWritableElement,
THEME,
} from "@excalidraw/common";
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n";
import { useApp, useExcalidrawContainer, useStylesPanelMode } from "../App";
import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import { slashIcon, strokeIcon } from "../icons";
import {
saveCaretPosition,
restoreCaretPosition,
temporarilyDisableTextEditorBlur,
} from "../../hooks/useTextEditorFocus";
import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading";
import { TopPicks } from "./TopPicks";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import {
ColorPickerDnDContext,
useColorPickerDnD,
useTopPicksDnD,
} from "./topPicksDnD";
import "./ColorPicker.scss";
import type { ColorPickerType } from "./colorPickerUtils";
import type { AppState, UIAppState } from "../../types";
interface ColorPickerProps {
type: ColorPickerType;
/**
* null indicates no color should be displayed as active
* (e.g. when multiple shapes selected with different colors)
*/
color: string | null;
onChange: (color: string) => void;
label: string;
elements: readonly ExcalidrawElement[];
appState: UIAppState;
palette?: ColorPaletteCustom | null;
topPicks?: ColorTuple;
updateData: (formData?: any) => void;
/** palette colors to hide from the popup, keeping hotkey positions */
excludedColors?: readonly string[];
/** allow users to customize the top picks strip by drag & dropping colors
* from the picker popup onto it. The value names the
* `appState.colorTopPicks` slot the customization is stored in */
customizableTopPicks?: keyof AppState["colorTopPicks"];
}
const ColorPickerPopupContent = ({
type,
color,
onChange,
label,
elements,
palette = COLOR_PALETTE,
updateData,
getOpenPopup,
appState,
excludedColors,
}: Pick<
ColorPickerProps,
| "type"
| "color"
| "onChange"
| "label"
| "elements"
| "palette"
| "updateData"
| "appState"
| "excludedColors"
> & {
getOpenPopup: () => AppState["openPopup"];
}) => {
const { container } = useExcalidrawContainer();
const app = useApp();
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
const isMobileMode = stylesPanelMode === "mobile";
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
const colorInputJSX = (
<div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
<ColorInput
color={color || ""}
label={label}
onChange={(color) => {
onChange(color);
}}
colorPickerType={type}
placeholder={t("colorPicker.color")}
/>
</div>
);
const colorPickerContentRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => {
colorPickerContentRef.current?.focus();
};
return (
<PropertiesPopover
container={container}
style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onFocusOutside={(event) => {
const target = event.target;
// focus moving into the top-picks context menu — let the menu keep
// it (stealing it back would clear the menu's focus-driven
// `data-highlighted` styling and break its keyboard navigation)
if (
target instanceof app.ownerWindow.HTMLElement &&
target.closest(".color-picker__context-menu")
) {
event.preventDefault();
return;
}
// refocus due to eye dropper
if (!isWritableElement(event.target)) {
if (
target instanceof app.ownerWindow.HTMLElement &&
target.matches(":active")
) {
// the focus escaped because a button outside is being pressed
// (e.g. a top pick) — stealing focus mid-press makes the browser
// drop the button's activation (`:active` styling), so wait for
// the release
app.ownerWindow.addEventListener("pointerup", focusPickerContent, {
once: true,
});
} else {
focusPickerContent();
}
}
event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault();
}
}}
onClose={() => {
// only clear if we're still the active popup (avoid racing with switch)
if (getOpenPopup() === type) {
updateData({ openPopup: null });
}
setActiveColorPickerSection(null);
// Refocus text editor when popover closes if we were editing text
if (appState.editingTextElement) {
app.ownerWindow.setTimeout(() => {
const textEditor = app.ownerDocument.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
>
{palette ? (
<Picker
ref={colorPickerContentRef}
palette={palette}
excludedColors={excludedColors}
color={color}
theme={appState.theme}
onChange={(changedColor) => {
// Save caret position before color change if editing text
const savedSelection = appState.editingTextElement
? saveCaretPosition(app.ownerDocument)
: null;
onChange(changedColor);
// Restore caret position after color change if editing text
if (appState.editingTextElement && savedSelection) {
restoreCaretPosition(savedSelection, app.ownerDocument);
}
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
colorPickerType: type,
};
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
colorPickerType: type,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else {
// close explicitly on Escape
updateData({ openPopup: null });
}
}}
type={type}
elements={elements}
updateData={updateData}
showTitle={isCompactMode}
showHotKey={!isMobileMode}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
</PropertiesPopover>
);
};
const isColorPickerPopup = (
popup: AppState["openPopup"],
): popup is ColorPickerType =>
popup === "elementStroke" ||
popup === "elementBackground" ||
popup === "canvasBackground";
const ColorPickerTrigger = ({
label,
color,
type,
theme,
mode = "background",
onToggle,
editingTextElement,
}: {
color: string | null;
label: string;
type: ColorPickerType;
theme: Theme;
mode?: "background" | "stroke";
onToggle: () => void;
editingTextElement?: boolean;
}) => {
const app = useApp();
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
const isMobileMode = stylesPanelMode === "mobile";
const dnd = useColorPickerDnD();
const displayColor = color
? applyDarkModeFilter(color, theme === THEME.DARK)
: null;
const handleClick = (e: React.MouseEvent) => {
// use pointerdown so we run before outside-close logic
e.preventDefault();
e.stopPropagation();
// If editing text, temporarily disable the wysiwyg blur event
if (editingTextElement) {
temporarilyDisableTextEditorBlur(app.ownerDocument);
}
onToggle();
};
return (
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": !color || color === "transparent",
"has-outline":
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
"compact-sizing": isCompactMode,
"mobile-border": isMobileMode,
})}
aria-label={label}
style={displayColor ? { "--swatch-color": displayColor } : undefined}
title={
type === "elementStroke"
? t("labels.showStroke")
: t("labels.showBackground")
}
data-openpopup={type}
onClick={handleClick}
// the active-color swatch can be dragged onto the top-picks strip to
// pin the current (possibly custom) color
onPointerDown={
dnd ? (event) => dnd.startSwatchDrag(event, color) : undefined
}
>
<div className="color-picker__button-outline">{!color && slashIcon}</div>
{isCompactMode && color && mode === "stroke" && (
<div className="color-picker__button-background">
<span
style={{
color:
displayColor &&
isColorDark(displayColor, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{strokeIcon}
</span>
</div>
)}
</Popover.Trigger>
);
};
const ColorPickerComponent = ({
type,
color,
onChange,
label,
elements,
palette = COLOR_PALETTE,
topPicks,
updateData,
appState,
excludedColors,
customizableTopPicks,
}: ColorPickerProps) => {
const openRef = useRef(appState.openPopup);
useEffect(() => {
openRef.current = appState.openPopup;
}, [appState.openPopup]);
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
const isTopPicksCustomizable = !!customizableTopPicks && !isCompactMode;
// user-pinned picks trump the (host-provided or default) baseline
const customTopPicks =
isTopPicksCustomizable && customizableTopPicks
? appState.colorTopPicks?.[customizableTopPicks]
: null;
// fully-resolved picks currently displayed in the strip — the baseline the
// drag & drop customization starts from
const effectiveTopPicks = customTopPicks?.length
? customTopPicks
: topPicks ??
(type === "elementStroke"
? DEFAULT_ELEMENT_STROKE_PICKS
: DEFAULT_ELEMENT_BACKGROUND_PICKS);
const dnd = useTopPicksDnD({
enabled: isTopPicksCustomizable,
picks: effectiveTopPicks,
onPicksChange: (picks) => {
if (!customizableTopPicks) {
return;
}
// NOTE the comparator comparing `appState.colorTopPicks` is what keeps
// this captured spread fresh — see `areColorPickerPropsEqual`
updateData({
colorTopPicks: {
...appState.colorTopPicks,
[customizableTopPicks]: picks,
},
});
},
});
return (
<ColorPickerDnDContext.Provider value={isTopPicksCustomizable ? dnd : null}>
<div
role="dialog"
aria-modal="true"
className={clsx("color-picker-container", {
"color-picker-container--no-top-picks": isCompactMode,
})}
>
{!isCompactMode && (
<TopPicks
theme={appState.theme}
activeColor={color}
onChange={(pickedColor) => {
onChange(pickedColor);
// if another color-picker popup is open, follow the user's
// focus over to this picker (same as clicking its trigger)
if (
isColorPickerPopup(appState.openPopup) &&
appState.openPopup !== type
) {
// deferred: each updateData spreads the full pre-commit
// appState, so issuing this in the same task would clobber
// the color change committed by `onChange` above
setTimeout(() => updateData({ openPopup: type }), 0);
}
}}
type={type}
topPicks={customTopPicks?.length ? customTopPicks : topPicks}
isCustomized={!!customTopPicks?.length}
onReset={
isTopPicksCustomizable && customizableTopPicks
? () => {
updateData({
colorTopPicks: {
...appState.colorTopPicks,
[customizableTopPicks]: null,
},
});
}
: undefined
}
/>
)}
{!isCompactMode && <ButtonSeparator />}
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {
if (open) {
updateData({ openPopup: type });
}
}}
>
{/* serves as an active color indicator as well */}
<ColorPickerTrigger
color={color}
label={label}
type={type}
theme={appState.theme}
mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement}
onToggle={() => {
// atomic switch: if another popup is open, close it first, then open this one next tick
if (appState.openPopup === type) {
// toggle off on same trigger
updateData({ openPopup: null });
} else if (appState.openPopup) {
updateData({ openPopup: type });
} else {
// open this one
updateData({ openPopup: type });
}
}}
/>
{/* popup content */}
{appState.openPopup === type && (
<ColorPickerPopupContent
type={type}
color={color}
onChange={onChange}
label={label}
elements={elements}
palette={palette}
excludedColors={excludedColors}
updateData={updateData}
getOpenPopup={() => openRef.current}
appState={appState}
/>
)}
</Popover.Root>
</div>
</ColorPickerDnDContext.Provider>
);
};
/**
* ColorPicker renders on every styles-panel render (selection churn during
* box-select and the like), while its actual inputs are a handful of resolved
* primitives and reference-stable objects — cheap `===` checks, no deep
* comparisons:
* - `palette` / `topPicks` / `excludedColors` are module constants,
* - `appState.colorTopPicks` identity changes exactly when its content does.
*
* Function props (`onChange`, `updateData`) are deliberately ignored — they
* are recreated every render but semantically stable (they read fresh state
* at call time). Same for `elements`, which is only sampled once when the
* popup opens (most-used custom colors).
*/
const areColorPickerPropsEqual = (
prev: ColorPickerProps,
next: ColorPickerProps,
) => {
// the open popup is interactive (hotkeys, eye dropper, most-used custom
// colors) — never memoize it, including the open/close transitions
if (
prev.appState.openPopup === prev.type ||
next.appState.openPopup === next.type
) {
return false;
}
return (
prev.type === next.type &&
prev.color === next.color &&
prev.label === next.label &&
// popup open/close is rare, and the strip's click handler branches on
// which popup is open (popup-switching) — keep its closure fresh
prev.appState.openPopup === next.appState.openPopup &&
prev.palette === next.palette &&
prev.topPicks === next.topPicks &&
prev.excludedColors === next.excludedColors &&
prev.customizableTopPicks === next.customizableTopPicks &&
prev.appState.theme === next.appState.theme &&
prev.appState.colorTopPicks === next.appState.colorTopPicks &&
// the trigger tweaks its click behavior while a text element is edited
!!prev.appState.editingTextElement === !!next.appState.editingTextElement
);
};
export const ColorPicker = memo(ColorPickerComponent, areColorPickerPropsEqual);

View File

@@ -0,0 +1,77 @@
import clsx from "clsx";
import { useEffect, useRef } from "react";
import { applyDarkModeFilter, THEME } from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { useAtom } from "../../editor-jotai";
import HotkeyLabel from "./HotkeyLabel";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useColorPickerDnD } from "./topPicksDnD";
interface CustomColorListProps {
theme: Theme;
colors: string[];
color: string | null;
onChange: (color: string) => void;
label: string;
}
export const CustomColorList = ({
theme,
colors,
color,
onChange,
label,
}: CustomColorListProps) => {
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const dnd = useColorPickerDnD();
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (btnRef.current) {
btnRef.current.focus();
}
}, [color, activeColorPickerSection]);
return (
<div className="color-picker-content--default">
{colors.map((c, i) => {
const displayColor = applyDarkModeFilter(c, theme === THEME.DARK);
return (
<button
ref={color === c ? btnRef : undefined}
tabIndex={-1}
type="button"
className={clsx(
"color-picker__button color-picker__button--large has-outline",
{
active: color === c,
"is-transparent": c === "transparent" || !c,
},
)}
onClick={() => {
onChange(c);
setActiveColorPickerSection("custom");
}}
onPointerDown={
dnd ? (event) => dnd.startSwatchDrag(event, c) : undefined
}
title={c}
aria-label={label}
style={{ "--swatch-color": displayColor }}
key={i}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={displayColor} keyLabel={i + 1} />
</button>
);
})}
</div>
);
};

View File

@@ -0,0 +1,27 @@
import React from "react";
import { isColorDark } from "@excalidraw/common";
interface HotkeyLabelProps {
color: string;
keyLabel: string | number;
isShade?: boolean;
}
const HotkeyLabel = ({
color,
keyLabel,
isShade = false,
}: HotkeyLabelProps) => {
return (
<div
className="color-picker__button__hotkey-label"
style={{
color: isColorDark(color) ? "#fff" : "#000",
}}
>
{isShade && "⇧"}
{keyLabel}
</div>
);
};
export default HotkeyLabel;

View File

@@ -0,0 +1,227 @@
import React, { useEffect, useImperativeHandle, useState } from "react";
import { EVENT } from "@excalidraw/common";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
KEYS,
} from "@excalidraw/common";
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
import type { ColorPaletteCustom } from "@excalidraw/common";
import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n";
import { CustomColorList } from "./CustomColorList";
import PickerColorList from "./PickerColorList";
import PickerHeading from "./PickerHeading";
import { ShadeList } from "./ShadeList";
import {
activeColorPickerSectionAtom,
getColorNameAndShadeFromColor,
getMostUsedCustomColors,
isCustomColor,
} from "./colorPickerUtils";
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
import { useColorPickerDnD } from "./topPicksDnD";
import type { ColorPickerType } from "./colorPickerUtils";
interface PickerProps {
theme: Theme;
color: string | null;
onChange: (color: string) => void;
type: ColorPickerType;
elements: readonly ExcalidrawElement[];
palette: ColorPaletteCustom;
updateData: (formData?: any) => void;
children?: React.ReactNode;
showTitle?: boolean;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
showHotKey?: boolean;
excludedColors?: readonly string[];
}
export const Picker = React.forwardRef(
(
{
theme,
color,
onChange,
type,
elements,
palette,
updateData,
children,
showTitle,
onEyeDropperToggle,
onEscape,
showHotKey = true,
excludedColors,
}: PickerProps,
ref,
) => {
const title = showTitle
? type === "elementStroke"
? t("labels.stroke")
: type === "elementBackground"
? t("labels.background")
: null
: null;
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
return [];
}
return getMostUsedCustomColors(elements, type, palette);
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const dnd = useColorPickerDnD();
const colorObj = getColorNameAndShadeFromColor({
color,
palette,
});
useEffect(() => {
if (!activeColorPickerSection) {
const isCustom = !!color && isCustomColor({ color, palette });
const isCustomButNotInList = isCustom && !customColors.includes(color);
setActiveColorPickerSection(
isCustomButNotInList
? null
: isCustom
? "custom"
: colorObj?.shade != null
? "shades"
: "baseColors",
);
}
}, [
activeColorPickerSection,
color,
palette,
setActiveColorPickerSection,
colorObj,
customColors,
]);
const [activeShade, setActiveShade] = useState(
colorObj?.shade ??
(type === "elementBackground"
? DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX
: DEFAULT_ELEMENT_STROKE_COLOR_INDEX),
);
useEffect(() => {
if (colorObj?.shade != null) {
setActiveShade(colorObj.shade);
}
const keyup = (event: KeyboardEvent) => {
if (event.key === KEYS.ALT) {
onEyeDropperToggle(false);
}
};
document.addEventListener(EVENT.KEYUP, keyup, { capture: true });
return () => {
document.removeEventListener(EVENT.KEYUP, keyup, { capture: true });
};
}, [colorObj, onEyeDropperToggle]);
const pickerRef = React.useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => pickerRef.current!);
useEffect(() => {
pickerRef?.current?.focus();
}, []);
return (
<div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
<div
ref={pickerRef}
onKeyDown={(event) => {
const handled = colorPickerKeyNavHandler({
event,
activeColorPickerSection,
palette,
color,
onChange,
onEyeDropperToggle,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
onEscape,
excludedColors,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}}
className="color-picker-content properties-content"
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>
{title && <div className="color-picker__title">{title}</div>}
{!!customColors.length && (
<div>
<PickerHeading>
{t("colorPicker.mostUsedCustomColors")}
</PickerHeading>
<CustomColorList
theme={theme}
colors={customColors}
color={color}
label={t("colorPicker.mostUsedCustomColors")}
onChange={onChange}
/>
</div>
)}
<div>
<PickerHeading>{t("colorPicker.colors")}</PickerHeading>
<PickerColorList
theme={theme}
color={color}
palette={palette}
onChange={onChange}
activeShade={activeShade}
showHotKey={showHotKey}
excludedColors={excludedColors}
/>
</div>
<div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
<ShadeList
theme={theme}
color={color}
onChange={onChange}
palette={palette}
showHotKey={showHotKey}
/>
</div>
{children}
{/* dnd context is only provided when top picks are customizable */}
{dnd && (
<div className="color-picker__tip">
{t("colorPicker.topPicksTip")}
</div>
)}
</div>
</div>
);
},
);

View File

@@ -0,0 +1,132 @@
import clsx from "clsx";
import { useEffect, useRef } from "react";
import {
applyDarkModeFilter,
isColorDark,
THEME,
type ColorPaletteCustom,
} from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n";
import HotkeyLabel from "./HotkeyLabel";
import {
activeColorPickerSectionAtom,
colorPickerHotkeyBindings,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import { useColorPickerDnD } from "./topPicksDnD";
import type { TranslationKeys } from "../../i18n";
interface PickerColorListProps {
theme: Theme;
palette: ColorPaletteCustom;
color: string | null;
onChange: (color: string) => void;
activeShade: number;
showHotKey?: boolean;
/**
* palette colors to hide. Hidden entries keep their position in the hotkey
* order (their hotkey goes dead instead of remapping the colors after them).
*/
excludedColors?: readonly string[];
}
const PickerColorList = ({
theme,
palette,
color,
onChange,
activeShade,
showHotKey = true,
excludedColors,
}: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color,
palette,
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const dnd = useColorPickerDnD();
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (btnRef.current && activeColorPickerSection === "baseColors") {
btnRef.current.focus();
}
}, [colorObj?.colorName, activeColorPickerSection]);
return (
<div className="color-picker-content--default">
{Object.entries(palette).map(([key, value], index) => {
const color =
(Array.isArray(value) ? value[activeShade] : value) || "transparent";
if (excludedColors?.includes(color)) {
// invisible placeholder so the remaining colors keep their exact
// grid positions (and hotkeys) — muscle memory stays intact
return (
<div
key={key}
className="color-picker__button color-picker__button--large"
style={{ visibility: "hidden" }}
aria-hidden="true"
/>
);
}
const keybinding = colorPickerHotkeyBindings[index];
const displayColor = applyDarkModeFilter(color, theme === THEME.DARK);
const label = t(
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
null,
"",
);
return (
<button
ref={colorObj?.colorName === key ? btnRef : undefined}
tabIndex={-1}
type="button"
className={clsx(
"color-picker__button color-picker__button--large",
{
"has-outline": !isColorDark(color, 255),
active: colorObj?.colorName === key,
"is-transparent": color === "transparent" || !color,
},
)}
onClick={() => {
onChange(color);
setActiveColorPickerSection("baseColors");
}}
onPointerDown={
dnd ? (event) => dnd.startSwatchDrag(event, color) : undefined
}
title={`${label}${
color.startsWith("#") ? ` ${color}` : ""
} — ${keybinding}`}
aria-label={`${label} — ${keybinding}`}
style={color ? { "--swatch-color": displayColor } : undefined}
data-testid={`color-${key}`}
key={key}
>
<div className="color-picker__button-outline" />
{showHotKey && (
<HotkeyLabel color={displayColor} keyLabel={keybinding} />
)}
</button>
);
})}
</div>
);
};
export default PickerColorList;

View File

@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
const PickerHeading = ({ children }: { children: ReactNode }) => (
<div className="color-picker__heading">{children}</div>
);
export default PickerHeading;

View File

@@ -0,0 +1,137 @@
import clsx from "clsx";
import { useEffect, useRef } from "react";
import {
applyDarkModeFilter,
THEME,
type ColorPaletteCustom,
} from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n";
import HotkeyLabel from "./HotkeyLabel";
import {
activeColorPickerSectionAtom,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import { useColorPickerDnD } from "./topPicksDnD";
interface ShadeListProps {
theme: Theme;
color: string | null;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
showHotKey?: boolean;
}
export const ShadeList = ({
theme,
color,
onChange,
palette,
showHotKey,
}: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
palette,
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const dnd = useColorPickerDnD();
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (btnRef.current && activeColorPickerSection === "shades") {
btnRef.current.focus();
}
}, [colorObj, activeColorPickerSection]);
if (colorObj) {
const { colorName, shade } = colorObj;
const shades = palette[colorName];
if (Array.isArray(shades)) {
return (
<div className="color-picker-content--default shades">
{shades.map((color, i) => {
const displayColor = applyDarkModeFilter(
color,
theme === THEME.DARK,
);
return (
<button
ref={
i === shade && activeColorPickerSection === "shades"
? btnRef
: undefined
}
tabIndex={-1}
key={i}
type="button"
className={clsx(
"color-picker__button color-picker__button--large",
{
active: i === shade,
},
)}
aria-label="Shade"
title={`${colorName} - ${i + 1}`}
style={color ? { "--swatch-color": displayColor } : undefined}
onClick={() => {
onChange(color);
setActiveColorPickerSection("shades");
}}
onPointerDown={
dnd ? (event) => dnd.startSwatchDrag(event, color) : undefined
}
>
<div className="color-picker__button-outline" />
{showHotKey && (
<HotkeyLabel color={displayColor} keyLabel={i + 1} isShade />
)}
</button>
);
})}
</div>
);
}
}
return (
<div
className="color-picker-content--default"
style={{ position: "relative" }}
tabIndex={-1}
>
<button
type="button"
tabIndex={-1}
className="color-picker__button color-picker__button--large color-picker__button--no-focus-visible"
/>
<div
tabIndex={-1}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
fontSize: "0.75rem",
}}
>
{t("colorPicker.noShades")}
</div>
</div>
);
};

View File

@@ -0,0 +1,184 @@
import clsx from "clsx";
import { ContextMenu } from "radix-ui";
import {
applyDarkModeFilter,
COLOR_OUTLINE_CONTRAST_THRESHOLD,
DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS,
isColorDark,
THEME,
} from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { t } from "../../i18n";
import { useExcalidrawContainer } from "../App";
import { useColorPickerDnD } from "./topPicksDnD";
import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps {
theme: Theme;
onChange: (color: string) => void;
type: ColorPickerType;
activeColor: string | null;
topPicks?: readonly string[];
/** present when the strip is user-customizable — enables the right-click
* context menu resetting the strip to its default picks */
onReset?: () => void;
/** whether custom picks are currently applied (enables the reset item) */
isCustomized?: boolean;
}
export const TopPicks = ({
theme,
onChange,
type,
activeColor,
topPicks,
onReset,
isCustomized,
}: TopPicksProps) => {
const dnd = useColorPickerDnD();
const dragState = dnd?.dragState ?? null;
const { container } = useExcalidrawContainer();
let colors;
if (type === "elementStroke") {
colors = DEFAULT_ELEMENT_STROKE_PICKS;
}
if (type === "elementBackground") {
colors = DEFAULT_ELEMENT_BACKGROUND_PICKS;
}
if (type === "canvasBackground") {
colors = DEFAULT_CANVAS_BACKGROUND_PICKS;
}
// this one can overwrite defaults
if (topPicks) {
colors = topPicks;
}
if (!colors) {
console.error("Invalid type for TopPicks");
return null;
}
// live preview of the reorder result — every pick translates to the slot
// it would occupy if dropped right now
const getReorderOffset = (index: number) => {
if (
!dragState ||
dragState.origin.kind !== "pick" ||
dragState.overIndex === null
) {
return 0;
}
const from = dragState.origin.index;
const to = dragState.overIndex;
if (from === to) {
return 0;
}
let newIndex = index;
if (index === from) {
newIndex = to;
} else {
if (index > from) {
newIndex -= 1;
}
if (newIndex >= to) {
newIndex += 1;
}
}
return (newIndex - index) * dragState.slotSpan;
};
const strip = (
<div
className={clsx("color-picker__top-picks", {
"is-dnd-active": !!dragState,
})}
ref={dnd?.setStripEl}
>
{dragState && (
<svg
className="color-picker__top-picks__dnd-outline"
aria-hidden="true"
>
<rect />
</svg>
)}
{colors.map((color: string, index: number) => {
const reorderOffset = getReorderOffset(index);
const displayColor = applyDarkModeFilter(color, theme === THEME.DARK);
return (
<button
className={clsx("color-picker__button", {
active: color === activeColor,
"is-transparent": color === "transparent" || !color,
"has-outline": !isColorDark(
color,
COLOR_OUTLINE_CONTRAST_THRESHOLD,
),
"is-dnd-source":
dragState?.origin.kind === "pick" &&
dragState.origin.index === index,
"is-dnd-target":
dragState?.origin.kind === "swatch" &&
dragState.overIndex === index,
"is-dnd-duplicate": dragState?.duplicateIndex === index,
})}
style={{
"--swatch-color": displayColor,
transform: reorderOffset
? `translateX(${reorderOffset}px)`
: undefined,
}}
key={color}
type="button"
title={color}
onClick={() => onChange(color)}
onPointerDown={
dnd
? (event) => dnd.startPickDrag(event, index, color)
: undefined
}
data-testid={`color-top-pick-${color}`}
data-top-pick-index={index}
>
<div className="color-picker__button-outline" />
</button>
);
})}
</div>
);
if (!onReset) {
return strip;
}
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>{strip}</ContextMenu.Trigger>
<ContextMenu.Portal container={container}>
<ContextMenu.Content
className="color-picker__context-menu"
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
>
<ContextMenu.Item
className="color-picker__context-menu-item"
disabled={!isCustomized}
onSelect={onReset}
>
{t("colorPicker.resetTopPicks")}
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
);
};

View File

@@ -0,0 +1,107 @@
import {
isTransparent,
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
} from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { ColorPickerColor, ColorPaletteCustom } from "@excalidraw/common";
import { atom } from "../../editor-jotai";
export const getColorNameAndShadeFromColor = ({
palette,
color,
}: {
palette: ColorPaletteCustom;
color: string | null;
}): {
colorName: ColorPickerColor;
shade: number | null;
} | null => {
if (!color) {
return null;
}
for (const [colorName, colorVal] of Object.entries(palette)) {
if (Array.isArray(colorVal)) {
const shade = colorVal.indexOf(color);
if (shade > -1) {
return { colorName: colorName as ColorPickerColor, shade };
}
} else if (colorVal === color) {
return { colorName: colorName as ColorPickerColor, shade: null };
}
}
return null;
};
export const colorPickerHotkeyBindings = [
["q", "w", "e", "r", "t"],
["a", "s", "d", "f", "g"],
["z", "x", "c", "v", "b"],
].flat();
export const isCustomColor = ({
color,
palette,
}: {
color: string;
palette: ColorPaletteCustom;
}) => {
const paletteValues = Object.values(palette).flat();
return !paletteValues.includes(color);
};
export const getMostUsedCustomColors = (
elements: readonly ExcalidrawElement[],
type: "elementBackground" | "elementStroke",
palette: ColorPaletteCustom,
) => {
const elementColorTypeMap = {
elementBackground: "backgroundColor",
elementStroke: "strokeColor",
};
const colors = elements.filter((element) => {
if (element.isDeleted) {
return false;
}
const color =
element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
// fully-transparent colors are "no color", not a reusable custom color
// (matters for palettes that exclude `transparent`, e.g. bucket fill)
return !isTransparent(color) && isCustomColor({ color, palette });
});
const colorCountMap = new Map<string, number>();
colors.forEach((element) => {
const color =
element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
if (colorCountMap.has(color)) {
colorCountMap.set(color, colorCountMap.get(color)! + 1);
} else {
colorCountMap.set(color, 1);
}
});
return [...colorCountMap.entries()]
.sort((a, b) => b[1] - a[1])
.map((c) => c[0])
.slice(0, MAX_CUSTOM_COLORS_USED_IN_CANVAS);
};
export type ActiveColorPickerSectionAtomType =
| "custom"
| "baseColors"
| "shades"
| "hex"
| null;
export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null);
export type ColorPickerType =
| "canvasBackground"
| "elementBackground"
| "elementStroke";

View File

@@ -0,0 +1,318 @@
import { COLORS_PER_ROW, COLOR_PALETTE, KEYS } from "@excalidraw/common";
import type {
ColorPickerColor,
ColorPalette,
ColorPaletteCustom,
} from "@excalidraw/common";
import type { ValueOf } from "@excalidraw/common/utility-types";
import {
colorPickerHotkeyBindings,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import type { ActiveColorPickerSectionAtomType } from "./colorPickerUtils";
const arrowHandler = (
eventKey: string,
currentIndex: number | null,
length: number,
) => {
const rows = Math.ceil(length / COLORS_PER_ROW);
currentIndex = currentIndex ?? -1;
switch (eventKey) {
case "ArrowLeft": {
const prevIndex = currentIndex - 1;
return prevIndex < 0 ? length - 1 : prevIndex;
}
case "ArrowRight": {
return (currentIndex + 1) % length;
}
case "ArrowDown": {
const nextIndex = currentIndex + COLORS_PER_ROW;
return nextIndex >= length ? currentIndex % COLORS_PER_ROW : nextIndex;
}
case "ArrowUp": {
const prevIndex = currentIndex - COLORS_PER_ROW;
const newIndex =
prevIndex < 0 ? COLORS_PER_ROW * rows + prevIndex : prevIndex;
return newIndex >= length ? undefined : newIndex;
}
}
};
interface HotkeyHandlerProps {
e: React.KeyboardEvent;
colorObj: { colorName: ColorPickerColor; shade: number | null } | null;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
customColors: string[];
setActiveColorPickerSection: (
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
) => void;
activeShade: number;
excludedColors?: readonly string[];
}
/**
* @returns true if the event was handled
*/
const hotkeyHandler = ({
e,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
excludedColors,
}: HotkeyHandlerProps): boolean => {
if (colorObj?.shade != null) {
// shift + numpad is extremely messed up on windows apparently
if (
["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].includes(e.code) &&
e.shiftKey
) {
const newShade = Number(e.code.slice(-1)) - 1;
onChange(palette[colorObj.colorName][newShade]);
setActiveColorPickerSection("shades");
return true;
}
}
if (["1", "2", "3", "4", "5"].includes(e.key)) {
const c = customColors[Number(e.key) - 1];
if (c) {
onChange(customColors[Number(e.key) - 1]);
setActiveColorPickerSection("custom");
return true;
}
}
if (colorPickerHotkeyBindings.includes(e.key)) {
const index = colorPickerHotkeyBindings.indexOf(e.key);
const paletteKey = Object.keys(palette)[index] as keyof ColorPalette;
const paletteValue = palette[paletteKey];
const r = Array.isArray(paletteValue)
? paletteValue[activeShade]
: paletteValue;
// hotkeys of excluded (hidden) or absent palette entries are dead, not
// remapped — this keeps every other color on its usual key. Still
// HANDLED: within the modal's keyboard scope the key is deliberately
// inert, and reporting it unhandled would let it escape to global
// shortcuts (`q` toggles the tool lock)
if (r == null || excludedColors?.includes(r)) {
return true;
}
onChange(r);
setActiveColorPickerSection("baseColors");
return true;
}
return false;
};
interface ColorPickerKeyNavHandlerProps {
event: React.KeyboardEvent;
activeColorPickerSection: ActiveColorPickerSectionAtomType;
palette: ColorPaletteCustom;
color: string | null;
onChange: (color: string) => void;
customColors: string[];
setActiveColorPickerSection: (
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
) => void;
updateData: (formData?: any) => void;
activeShade: number;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
excludedColors?: readonly string[];
}
/**
* @returns true if the event was handled
*/
export const colorPickerKeyNavHandler = ({
event,
activeColorPickerSection,
palette,
color,
onChange,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
onEyeDropperToggle,
onEscape,
excludedColors,
}: ColorPickerKeyNavHandlerProps): boolean => {
if (event[KEYS.CTRL_OR_CMD]) {
return false;
}
if (event.key === KEYS.ESCAPE) {
onEscape(event);
return true;
}
// checkt using `key` to ignore combos with Alt modifier
if (event.key === KEYS.ALT) {
onEyeDropperToggle(true);
return true;
}
if (event.key === KEYS.I) {
onEyeDropperToggle();
return true;
}
const colorObj = getColorNameAndShadeFromColor({ color, palette });
if (event.key === KEYS.TAB) {
const sectionsMap: Record<
NonNullable<ActiveColorPickerSectionAtomType>,
boolean
> = {
custom: !!customColors.length,
baseColors: true,
shades: colorObj?.shade != null,
hex: true,
};
const sections = Object.entries(sectionsMap).reduce((acc, [key, value]) => {
if (value) {
acc.push(key as ActiveColorPickerSectionAtomType);
}
return acc;
}, [] as ActiveColorPickerSectionAtomType[]);
const activeSectionIndex = sections.indexOf(activeColorPickerSection);
const indexOffset = event.shiftKey ? -1 : 1;
const nextSectionIndex =
activeSectionIndex + indexOffset > sections.length - 1
? 0
: activeSectionIndex + indexOffset < 0
? sections.length - 1
: activeSectionIndex + indexOffset;
const nextSection = sections[nextSectionIndex];
if (nextSection) {
setActiveColorPickerSection(nextSection);
}
if (nextSection === "custom") {
onChange(customColors[0]);
} else if (nextSection === "baseColors") {
const baseColorName = (
Object.entries(palette) as [string, ValueOf<ColorPalette>][]
).find(([name, shades]) => {
if (Array.isArray(shades)) {
return shades.includes(color);
} else if (shades === color) {
return name;
}
return null;
});
if (!baseColorName) {
onChange(COLOR_PALETTE.black);
}
}
event.preventDefault();
event.stopPropagation();
return true;
}
if (
hotkeyHandler({
e: event,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
excludedColors,
})
) {
return true;
}
if (activeColorPickerSection === "shades") {
if (colorObj) {
const { shade } = colorObj;
const newShade = arrowHandler(event.key, shade, COLORS_PER_ROW);
if (newShade !== undefined) {
onChange(palette[colorObj.colorName][newShade]);
return true;
}
}
}
if (activeColorPickerSection === "baseColors") {
if (colorObj) {
const { colorName } = colorObj;
const colorNames = Object.keys(palette) as (keyof ColorPalette)[];
const indexOfColorName = colorNames.indexOf(colorName);
let newColorIndex = arrowHandler(
event.key,
indexOfColorName,
colorNames.length,
);
// step over excluded (hidden) palette entries, continuing in the
// arrow's direction
let guard = 0;
while (newColorIndex !== undefined && guard++ < colorNames.length) {
const value = palette[colorNames[newColorIndex]];
const resolved = Array.isArray(value) ? value[activeShade] : value;
if (!excludedColors?.includes(resolved)) {
break;
}
newColorIndex = arrowHandler(
event.key,
newColorIndex,
colorNames.length,
);
}
if (newColorIndex !== undefined) {
const newColorName = colorNames[newColorIndex];
const newColorNameValue = palette[newColorName];
onChange(
Array.isArray(newColorNameValue)
? newColorNameValue[activeShade]
: newColorNameValue,
);
return true;
}
}
}
if (activeColorPickerSection === "custom") {
const indexOfColor = color != null ? customColors.indexOf(color) : 0;
const newColorIndex = arrowHandler(
event.key,
indexOfColor,
customColors.length,
);
if (newColorIndex !== undefined) {
const newColor = customColors[newColorIndex];
onChange(newColor);
return true;
}
}
return false;
};

View File

@@ -0,0 +1,572 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { colorToHex, isTransparent } from "@excalidraw/common";
/**
* Custom (non-native) drag & drop for pinning colors to the color-picker
* top-picks strip and reordering the strip itself.
*
* Implemented with pointer events + a manually rendered "ghost" swatch so we
* control the visuals fully (native HTML5 dnd flickers and forces ugly
* ghosting/cursors).
*/
const GHOST_CLASS = "excalidraw-color-dnd-ghost";
const BODY_CLASS = "excalidraw-color-dnd-active";
const DRAG_THRESHOLD = 10;
/** a fast sloppy click can travel many px — releases faster than this stay
* clicks; the drag only starts once the pointer is held this long */
const DRAG_TIME_THRESHOLD_MS = 100;
/** value-equality of colors — normalizes notation (`#fff` vs `#ffffff` vs
* `white`) so visually identical colors can't occupy multiple pick slots */
const isSameColor = (a: string, b: string) => {
if (a.toLowerCase() === b.toLowerCase()) {
return true;
}
const aHex = colorToHex(a);
return aHex !== null && aHex === colorToHex(b);
};
type DragOrigin =
// a color dragged from the picker popup (palette/shades/custom) or the
// active-color trigger — dropping replaces the hovered pick
| { kind: "swatch" }
// a pick dragged from the top-picks strip itself — dropping reorders
| { kind: "pick"; index: number };
export type TopPicksDragState = {
color: string;
origin: DragOrigin;
/** hovered strip slot — the slot to replace (swatch drags) or the final
* position (pick reorders). null while the pointer is outside the strip */
overIndex: number | null;
/** index of an already-pinned identical color that blocks the drop */
duplicateIndex: number | null;
/** signed distance between strip slot centers (for reorder preview) */
slotSpan: number;
} | null;
type DragSession = {
pointerId: number;
startX: number;
startY: number;
startTime: number;
lastX: number;
lastY: number;
/** pending delayed activation (spatial threshold crossed before the
* temporal one) */
activationTimer: number | null;
color: string;
origin: DragOrigin;
sourceEl: HTMLElement;
sourceRect: DOMRect;
activated: boolean;
ghost: HTMLDivElement | null;
ghostW: number;
ghostH: number;
slotRects: DOMRect[];
slotSpan: number;
hitRect: { left: number; right: number; top: number; bottom: number } | null;
overIndex: number | null;
duplicateIndex: number | null;
};
export type ColorPickerDnD = {
dragState: TopPicksDragState;
startSwatchDrag: (event: React.PointerEvent, color: string | null) => void;
startPickDrag: (
event: React.PointerEvent,
index: number,
color: string,
) => void;
setStripEl: (el: HTMLDivElement | null) => void;
};
export const ColorPickerDnDContext = createContext<ColorPickerDnD | null>(null);
export const useColorPickerDnD = () => useContext(ColorPickerDnDContext);
export const useTopPicksDnD = ({
enabled,
picks,
onPicksChange,
}: {
enabled: boolean;
picks: readonly string[];
onPicksChange: (picks: string[]) => void;
}): ColorPickerDnD => {
const [dragState, setDragState] = useState<TopPicksDragState>(null);
const stripRef = useRef<HTMLDivElement | null>(null);
const latestRef = useRef({ enabled, picks, onPicksChange });
latestRef.current = { enabled, picks, onPicksChange };
const controller = useMemo(() => {
let session: DragSession | null = null;
const publish = () => {
if (session?.activated) {
setDragState({
color: session.color,
origin: session.origin,
overIndex: session.overIndex,
duplicateIndex: session.duplicateIndex,
slotSpan: session.slotSpan,
});
} else {
setDragState(null);
}
};
const measureStrip = () => {
const strip = stripRef.current;
if (!strip || !session) {
return false;
}
const buttons = Array.from(
strip.querySelectorAll<HTMLElement>("[data-top-pick-index]"),
).sort(
(a, b) =>
Number(a.dataset.topPickIndex) - Number(b.dataset.topPickIndex),
);
if (!buttons.length) {
return false;
}
session.slotRects = buttons.map((button) =>
button.getBoundingClientRect(),
);
const [first, second] = session.slotRects;
// strip is evenly spaced (space-between); sign flips under RTL
session.slotSpan = second ? second.left - first.left : first.width + 4;
const stripRect = strip.getBoundingClientRect();
const padX = Math.max(Math.abs(session.slotSpan) / 2, 10);
session.hitRect = {
left: stripRect.left - padX,
right: stripRect.right + padX,
top: stripRect.top - 14,
bottom: stripRect.bottom + 14,
};
return true;
};
const positionGhost = (x: number, y: number) => {
if (session?.ghost) {
session.ghost.style.transform = `translate(${
x - session.ghostW / 2
}px, ${y - session.ghostH / 2}px)`;
}
};
const setGhostSize = (
width: number,
height: number,
x: number,
y: number,
) => {
if (!session?.ghost) {
return;
}
if (session.ghostW !== width || session.ghostH !== height) {
session.ghostW = width;
session.ghostH = height;
session.ghost.style.width = `${width}px`;
session.ghost.style.height = `${height}px`;
positionGhost(x, y);
}
};
const activate = (x: number, y: number) => {
if (!session) {
return;
}
const { sourceRect, sourceEl, color } = session;
const ghost = document.createElement("div");
ghost.className = GHOST_CLASS;
const swatch = document.createElement("div");
swatch.className = `${GHOST_CLASS}__swatch`;
if (isTransparent(color)) {
swatch.classList.add("is-transparent");
} else {
// swatches render the theme-adjusted color (dark mode remaps colors
// rather than CSS-filtering them) — sample the rendered color so the
// ghost matches what the user picked up
const rendered = getComputedStyle(sourceEl).backgroundColor;
swatch.style.backgroundColor =
rendered && rendered !== "rgba(0, 0, 0, 0)" ? rendered : color;
}
ghost.appendChild(swatch);
document.body.appendChild(ghost);
session.ghost = ghost;
session.ghostW = sourceRect.width;
session.ghostH = sourceRect.height;
ghost.style.width = `${sourceRect.width}px`;
ghost.style.height = `${sourceRect.height}px`;
positionGhost(x, y);
// let the spawn frame paint at rest, then "lift" (scale-up transition)
requestAnimationFrame(() => {
// unless the drag already ended — don't restyle a ghost that's
// mid-flight in its release animation (or already removed)
if (session?.ghost === ghost) {
ghost.classList.add(`${GHOST_CLASS}--lifted`);
}
});
document.body.classList.add(BODY_CLASS);
session.activated = true;
publish();
};
const hitTest = (x: number, y: number) => {
if (!session?.hitRect) {
return;
}
const { hitRect, slotRects } = session;
let overIndex: number | null = null;
let duplicateIndex: number | null = null;
if (
x >= hitRect.left &&
x <= hitRect.right &&
y >= hitRect.top &&
y <= hitRect.bottom
) {
let best = 0;
let bestDistance = Infinity;
slotRects.forEach((rect, index) => {
const distance = Math.abs(x - (rect.left + rect.width / 2));
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
});
if (session.origin.kind === "swatch") {
const duplicate = latestRef.current.picks.findIndex((pick) =>
isSameColor(pick, session!.color),
);
if (duplicate !== -1) {
duplicateIndex = duplicate;
} else {
overIndex = best;
}
} else {
overIndex = best;
}
}
if (
overIndex !== session.overIndex ||
duplicateIndex !== session.duplicateIndex
) {
session.overIndex = overIndex;
session.duplicateIndex = duplicateIndex;
const { ghost } = session;
if (ghost) {
ghost.classList.toggle(`${GHOST_CLASS}--over`, overIndex !== null);
ghost.classList.toggle(
`${GHOST_CLASS}--blocked`,
duplicateIndex !== null,
);
}
publish();
}
// over the strip the ghost morphs to slot size to preview the landing
if (overIndex !== null) {
const rect = session.slotRects[overIndex];
setGhostSize(rect.width, rect.height, x, y);
} else {
setGhostSize(session.sourceRect.width, session.sourceRect.height, x, y);
}
};
const releaseGhost = (target: { rect: DOMRect | null }) => {
if (!session?.ghost) {
return;
}
const { ghost } = session;
session.ghost = null;
ghost.classList.add(`${GHOST_CLASS}--dropping`);
const rect = target.rect;
if (rect) {
ghost.style.width = `${rect.width}px`;
ghost.style.height = `${rect.height}px`;
ghost.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
}
window.setTimeout(() => {
ghost.classList.add(`${GHOST_CLASS}--fade`);
}, 160);
window.setTimeout(() => {
ghost.remove();
}, 340);
};
// kill any in-flight (or retargetable) reorder-preview transitions before
// the drag state is torn down. Running CSS transitions survive both the
// removal of the `transition` property (spec: transition-* changes don't
// affect running transitions) and React's keyed DOM reorder on commit —
// the browser retargets them, which made the picks visibly re-shift on
// drop. Clearing the transforms with transitions disabled (+ forced
// reflow) cancels them for good, so the commit paints the final order
// directly.
const settleStripInstantly = () => {
const strip = stripRef.current;
if (!strip || !session?.activated) {
return;
}
const buttons = Array.from(
strip.querySelectorAll<HTMLElement>("[data-top-pick-index]"),
);
for (const button of buttons) {
button.style.transition = "none";
button.style.transform = "none";
}
// flush the non-animated state...
void strip.offsetWidth;
// ...then let the stylesheet govern transitions again (next drag)
for (const button of buttons) {
button.style.transition = "";
}
};
const suppressNextClick = () => {
const suppress = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
window.addEventListener("click", suppress, { capture: true, once: true });
window.setTimeout(() => {
window.removeEventListener("click", suppress, { capture: true });
}, 100);
};
const removeListeners = () => {
window.removeEventListener("pointermove", onPointerMove, true);
window.removeEventListener("pointerup", onPointerUp, true);
window.removeEventListener("pointercancel", onPointerCancel, true);
window.removeEventListener("keydown", onKeyDown, true);
};
const dispose = () => {
removeListeners();
document.body.classList.remove(BODY_CLASS);
if (session?.activationTimer != null) {
window.clearTimeout(session.activationTimer);
}
session = null;
setDragState(null);
};
const cancelDrag = (animate: boolean) => {
if (!session) {
return;
}
settleStripInstantly();
if (session.ghost) {
if (animate) {
releaseGhost({ rect: session.sourceRect });
} else {
session.ghost.remove();
session.ghost = null;
}
}
dispose();
};
const tryActivate = (x: number, y: number) => {
if (!session || session.activated) {
return;
}
if (!measureStrip()) {
dispose();
return;
}
activate(x, y);
hitTest(x, y);
};
const onPointerMove = (event: PointerEvent) => {
if (!session || event.pointerId !== session.pointerId) {
return;
}
session.lastX = event.clientX;
session.lastY = event.clientY;
if (!session.activated) {
if (
Math.hypot(
event.clientX - session.startX,
event.clientY - session.startY,
) < DRAG_THRESHOLD
) {
// back inside the click tolerance — a delayed activation scheduled
// while we were beyond it no longer applies (wiggle-and-return
// must stay a click)
if (session.activationTimer !== null) {
window.clearTimeout(session.activationTimer);
session.activationTimer = null;
}
return;
}
const elapsed = performance.now() - session.startTime;
if (elapsed < DRAG_TIME_THRESHOLD_MS) {
// spatial threshold crossed, temporal not yet — likely a fast
// sloppy click. Wait out the rest of the grace period; the timeout
// covers "flick then hold still", where no further moves fire
if (session.activationTimer === null) {
session.activationTimer = window.setTimeout(() => {
if (session) {
session.activationTimer = null;
// the pointer may have returned inside the tolerance after
// the last event we saw — never activate from within it
if (
Math.hypot(
session.lastX - session.startX,
session.lastY - session.startY,
) >= DRAG_THRESHOLD
) {
tryActivate(session.lastX, session.lastY);
}
}
}, DRAG_TIME_THRESHOLD_MS - elapsed);
}
return;
}
tryActivate(event.clientX, event.clientY);
if (!session?.activated) {
return;
}
}
event.preventDefault();
positionGhost(event.clientX, event.clientY);
hitTest(event.clientX, event.clientY);
};
const onPointerUp = (event: PointerEvent) => {
if (!session || event.pointerId !== session.pointerId) {
return;
}
if (!session.activated) {
// never became a drag — let the regular click happen
dispose();
return;
}
event.preventDefault();
event.stopPropagation();
suppressNextClick();
const { overIndex, origin, color, slotRects } = session;
const { picks, onPicksChange } = latestRef.current;
settleStripInstantly();
if (overIndex !== null) {
if (origin.kind === "swatch") {
if (!picks.some((pick) => isSameColor(pick, color))) {
const next = [...picks];
next[overIndex] = color;
onPicksChange(next);
}
} else if (origin.index !== overIndex) {
const next = [...picks];
const [moved] = next.splice(origin.index, 1);
next.splice(overIndex, 0, moved);
onPicksChange(next);
}
releaseGhost({ rect: slotRects[overIndex] });
} else {
releaseGhost({ rect: session.sourceRect });
}
dispose();
};
const onPointerCancel = (event: PointerEvent) => {
if (session && event.pointerId === session.pointerId) {
cancelDrag(true);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (session?.activated && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
cancelDrag(true);
}
};
const begin = (
event: React.PointerEvent,
color: string | null,
origin: DragOrigin,
) => {
if (
!latestRef.current.enabled ||
session ||
event.button !== 0 ||
!color
) {
return;
}
session = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startTime: performance.now(),
lastX: event.clientX,
lastY: event.clientY,
activationTimer: null,
color,
origin,
sourceEl: event.currentTarget as HTMLElement,
sourceRect: (
event.currentTarget as HTMLElement
).getBoundingClientRect(),
activated: false,
ghost: null,
ghostW: 0,
ghostH: 0,
slotRects: [],
slotSpan: 0,
hitRect: null,
overIndex: null,
duplicateIndex: null,
};
window.addEventListener("pointermove", onPointerMove, true);
window.addEventListener("pointerup", onPointerUp, true);
window.addEventListener("pointercancel", onPointerCancel, true);
window.addEventListener("keydown", onKeyDown, true);
};
return {
begin,
destroy: () => cancelDrag(false),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// cancel a drag in flight if the picker unmounts mid-drag
useEffect(() => () => controller.destroy(), [controller]);
return useMemo(
() => ({
dragState,
startSwatchDrag: (event, color) =>
controller.begin(event, color, { kind: "swatch" }),
startPickDrag: (event, index, color) =>
controller.begin(event, color, { kind: "pick", index }),
setStripEl: (el) => {
stripRef.current = el;
},
}),
[dragState, controller],
);
};

View File

@@ -0,0 +1,159 @@
@use "../../css/variables.module" as *;
$verticalBreakpoint: 861px;
.excalidraw {
.command-palette-dialog {
user-select: none;
.Modal__content {
height: auto;
max-height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
max-height: 750px;
height: 100%;
}
.Island {
height: 100%;
padding: 1.5rem;
}
.Dialog__content {
height: 100%;
display: flex;
flex-direction: column;
}
}
.shortcuts-wrapper {
display: flex;
justify-content: center;
align-items: center;
margin-top: 12px;
gap: 1.5rem;
}
.shortcut {
display: flex;
justify-content: center;
align-items: center;
height: 16px;
font-size: 10px;
gap: 0.25rem;
.shortcut-wrapper {
display: flex;
}
.shortcut-plus {
margin: 0px 4px;
}
.shortcut-key {
padding: 0px 4px;
height: 16px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--color-primary-light);
}
.shortcut-desc {
margin-left: 4px;
color: var(--color-gray-50);
}
}
.commands {
overflow-y: auto;
box-sizing: border-box;
margin-top: 12px;
color: var(--popup-text-color);
user-select: none;
.command-category {
display: flex;
flex-direction: column;
padding: 12px 0px;
margin-right: 0.25rem;
}
.command-category-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 6px;
display: flex;
align-items: center;
}
.command-item {
color: var(--popup-text-color);
height: 2.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
padding: 0 0.5rem;
border-radius: var(--border-radius-lg);
cursor: pointer;
--icon-size: 1rem;
&.command-item-large {
height: 2.75rem;
--icon-size: 1.5rem;
.icon {
width: var(--icon-size);
height: var(--icon-size);
margin-right: 0.625rem;
}
}
&:active {
background-color: var(--color-surface-low);
}
.name {
display: flex;
align-items: center;
gap: 0.25rem;
overflow: hidden;
}
}
.item-selected {
background-color: var(--color-surface-mid);
}
.item-disabled {
opacity: 0.3;
cursor: not-allowed;
}
.no-match {
display: flex;
justify-content: center;
align-items: center;
margin-top: 36px;
}
}
.icon {
width: var(--icon-size, 1rem);
height: var(--icon-size, 1rem);
margin-right: 0.375rem;
.library-item-icon {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,25 @@
import type { ActionManager } from "../../actions/manager";
import type { Action } from "../../actions/types";
export type CommandPaletteItem = {
label: string;
/** additional keywords to match against
* (appended to haystack, not displayed) */
keywords?: string[];
/**
* string we should match against when searching
* (deburred name + keywords)
*/
haystack?: string;
icon?: Action["icon"];
category: string;
order?: number;
predicate?: boolean | Action["predicate"];
shortcut?: string | null;
/** if false, command will not show while in view mode */
viewMode?: boolean;
perform: (data: {
actionManager: ActionManager;
event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent;
}) => void;
};

View File

@@ -0,0 +1,11 @@
@use "../css/variables.module" as *;
.excalidraw {
.confirm-dialog {
&-buttons {
display: flex;
column-gap: 0.5rem;
justify-content: flex-end;
}
}
}

View File

@@ -0,0 +1,81 @@
import { flushSync } from "react-dom";
import { useSetAtom } from "../editor-jotai";
import { t } from "../i18n";
import { Dialog } from "./Dialog";
import DialogActionButton from "./DialogActionButton";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
import "./ConfirmDialog.scss";
import type { DialogProps } from "./Dialog";
interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void;
onCancel: () => void;
confirmText?: string;
cancelText?: string;
}
const ConfirmDialog = (props: Props) => {
const {
onConfirm,
onCancel,
children,
confirmText = t("buttons.confirm"),
cancelText = t("buttons.cancel"),
className = "",
...rest
} = props;
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const { container } = useExcalidrawContainer();
return (
<Dialog
onCloseRequest={onCancel}
size="small"
{...rest}
className={`confirm-dialog ${className}`}
>
{children}
<div className="confirm-dialog-buttons">
<DialogActionButton
label={cancelText}
onClick={() => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
// flush any pending updates synchronously,
// otherwise it could lead to crash in some chromium versions (131.0.6778.86),
// when `.focus` is invoked with container in some intermediate state
// (container seems mounted in DOM, but focus still causes a crash)
flushSync(() => {
onCancel();
});
container?.focus();
}}
/>
<DialogActionButton
label={confirmText}
onClick={() => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
// flush any pending updates synchronously,
// otherwise it leads to crash in some chromium versions (131.0.6778.86),
// when `.focus` is invoked with container in some intermediate state
// (container seems mounted in DOM, but focus still causes a crash)
flushSync(() => {
onConfirm();
});
container?.focus();
}}
actionType="danger"
/>
</div>
</Dialog>
);
};
export default ConfirmDialog;

View File

@@ -0,0 +1,102 @@
@use "sass:color";
@use "../css/variables.module" as *;
.excalidraw {
.context-menu-popover {
z-index: var(--zIndex-ui-context-menu);
}
.context-menu {
position: relative;
border-radius: 4px;
box-shadow: 0 3px 10px color.adjust(#000, $alpha: -0.8);
list-style: none;
user-select: none;
margin: 0;
padding: 0.5rem 0;
background-color: var(--popup-secondary-bg-color);
border: 1px solid var(--button-gray-3);
cursor: default;
}
.context-menu button {
color: var(--popup-text-color);
}
.context-menu-item {
position: relative;
width: 100%;
min-width: 9.5rem;
margin: 0;
padding: 0.25rem 1rem 0.25rem 1.25rem;
text-align: start;
border-radius: 0;
background-color: transparent;
border: none;
white-space: nowrap;
font-family: inherit;
display: grid;
grid-template-columns: 1fr 0.2fr;
align-items: center;
&.checkmark::before {
position: absolute;
left: 6px;
margin-bottom: 1px;
content: "\2713";
}
&.dangerous {
.context-menu-item__label {
color: $color-red-7;
}
}
.context-menu-item__label {
justify-self: start;
margin-inline-end: 20px;
}
.context-menu-item__shortcut {
justify-self: end;
opacity: 0.6;
font-family: inherit;
font-size: 0.7rem;
}
}
.context-menu-item:hover {
color: var(--popup-bg-color);
background-color: var(--select-highlight-color);
&.dangerous {
.context-menu-item__label {
color: var(--popup-bg-color);
}
background-color: $color-red-6;
}
}
.context-menu-item:focus {
z-index: 1;
}
@include isMobile {
.context-menu-item {
display: block;
.context-menu-item__label {
margin-inline-end: 0;
}
.context-menu-item__shortcut {
display: none;
}
}
}
.context-menu-item-separator {
border: none;
border-top: 1px solid $color-gray-5;
}
}

View File

@@ -0,0 +1,132 @@
import clsx from "clsx";
import React from "react";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { t } from "../i18n";
import { useExcalidrawAppState, useExcalidrawElements } from "./App";
import { Popover } from "./Popover";
import "./ContextMenu.scss";
import type { ActionManager } from "../actions/manager";
import type { ShortcutName } from "../actions/shortcuts";
import type { Action } from "../actions/types";
import type { TranslationKeys } from "../i18n";
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[];
type ContextMenuProps = {
actionManager: ActionManager;
items: ContextMenuItems;
top: number;
left: number;
onClose: (callback?: () => void) => void;
};
export const CONTEXT_MENU_SEPARATOR = "separator";
export const ContextMenu = React.memo(
({ actionManager, items, top, left, onClose }: ContextMenuProps) => {
const appState = useExcalidrawAppState();
const elements = useExcalidrawElements();
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
if (
item &&
(item === CONTEXT_MENU_SEPARATOR ||
!item.predicate ||
item.predicate(
elements,
appState,
actionManager.app.props,
actionManager.app,
))
) {
acc.push(item);
}
return acc;
}, []);
return (
<Popover
onCloseRequest={() => {
onClose();
}}
top={top}
left={left}
fitInViewport={true}
viewportWidth={appState.width}
viewportHeight={appState.height}
className="context-menu-popover"
>
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{filteredItems.map((item, idx) => {
if (item === CONTEXT_MENU_SEPARATOR) {
if (
!filteredItems[idx - 1] ||
filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR
) {
return null;
}
return <hr key={idx} className="context-menu-item-separator" />;
}
const actionName = item.name;
let label = "";
if (item.label) {
if (typeof item.label === "function") {
label = t(
item.label(
elements,
appState,
actionManager.app,
) as unknown as TranslationKeys,
);
} else {
label = t(item.label as unknown as TranslationKeys);
}
}
return (
<li
key={idx}
data-testid={actionName}
onClick={() => {
// we need update state before executing the action in case
// the action uses the appState it's being passed (that still
// contains a defined contextMenu) to return the next state.
onClose(() => {
actionManager.executeAction(item, "contextMenu");
});
}}
>
<button
type="button"
className={clsx("context-menu-item", {
dangerous: actionName === "deleteSelectedElements",
checkmark: item.checked?.(appState),
})}
>
<div className="context-menu-item__label">{label}</div>
<kbd className="context-menu-item__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
);
},
);

View File

@@ -0,0 +1,18 @@
@use "../css/variables.module" as *;
.excalidraw {
.ConvertElementTypePopup {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.2rem;
border-radius: 0.5rem;
background: var(--island-bg-color);
box-shadow: var(--shadow-island);
padding: 0.5rem;
&:focus {
outline: none;
}
}
}

View File

@@ -0,0 +1,954 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import {
bumpVersion,
getLinearElementSubType,
mutateElement,
updateElbowArrowPoints,
} from "@excalidraw/element";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
import {
hasBoundTextElement,
isArrowBoundToElement,
isArrowElement,
isElbowArrow,
isLinearElement,
isUsingAdaptiveRadius,
} from "@excalidraw/element";
import {
getCommonBoundingBox,
getElementAbsoluteCoords,
} from "@excalidraw/element";
import {
getBoundTextElement,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
redrawTextBoundingBox,
} from "@excalidraw/element";
import { wrapText } from "@excalidraw/element";
import {
assertNever,
CLASSES,
getFontString,
isProdEnv,
mapFind,
reduceToCommonValue,
ROUNDNESS,
sceneCoordsToViewportCoords,
updateActiveTool,
} from "@excalidraw/common";
import { measureText } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import {
newArrowElement,
newElement,
newLinearElement,
} from "@excalidraw/element";
import { ShapeCache } from "@excalidraw/element";
import { updateBindings } from "@excalidraw/element";
import type {
ConvertibleGenericTypes,
ConvertibleLinearTypes,
ConvertibleTypes,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawLinearElement,
ExcalidrawRectangleElement,
ExcalidrawSelectionElement,
ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer,
FixedSegment,
NonDeleted,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type { Scene } from "@excalidraw/element";
import { trackEvent } from "../analytics";
import { atom } from "../editor-jotai";
import "./ConvertElementTypePopup.scss";
import { IconButton } from "./IconButton";
import {
DiamondIcon,
elbowArrowIcon,
EllipseIcon,
LineIcon,
RectangleIcon,
roundArrowIcon,
sharpArrowIcon,
} from "./icons";
import type App from "./App";
import type { AppClassProperties } from "../types";
const GAP_HORIZONTAL = 8;
const GAP_VERTICAL = 10;
type ExcalidrawConvertibleElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawLinearElement;
// indicates order of switching
const GENERIC_TYPES = ["rectangle", "diamond", "ellipse"] as const;
// indicates order of switching
const LINEAR_TYPES = [
"line",
"sharpArrow",
"curvedArrow",
"elbowArrow",
] as const;
const CONVERTIBLE_GENERIC_TYPES: ReadonlySet<ConvertibleGenericTypes> = new Set(
GENERIC_TYPES,
);
const CONVERTIBLE_LINEAR_TYPES: ReadonlySet<ConvertibleLinearTypes> = new Set(
LINEAR_TYPES,
);
const isConvertibleGenericType = (
elementType: string,
): elementType is ConvertibleGenericTypes =>
CONVERTIBLE_GENERIC_TYPES.has(elementType as ConvertibleGenericTypes);
const isConvertibleLinearType = (
elementType: string,
): elementType is ConvertibleLinearTypes =>
elementType === "arrow" ||
CONVERTIBLE_LINEAR_TYPES.has(elementType as ConvertibleLinearTypes);
export const convertElementTypePopupAtom = atom<{
type: "panel";
} | null>(null);
type CacheKey = string & { _brand: "CacheKey" };
const FONT_SIZE_CONVERSION_CACHE = new Map<
ExcalidrawElement["id"],
{
fontSize: number;
}
>();
const LINEAR_ELEMENT_CONVERSION_CACHE = new Map<
CacheKey,
ExcalidrawLinearElement
>();
const ConvertElementTypePopup = ({ app }: { app: App }) => {
const selectedElements = app.scene.getSelectedElements(app.state);
const elementsCategoryRef = useRef<ConversionType>(null);
// close shape switch panel if selecting different "types" of elements
useEffect(() => {
if (selectedElements.length === 0) {
app.updateEditorAtom(convertElementTypePopupAtom, null);
return;
}
const conversionType = getConversionTypeFromElements(selectedElements);
if (conversionType && !elementsCategoryRef.current) {
elementsCategoryRef.current = conversionType;
} else if (
(elementsCategoryRef.current && !conversionType) ||
(elementsCategoryRef.current &&
conversionType !== elementsCategoryRef.current)
) {
app.updateEditorAtom(convertElementTypePopupAtom, null);
elementsCategoryRef.current = null;
}
}, [selectedElements, app]);
useEffect(() => {
return () => {
FONT_SIZE_CONVERSION_CACHE.clear();
LINEAR_ELEMENT_CONVERSION_CACHE.clear();
};
}, []);
return <Panel app={app} elements={selectedElements} />;
};
const Panel = ({
app,
elements,
}: {
app: App;
elements: readonly NonDeletedExcalidrawElement[];
}) => {
const conversionType = getConversionTypeFromElements(elements);
const genericElements = useMemo(() => {
return conversionType === "generic"
? filterGenericConvetibleElements(elements)
: [];
}, [conversionType, elements]);
const linearElements = useMemo(() => {
return conversionType === "linear"
? filterLinearConvertibleElements(elements)
: [];
}, [conversionType, elements]);
const sameType =
conversionType === "generic"
? genericElements.every(
(element) => element.type === genericElements[0].type,
)
: conversionType === "linear"
? linearElements.every(
(element) =>
getLinearElementSubType(element) ===
getLinearElementSubType(linearElements[0]),
)
: false;
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
const positionRef = useRef("");
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const elements = [...genericElements, ...linearElements].sort((a, b) =>
a.id.localeCompare(b.id),
);
const newPositionRef = `
${app.state.scrollX}${app.state.scrollY}${app.state.offsetTop}${
app.state.offsetLeft
}${app.state.zoom.value}${elements.map((el) => el.id).join(",")}`;
if (newPositionRef === positionRef.current) {
return;
}
positionRef.current = newPositionRef;
let bottomLeft;
if (elements.length === 1) {
const [x1, , , y2, cx, cy] = getElementAbsoluteCoords(
elements[0],
app.scene.getNonDeletedElementsMap(),
);
bottomLeft = pointRotateRads(
pointFrom(x1, y2),
pointFrom(cx, cy),
elements[0].angle,
);
} else {
const { minX, maxY } = getCommonBoundingBox(elements);
bottomLeft = pointFrom(minX, maxY);
}
const { x, y } = sceneCoordsToViewportCoords(
{ sceneX: bottomLeft[0], sceneY: bottomLeft[1] },
app.state,
);
setPanelPosition({ x, y });
}, [genericElements, linearElements, app.scene, app.state]);
useEffect(() => {
for (const linearElement of linearElements) {
const cacheKey = toCacheKey(
linearElement.id,
getConvertibleType(linearElement),
);
if (!LINEAR_ELEMENT_CONVERSION_CACHE.has(cacheKey)) {
LINEAR_ELEMENT_CONVERSION_CACHE.set(cacheKey, linearElement);
}
}
}, [linearElements]);
useEffect(() => {
for (const element of genericElements) {
if (!FONT_SIZE_CONVERSION_CACHE.has(element.id)) {
const boundText = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundText) {
FONT_SIZE_CONVERSION_CACHE.set(element.id, {
fontSize: boundText.fontSize,
});
}
}
}
}, [genericElements, app.scene]);
const SHAPES: [string, ReactNode][] =
conversionType === "linear"
? [
["line", LineIcon],
["sharpArrow", sharpArrowIcon],
["curvedArrow", roundArrowIcon],
["elbowArrow", elbowArrowIcon],
]
: conversionType === "generic"
? [
["rectangle", RectangleIcon],
["diamond", DiamondIcon],
["ellipse", EllipseIcon],
]
: [];
return (
<div
ref={panelRef}
tabIndex={-1}
style={{
position: "absolute",
top: `${
panelPosition.y +
(GAP_VERTICAL + 8) * app.state.zoom.value -
app.state.offsetTop
}px`,
left: `${panelPosition.x - app.state.offsetLeft - GAP_HORIZONTAL}px`,
zIndex: 2,
}}
className={CLASSES.CONVERT_ELEMENT_TYPE_POPUP}
>
{SHAPES.map(([type, icon]) => {
const isSelected =
sameType &&
((conversionType === "generic" && genericElements[0].type === type) ||
(conversionType === "linear" &&
getLinearElementSubType(linearElements[0]) === type));
return (
<IconButton
key={`${elements[0].id}${elements[0].version}_${type}`}
type="toggle"
icon={icon}
checked={isSelected}
title={type}
aria-label={type}
data-testid={`toolbar-${type}`}
onSelect={() => {
// selecting the already-selected type is a no-op
if (isSelected) {
return;
}
if (app.state.activeTool.type !== type) {
trackEvent("convertElementType", type, "ui");
}
convertElementTypes(app, {
conversionType,
nextType: type as
| ConvertibleGenericTypes
| ConvertibleLinearTypes,
});
panelRef.current?.focus();
}}
/>
);
})}
</div>
);
};
export const adjustBoundTextSize = (
container: ExcalidrawTextContainer,
boundText: ExcalidrawTextElementWithContainer,
scene: Scene,
) => {
const maxWidth = getBoundTextMaxWidth(container, boundText);
const maxHeight = getBoundTextMaxHeight(container, boundText);
const wrappedText = wrapText(
boundText.text,
getFontString(boundText),
maxWidth,
);
let metrics = measureText(
wrappedText,
getFontString(boundText),
boundText.lineHeight,
);
let nextFontSize = boundText.fontSize;
while (
(metrics.width > maxWidth || metrics.height > maxHeight) &&
nextFontSize > 0
) {
nextFontSize -= 1;
const _updatedTextElement = {
...boundText,
fontSize: nextFontSize,
};
metrics = measureText(
boundText.text,
getFontString(_updatedTextElement),
boundText.lineHeight,
);
}
mutateElement(boundText, scene.getNonDeletedElementsMap(), {
fontSize: nextFontSize,
width: metrics.width,
height: metrics.height,
});
redrawTextBoundingBox(boundText, container, scene);
};
type ConversionType = "generic" | "linear" | null;
export const convertElementTypes = (
app: App,
{
conversionType,
nextType,
direction = "right",
}: {
conversionType: ConversionType;
nextType?: ConvertibleTypes;
direction?: "left" | "right";
},
): boolean => {
if (!conversionType) {
return false;
}
const selectedElements = app.scene.getSelectedElements(app.state);
const selectedElementIds = selectedElements.reduce(
(acc, element) => ({ ...acc, [element.id]: true }),
{},
);
const advancement = direction === "right" ? 1 : -1;
if (conversionType === "generic") {
const convertibleGenericElements =
filterGenericConvetibleElements(selectedElements);
const sameType = convertibleGenericElements.every(
(element) => element.type === convertibleGenericElements[0].type,
);
const index = sameType
? GENERIC_TYPES.indexOf(convertibleGenericElements[0].type)
: -1;
nextType =
nextType ??
GENERIC_TYPES[
(index + GENERIC_TYPES.length + advancement) % GENERIC_TYPES.length
];
if (nextType && isConvertibleGenericType(nextType)) {
const convertedElements: Record<string, NonDeletedExcalidrawElement> = {};
for (const element of convertibleGenericElements) {
const convertedElement = convertElementType(element, nextType, app);
convertedElements[convertedElement.id] = convertedElement;
}
const nextElements = [];
for (const element of app.scene.getElementsIncludingDeleted()) {
if (convertedElements[element.id]) {
nextElements.push(convertedElements[element.id]);
} else {
nextElements.push(element);
}
}
app.scene.replaceAllElements(nextElements);
for (const element of Object.values(convertedElements)) {
const boundText = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundText) {
if (FONT_SIZE_CONVERSION_CACHE.get(element.id)) {
mutateElement(boundText, app.scene.getNonDeletedElementsMap(), {
fontSize:
FONT_SIZE_CONVERSION_CACHE.get(element.id)?.fontSize ??
boundText.fontSize,
});
}
adjustBoundTextSize(
element as ExcalidrawTextContainer,
boundText,
app.scene,
);
}
}
app.setState((prevState) => {
return {
selectedElementIds,
activeTool: updateActiveTool(prevState, {
type: "selection",
}),
};
});
}
}
if (conversionType === "linear") {
const convertibleLinearElements =
filterLinearConvertibleElements(selectedElements);
if (!nextType) {
const commonSubType = reduceToCommonValue(
convertibleLinearElements,
getLinearElementSubType,
);
const index = commonSubType ? LINEAR_TYPES.indexOf(commonSubType) : -1;
nextType =
LINEAR_TYPES[
(index + LINEAR_TYPES.length + advancement) % LINEAR_TYPES.length
];
}
if (isConvertibleLinearType(nextType)) {
const convertedElements: ExcalidrawElement[] = [];
const nextElementsMap: Map<ExcalidrawElement["id"], ExcalidrawElement> =
app.scene.getElementsMapIncludingDeleted();
for (const element of convertibleLinearElements) {
const cachedElement = LINEAR_ELEMENT_CONVERSION_CACHE.get(
toCacheKey(element.id, nextType),
);
// if switching to the original subType or a subType we've already
// converted to, reuse the cached element to get the original properties
// (needed for simple->elbow->simple conversions or between line
// and arrows)
if (
cachedElement &&
getLinearElementSubType(cachedElement) === nextType
) {
nextElementsMap.set(cachedElement.id, cachedElement);
convertedElements.push(cachedElement);
} else {
const converted = convertElementType(element, nextType, app);
nextElementsMap.set(converted.id, converted);
convertedElements.push(converted);
}
}
app.scene.replaceAllElements(nextElementsMap);
// post normalization
for (const element of convertedElements) {
if (isLinearElement(element)) {
if (isElbowArrow(element)) {
const nextPoints = convertLineToElbow(element);
if (nextPoints.length < 2) {
// skip if not enough points to form valid segments
continue;
}
const fixedSegments: FixedSegment[] = [];
for (let i = 1; i < nextPoints.length - 2; i++) {
fixedSegments.push({
start: nextPoints[i],
end: nextPoints[i + 1],
index: i + 1,
});
}
const updates = updateElbowArrowPoints(
element,
app.scene.getNonDeletedElementsMap(),
{
points: nextPoints,
fixedSegments,
},
);
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
...updates,
endArrowhead: "arrow",
});
} else {
// if we're converting to non-elbow linear element, check if
// we've already cached one of these linear elements so we can
// reuse the points (case: curved->elbow->line and similar)
const similarCachedLinearElement = mapFind(
["line", "sharpArrow", "curvedArrow"] as const,
(type) =>
LINEAR_ELEMENT_CONVERSION_CACHE.get(
toCacheKey(element.id, type),
),
);
if (similarCachedLinearElement) {
const points = similarCachedLinearElement.points;
app.scene.mutateElement(element, {
points,
});
}
}
}
}
}
const convertedSelectedLinearElements = filterLinearConvertibleElements(
app.scene.getSelectedElements(app.state),
);
app.setState((prevState) => ({
selectedElementIds,
selectedLinearElement:
convertedSelectedLinearElements.length === 1
? new LinearElementEditor(
convertedSelectedLinearElements[0],
app.scene.getNonDeletedElementsMap(),
)
: null,
activeTool: updateActiveTool(prevState, {
type: "selection",
}),
}));
}
return true;
};
export const getConversionTypeFromElements = (
elements: readonly ExcalidrawElement[],
): ConversionType => {
if (elements.length === 0) {
return null;
}
let canBeLinear = false;
for (const element of elements) {
if (isConvertibleGenericType(element.type)) {
// generic type conversion have preference
return "generic";
}
if (isEligibleLinearElement(element)) {
canBeLinear = true;
}
}
if (canBeLinear) {
return "linear";
}
return null;
};
const isEligibleLinearElement = (element: ExcalidrawElement) => {
return (
isLinearElement(element) &&
(!isArrowElement(element) ||
(!isArrowBoundToElement(element) && !hasBoundTextElement(element)))
);
};
const toCacheKey = (
elementId: ExcalidrawElement["id"],
convertitleType: ConvertibleTypes,
) => {
return `${elementId}:${convertitleType}` as CacheKey;
};
const filterGenericConvetibleElements = <T extends ExcalidrawElement>(
elements: readonly T[],
) =>
elements.filter((element) => isConvertibleGenericType(element.type)) as Array<
T extends NonDeletedExcalidrawElement
? NonDeleted<
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
>
:
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
>;
const filterLinearConvertibleElements = <T extends ExcalidrawElement>(
elements: readonly T[],
) =>
elements.filter((element) =>
isEligibleLinearElement(element),
) as (T extends NonDeletedExcalidrawElement
? NonDeleted<ExcalidrawLinearElement>
: ExcalidrawLinearElement)[];
const THRESHOLD = 20;
const isVert = (a: LocalPoint, b: LocalPoint) => a[0] === b[0];
const isHorz = (a: LocalPoint, b: LocalPoint) => a[1] === b[1];
const dist = (a: LocalPoint, b: LocalPoint) =>
isVert(a, b) ? Math.abs(a[1] - b[1]) : Math.abs(a[0] - b[0]);
const convertLineToElbow = (line: ExcalidrawLinearElement): LocalPoint[] => {
// 1. build an *orthogonal* route, snapping offsets < SNAP
const ortho: LocalPoint[] = [line.points[0]];
const src = sanitizePoints(line.points);
for (let i = 1; i < src.length; ++i) {
const start = ortho[ortho.length - 1];
const end = [...src[i]] as LocalPoint; // clone
// snap tiny offsets onto the current axis
if (Math.abs(end[0] - start[0]) < THRESHOLD) {
end[0] = start[0];
} else if (Math.abs(end[1] - start[1]) < THRESHOLD) {
end[1] = start[1];
}
// straight or needs a 90 ° bend?
if (isVert(start, end) || isHorz(start, end)) {
ortho.push(end);
} else {
ortho.push(pointFrom<LocalPoint>(start[0], end[1]));
ortho.push(end);
}
}
// 2. drop obviously colinear middle points
const trimmed: LocalPoint[] = [ortho[0]];
for (let i = 1; i < ortho.length - 1; ++i) {
if (
!(
(isVert(ortho[i - 1], ortho[i]) && isVert(ortho[i], ortho[i + 1])) ||
(isHorz(ortho[i - 1], ortho[i]) && isHorz(ortho[i], ortho[i + 1]))
)
) {
trimmed.push(ortho[i]);
}
}
trimmed.push(ortho[ortho.length - 1]);
// 3. collapse micro “jogs” (V-H-V / H-V-H whose short leg < SNAP)
const clean: LocalPoint[] = [trimmed[0]];
for (let i = 1; i < trimmed.length - 1; ++i) {
const a = clean[clean.length - 1];
const b = trimmed[i];
const c = trimmed[i + 1];
const v1 = isVert(a, b);
const v2 = isVert(b, c);
if (v1 !== v2) {
const d1 = dist(a, b);
const d2 = dist(b, c);
if (d1 < THRESHOLD || d2 < THRESHOLD) {
// pick the shorter leg to remove
if (d2 < d1) {
// … absorb leg 2 – pull *c* onto axis of *a-b*
if (v1) {
c[0] = a[0];
} else {
c[1] = a[1];
}
} else {
// … absorb leg 1 – slide the whole first leg onto *b-c* axis
// eslint-disable-next-line no-lonely-if
if (v2) {
for (
let k = clean.length - 1;
k >= 0 && clean[k][0] === a[0];
--k
) {
clean[k][0] = b[0];
}
} else {
for (
let k = clean.length - 1;
k >= 0 && clean[k][1] === a[1];
--k
) {
clean[k][1] = b[1];
}
}
}
// *b* is gone, don’t add it
continue;
}
}
clean.push(b);
}
clean.push(trimmed[trimmed.length - 1]);
return clean;
};
const sanitizePoints = (points: readonly LocalPoint[]): LocalPoint[] => {
if (points.length === 0) {
return [];
}
const sanitized: LocalPoint[] = [points[0]];
for (let i = 1; i < points.length; i++) {
const [x1, y1] = sanitized[sanitized.length - 1];
const [x2, y2] = points[i];
if (x1 !== x2 || y1 !== y2) {
sanitized.push(points[i]);
}
}
return sanitized;
};
/**
* Converts an element to a new type, adding or removing properties as needed
* so that the element object is always valid.
*
* Valid conversions at this point:
* - switching between generic elements
* e.g. rectangle -> diamond
* - switching between linear elements
* e.g. elbow arrow -> line
*/
const convertElementType = <
TElement extends Exclude<
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement
>,
>(
element: TElement,
targetType: ConvertibleTypes,
app: AppClassProperties,
): NonDeletedExcalidrawElement => {
if (!isValidConversion(element.type, targetType)) {
if (!isProdEnv()) {
throw Error(`Invalid conversion from ${element.type} to ${targetType}.`);
}
return element;
}
if (element.type === targetType) {
return element;
}
ShapeCache.delete(element);
if (isConvertibleGenericType(targetType)) {
const nextElement = bumpVersion(
newElement({
...element,
type: targetType,
roundness: element.roundness
? {
type: isUsingAdaptiveRadius(targetType)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: element.roundness,
}),
) as typeof element;
updateBindings(nextElement, app.scene, app.state);
return nextElement;
}
if (isConvertibleLinearType(targetType)) {
switch (targetType) {
case "line": {
return bumpVersion(
newLinearElement({
...element,
type: "line",
}),
);
}
case "sharpArrow": {
return bumpVersion(
newArrowElement({
...element,
type: "arrow",
elbowed: false,
roundness: null,
startArrowhead: app.state.currentItemStartArrowhead,
endArrowhead: app.state.currentItemEndArrowhead,
}),
);
}
case "curvedArrow": {
return bumpVersion(
newArrowElement({
...element,
type: "arrow",
elbowed: false,
roundness: {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
},
startArrowhead: app.state.currentItemStartArrowhead,
endArrowhead: app.state.currentItemEndArrowhead,
}),
);
}
case "elbowArrow": {
return bumpVersion(
newArrowElement({
...element,
type: "arrow",
elbowed: true,
fixedSegments: null,
roundness: null,
}),
);
}
}
}
assertNever(targetType, `unhandled conversion type: ${targetType}`);
return element;
};
const isValidConversion = (
startType: string,
targetType: ConvertibleTypes,
): startType is ConvertibleTypes => {
if (
isConvertibleGenericType(startType) &&
isConvertibleGenericType(targetType)
) {
return true;
}
if (
isConvertibleLinearType(startType) &&
isConvertibleLinearType(targetType)
) {
return true;
}
// NOTE: add more conversions when needed
return false;
};
const getConvertibleType = (
element: ExcalidrawConvertibleElement,
): ConvertibleTypes => {
if (isLinearElement(element)) {
return getLinearElementSubType(element);
}
return element.type;
};
export default ConvertElementTypePopup;

View File

@@ -0,0 +1,43 @@
.excalidraw {
.CursorHint {
position: absolute;
top: 0;
left: 0;
z-index: var(--zIndex-cursorHint);
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
// dark-theme island colors, in both themes (keeps the hint high-contrast
// over the mostly-light canvas)
background-color: #232329;
color: #e3e3e8;
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-md);
animation: CursorHint-fade-in 100ms ease-out;
// fade-out duration (keep in sync with CursorHint.tsx)
transition: opacity 100ms ease-out;
&--fade-out {
opacity: 0;
}
svg {
width: 1rem;
height: 1rem;
}
}
@keyframes CursorHint-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,209 @@
import clsx from "clsx";
import { useLayoutEffect, useEffect, useRef, useState } from "react";
import { ARROW_TYPE, EVENT } from "@excalidraw/common";
import { atom, useAtom } from "../editor-jotai";
import { useApp, useExcalidrawContainer } from "./App";
import { positionElementBesideCursor } from "./positionElementBesideCursor";
import {
LineIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "./icons";
import "./CursorHint.scss";
import type { AppClassProperties, AppState } from "../types";
/** how long the hint stays visible before it starts fading out */
const CURSOR_HINT_DURATION = 700;
/** fade-out duration (keep in sync with CursorHint.scss) */
const CURSOR_HINT_FADE_DURATION = 100;
/** distance from the pointer so the hint isn't covered by the cursor */
const CURSOR_HINT_GAP = 16;
/**
* While a recently shown hint is still fresh in memory, tool-switch hints
* are suppressed (repeatedly re-picking a tool you just used doesn't need
* the reminder). Cycling arrow types and numeric shortcuts bypass this.
*/
export const CURSOR_HINT_COOLDOWN = 5 * 60 * 1000;
export const cursorHintAtom = atom<{
content: React.ReactNode;
/** unique per trigger so a re-trigger restarts the hide timer */
nonce: number;
} | null>(null);
const getArrowTypeIcon = (arrowType: AppState["currentItemArrowType"]) =>
arrowType === ARROW_TYPE.elbow
? elbowArrowIcon
: arrowType === ARROW_TYPE.round
? roundArrowIcon
: sharpArrowIcon;
/**
* Owns the cursor-hint policy. App reports semantic interaction events
* (what the user did); all decisions about whether and what to show —
* cooldown, bypasses, hint content — are made here.
*/
export class CursorHints {
private app: AppClassProperties;
private lastShownAt = 0;
constructor(app: AppClassProperties) {
this.app = app;
}
/**
* Shows a transient tooltip next to the cursor, hidden automatically
* after a short delay. Repeated calls replace the content and restart
* the timer.
*/
show = (content: React.ReactNode) => {
// `viewport.lastPosition` stays at its initial (0, 0) until the first
// pointermove, so in pointer-less flows (e.g. keyboard-only session so
// far) we don't know where to show the hint — don't show it at all
const { x, y } = this.app.viewport.lastPosition;
if (x === 0 && y === 0) {
return;
}
this.lastShownAt = Date.now();
this.app.updateEditorAtom(cursorHintAtom, {
content,
nonce: Math.random(),
});
};
private isOnCooldown = () =>
Date.now() - this.lastShownAt < CURSOR_HINT_COOLDOWN;
/** arrow type cycled via shortcut (arrow tool already active) */
onArrowTypeCycled = (arrowType: AppState["currentItemArrowType"]) => {
// always worth hinting — you need to see what you switched to
this.show(getArrowTypeIcon(arrowType));
};
/** arrow/line tool picked via keyboard shortcut */
onToolShortcut = (tool: "arrow" | "line", source: "letter" | "digit") => {
// digit shortcuts always hint (often pressed blind, without certainty
// which tool the digit maps to); letter shortcuts only after a
// cooldown — re-picking a tool you used moments ago doesn't need the
// reminder
if (source === "digit" || !this.isOnCooldown()) {
this.show(
tool === "line"
? LineIcon
: getArrowTypeIcon(this.app.state.currentItemArrowType),
);
}
};
}
/**
* Transient tooltip shown next to the cursor for added affordance after
* actions that have no other visual feedback near the pointer (e.g. cycling
* arrow types via shortcut). Trigger via `app.cursorHints`.
*/
export const CursorHint = () => {
const [hint, setHint] = useAtom(cursorHintAtom);
const app = useApp();
const { container } = useExcalidrawContainer();
const hintRef = useRef<HTMLDivElement>(null);
const [isFadingOut, setIsFadingOut] = useState(false);
const nonce = hint?.nonce;
useLayoutEffect(() => {
if (nonce == null || !container) {
return;
}
const updatePosition = (clientX: number, clientY: number) => {
const element = hintRef.current;
if (!element) {
return;
}
const { left, top } = positionElementBesideCursor({
cursor: { x: clientX, y: clientY },
element: {
width: element.offsetWidth,
height: element.offsetHeight,
},
container: container.getBoundingClientRect(),
gap: CURSOR_HINT_GAP,
});
element.style.transform = `translate(${left}px, ${top}px)`;
};
updatePosition(app.viewport.lastPosition.x, app.viewport.lastPosition.y);
const onPointerMove = (event: PointerEvent) => {
updatePosition(event.clientX, event.clientY);
};
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
return () => {
window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
};
}, [nonce, container, app]);
// hints are transient — don't let a stale one reappear on remount
useEffect(() => {
return () => {
setHint(null);
};
}, [setHint]);
useEffect(() => {
if (nonce == null) {
return;
}
setIsFadingOut(false);
const fadeTimer = window.setTimeout(() => {
setIsFadingOut(true);
}, CURSOR_HINT_DURATION);
const hideTimer = window.setTimeout(() => {
setHint(null);
}, CURSOR_HINT_DURATION + CURSOR_HINT_FADE_DURATION);
// hide immediately when the user starts interacting (e.g. drawing)
// so the hint doesn't get in the way
const onPointerDown = () => {
setHint(null);
};
window.addEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
return () => {
window.clearTimeout(fadeTimer);
window.clearTimeout(hideTimer);
window.removeEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
};
}, [nonce, setHint]);
if (!hint) {
return null;
}
return (
<div
ref={hintRef}
className={clsx("CursorHint", {
"CursorHint--fade-out": isFadingOut,
})}
data-testid="cursor-hint"
>
{hint.content}
</div>
);
};

View File

@@ -0,0 +1,55 @@
import { THEME } from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { t } from "../i18n";
import { IconButton } from "./IconButton";
import "./ToolIcon.scss";
// We chose to use only explicit toggle and not a third option for system value,
// but this could be added in the future.
export const DarkModeToggle = (props: {
value: Theme;
onChange: (value: Theme) => void;
title?: string;
}) => {
const title =
props.title ||
(props.value === THEME.DARK
? t("buttons.lightMode")
: t("buttons.darkMode"));
return (
<IconButton
type="icon"
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
title={title}
aria-label={title}
onClick={() =>
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
}
data-testid="toggle-dark-mode"
/>
);
};
const ICONS = {
SUN: (
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
<path
fill="currentColor"
d="M256 160c-52.9 0-96 43.1-96 96s43.1 96 96 96 96-43.1 96-96-43.1-96-96-96zm246.4 80.5l-94.7-47.3 33.5-100.4c4.5-13.6-8.4-26.5-21.9-21.9l-100.4 33.5-47.4-94.8c-6.4-12.8-24.6-12.8-31 0l-47.3 94.7L92.7 70.8c-13.6-4.5-26.5 8.4-21.9 21.9l33.5 100.4-94.7 47.4c-12.8 6.4-12.8 24.6 0 31l94.7 47.3-33.5 100.5c-4.5 13.6 8.4 26.5 21.9 21.9l100.4-33.5 47.3 94.7c6.4 12.8 24.6 12.8 31 0l47.3-94.7 100.4 33.5c13.6 4.5 26.5-8.4 21.9-21.9l-33.5-100.4 94.7-47.3c13-6.5 13-24.7.2-31.1zm-155.9 106c-49.9 49.9-131.1 49.9-181 0-49.9-49.9-49.9-131.1 0-181 49.9-49.9 131.1-49.9 181 0 49.9 49.9 49.9 131.1 0 181z"
></path>
</svg>
),
MOON: (
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
<path
fill="currentColor"
d="M283.211 512c78.962 0 151.079-35.925 198.857-94.792 7.068-8.708-.639-21.43-11.562-19.35-124.203 23.654-238.262-71.576-238.262-196.954 0-72.222 38.662-138.635 101.498-174.394 9.686-5.512 7.25-20.197-3.756-22.23A258.156 258.156 0 0 0 283.211 0c-141.309 0-256 114.511-256 256 0 141.309 114.511 256 256 256z"
></path>
</svg>
),
};

View File

@@ -0,0 +1,147 @@
import React from "react";
import { DEFAULT_SIDEBAR } from "@excalidraw/common";
import { DefaultSidebar } from "../index";
import {
fireEvent,
waitFor,
withExcalidrawDimensions,
} from "../tests/test-utils";
import {
assertExcalidrawWithSidebar,
assertSidebarDockButton,
} from "./Sidebar/siderbar.test.helpers";
const { h } = window;
describe("DefaultSidebar", () => {
it("when `docked={undefined}` & `onDock={undefined}`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `docked={undefined}` & `onDock`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={() => {}} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `docked={true}` & `onDock`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={() => {}} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `onDock={false}`, should disable docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={false} />,
DEFAULT_SIDEBAR.name,
async () => {
await withExcalidrawDimensions(
{ width: 1920, height: 1080 },
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
await assertSidebarDockButton(false);
},
);
},
);
});
it("when `docked={true}` & `onDock={false}`, should force-dock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked onDock={false} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).toHaveClass("sidebar--docked");
},
);
});
it("when `docked={true}` & `onDock={undefined}`, should force-dock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).toHaveClass("sidebar--docked");
},
);
});
it("when `docked={false}` & `onDock={undefined}`, should force-undock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked={false} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).not.toHaveClass("sidebar--docked");
},
);
});
});

View File

@@ -0,0 +1,127 @@
import clsx from "clsx";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
composeEventHandlers,
} from "@excalidraw/common";
import type { MarkOptional, Merge } from "@excalidraw/common/utility-types";
import { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../context/ui-appState";
import "../components/dropdownMenu/DropdownMenu.scss";
import { useExcalidrawSetAppState } from "./App";
import { LibraryMenu } from "./LibraryMenu";
import { SearchMenu } from "./SearchMenu";
import { Sidebar } from "./Sidebar/Sidebar";
import { withInternalFallback } from "./hoc/withInternalFallback";
import { LibraryIcon, searchIcon } from "./icons";
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
const DefaultSidebarTrigger = withInternalFallback(
"DefaultSidebarTrigger",
(
props: Omit<SidebarTriggerProps, "name"> &
React.HTMLAttributes<HTMLDivElement>,
) => {
const { DefaultSidebarTriggerTunnel } = useTunnels();
return (
<DefaultSidebarTriggerTunnel.In>
<Sidebar.Trigger
{...props}
className="default-sidebar-trigger"
name={DEFAULT_SIDEBAR.name}
/>
</DefaultSidebarTriggerTunnel.In>
);
},
);
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return (
<DefaultSidebarTabTriggersTunnel.In>
{children}
</DefaultSidebarTabTriggersTunnel.In>
);
};
DefaultTabTriggers.displayName = "DefaultTabTriggers";
export const DefaultSidebar = Object.assign(
withInternalFallback(
"DefaultSidebar",
({
children,
className,
onDock,
docked,
...rest
}: Merge<
MarkOptional<Omit<SidebarProps, "name">, "children">,
{
/** pass `false` to disable docking */
onDock?: SidebarProps["onDock"] | false;
}
>) => {
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
return (
<Sidebar
{...rest}
name="default"
key="default"
className={clsx("default-sidebar", className)}
docked={
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
}
onDock={
// `onDock=false` disables docking.
// if `docked` passed, but no onDock passed, disable manual docking.
isForceDocked || onDock === false || (!onDock && docked != null)
? undefined
: // compose to allow the host app to listen on default behavior
composeEventHandlers(onDock, (docked) => {
setAppState({ defaultSidebarDockedPreference: docked });
})
}
>
<Sidebar.Tabs>
<Sidebar.Header>
<Sidebar.TabTriggers>
<Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
{searchIcon}
</Sidebar.TabTrigger>
<Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
{LibraryIcon}
</Sidebar.TabTrigger>
<DefaultSidebarTabTriggersTunnel.Out />
</Sidebar.TabTriggers>
</Sidebar.Header>
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
<LibraryMenu />
</Sidebar.Tab>
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
<SearchMenu />
</Sidebar.Tab>
{children}
</Sidebar.Tabs>
</Sidebar>
);
},
),
{
Trigger: DefaultSidebarTrigger,
TabTriggers: DefaultTabTriggers,
},
);

View File

@@ -0,0 +1,19 @@
import { useLayoutEffect } from "react";
import { useApp } from "../App";
import type { GenerateDiagramToCode } from "../../types";
export const DiagramToCodePlugin = (props: {
generate: GenerateDiagramToCode;
}) => {
const app = useApp();
useLayoutEffect(() => {
app.setPlugins({
diagramToCode: { generate: props.generate },
});
}, [app, props.generate]);
return null;
};

View File

@@ -0,0 +1,54 @@
@use "../css/variables.module" as *;
.excalidraw {
.Dialog {
user-select: text;
cursor: auto;
}
.Dialog__title {
margin: 0;
text-align: left;
font-size: 1.25rem;
border-bottom: 1px solid var(--dialog-border-color);
padding: 0 0 0.75rem;
margin-bottom: 1.5rem;
}
.Dialog__close {
color: var(--color-gray-40);
margin: 0;
position: absolute;
top: 0.75rem;
right: 0.5rem;
border: 0;
background-color: transparent;
line-height: 0;
cursor: pointer;
&:hover {
color: var(--color-gray-60);
}
&:active {
color: var(--color-gray-40);
}
svg {
width: 1.5rem;
height: 1.5rem;
}
& + .Dialog__content {
--offset: 28px;
height: calc(100% - var(--offset)) !important;
margin-top: var(--offset) !important;
}
}
.Dialog--fullscreen {
.Dialog__close {
top: 1.25rem;
right: 1.25rem;
}
}
}

View File

@@ -0,0 +1,137 @@
import clsx from "clsx";
import React, { useEffect, useState } from "react";
import { KEYS, queryFocusableElements } from "@excalidraw/common";
import { useSetAtom } from "../editor-jotai";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import {
useExcalidrawContainer,
useEditorInterface,
useExcalidrawSetAppState,
} from "./App";
import { Island } from "./Island";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { Modal } from "./Modal";
import { CloseIcon } from "./icons";
import "./Dialog.scss";
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
export interface DialogProps {
children: React.ReactNode;
className?: string;
size?: DialogSize;
onCloseRequest(): void;
title: React.ReactNode | false;
autofocus?: boolean;
closeOnClickOutside?: boolean;
}
function getDialogSize(size: DialogSize): number {
if (size && typeof size === "number") {
return size;
}
switch (size) {
case "small":
return 550;
case "wide":
return 1024;
case "regular":
default:
return 800;
}
}
export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
const isFullscreen = useEditorInterface().formFactor === "phone";
useEffect(() => {
if (!islandNode) {
return;
}
const focusableElements = queryFocusableElements(islandNode);
setTimeout(() => {
if (focusableElements.length > 0 && props.autofocus !== false) {
// If there's an element other than close, focus it.
(focusableElements[1] || focusableElements[0]).focus();
}
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.TAB) {
const focusableElements = queryFocusableElements(islandNode);
const { activeElement } = document;
const currentIndex = focusableElements.findIndex(
(element) => element === activeElement,
);
if (currentIndex === 0 && event.shiftKey) {
focusableElements[focusableElements.length - 1].focus();
event.preventDefault();
} else if (
currentIndex === focusableElements.length - 1 &&
!event.shiftKey
) {
focusableElements[0].focus();
event.preventDefault();
}
}
};
islandNode.addEventListener("keydown", handleKeyDown);
return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const onClose = () => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
(lastActiveElement as HTMLElement).focus();
props.onCloseRequest();
};
return (
<Modal
className={clsx("Dialog", props.className, {
"Dialog--fullscreen": isFullscreen,
})}
labelledBy="dialog-title"
maxWidth={getDialogSize(props.size)}
onCloseRequest={onClose}
closeOnClickOutside={props.closeOnClickOutside}
>
<Island ref={setIslandNode}>
{props.title && (
<h2 id={`${id}-dialog-title`} className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
</h2>
)}
{isFullscreen && (
<button
className="Dialog__close"
onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")}
type="button"
>
{CloseIcon}
</button>
)}
<div className="Dialog__content">{props.children}</div>
</Island>
</Modal>
);
};

View File

@@ -0,0 +1,47 @@
.excalidraw {
.Dialog__action-button {
position: relative;
display: flex;
column-gap: 0.5rem;
align-items: center;
padding: 0.5rem 1.5rem;
border: 1px solid var(--default-border-color);
background-color: transparent;
height: 3rem;
border-radius: var(--border-radius-lg);
letter-spacing: 0.4px;
color: inherit;
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
user-select: none;
svg {
display: block;
width: 1rem;
height: 1rem;
}
&--danger {
background-color: var(--color-danger);
border-color: var(--color-danger);
color: #fff;
}
&--primary {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
}
&.theme--dark {
.Dialog__action-button--danger {
color: var(--color-gray-100);
}
.Dialog__action-button--primary {
color: var(--color-gray-100);
}
}
}

View File

@@ -0,0 +1,49 @@
import clsx from "clsx";
import Spinner from "./Spinner";
import "./DialogActionButton.scss";
import type { ReactNode } from "react";
interface DialogActionButtonProps {
label: string;
children?: ReactNode;
actionType?: "primary" | "danger";
isLoading?: boolean;
}
const DialogActionButton = ({
label,
onClick,
className,
children,
actionType,
type = "button",
isLoading,
...rest
}: DialogActionButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const cs = actionType ? `Dialog__action-button--${actionType}` : "";
return (
<button
className={clsx("Dialog__action-button", cs, className)}
type={type}
aria-label={label}
onClick={onClick}
{...rest}
>
{children && (
<div style={isLoading ? { visibility: "hidden" } : {}}>{children}</div>
)}
<div style={isLoading ? { visibility: "hidden" } : {}}>{label}</div>
{isLoading && (
<div style={{ position: "absolute", inset: 0 }}>
<Spinner />
</div>
)}
</button>
);
};
export default DialogActionButton;

View File

@@ -0,0 +1,14 @@
.excalidraw {
.excalidraw-canvas-buttons {
position: absolute;
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
z-index: var(--zIndex-canvasButtons);
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
}

View File

@@ -0,0 +1,69 @@
import { sceneCoordsToViewportCoords } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "@excalidraw/element";
import type {
ElementsMap,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import { useExcalidrawAppState } from "../components/App";
import "./ElementCanvasButtons.scss";
import type { AppState } from "../types";
const CONTAINER_PADDING = 5;
const getContainerCoords = (
element: NonDeletedExcalidrawElement,
appState: AppState,
elementsMap: ElementsMap,
) => {
const [x1, y1] = getElementAbsoluteCoords(element, elementsMap);
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{ sceneX: x1 + element.width, sceneY: y1 },
appState,
);
const x = viewportX - appState.offsetLeft + 10;
const y = viewportY - appState.offsetTop;
return { x, y };
};
export const ElementCanvasButtons = ({
children,
element,
elementsMap,
}: {
children: React.ReactNode;
element: NonDeletedExcalidrawElement;
elementsMap: ElementsMap;
}) => {
const appState = useExcalidrawAppState();
if (
appState.contextMenu ||
appState.newElement ||
appState.resizingElement ||
appState.isRotating ||
appState.openMenu ||
appState.viewModeEnabled
) {
return null;
}
const { x, y } = getContainerCoords(element, appState, elementsMap);
return (
<div
className="excalidraw-canvas-buttons"
style={{
top: `${y}px`,
left: `${x}px`,
// width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,87 @@
@use "../css/variables.module" as *;
.excalidraw {
.ElementLinkDialog {
position: absolute;
top: var(--editor-container-padding);
left: var(--editor-container-padding);
z-index: var(--zIndex-modal);
border-radius: 10px;
padding: 1.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: var(--shadow-island);
background-color: var(--island-bg-color);
@include isMobile {
left: 0;
margin-left: 0.5rem;
margin-right: 0.5rem;
width: calc(100% - 1rem);
box-sizing: border-box;
z-index: 5;
}
.ElementLinkDialog__header {
h2 {
margin-top: 0;
margin-bottom: 0.5rem;
@include isMobile {
font-size: 1.25rem;
}
}
p {
margin: 0;
@include isMobile {
font-size: 0.875rem;
}
}
margin-bottom: 1.5rem;
@include isMobile {
margin-bottom: 1rem;
}
}
.ElementLinkDialog__input {
display: flex;
.ElementLinkDialog__input-field {
flex: 1;
}
.ElementLinkDialog__remove {
color: $color-red-9;
margin-left: 1rem;
.ToolIcon__icon {
width: 2rem;
height: 2rem;
}
.ToolIcon__icon svg {
color: $color-red-6;
}
}
}
.ElementLinkDialog__actions {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
@include isMobile {
font-size: 0.875rem;
margin-top: 1rem;
}
}
}
}

View File

@@ -0,0 +1,180 @@
import { useCallback, useEffect, useState } from "react";
import { normalizeLink, KEYS } from "@excalidraw/common";
import {
defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection,
} from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Scene } from "@excalidraw/element";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import DialogActionButton from "./DialogActionButton";
import { TextField } from "./TextField";
import { IconButton } from "./IconButton";
import { TrashIcon } from "./icons";
import "./ElementLinkDialog.scss";
import type { AppProps, AppState, UIAppState } from "../types";
const ElementLinkDialog = ({
sourceElementId,
onClose,
appState,
scene,
generateLinkForSelection = defaultGetElementLinkFromSelection,
}: {
sourceElementId: ExcalidrawElement["id"];
appState: UIAppState;
scene: Scene;
onClose?: () => void;
generateLinkForSelection: AppProps["generateLinkForSelection"];
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const originalLink = elementsMap.get(sourceElementId)?.link ?? null;
const [nextLink, setNextLink] = useState<string | null>(originalLink);
const [linkEdited, setLinkEdited] = useState(false);
useEffect(() => {
const selectedElements = getSelectedElements(elementsMap, appState);
let nextLink = originalLink;
if (selectedElements.length > 0 && generateLinkForSelection) {
const idAndType = getLinkIdAndTypeFromSelection(
selectedElements,
appState as AppState,
);
if (idAndType) {
nextLink = normalizeLink(
generateLinkForSelection(idAndType.id, idAndType.type),
);
}
}
setNextLink(nextLink);
}, [
elementsMap,
appState,
appState.selectedElementIds,
originalLink,
generateLinkForSelection,
]);
const handleConfirm = useCallback(() => {
if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) {
const elementToLink = elementsMap.get(sourceElementId);
elementToLink &&
scene.mutateElement(elementToLink, {
link: nextLink,
});
}
if (!nextLink && linkEdited && sourceElementId) {
const elementToLink = elementsMap.get(sourceElementId);
elementToLink &&
scene.mutateElement(elementToLink, {
link: null,
});
}
onClose?.();
}, [sourceElementId, nextLink, elementsMap, linkEdited, scene, onClose]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
appState.openDialog?.name === "elementLinkSelector" &&
event.key === KEYS.ENTER
) {
handleConfirm();
}
if (
appState.openDialog?.name === "elementLinkSelector" &&
event.key === KEYS.ESCAPE
) {
onClose?.();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [appState, onClose, handleConfirm]);
return (
<div className="ElementLinkDialog">
<div className="ElementLinkDialog__header">
<h2>{t("elementLink.title")}</h2>
<p>{t("elementLink.desc")}</p>
</div>
<div className="ElementLinkDialog__input">
<TextField
value={nextLink ?? ""}
onChange={(value) => {
if (!linkEdited) {
setLinkEdited(true);
}
setNextLink(value);
}}
onKeyDown={(event) => {
if (event.key === KEYS.ENTER) {
handleConfirm();
}
}}
className="ElementLinkDialog__input-field"
selectOnRender
/>
{originalLink && nextLink && (
<IconButton
type="button"
title={t("buttons.remove")}
aria-label={t("buttons.remove")}
label={t("buttons.remove")}
onClick={() => {
// removes the link from the input
// but doesn't update the element
// when confirmed, will remove the link from the element
setNextLink(null);
setLinkEdited(true);
}}
className="ElementLinkDialog__remove"
icon={TrashIcon}
/>
)}
</div>
<div className="ElementLinkDialog__actions">
<DialogActionButton
label={t("buttons.cancel")}
onClick={() => {
onClose?.();
}}
style={{
marginRight: 10,
}}
/>
<DialogActionButton
label={t("buttons.confirm")}
onClick={handleConfirm}
actionType="primary"
/>
</div>
</div>
);
};
export default ElementLinkDialog;

View File

@@ -0,0 +1,18 @@
export const Ellipsify = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
{...rest}
style={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
...rest.style,
}}
>
{children}
</span>
);
};

View File

@@ -0,0 +1,41 @@
import React, { useState } from "react";
import { t } from "../i18n";
import { useExcalidrawContainer } from "./App";
import { Dialog } from "./Dialog";
export const ErrorDialog = ({
children,
onClose,
}: {
children?: React.ReactNode;
onClose?: () => void;
}) => {
const [modalIsShown, setModalIsShown] = useState(!!children);
const { container: excalidrawContainer } = useExcalidrawContainer();
const handleClose = React.useCallback(() => {
setModalIsShown(false);
if (onClose) {
onClose();
}
// TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
excalidrawContainer?.focus();
}, [onClose, excalidrawContainer]);
return (
<>
{modalIsShown && (
<Dialog
size="small"
onCloseRequest={handleClose}
title={t("errorDialog.title")}
>
<div style={{ whiteSpace: "pre-wrap" }}>{children}</div>
</Dialog>
)}
</>
);
};

View File

@@ -0,0 +1,87 @@
.excalidraw {
.ExcalidrawLogo {
--logo-icon--mobile: 1rem;
--logo-text--mobile: 0.75rem;
--logo-icon--xs: 2rem;
--logo-text--xs: 1.5rem;
--logo-icon--small: 2.5rem;
--logo-text--small: 1.75rem;
--logo-icon--normal: 3rem;
--logo-text--normal: 2.2rem;
--logo-icon--large: 90px;
--logo-text--large: 65px;
display: flex;
align-items: center;
svg {
flex: 0 0 auto;
}
.ExcalidrawLogo-icon {
width: auto;
color: var(--color-logo-icon);
}
.ExcalidrawLogo-text {
margin-left: 0.75rem;
width: auto;
color: var(--color-logo-text);
}
&.is-mobile {
.ExcalidrawLogo-icon {
height: var(--logo-icon--mobile);
}
.ExcalidrawLogo-text {
height: var(--logo-text--mobile);
margin-left: 0.5rem;
}
}
&.is-xs {
.ExcalidrawLogo-icon {
height: var(--logo-icon--xs);
}
.ExcalidrawLogo-text {
height: var(--logo-text--xs);
}
}
&.is-small {
.ExcalidrawLogo-icon {
height: var(--logo-icon--small);
}
.ExcalidrawLogo-text {
height: var(--logo-text--small);
}
}
&.is-normal {
.ExcalidrawLogo-icon {
height: var(--logo-icon--normal);
}
.ExcalidrawLogo-text {
height: var(--logo-text--normal);
}
}
&.is-large {
.ExcalidrawLogo-icon {
height: var(--logo-icon--large);
}
.ExcalidrawLogo-text {
height: var(--logo-text--large);
}
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,129 @@
@use "../css/variables.module" as *;
.excalidraw {
.ExportDialog__preview {
--preview-padding: calc(var(--space-factor) * 4);
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
text-align: center;
padding: var(--preview-padding);
margin-bottom: calc(var(--space-factor) * 3);
display: flex;
justify-content: center;
align-items: center;
}
.ExportDialog__preview canvas {
max-width: calc(100% - var(--preview-padding) * 2);
max-height: 25rem;
}
&.theme--dark .ExportDialog__preview canvas {
filter: none;
}
.ExportDialog__actions {
width: 100%;
display: flex;
grid-gap: calc(var(--space-factor) * 2);
align-items: top;
justify-content: space-between;
}
@include isMobile {
.ExportDialog {
display: flex;
flex-direction: column;
}
.ExportDialog__actions {
flex-direction: column;
align-items: center;
}
.ExportDialog__actions > * {
margin-bottom: calc(var(--space-factor) * 3);
}
.ExportDialog__preview canvas {
max-height: 30vh;
}
.ExportDialog__dialog,
.ExportDialog__dialog .Island {
height: 100%;
box-sizing: border-box;
}
.ExportDialog__dialog .Island {
overflow-y: auto;
}
}
.ExportDialog--json {
.ExportDialog-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
justify-items: center;
row-gap: 2em;
@media (max-width: 460px) {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
.Card-details {
min-height: 40px;
}
}
.ProjectName {
width: fit-content;
margin: 1em auto;
align-items: flex-start;
flex-direction: column;
.TextInput {
width: auto;
}
}
.ProjectName-label {
margin: 0.625em 0;
font-weight: bold;
}
}
}
button.ExportDialog-imageExportButton {
border: 0;
width: 5rem;
height: 5rem;
margin: 0 0.2em;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 1rem;
background-color: var(--button-color);
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
0 6px 10px 0 rgba(0, 0, 0, 0.14);
font-family: Cascadia;
font-size: 1.8em;
color: #fff;
&:hover {
background-color: var(--button-color-darker);
}
&:active {
background-color: var(--button-color-darkest);
box-shadow: none;
}
svg {
width: 0.9em;
}
}
}

View File

@@ -0,0 +1,50 @@
.excalidraw {
.excalidraw-eye-dropper-container,
.excalidraw-eye-dropper-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: var(--zIndex-eyeDropperBackdrop);
touch-action: none;
}
.excalidraw-eye-dropper-container {
pointer-events: none;
}
.excalidraw-eye-dropper-backdrop {
pointer-events: all;
}
.excalidraw-eye-dropper-preview {
pointer-events: none;
width: 2rem;
height: 2rem;
position: absolute;
z-index: var(--zIndex-eyeDropperPreview);
border-radius: 50%;
border: 1px solid var(--eye-dropper-preview-border-color, #222);
box-shadow: 2px 2px 0 0 #4949497c;
}
.excalidraw-eye-dropper-trigger {
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
padding: 4px;
margin-right: -4px;
margin-left: -2px;
border-radius: 0.5rem;
color: var(--icon-fill-color);
&:hover {
background: var(--button-hover-bg);
}
&.selected {
color: var(--color-primary);
background: var(--color-primary-light);
}
}
}

View File

@@ -0,0 +1,284 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom";
import {
EVENT,
isColorDark,
KEYS,
MIME_TYPES,
THEME,
removeDarkModeFilter,
rgbToHex,
} from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { useUIAppState } from "../context/ui-appState";
import { atom } from "../editor-jotai";
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
import { useOutsideClick } from "../hooks/useOutsideClick";
import { useStable } from "../hooks/useStable";
import { getSelectedElements } from "../scene";
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
import { eyeDropperIconSvgPaths } from "./icons";
import { positionElementBesideCursor } from "./positionElementBesideCursor";
import "./EyeDropper.scss";
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
const eyeDropperCursorPaths = eyeDropperIconSvgPaths
.map((path, idx) => `<path fill="${idx === 0 ? `#fff` : ``}" d="${path}" />`)
.join("");
const eyeDropperCursor = `url(data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke-linecap="round" stroke-linejoin="round"><g stroke="#fff" stroke-width="5">${eyeDropperCursorPaths}</g><g stroke="#1b1b1f" stroke-width="1.25">${eyeDropperCursorPaths}</g></svg>`,
)}) 2 21, auto`;
export type EyeDropperProperties = {
keepOpenOnAlt: boolean;
swapPreviewOnAlt?: boolean;
/** called when user picks color (on pointerup) */
onSelect: (color: string, event: PointerEvent) => void;
/**
* property of selected elements to update live when alt-dragging.
* Supply `null` if not applicable (e.g. updating the canvas bg instead of
* elements)
**/
colorPickerType: ColorPickerType;
};
export const activeEyeDropperAtom = atom<null | EyeDropperProperties>(null);
export const EyeDropper: React.FC<{
onCancel: () => void;
onSelect: EyeDropperProperties["onSelect"];
/** called when color changes, on pointerdown for preview */
onChange: (
type: ColorPickerType,
color: string,
selectedElements: ExcalidrawElement[],
event: { altKey: boolean },
) => void;
colorPickerType: EyeDropperProperties["colorPickerType"];
}> = ({ onCancel, onChange, onSelect, colorPickerType }) => {
const eyeDropperContainer = useCreatePortalContainer({
className: "excalidraw-eye-dropper-backdrop",
parentSelector: ".excalidraw-eye-dropper-container",
});
const appState = useUIAppState();
const elements = useExcalidrawElements();
const app = useApp();
const ownerWindow = app.ownerWindow;
const selectedElements = getSelectedElements(elements, appState);
const stableProps = useStable({
app,
onCancel,
onChange,
onSelect,
selectedElements,
});
const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => {
if (!eyeDropperContainer) {
return;
}
eyeDropperContainer.style.cursor = eyeDropperCursor;
return () => {
eyeDropperContainer.style.cursor = "";
};
}, [eyeDropperContainer]);
useEffect(() => {
const colorPreviewDiv = ref.current;
if (!colorPreviewDiv || !app.canvas || !eyeDropperContainer) {
return;
}
let isHoldingPointerDown = false;
const ctx = app.canvas.getContext("2d")!;
const getCurrentColor = ({
clientX,
clientY,
}: {
clientX: number;
clientY: number;
}) => {
const pixel = ctx.getImageData(
(clientX - appState.offsetLeft) * ownerWindow.devicePixelRatio,
(clientY - appState.offsetTop) * ownerWindow.devicePixelRatio,
1,
1,
).data;
return rgbToHex(pixel[0], pixel[1], pixel[2]);
};
const getColorToApply = (color: string) =>
appState.theme === THEME.DARK ? removeDarkModeFilter(color) : color;
const mouseMoveListener = ({
clientX,
clientY,
altKey,
}: {
clientX: number;
clientY: number;
altKey: boolean;
}) => {
const { top, left } = positionElementBesideCursor({
cursor: { x: clientX, y: clientY },
element: {
width: colorPreviewDiv.offsetWidth,
height: colorPreviewDiv.offsetHeight,
},
container: eyeDropperContainer.getBoundingClientRect(),
gap: 7,
});
colorPreviewDiv.style.top = `${top}px`;
colorPreviewDiv.style.left = `${left}px`;
const currentColor = getCurrentColor({ clientX, clientY });
if (isHoldingPointerDown) {
stableProps.onChange(
colorPickerType,
getColorToApply(currentColor),
stableProps.selectedElements,
{ altKey },
);
}
colorPreviewDiv.style.background = currentColor;
colorPreviewDiv.style.setProperty(
"--eye-dropper-preview-border-color",
isColorDark(currentColor) ? "#fff" : "#222",
);
};
const onCancel = () => {
stableProps.onCancel();
};
const onSelect: Required<EyeDropperProperties>["onSelect"] = (
color,
event,
) => {
stableProps.onSelect(color, event);
};
const pointerDownListener = (event: PointerEvent) => {
isHoldingPointerDown = true;
// NOTE we can't event.preventDefault() as that would stop
// pointermove events
event.stopImmediatePropagation();
};
const pointerUpListener = (event: PointerEvent) => {
isHoldingPointerDown = false;
// since we're not preventing default on pointerdown, the focus would
// goes back to `body` so we want to refocus the editor container instead
excalidrawContainer?.focus();
event.stopImmediatePropagation();
event.preventDefault();
onSelect(getColorToApply(getCurrentColor(event)), event);
};
const keyDownListener = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
event.preventDefault();
event.stopImmediatePropagation();
onCancel();
}
};
// -------------------------------------------------------------------------
eyeDropperContainer.tabIndex = -1;
// focus container so we can listen on keydown events
eyeDropperContainer.focus();
// init color preview else it would show only after the first mouse move
mouseMoveListener({
clientX: stableProps.app.viewport.lastPosition.x,
clientY: stableProps.app.viewport.lastPosition.y,
altKey: false,
});
eyeDropperContainer.addEventListener(EVENT.KEYDOWN, keyDownListener);
eyeDropperContainer.addEventListener(
EVENT.POINTER_DOWN,
pointerDownListener,
);
eyeDropperContainer.addEventListener(EVENT.POINTER_UP, pointerUpListener);
ownerWindow.addEventListener("pointermove", mouseMoveListener, {
passive: true,
});
ownerWindow.addEventListener(EVENT.BLUR, onCancel);
return () => {
isHoldingPointerDown = false;
eyeDropperContainer.removeEventListener(EVENT.KEYDOWN, keyDownListener);
eyeDropperContainer.removeEventListener(
EVENT.POINTER_DOWN,
pointerDownListener,
);
eyeDropperContainer.removeEventListener(
EVENT.POINTER_UP,
pointerUpListener,
);
ownerWindow.removeEventListener("pointermove", mouseMoveListener);
ownerWindow.removeEventListener(EVENT.BLUR, onCancel);
};
}, [
stableProps,
app.canvas,
eyeDropperContainer,
colorPickerType,
excalidrawContainer,
appState.offsetLeft,
appState.offsetTop,
appState.theme,
ownerWindow,
]);
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(
ref,
() => {
onCancel();
},
(event) => {
if (
event.target.closest(
".excalidraw-eye-dropper-trigger, .excalidraw-eye-dropper-backdrop",
)
) {
return true;
}
// consider all other clicks as outside
return false;
},
);
if (!eyeDropperContainer) {
return null;
}
return createPortal(
<div ref={ref} className="excalidraw-eye-dropper-preview" />,
eyeDropperContainer,
);
};

View File

@@ -0,0 +1,330 @@
@use "../css/variables.module" as *;
@keyframes successStatusAnimation {
0% {
transform: scale(0.35);
}
50% {
transform: scale(1.25);
}
100% {
transform: scale(1);
}
}
.excalidraw {
.ExcButton {
--text-color: transparent;
--border-color: transparent;
--back-color: transparent;
color: var(--text-color);
background-color: var(--back-color);
border-color: var(--border-color);
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&:hover {
transition: all 150ms ease-out;
}
.Spinner {
--spinner-color: var(--color-surface-lowest);
}
.ExcButton__statusIcon {
visibility: visible;
position: absolute;
width: 1.2rem;
height: 1.2rem;
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
}
&.ExcButton--status-loading,
&.ExcButton--status-success {
pointer-events: none;
background-color: var(--color-success);
.ExcButton__contents {
visibility: hidden;
}
}
&[disabled] {
cursor: not-allowed;
&.ExcButton--variant-filled,
&:hover {
--back-color: var(--color-surface-low) !important;
--text-color: var(--color-on-surface-variant) !important;
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-on-surface-variant);
--border-color: var(--color-surface-high);
}
}
&,
&__contents {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
flex-wrap: nowrap;
// needed because of .Spinner
position: relative;
}
&--color-primary {
&.ExcButton--variant-filled {
--text-color: var(--color-surface-lowest);
--back-color: var(--color-primary);
.Spinner {
--spinner-color: var(--text-color);
}
&:hover {
--back-color: var(--color-brand-hover);
}
&:active {
--back-color: var(--color-brand-active);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-primary);
--border-color: var(--color-primary);
--back-color: transparent;
.Spinner {
--spinner-color: var(--text-color);
}
&:hover {
--text-color: var(--color-brand-hover);
--border-color: var(--color-brand-hover);
}
&:active {
--text-color: var(--color-brand-active);
--border-color: var(--color-brand-active);
}
}
}
&--color-danger {
&.ExcButton--variant-filled {
--text-color: var(--color-danger-text);
--back-color: var(--color-danger-dark);
.Spinner {
--spinner-color: var(--text-color);
}
&:hover {
--back-color: var(--color-danger-darker);
}
&:active {
--back-color: var(--color-danger-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-danger);
--border-color: var(--color-danger);
--back-color: transparent;
.Spinner {
--spinner-color: var(--text-color);
}
&:hover {
--text-color: var(--color-danger-darkest);
--border-color: var(--color-danger-darkest);
}
&:active {
--text-color: var(--color-danger-darker);
--border-color: var(--color-danger-darker);
}
}
}
&--color-success {
&.ExcButton--variant-filled {
--text-color: var(--color-success-text);
--back-color: var(--color-success);
.Spinner {
--spinner-color: var(--color-success);
}
&:hover {
--back-color: var(--color-success-darker);
}
&:active {
--back-color: var(--color-success-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-success-contrast);
--border-color: var(--color-success-contrast);
--back-color: transparent;
.Spinner {
--spinner-color: var(--color-success-contrast);
}
&:hover {
--text-color: var(--color-success-contrast-hover);
--border-color: var(--color-success-contrast-hover);
}
&:active {
--text-color: var(--color-success-contrast-active);
--border-color: var(--color-success-contrast-active);
}
}
}
&--color-muted {
&.ExcButton--variant-filled {
--text-color: var(--island-bg-color);
--back-color: var(--color-gray-50);
.Spinner {
--spinner-color: var(--text-color);
}
&:hover {
--back-color: var(--color-gray-60);
}
&:active {
--back-color: var(--color-gray-80);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-muted-background);
--border-color: var(--color-muted);
--back-color: var(--island-bg-color);
.Spinner {
--spinner-color: var(--text-color);
}
&:hover {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darker);
}
&:active {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darkest);
}
}
}
&--color-warning {
&.ExcButton--variant-filled {
--text-color: black;
--back-color: var(--color-warning-dark);
.Spinner {
--spinner-color: var(--text-color);
}
&:hover {
--back-color: var(--color-warning-darker);
}
&:active {
--back-color: var(--color-warning-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-warning-dark);
--border-color: var(--color-warning-dark);
--back-color: var(--input-bg-color);
.Spinner {
--spinner-color: var(--text-color);
}
&:hover {
--text-color: var(--color-warning-darker);
--border-color: var(--color-warning-darker);
}
&:active {
--text-color: var(--color-warning-darkest);
--border-color: var(--color-warning-darkest);
}
}
}
&--size-large {
font-weight: 600;
font-size: 0.875rem;
min-height: 3rem;
padding: 0.5rem 1.5rem;
letter-spacing: 0.4px;
.ExcButton__contents {
gap: 0.75rem;
}
}
&--size-medium {
font-weight: 600;
font-size: 0.75rem;
min-height: 2.5rem;
padding: 0.5rem 1rem;
letter-spacing: normal;
.ExcButton__contents {
gap: 0.5rem;
}
}
&--variant-icon {
padding: 0.5rem 0.75rem;
width: 3rem;
}
&--fullWidth {
width: 100%;
}
&__icon {
width: 1.25rem;
height: 1.25rem;
}
}
}

View File

@@ -0,0 +1,119 @@
import clsx from "clsx";
import React, { forwardRef, useState } from "react";
import { isPromiseLike } from "@excalidraw/common";
import { AbortError } from "../errors";
import Spinner from "./Spinner";
import { tablerCheckIcon } from "./icons";
import "./FilledButton.scss";
export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor =
| "primary"
| "danger"
| "warning"
| "muted"
| "success";
export type ButtonSize = "medium" | "large";
export type FilledButtonProps = {
label?: string;
children?: React.ReactNode;
onClick?: (event: React.MouseEvent) => void;
status?: null | "loading" | "success";
variant?: ButtonVariant;
color?: ButtonColor;
size?: ButtonSize;
className?: string;
fullWidth?: boolean;
icon?: React.ReactNode;
disabled?: boolean;
};
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
(
{
children,
icon,
onClick,
label,
variant = "filled",
color = "primary",
size = "medium",
fullWidth,
className,
status,
disabled,
},
ref,
) => {
const [isLoading, setIsLoading] = useState(false);
const _onClick = async (event: React.MouseEvent) => {
const ret = onClick?.(event);
if (isPromiseLike(ret)) {
// delay loading state to prevent flicker in case of quick response
const timer = window.setTimeout(() => {
setIsLoading(true);
}, 50);
try {
await ret;
} catch (error: any) {
if (!(error instanceof AbortError)) {
throw error;
} else {
console.warn(error);
}
} finally {
clearTimeout(timer);
setIsLoading(false);
}
}
};
const _status = isLoading ? "loading" : status;
color = _status === "success" ? "success" : color;
return (
<button
className={clsx(
"ExcButton",
`ExcButton--color-${color}`,
`ExcButton--variant-${variant}`,
`ExcButton--size-${size}`,
`ExcButton--status-${_status}`,
{ "ExcButton--fullWidth": fullWidth },
className,
)}
onClick={_onClick}
type="button"
aria-label={label}
ref={ref}
disabled={disabled || _status === "loading" || _status === "success"}
>
<div className="ExcButton__contents">
{_status === "loading" ? (
<Spinner className="ExcButton__statusIcon" />
) : (
_status === "success" && (
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
)
)}
{icon && (
<div className="ExcButton__icon" aria-hidden>
{icon}
</div>
)}
{variant !== "icon" && (children ?? label)}
</div>
</button>
);
},
);

View File

@@ -0,0 +1,39 @@
@use "../css/variables.module" as *;
.excalidraw {
.FixedSideContainer {
position: absolute;
pointer-events: none;
}
.FixedSideContainer > * {
pointer-events: var(--ui-pointerEvents);
}
.FixedSideContainer_side_top {
left: var(--editor-container-padding);
top: var(--editor-container-padding);
right: var(--editor-container-padding);
bottom: var(--editor-container-padding);
}
.FixedSideContainer_side_top.zen-mode {
right: 42px;
}
}
/* TODO: if these are used, make sure to implement RTL support
.FixedSideContainer_side_left {
left: var(--space-factor);
top: var(--space-factor);
bottom: var(--space-factor);
z-index: 1;
}
.FixedSideContainer_side_right {
right: var(--space-factor);
top: var(--space-factor);
bottom: var(--space-factor);
z-index: 3;
}
*/

View File

@@ -0,0 +1,26 @@
import React from "react";
import clsx from "clsx";
import "./FixedSideContainer.scss";
type FixedSideContainerProps = {
children: React.ReactNode;
side: "top" | "left" | "right";
className?: string;
};
export const FixedSideContainer = ({
children,
side,
className,
}: FixedSideContainerProps) => (
<div
className={clsx(
"FixedSideContainer",
`FixedSideContainer_side_${side}`,
className,
)}
>
{children}
</div>
);

View File

@@ -0,0 +1,20 @@
@use "../../css/variables.module" as *;
.excalidraw {
.FontPicker__container {
display: grid;
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
align-items: center;
@include isMobile {
max-width: calc(
2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons
}
&--compact {
display: block;
grid-template-columns: none;
}
}
}

View File

@@ -0,0 +1,31 @@
import { KEYS } from "@excalidraw/common";
import { Excalidraw } from "../..";
import { Keyboard } from "../../tests/helpers/ui";
import { act, render } from "../../tests/test-utils";
describe("FontPicker", () => {
it("should be able to open font picker", async () => {
(global as any).ResizeObserver =
(global as any).ResizeObserver ||
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
const { queryByTestId } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
Keyboard.keyPress(KEYS.T);
const fontPickerTrigger = queryByTestId("font-family-show-fonts");
expect(fontPickerTrigger).not.toBeNull();
act(() => {
fontPickerTrigger!.click();
});
});
});

View File

@@ -0,0 +1,130 @@
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { useCallback, useMemo } from "react";
import { FONT_FAMILY } from "@excalidraw/common";
import type { FontFamilyValues } from "@excalidraw/element/types";
import { t } from "../../i18n";
import { RadioSelection } from "../RadioSelection";
import { ButtonSeparator } from "../ButtonSeparator";
import {
FontFamilyCodeIcon,
FontFamilyNormalIcon,
FreedrawIcon,
} from "../icons";
import { FontPickerList } from "./FontPickerList";
import { FontPickerTrigger } from "./FontPickerTrigger";
import "./FontPicker.scss";
export const DEFAULT_FONTS = [
{
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-hand-drawn",
},
{
value: FONT_FAMILY.Nunito,
icon: FontFamilyNormalIcon,
text: t("labels.normal"),
testId: "font-family-normal",
},
{
value: FONT_FAMILY["Comic Shanns"],
icon: FontFamilyCodeIcon,
text: t("labels.code"),
testId: "font-family-code",
},
];
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
export const isDefaultFont = (fontFamily: number | null) => {
if (!fontFamily) {
return false;
}
return defaultFontFamilies.has(fontFamily);
};
interface FontPickerProps {
isOpened: boolean;
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (fontFamily: FontFamilyValues) => void;
onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void;
onPopupChange: (open: boolean) => void;
compactMode?: boolean;
}
export const FontPicker = React.memo(
({
isOpened,
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onPopupChange,
compactMode = false,
}: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback(
(value: number | false) => {
if (value) {
onSelect(value);
}
},
[onSelect],
);
return (
<div
role="dialog"
aria-modal="true"
className={clsx("FontPicker__container", {
"FontPicker__container--compact": compactMode,
})}
>
{!compactMode && (
<div className="buttonList">
<RadioSelection<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
</div>
)}
{!compactMode && <ButtonSeparator />}
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger
selectedFontFamily={selectedFontFamily}
isOpened={isOpened}
compactMode={compactMode}
/>
{isOpened && (
<FontPickerList
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={hoveredFontFamily}
onSelect={onSelectCallback}
onHover={onHover}
onLeave={onLeave}
onOpen={() => onPopupChange(true)}
onClose={() => onPopupChange(false)}
/>
)}
</Popover.Root>
</div>
);
},
(prev, next) =>
prev.isOpened === next.isOpened &&
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);

View File

@@ -0,0 +1,413 @@
import React, {
useMemo,
useState,
useRef,
useEffect,
useCallback,
type KeyboardEventHandler,
} from "react";
import { type FontFamilyValues } from "@excalidraw/element/types";
import {
arrayToList,
debounce,
FONT_FAMILY,
getFontFamilyString,
} from "@excalidraw/common";
import type { ValueOf } from "@excalidraw/common/utility-types";
import { Fonts } from "../../fonts";
import { t } from "../../i18n";
import {
useApp,
useAppProps,
useExcalidrawContainer,
useStylesPanelMode,
} from "../App";
import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent";
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
import {
FontFamilyCodeIcon,
FontFamilyHeadingIcon,
FontFamilyNormalIcon,
FreedrawIcon,
} from "../icons";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import type { JSX } from "react";
import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace";
export interface FontDescriptor {
value: number;
icon: JSX.Element;
text: string;
deprecated?: true;
badge?: {
type: ValueOf<typeof DropDownMenuItemBadgeType>;
placeholder: string;
};
}
interface FontPickerListProps {
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (value: number) => void;
onHover: (value: number) => void;
onLeave: () => void;
onOpen: () => void;
onClose: () => void;
}
const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => {
switch (fontFamily) {
case FONT_FAMILY.Excalifont:
case FONT_FAMILY.Virgil:
return FreedrawIcon;
case FONT_FAMILY.Nunito:
case FONT_FAMILY.Helvetica:
return FontFamilyNormalIcon;
case FONT_FAMILY["Lilita One"]:
return FontFamilyHeadingIcon;
case FONT_FAMILY["Comic Shanns"]:
case FONT_FAMILY.Cascadia:
return FontFamilyCodeIcon;
default:
return FontFamilyNormalIcon;
}
};
const getFontFamilyLabel = (
fontFamily: FontFamilyValues,
fontFaces: ExcalidrawFontFace[],
) =>
// prefer our config as the browser resolved names may be wrapped in quotes and such
Object.entries(FONT_FAMILY).find(([, id]) => id === fontFamily)?.[0] ??
fontFaces[0]?.fontFace?.family ??
"Unknown";
export const FontPickerList = React.memo(
({
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onOpen,
onClose,
}: FontPickerListProps) => {
const { container } = useExcalidrawContainer();
const app = useApp();
const { fonts } = app;
const { showDeprecatedFonts } = useAppProps();
const stylesPanelMode = useStylesPanelMode();
const [searchTerm, setSearchTerm] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const allFonts = useMemo(
() =>
Array.from(Fonts.registered.entries())
.filter(
([_, { metadata }]) => !metadata.private && !metadata.fallback,
)
.map(([familyId, { metadata, fontFaces }]) => {
const fontDescriptor = {
value: familyId,
icon: getFontFamilyIcon(familyId),
text: getFontFamilyLabel(familyId, fontFaces),
};
if (metadata.deprecated) {
Object.assign(fontDescriptor, {
deprecated: metadata.deprecated,
badge: {
type: DropDownMenuItemBadgeType.RED,
placeholder: t("fontList.badge.old"),
},
});
}
return fontDescriptor as FontDescriptor;
})
.sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
),
[],
);
const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFamilies()),
// cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily],
);
const sceneFonts = useMemo(
() => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
[allFonts, sceneFamilies],
);
const availableFonts = useMemo(
() =>
allFonts.filter(
(font) =>
!sceneFamilies.has(font.value) &&
(showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
),
[allFonts, sceneFamilies, showDeprecatedFonts],
);
const filteredFonts = useMemo(
() =>
arrayToList(
[...sceneFonts, ...availableFonts].filter((font) =>
font.text?.toLowerCase().includes(searchTerm),
),
),
[sceneFonts, availableFonts, searchTerm],
);
const hoveredFont = useMemo(() => {
let font;
if (hoveredFontFamily) {
font = filteredFonts.find((font) => font.value === hoveredFontFamily);
} else if (selectedFontFamily) {
font = filteredFonts.find((font) => font.value === selectedFontFamily);
}
if (!font && searchTerm) {
if (filteredFonts[0]?.value) {
// hover first element on search
onHover(filteredFonts[0].value);
} else {
// re-render cache on no results
onLeave();
}
}
return font;
}, [
hoveredFontFamily,
selectedFontFamily,
searchTerm,
filteredFonts,
onHover,
onLeave,
]);
// Create a wrapped onSelect function that preserves caret position
const wrappedOnSelect = useCallback(
(fontFamily: FontFamilyValues) => {
// Save caret position before font selection if editing text
let savedSelection: { start: number; end: number } | null = null;
if (app.state.editingTextElement) {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
savedSelection = {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
}
onSelect(fontFamily);
// Restore caret position after font selection if editing text
if (app.state.editingTextElement && savedSelection) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor && savedSelection) {
textEditor.focus();
textEditor.selectionStart = savedSelection.start;
textEditor.selectionEnd = savedSelection.end;
}
}, 0);
}
},
[onSelect, app.state.editingTextElement],
);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => {
const handled = fontPickerKeyHandler({
event,
inputRef,
hoveredFont,
filteredFonts,
onSelect: wrappedOnSelect,
onHover,
onClose,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
);
useEffect(() => {
onOpen();
return () => {
onClose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sceneFilteredFonts = useMemo(
() => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const availableFilteredFonts = useMemo(
() => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const FontPickerListItem = ({
font,
order,
}: {
font: FontDescriptor;
order: number;
}) => {
const ref = useRef<HTMLButtonElement>(null);
const isHovered = font.value === hoveredFont?.value;
const isSelected = font.value === selectedFontFamily;
useEffect(() => {
if (!isHovered) {
return;
}
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView?.({ block: "end" });
} else {
ref.current?.scrollIntoView?.({ block: "nearest" });
}
}, [isHovered, order]);
return (
<button
ref={ref}
type="button"
value={font.value}
className={getDropdownMenuItemClassName("", isSelected, isHovered)}
title={font.text}
// allow to tab between search and selected font
tabIndex={isSelected ? 0 : -1}
onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
<MenuItemContent
icon={font.icon}
badge={
font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)
}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
>
{font.text}
</MenuItemContent>
</button>
);
};
const groups = [];
if (sceneFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map((font, index) => (
<FontPickerListItem key={font.value} font={font} order={index} />
))}
</DropdownMenuGroup>,
);
}
if (availableFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) => (
<FontPickerListItem
key={font.value}
font={font}
order={index + sceneFilteredFonts.length}
/>
))}
</DropdownMenuGroup>,
);
}
return (
<PropertiesPopover
className="properties-content"
container={container}
style={{ width: "15rem" }}
onClose={() => {
onClose();
// Refocus text editor when font picker closes if we were editing text
if (app.state.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement}
>
{stylesPanelMode === "full" && (
<QuickSearch
ref={inputRef}
placeholder={t("quickSearch.placeholder")}
onChange={debounce(setSearchTerm, 20)}
/>
)}
<ScrollableList
className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")}
>
{groups.length ? groups : null}
</ScrollableList>
</PropertiesPopover>
);
},
(prev, next) =>
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);

View File

@@ -0,0 +1,59 @@
import { Popover } from "radix-ui";
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
import type { FontFamilyValues } from "@excalidraw/element/types";
import { t } from "../../i18n";
import { IconButton } from "../IconButton";
import { TextIcon } from "../icons";
import { useExcalidrawSetAppState } from "../App";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
isOpened?: boolean;
compactMode?: boolean;
}
export const FontPickerTrigger = ({
selectedFontFamily,
isOpened = false,
compactMode = false,
}: FontPickerTriggerProps) => {
const setAppState = useExcalidrawSetAppState();
const compactStyle = compactMode
? {
...MOBILE_ACTION_BUTTON_BG,
width: "2rem",
height: "2rem",
}
: {};
return (
<Popover.Trigger asChild>
<div data-openpopup="fontFamily" className="properties-trigger">
<IconButton
type="toggle"
icon={TextIcon}
title={t("labels.showFonts")}
aria-label={t("labels.showFonts")}
className="standalone properties-trigger"
data-testid="font-family-show-fonts"
checked={isOpened}
onSelect={() => {
setAppState((appState) => ({
openPopup:
appState.openPopup === "fontFamily" ? null : appState.openPopup,
}));
}}
style={{
border: "none",
...compactStyle,
}}
/>
</div>
</Popover.Trigger>
);
};

View File

@@ -0,0 +1,68 @@
import { KEYS } from "@excalidraw/common";
import type { Node } from "@excalidraw/common";
import { type FontDescriptor } from "./FontPickerList";
interface FontPickerKeyNavHandlerProps {
event: React.KeyboardEvent<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement | null>;
hoveredFont: Node<FontDescriptor> | undefined;
filteredFonts: Node<FontDescriptor>[];
onClose: () => void;
onSelect: (value: number) => void;
onHover: (value: number) => void;
}
export const fontPickerKeyHandler = ({
event,
inputRef,
hoveredFont,
filteredFonts,
onClose,
onSelect,
onHover,
}: FontPickerKeyNavHandlerProps) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.F
) {
// refocus input on the popup trigger shortcut
inputRef.current?.focus();
return true;
}
if (event.key === KEYS.ESCAPE) {
onClose();
return true;
}
if (event.key === KEYS.ENTER) {
if (hoveredFont?.value) {
onSelect(hoveredFont.value);
}
return true;
}
if (event.key === KEYS.ARROW_DOWN) {
if (hoveredFont?.next) {
onHover(hoveredFont.next.value);
} else if (filteredFonts[0]?.value) {
onHover(filteredFonts[0].value);
}
return true;
}
if (event.key === KEYS.ARROW_UP) {
if (hoveredFont?.prev) {
onHover(hoveredFont.prev.value);
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
onHover(filteredFonts[filteredFonts.length - 1].value);
}
return true;
}
};

View File

@@ -0,0 +1,23 @@
import { t } from "../i18n";
import { HelpIcon } from "./icons";
import { Tooltip } from "./Tooltip";
type HelpButtonProps = {
name?: string;
id?: string;
onClick?(): void;
};
export const HelpButton = (props: HelpButtonProps) => (
<Tooltip label={`${t("helpDialog.title")} — ?`}>
<button
className="help-icon"
onClick={props.onClick}
type="button"
aria-label={t("helpDialog.title")}
>
{HelpIcon}
</button>
</Tooltip>
);

View File

@@ -0,0 +1,131 @@
@use "../css/variables.module" as *;
.excalidraw {
.HelpDialog {
.Modal__content {
max-width: 960px;
}
h3 {
margin: 1.5rem 0;
font-weight: 700;
font-size: 1.125rem;
}
&__header {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
&__btn {
--background: var(--color-surface-mid);
display: flex;
column-gap: 0.5rem;
align-items: center;
background-color: var(--background);
padding: 0.625rem 1rem;
border: 1px solid var(--background);
border-radius: var(--border-radius-lg);
color: var(--text-primary-color);
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.4px;
@at-root .excalidraw.theme--dark#{&} {
--background: var(--color-surface-high);
&:hover {
--background: #363541;
}
}
&:hover {
--background: var(--color-surface-high);
text-decoration: none;
}
&:active {
border-color: var(--color-primary);
}
}
&__link-icon {
line-height: 0;
svg {
width: 1rem;
height: 1rem;
}
}
&__islands-container {
display: grid;
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
@media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr;
}
}
@media screen and (min-width: 1024px) {
&__island--tools {
grid-area: 1 / 1 / 2 / 2;
}
&__island--view {
grid-area: 2 / 1 / 3 / 2;
}
&__island--editor {
grid-area: 1 / 2 / 3 / 3;
}
}
&__island {
h4 {
font-size: 1rem;
font-weight: 700;
margin: 0;
margin-bottom: 0.625rem;
}
&-content {
border: 1px solid var(--dialog-border-color);
border-radius: var(--border-radius-lg);
}
}
&__shortcut {
border-bottom: 1px solid var(--dialog-border-color);
padding: 0.375rem 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
column-gap: 0.5rem;
&:last-child {
border-bottom: none;
}
}
&__key-container {
display: flex;
align-items: center;
column-gap: 0.25rem;
flex-shrink: 0;
}
&__key {
display: flex;
box-sizing: border-box;
font-size: 0.625rem;
background-color: var(--color-primary-light);
border-radius: var(--border-radius-md);
padding: 0.5rem;
word-break: keep-all;
align-items: center;
font-family: inherit;
line-height: 1;
}
}
}

View File

@@ -0,0 +1,521 @@
import React from "react";
import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
import { KEYS } from "@excalidraw/common";
import { actionToggleTheme } from "../actions";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { probablySupportsClipboardBlob } from "../clipboard";
import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { useExcalidrawActionManager } from "./App";
import { Dialog } from "./Dialog";
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
import "./HelpDialog.scss";
import type { JSX } from "react";
const Header = () => (
<div className="HelpDialog__header">
<a
className="HelpDialog__btn"
href="https://docs.excalidraw.com"
target="_blank"
rel="noopener"
>
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
{t("helpDialog.documentation")}
</a>
<a
className="HelpDialog__btn"
href="https://plus.excalidraw.com/blog"
target="_blank"
rel="noopener"
>
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
{t("helpDialog.blog")}
</a>
<a
className="HelpDialog__btn"
href="https://github.com/excalidraw/excalidraw/issues"
target="_blank"
rel="noopener noreferrer"
>
<div className="HelpDialog__link-icon">{GithubIcon}</div>
{t("helpDialog.github")}
</a>
<a
className="HelpDialog__btn"
href="https://youtube.com/@excalidraw"
target="_blank"
rel="noopener noreferrer"
>
<div className="HelpDialog__link-icon">{youtubeIcon}</div>
YouTube
</a>
</div>
);
const Section = (props: { title: string; children: React.ReactNode }) => (
<>
<h3>{props.title}</h3>
<div className="HelpDialog__islands-container">{props.children}</div>
</>
);
const ShortcutIsland = (props: {
caption: string;
children: React.ReactNode;
className?: string;
}) => (
<div className={`HelpDialog__island ${props.className}`}>
<h4 className="HelpDialog__island-title">{props.caption}</h4>
<div className="HelpDialog__island-content">{props.children}</div>
</div>
);
function* intersperse(as: JSX.Element[][], delim: string | null) {
let first = true;
for (const x of as) {
if (!first) {
yield delim;
}
first = false;
yield x;
}
}
const upperCaseSingleChars = (str: string) => {
return str.replace(/\b[a-z]\b/, (c) => c.toUpperCase());
};
const Shortcut = ({
label,
shortcuts,
isOr = true,
}: {
label: string;
shortcuts: string[];
isOr?: boolean;
}) => {
const splitShortcutKeys = shortcuts.map((shortcut) => {
const keys = shortcut.endsWith("++")
? [...shortcut.slice(0, -2).split("+"), "+"]
: shortcut.split("+");
return keys.map((key) => (
<ShortcutKey key={key}>{upperCaseSingleChars(key)}</ShortcutKey>
));
});
return (
<div className="HelpDialog__shortcut">
<div>{label}</div>
<div className="HelpDialog__key-container">
{[...intersperse(splitShortcutKeys, isOr ? t("helpDialog.or") : "")]}
</div>
</div>
);
};
const ShortcutKey = (props: { children: React.ReactNode }) => (
<kbd className="HelpDialog__key" {...props} />
);
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
const actionManager = useExcalidrawActionManager();
const handleClose = React.useCallback(() => {
if (onClose) {
onClose();
}
}, [onClose]);
return (
<>
<Dialog
onCloseRequest={handleClose}
title={t("helpDialog.title")}
className={"HelpDialog"}
>
<Header />
<Section title={t("helpDialog.shortcuts")}>
<ShortcutIsland
className="HelpDialog__island--tools"
caption={t("helpDialog.tools")}
>
<Shortcut label={t("toolBar.hand")} shortcuts={[KEYS.H]} />
<Shortcut
label={t("toolBar.selection")}
shortcuts={[KEYS.V, KEYS["1"]]}
/>
<Shortcut
label={t("toolBar.rectangle")}
shortcuts={[KEYS.R, KEYS["2"]]}
/>
<Shortcut
label={t("toolBar.diamond")}
shortcuts={[KEYS.D, KEYS["3"]]}
/>
<Shortcut
label={t("toolBar.ellipse")}
shortcuts={[KEYS.O, KEYS["4"]]}
/>
<Shortcut
label={t("toolBar.arrow")}
shortcuts={[KEYS.A, KEYS["5"]]}
/>
<Shortcut
label={t("toolBar.line")}
shortcuts={[KEYS.L, KEYS["6"]]}
/>
<Shortcut
label={t("toolBar.freedraw")}
shortcuts={[KEYS.P, KEYS["7"]]}
/>
<Shortcut
label={t("toolBar.text")}
shortcuts={[KEYS.T, KEYS["8"]]}
/>
<Shortcut label={t("toolBar.stickynote")} shortcuts={[KEYS.N]} />
<Shortcut label={t("toolBar.image")} shortcuts={[KEYS["9"]]} />
<Shortcut
label={t("toolBar.eraser")}
shortcuts={[KEYS.E, KEYS["0"]]}
/>
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
<Shortcut label={t("toolBar.bucketfill")} shortcuts={[KEYS.B]} />
<Shortcut
label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
/>
<Shortcut
label={t("helpDialog.editLineArrowPoints")}
shortcuts={[getShortcutKey("CtrlOrCmd+Enter")]}
/>
<Shortcut
label={t("helpDialog.editText")}
shortcuts={[getShortcutKey("Enter")]}
/>
<Shortcut
label={t("helpDialog.textNewLine")}
shortcuts={[
getShortcutKey("Enter"),
getShortcutKey("Shift+Enter"),
]}
/>
<Shortcut
label={t("helpDialog.textFinish")}
shortcuts={[
getShortcutKey("Esc"),
getShortcutKey("CtrlOrCmd+Enter"),
]}
/>
<Shortcut
label={t("helpDialog.curvedArrow")}
shortcuts={[
"A",
t("helpDialog.click"),
t("helpDialog.click"),
t("helpDialog.click"),
]}
isOr={false}
/>
<Shortcut
label={t("helpDialog.curvedLine")}
shortcuts={[
"L",
t("helpDialog.click"),
t("helpDialog.click"),
t("helpDialog.click"),
]}
isOr={false}
/>
<Shortcut
label={t("helpDialog.cropStart")}
shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
isOr={true}
/>
<Shortcut
label={t("helpDialog.cropFinish")}
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
isOr={true}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut
label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]}
/>
<Shortcut
label={t("toolBar.link")}
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
/>
<Shortcut
label={t("toolBar.convertElementType")}
shortcuts={["Tab", "Shift+Tab"]}
isOr={true}
/>
</ShortcutIsland>
<ShortcutIsland
className="HelpDialog__island--view"
caption={t("helpDialog.view")}
>
<Shortcut
label={t("buttons.zoomIn")}
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
/>
<Shortcut
label={t("buttons.zoomOut")}
shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
/>
<Shortcut
label={t("buttons.resetZoom")}
shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
/>
<Shortcut
label={t("helpDialog.zoomToFit")}
shortcuts={["Shift+1"]}
/>
<Shortcut
label={t("helpDialog.zoomToSelection")}
shortcuts={["Shift+2"]}
/>
<Shortcut
label={t("helpDialog.movePageUpDown")}
shortcuts={["PgUp/PgDn"]}
/>
<Shortcut
label={t("helpDialog.movePageLeftRight")}
shortcuts={["Shift+PgUp/PgDn"]}
/>
<Shortcut
label={t("buttons.zenMode")}
shortcuts={[getShortcutKey("Alt+Z")]}
/>
<Shortcut
label={t("buttons.objectsSnapMode")}
shortcuts={[getShortcutKey("Alt+S")]}
/>
<Shortcut
label={t("labels.toggleGrid")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
/>
<Shortcut
label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]}
/>
{actionManager.isActionEnabled(actionToggleTheme) && (
<Shortcut
label={t("labels.toggleTheme")}
shortcuts={[getShortcutKey("Alt+Shift+D")]}
/>
)}
<Shortcut
label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]}
/>
<Shortcut
label={t("search.title")}
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
/>
<Shortcut
label={t("commandPalette.title")}
shortcuts={
isFirefox
? [getShortcutFromShortcutName("commandPalette")]
: [
getShortcutFromShortcutName("commandPalette"),
getShortcutFromShortcutName("commandPalette", 1),
]
}
/>
</ShortcutIsland>
<ShortcutIsland
className="HelpDialog__island--editor"
caption={t("helpDialog.editor")}
>
<Shortcut
label={t("helpDialog.createFlowchart")}
shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
isOr={true}
/>
<Shortcut
label={t("helpDialog.navigateFlowchart")}
shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
isOr={true}
/>
<Shortcut
label={t("labels.moveCanvas")}
shortcuts={[
getShortcutKey(`Space+${t("helpDialog.drag")}`),
getShortcutKey(`Wheel+${t("helpDialog.drag")}`),
]}
isOr={true}
/>
<Shortcut
label={t("buttons.clearReset")}
shortcuts={[getShortcutKey("CtrlOrCmd+Delete")]}
/>
<Shortcut
label={t("labels.delete")}
shortcuts={[getShortcutKey("Delete")]}
/>
<Shortcut
label={t("labels.cut")}
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
/>
<Shortcut
label={t("labels.copy")}
shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
/>
<Shortcut
label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/>
<Shortcut
label={t("labels.pasteAsPlaintext")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
/>
<Shortcut
label={t("labels.selectAll")}
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
/>
<Shortcut
label={t("labels.multiSelect")}
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
/>
<Shortcut
label={t("helpDialog.deepSelect")}
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`)]}
/>
<Shortcut
label={t("helpDialog.deepBoxSelect")}
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`)]}
/>
{/* firefox supports clipboard API under a flag, so we'll
show users what they can do in the error message */}
{(probablySupportsClipboardBlob || isFirefox) && (
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}
/>
)}
<Shortcut
label={t("labels.copyStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
/>
<Shortcut
label={t("labels.pasteStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
/>
<Shortcut
label={t("labels.sendToBack")}
shortcuts={[
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+["),
]}
/>
<Shortcut
label={t("labels.bringToFront")}
shortcuts={[
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]"),
]}
/>
<Shortcut
label={t("labels.sendBackward")}
shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
/>
<Shortcut
label={t("labels.bringForward")}
shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
/>
<Shortcut
label={t("labels.alignTop")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
/>
<Shortcut
label={t("labels.alignBottom")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
/>
<Shortcut
label={t("labels.alignLeft")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
/>
<Shortcut
label={t("labels.alignRight")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
/>
<Shortcut
label={t("labels.duplicateSelection")}
shortcuts={[
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
]}
/>
<Shortcut
label={t("helpDialog.toggleElementLock")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
/>
<Shortcut
label={t("buttons.undo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
/>
<Shortcut
label={t("buttons.redo")}
shortcuts={
isWindows
? [
getShortcutKey("CtrlOrCmd+Y"),
getShortcutKey("CtrlOrCmd+Shift+Z"),
]
: [getShortcutKey("CtrlOrCmd+Shift+Z")]
}
/>
<Shortcut
label={t("labels.group")}
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
/>
<Shortcut
label={t("labels.ungroup")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
/>
<Shortcut
label={t("labels.flipHorizontal")}
shortcuts={[getShortcutKey("Shift+H")]}
/>
<Shortcut
label={t("labels.flipVertical")}
shortcuts={[getShortcutKey("Shift+V")]}
/>
<Shortcut
label={t("labels.showStroke")}
shortcuts={[getShortcutKey("S")]}
/>
<Shortcut
label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]}
/>
<Shortcut
label={t("labels.showFonts")}
shortcuts={[getShortcutKey("Shift+F")]}
/>
<Shortcut
label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
/>
<Shortcut
label={t("labels.increaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
/>
</ShortcutIsland>
</Section>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,51 @@
@use "../css/variables.module" as *;
// this is loosely based on the longest hint text
$wide-viewport-width: 1000px;
.excalidraw {
.HintViewer {
pointer-events: none;
box-sizing: border-box;
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
left: 0;
top: 100%;
max-width: 100%;
width: 100%;
margin-top: 0.5rem;
text-align: center;
color: var(--color-gray-40);
font-size: 0.75rem;
@include isMobile {
position: static;
padding-right: 2rem;
}
> span {
padding: 0.25rem;
}
kbd {
display: inline-block;
margin: 0 1px;
font-family: monospace;
border: 1px solid var(--color-gray-40);
border-radius: 4px;
padding: 1px 3px;
font-size: 10px;
}
}
&.theme--dark {
.HintViewer {
color: var(--color-gray-60);
kbd {
border-color: var(--color-gray-60);
}
}
}
}

View File

@@ -0,0 +1,305 @@
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common";
import {
isArrowElement,
isFlowchartNodeElement,
isImageElement,
isLinearElement,
isLineElement,
isStickyNoteElement,
isTextBindableContainer,
isTextElement,
} from "@excalidraw/element";
import { isNodeInFlowchart } from "@excalidraw/element";
import type { EditorInterface } from "@excalidraw/common";
import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { isEraserActive } from "../appState";
import { isGridModeEnabled } from "../snapping";
import "./HintViewer.scss";
import type { AppClassProperties, UIAppState } from "../types";
interface HintViewerProps {
appState: UIAppState;
isMobile: boolean;
editorInterface: EditorInterface;
app: AppClassProperties;
}
const getTaggedShortcutKey = (key: string | string[]) =>
Array.isArray(key)
? `<kbd>${key.map(getShortcutKey).join(" + ")}</kbd>`
: `<kbd>${getShortcutKey(key)}</kbd>`;
const getHints = ({
appState,
isMobile,
editorInterface,
app,
}: HintViewerProps): null | string | string[] => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
appState.searchMatches?.matches.length
) {
return t("hints.dismissSearch", {
shortcut: getTaggedShortcutKey("Escape"),
});
}
if (appState.openSidebar && !editorInterface.canFitSidebar) {
return null;
}
if (isEraserActive(appState)) {
return t("hints.eraserRevert", {
shortcut: getTaggedShortcutKey("Alt"),
});
}
const selectedElements = app.scene.getSelectedElements(appState);
// creating or dragging arrow point
if (
appState.selectedLinearElement?.isDragging &&
selectedElements[0]?.type === "arrow"
) {
return t("hints.arrowBindModifiers", {
shortcut_1: getTaggedShortcutKey("Ctrl"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
}
if (activeTool.type === "arrow" || activeTool.type === "line") {
if (multiMode) {
return t("hints.linearElementMulti", {
shortcut_1: getTaggedShortcutKey("Escape"),
shortcut_2: getTaggedShortcutKey("Enter"),
});
}
if (activeTool.type === "arrow") {
return t("hints.arrowTool", {
shortcut: getTaggedShortcutKey("A"),
});
}
return t("hints.linearElement");
}
if (activeTool.type === "freedraw") {
return t("hints.freeDraw");
}
if (activeTool.type === "text") {
return t("hints.text");
}
if (activeTool.type === "embeddable") {
return t("hints.embeddable");
}
if (activeTool.type === "stickynote") {
return t("hints.stickynote");
}
if (activeTool.type === "autoshape") {
return t("hints.autoshape");
}
if (
isResizing &&
lastPointerDownWith === "mouse" &&
selectedElements.length === 1
) {
const targetElement = selectedElements[0];
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
return t("hints.lockAngle", {
shortcut: getTaggedShortcutKey("Shift"),
});
}
if (
isStickyNoteElement(targetElement) &&
// a note's corners are proportional by default (Shift frees them); its
// edges are free by default, so they get the generic hint below
app.activeResizeHandle?.length === 2
) {
return t("hints.resizeStickyNote", {
shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
}
return isImageElement(targetElement)
? t("hints.resizeImage", {
shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
})
: t("hints.resize", {
shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
}
if (isRotating && lastPointerDownWith === "mouse") {
return t("hints.rotate", {
shortcut: getTaggedShortcutKey("Shift"),
});
}
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected", {
shortcut: getTaggedShortcutKey("Enter"),
});
}
if (appState.editingTextElement) {
return t("hints.text_editing", {
shortcut_1: getTaggedShortcutKey("Escape"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
});
}
if (appState.croppingElementId) {
return t("hints.leaveCropEditor", {
shortcut_1: getTaggedShortcutKey("Enter"),
shortcut_2: getTaggedShortcutKey("Escape"),
});
}
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
return t("hints.enterCropEditor", {
shortcut: getTaggedShortcutKey("Enter"),
});
}
if (activeTool.type === "selection") {
if (
appState.selectionElement &&
!selectedElements.length &&
!appState.editingTextElement &&
!appState.selectedLinearElement?.isEditing
) {
return t("hints.deepBoxSelect", {
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
});
}
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
return t("hints.disableSnapping", {
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
});
}
if (!selectedElements.length && !isMobile) {
return t("hints.canvasPanning", {
shortcut_1: getTaggedShortcutKey(t("keys.mmb")),
shortcut_2: getTaggedShortcutKey("Space"),
});
}
if (selectedElements.length === 1) {
if (isLinearElement(selectedElements[0])) {
if (
isArrowElement(selectedElements[0]) &&
appState.selectedLinearElement?.elementId ===
selectedElements[0].id &&
(appState.selectedLinearElement.hoverPointIndex === 0 ||
appState.selectedLinearElement.hoverPointIndex ===
selectedElements[0].points.length - 1)
) {
return t("hints.toggleArrowhead");
}
if (appState.selectedLinearElement?.isEditing) {
return appState.selectedLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected", {
shortcut_1: getTaggedShortcutKey("Delete"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "D"]),
})
: t("hints.lineEditor_nothingSelected", {
shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
}
return isLineElement(selectedElements[0])
? t("hints.lineEditor_line_info", {
shortcut: getTaggedShortcutKey("Enter"),
})
: t("hints.lineEditor_info", {
shortcut_1: getTaggedShortcutKey("CtrlOrCmd"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
});
}
if (
!appState.newElement &&
!appState.selectedElementsAreBeingDragged &&
isTextBindableContainer(selectedElements[0])
) {
const bindTextToElement = t("hints.bindTextToElement", {
shortcut: getTaggedShortcutKey("Enter"),
});
const createFlowchart = t("hints.createFlowchart", {
shortcut: getTaggedShortcutKey(["CtrlOrCmd", "↑↓"]),
});
if (isFlowchartNodeElement(selectedElements[0])) {
if (
isNodeInFlowchart(
selectedElements[0],
app.scene.getNonDeletedElementsMap(),
)
) {
return [bindTextToElement, createFlowchart];
}
return [bindTextToElement, createFlowchart];
}
return bindTextToElement;
}
}
}
return null;
};
export const HintViewer = ({
appState,
isMobile,
editorInterface,
app,
}: HintViewerProps) => {
const hints = getHints({
appState,
isMobile,
editorInterface,
app,
});
if (!hints) {
return null;
}
const hint = Array.isArray(hints)
? hints.map((hint) => hint.replace(/\. ?$/, "")).join(", ")
: hints;
const hintJSX = hint.split(/(<kbd>[^<]+<\/kbd>)/g).map((part, index) => {
if (index % 2 === 1) {
const shortcutMatch =
part[0] === "<" && part.match(/^<kbd>([^<]+)<\/kbd>$/);
return <kbd key={index}>{shortcutMatch ? shortcutMatch[1] : part}</kbd>;
}
return part;
});
return (
<div className="HintViewer">
<span>{hintJSX}</span>
</div>
);
};

View File

@@ -0,0 +1,202 @@
import clsx from "clsx";
import React, { useEffect, useRef, useState } from "react";
import { isPromiseLike } from "@excalidraw/common";
import type { PointerType } from "@excalidraw/element/types";
import { AbortError } from "../errors";
import "./ToolIcon.scss";
import Spinner from "./Spinner";
import type { CSSProperties } from "react";
export type IconButtonSize = "small" | "medium";
type IconButtonBaseProps = {
icon?: React.ReactNode;
"aria-label": string;
"aria-keyshortcuts"?: string;
"data-testid"?: string;
label?: string;
title?: string;
size?: IconButtonSize;
keyBindingLabel?: string | null;
showAriaLabel?: boolean;
hidden?: boolean;
visible?: boolean;
disabled?: boolean;
className?: string;
style?: CSSProperties;
isLoading?: boolean;
};
type IconButtonProps =
| (IconButtonBaseProps & {
type: "button";
children?: React.ReactNode;
onClick?(event: React.MouseEvent): void;
})
| (IconButtonBaseProps & {
type: "icon";
children?: React.ReactNode;
onClick?(): void;
})
// a stateful (pressed/unpressed) tool button
| (IconButtonBaseProps & {
type: "toggle";
checked: boolean;
/**
* Fired on activation — a completed pointer gesture (via `click`, so a
* press canceled by sliding off the button doesn't select) or
* keyboard/AT activation, in which case pointerType is null.
* pointerType is captured on pointer-down, where it's reliable
* (unlike the click event's own pointerType on iOS).
*/
onSelect?(data: { pointerType: PointerType | null }): void;
/** the press that may become a click — e.g. to arm a drag out of the toolbar */
onPointerDown?(event: React.PointerEvent<HTMLButtonElement>): void;
});
export const IconButton = React.forwardRef(
(
{
size = "medium",
visible = true,
className = "",
...props
}: IconButtonProps,
ref,
) => {
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${size}`;
const [isLoading, setIsLoading] = useState(false);
const isMountedRef = useRef(true);
const onClick = async (event: React.MouseEvent) => {
const ret = "onClick" in props && props.onClick?.(event);
if (isPromiseLike(ret)) {
try {
setIsLoading(true);
await ret;
} catch (error: any) {
if (!(error instanceof AbortError)) {
throw error;
} else {
console.warn(error);
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}
};
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null);
if (props.type === "button" || props.type === "icon") {
return (
<button
className={clsx(
"ToolIcon_type_button",
sizeCn,
className,
visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title}
aria-label={props["aria-label"]}
type="button"
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
{props.isLoading && <Spinner />}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
);
}
return (
<button
className={clsx("ToolIcon", "ToolIcon_type_toggle", sizeCn, className, {
"ToolIcon--checked": props.checked,
})}
type="button"
style={props.style}
title={props.title}
aria-label={props["aria-label"]}
aria-keyshortcuts={props["aria-keyshortcuts"]}
aria-pressed={props.checked}
data-testid={props["data-testid"]}
disabled={!!props.disabled}
aria-disabled={!!props.disabled}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.(event);
}}
onPointerUp={() => {
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
onClick={() => {
props.onSelect?.({ pointerType: lastPointerTypeRef.current });
}}
ref={innerRef}
>
<div className="ToolIcon__icon">
{props.icon}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
</div>
</button>
);
},
);
IconButton.displayName = "IconButton";

View File

@@ -0,0 +1,122 @@
@use "sass:color";
@use "../css/variables.module" as *;
.excalidraw {
.picker {
padding: 0.5rem;
background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: var(--shadow-island-stronger);
border-radius: 4px;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-sections,
.picker-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.picker-container button,
.picker button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
&:focus-visible {
outline: transparent;
background-color: var(--button-gray-2);
& svg {
opacity: 1;
}
}
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
&:disabled {
cursor: not-allowed;
}
svg {
margin: 0;
width: 36px;
height: 18px;
pointer-events: none;
}
}
.picker button {
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
}
.picker-content {
display: grid;
grid-template-columns: repeat(4, auto);
grid-gap: 0.5rem;
border-radius: 4px;
}
.picker-collapsible {
font-size: 0.75rem;
padding: 0;
color: var(--text-primary-color);
}
.picker-section-label {
font-size: 0.75rem;
color: var(--text-primary-color);
}
.picker-keybinding {
position: absolute;
bottom: 2px;
font-size: 0.7em;
color: var(--keybinding-color);
:root[dir="ltr"] & {
right: 2px;
}
:root[dir="rtl"] & {
left: 2px;
}
@include isMobile {
display: none;
}
}
.picker-type-canvasBackground .picker-keybinding {
color: #aaa;
}
.picker-type-elementBackground .picker-keybinding {
color: #fff;
}
.picker-swatch[aria-label="transparent"] .picker-keybinding {
color: #aaa;
}
.picker-type-elementStroke .picker-keybinding {
color: #d4d4d4;
}
&.theme--dark {
.picker-type-elementBackground .picker-keybinding {
color: #000;
}
.picker-swatch[aria-label="transparent"] .picker-keybinding {
color: #000;
}
}
}

View File

@@ -0,0 +1,333 @@
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { useEffect, useMemo } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common";
import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible";
import { useExcalidrawContainer } from "./App";
import "./IconPicker.scss";
import type { JSX } from "react";
const moreOptionsAtom = atom(false);
const PICKER_COLUMNS = 4;
const DEFAULT_SECTION_NAME = "default";
type Option<T> = {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
};
type PickerSection<T> = {
name: string;
options: readonly Option<T>[];
};
const flattenOptions = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) => section.options);
const findOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => {
for (const section of sections) {
const option = section.options.find(predicate);
if (option) {
return option;
}
}
return null;
};
const hasOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => sections.some((section) => section.options.some(predicate));
const getNavigationRows = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) =>
Array.from(
{ length: Math.ceil(section.options.length / PICKER_COLUMNS) },
(_, index) =>
section.options.slice(
index * PICKER_COLUMNS,
index * PICKER_COLUMNS + PICKER_COLUMNS,
),
),
);
function Picker<T>({
visibleSections,
hiddenSections = [],
value,
label,
onChange,
onClose,
}: {
label: string;
value: T;
visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
onChange: (value: T) => void;
onClose: () => void;
}) {
const { container } = useExcalidrawContainer();
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const allSections = [...visibleSections, ...hiddenSections];
const allOptions = flattenOptions(allSections);
const navigationRows = getNavigationRows([
...visibleSections,
...(showMoreOptions ? hiddenSections : []),
]);
const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = allOptions.find(
(option) => option.keyBinding === event.key.toLowerCase(),
);
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation
onChange(pressedOption.value);
event.preventDefault();
} else if (event.key === KEYS.TAB) {
const index = allOptions.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey
? (allOptions.length + index - 1) % allOptions.length
: (index + 1) % allOptions.length;
onChange(allOptions[nextIndex].value);
} else if (isArrowKey(event.key)) {
// Arrow navigation
const isRTL = getLanguage().rtl;
const index = allOptions.findIndex((option) => option.value === value);
if (index !== -1) {
const length = allOptions.length;
let nextIndex = index;
switch (event.key) {
// Select the next option
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
nextIndex = (index + 1) % length;
break;
// Select the previous option
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
nextIndex = (length + index - 1) % length;
break;
// Go the next row
case KEYS.ARROW_DOWN: {
const currentRowIndex = navigationRows.findIndex((row) =>
row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const nextRow =
navigationRows[(currentRowIndex + 1) % navigationRows.length];
const nextOption =
nextRow[Math.min(column, nextRow.length - 1)] ??
allOptions[index];
onChange(nextOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break;
}
// Go the previous row
case KEYS.ARROW_UP: {
const currentRowIndex = navigationRows.findIndex((row) =>
row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const previousRow =
navigationRows[
(navigationRows.length + currentRowIndex - 1) %
navigationRows.length
];
const previousOption =
previousRow[Math.min(column, previousRow.length - 1)] ??
allOptions[index];
onChange(previousOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break;
}
}
onChange(allOptions[nextIndex].value);
}
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
// Close on escape or enter
event.preventDefault();
onClose();
}
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
};
useEffect(() => {
if (hasOption(hiddenSections, (option) => option.value === value)) {
setShowMoreOptions(true);
}
}, [value, hiddenSections, setShowMoreOptions]);
const renderOptions = (options: readonly Option<T>[]) => {
return (
<div className="picker-content">
{options.map((option) => (
<button
type="button"
className={clsx("picker-option", {
active: value === option.value,
})}
onClick={() => {
onChange(option.value);
}}
title={
option.keyBinding
? `${option.text} — ${option.keyBinding.toUpperCase()}`
: option.text
}
aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined}
key={option.text}
ref={(ref) => {
if (value === option.value) {
// Use a timeout here to render focus properly
setTimeout(() => {
ref?.focus();
}, 0);
}
}}
>
{option.icon}
{option.keyBinding && (
<span className="picker-keybinding">{option.keyBinding}</span>
)}
</button>
))}
</div>
);
};
const renderSections = (sections: readonly PickerSection<T>[]) =>
sections.map((section, index) =>
section.name === DEFAULT_SECTION_NAME ? (
<React.Fragment key={`${section.name}-${index}`}>
{renderOptions(section.options)}
</React.Fragment>
) : (
<div className="picker-section" key={`${section.name}-${index}`}>
<div className="picker-section-label">{section.name}</div>
{renderOptions(section.options)}
</div>
),
);
return (
<Popover.Content
className="picker"
role="dialog"
aria-modal="true"
aria-label={label}
side={"bottom"}
align="start"
sideOffset={12}
alignOffset={12}
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined}
>
<div className="picker-sections">
{renderSections(visibleSections)}
{hiddenSections.length > 0 && (
<Collapsible
label={t("labels.more_options")}
open={showMoreOptions}
openTrigger={() => {
setShowMoreOptions((value) => !value);
}}
className="picker-collapsible"
>
<div className="picker-sections">
{renderSections(hiddenSections)}
</div>
</Collapsible>
)}
</div>
</Popover.Content>
);
}
export function IconPicker<T>({
value,
label,
visibleSections,
hiddenSections,
onChange,
}: {
label: string;
value: T;
visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
onChange: (value: T) => void;
}) {
const [isActive, setActive] = React.useState(false);
const selectedOption = useMemo(
() =>
findOption(visibleSections, (option) => option.value === value) ??
findOption(hiddenSections ?? [], (option) => option.value === value),
[visibleSections, hiddenSections, value],
);
return (
<div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger
type="button"
aria-label={label}
onClick={() => setActive(!isActive)}
className={isActive ? "active" : ""}
>
{selectedOption?.icon}
</Popover.Trigger>
{isActive && (
<Picker
visibleSections={visibleSections}
hiddenSections={hiddenSections}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
}}
/>
)}
</Popover.Root>
</div>
);
}

View File

@@ -0,0 +1,175 @@
@use "../css/variables.module" as *;
.excalidraw {
--ImageExportModal-preview-border: #d6d6d6;
&.theme--dark {
--ImageExportModal-preview-border: #5c5c5c;
}
.ImageExportModal {
display: flex;
flex-direction: row;
justify-content: space-between;
user-select: none;
& h3 {
font-family: "Assistant";
font-style: normal;
font-weight: 700;
font-size: 1.313rem;
line-height: 130%;
padding: 0;
margin: 0;
@include isMobile {
display: none;
}
}
& > h3 {
display: none;
@include isMobile {
display: block;
}
}
@include isMobile {
flex-direction: column;
height: calc(100vh - 5rem);
}
&__preview {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
height: 360px;
width: 55%;
margin-right: 1.5rem;
@include isMobile {
max-width: unset;
margin-right: unset;
width: 100%;
height: unset;
flex-grow: 1;
}
&__filename {
& > input {
margin-top: 1rem;
}
}
&__canvas {
box-sizing: border-box;
width: 100%;
height: 100%;
display: flex;
flex-grow: 1;
justify-content: center;
align-items: center;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border: 1px solid var(--ImageExportModal-preview-border);
border-radius: 12px;
overflow: hidden;
padding: 1rem;
& > canvas {
max-width: calc(100% - 2rem);
max-height: calc(100% - 2rem);
filter: none !important;
@include isMobile {
max-height: 100%;
}
}
@include isMobile {
margin-top: 24px;
max-width: unset;
}
}
}
&__settings {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 18px;
@include isMobile {
margin-left: unset;
margin-top: 1rem;
flex-direction: row;
gap: 6px 34px;
align-content: flex-start;
}
&__setting {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
@include isMobile {
flex-direction: column;
align-items: start;
justify-content: unset;
height: 52px;
}
&__label {
display: flex;
flex-direction: row;
align-items: center;
font-family: "Assistant";
font-weight: 600;
font-size: 1rem;
line-height: 150%;
& svg {
width: 20px;
height: 20px;
margin-left: 10px;
}
}
&__content {
display: flex;
height: 100%;
align-items: center;
}
}
&__buttons {
flex-grow: 1;
flex-wrap: wrap;
display: flex;
flex-direction: row;
gap: 11px;
align-items: flex-end;
align-content: flex-end;
@include isMobile {
padding-top: 32px;
flex-basis: 100%;
justify-content: center;
}
}
}
}
}

View File

@@ -0,0 +1,426 @@
import { exportToCanvas } from "@excalidraw/utils/export";
import React, { useEffect, useRef, useState } from "react";
import {
DEFAULT_EXPORT_PADDING,
EXPORT_IMAGE_TYPES,
isFirefox,
EXPORT_SCALES,
cloneJSON,
} from "@excalidraw/common";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import {
actionExportWithDarkMode,
actionChangeExportBackground,
actionChangeExportEmbedScene,
actionChangeExportScale,
actionChangeProjectName,
} from "../actions/actionExport";
import { probablySupportsClipboardBlob } from "../clipboard";
import { prepareElementsForExport } from "../data";
import { canvasToBlob } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog";
import { RadioGroup } from "./RadioGroup";
import { Switch } from "./Switch";
import { Tooltip } from "./Tooltip";
import { FilledButton } from "./FilledButton";
import "./ImageExportDialog.scss";
import type { ActionManager } from "../actions/manager";
import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
export const ErrorCanvasPreview = () => {
return (
<div>
<h3>{t("canvasError.cannotShowPreview")}</h3>
<p>
<span>{t("canvasError.canvasTooBig")}</span>
</p>
<em>({t("canvasError.canvasTooBigTip")})</em>
</div>
);
};
type ImageExportModalProps = {
appStateSnapshot: Readonly<UIAppState>;
elementsSnapshot: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
name: string;
exportWithDarkMode: boolean;
};
const ImageExportModal = ({
appStateSnapshot,
elementsSnapshot,
files,
actionManager,
onExportImage,
name,
exportWithDarkMode,
}: ImageExportModalProps) => {
const hasSelection = isSomeElementSelected(
elementsSnapshot,
appStateSnapshot,
);
const [projectName, setProjectName] = useState(name);
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
const [exportWithBackground, setExportWithBackground] = useState(
appStateSnapshot.exportBackground,
);
const [embedScene, setEmbedScene] = useState(
appStateSnapshot.exportEmbedScene,
);
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
const previewRef = useRef<HTMLDivElement>(null);
const previewRenderRequestIdRef = useRef(0);
const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
useEffect(() => {
// if user changes setting right after export to clipboard, reset the status
// so they don't have to wait for the timeout to click the button again
resetCopyStatus();
}, [
projectName,
exportWithBackground,
exportWithDarkMode,
exportScale,
embedScene,
resetCopyStatus,
]);
const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot,
appStateSnapshot,
exportSelectionOnly,
);
useEffect(() => {
const previewNode = previewRef.current;
if (!previewNode) {
return;
}
const maxWidth = previewNode.offsetWidth;
const maxHeight = previewNode.offsetHeight;
if (!maxWidth) {
return;
}
const requestId = ++previewRenderRequestIdRef.current;
const isStaleRequest = () => {
return requestId !== previewRenderRequestIdRef.current;
};
exportToCanvas({
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode,
exportScale,
exportEmbedScene: embedScene,
},
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
})
.then(async (canvas) => {
if (isStaleRequest()) {
return;
}
// If converting to blob fails, there's some problem that will likely
// prevent preview and export (e.g. canvas too big).
try {
await canvasToBlob(canvas);
} catch (error: any) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
throw error;
}
if (isStaleRequest()) {
return;
}
setRenderError(null);
previewNode.replaceChildren(canvas);
})
.catch((error) => {
if (isStaleRequest()) {
return;
}
console.error(error);
setRenderError(error);
});
return () => {
previewRenderRequestIdRef.current += 1;
};
}, [
appStateSnapshot,
files,
exportedElements,
exportingFrame,
projectName,
exportWithBackground,
exportWithDarkMode,
exportScale,
embedScene,
]);
return (
<div className="ImageExportModal">
<h3>{t("imageExportDialog.header")}</h3>
<div className="ImageExportModal__preview">
<div className="ImageExportModal__preview__canvas" ref={previewRef}>
{renderError && <ErrorCanvasPreview />}
</div>
<div className="ImageExportModal__preview__filename">
{!nativeFileSystemSupported && (
<input
type="text"
className="TextInput"
value={projectName}
style={{ width: "30ch" }}
onChange={(event) => {
setProjectName(event.target.value);
actionManager.executeAction(
actionChangeProjectName,
"ui",
event.target.value,
);
}}
/>
)}
</div>
</div>
<div className="ImageExportModal__settings">
<h3>{t("imageExportDialog.header")}</h3>
{hasSelection && (
<ExportSetting
label={t("imageExportDialog.label.onlySelected")}
name="exportOnlySelected"
>
<Switch
name="exportOnlySelected"
checked={exportSelectionOnly}
onChange={(checked) => {
setExportSelectionOnly(checked);
}}
/>
</ExportSetting>
)}
<ExportSetting
label={t("imageExportDialog.label.withBackground")}
name="exportBackgroundSwitch"
>
<Switch
name="exportBackgroundSwitch"
checked={exportWithBackground}
onChange={(checked) => {
setExportWithBackground(checked);
actionManager.executeAction(
actionChangeExportBackground,
"ui",
checked,
);
}}
/>
</ExportSetting>
<ExportSetting
label={t("imageExportDialog.label.darkMode")}
name="exportDarkModeSwitch"
>
<Switch
name="exportDarkModeSwitch"
checked={exportWithDarkMode}
onChange={(checked) => {
actionManager.executeAction(
actionExportWithDarkMode,
"ui",
checked,
);
}}
/>
</ExportSetting>
<ExportSetting
label={t("imageExportDialog.label.embedScene")}
tooltip={t("imageExportDialog.tooltip.embedScene")}
name="exportEmbedSwitch"
>
<Switch
name="exportEmbedSwitch"
checked={embedScene}
onChange={(checked) => {
setEmbedScene(checked);
actionManager.executeAction(
actionChangeExportEmbedScene,
"ui",
checked,
);
}}
/>
</ExportSetting>
<ExportSetting
label={t("imageExportDialog.label.scale")}
name="exportScale"
>
<RadioGroup
name="exportScale"
value={exportScale}
onChange={(scale) => {
setExportScale(scale);
actionManager.executeAction(actionChangeExportScale, "ui", scale);
}}
choices={EXPORT_SCALES.map((scale) => ({
value: scale,
label: `${scale}\u00d7`,
}))}
/>
</ExportSetting>
<div className="ImageExportModal__settings__buttons">
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToPng")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
exportingFrame,
})
}
icon={downloadIcon}
>
{t("imageExportDialog.button.exportToPng")}
</FilledButton>
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToSvg")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
exportingFrame,
})
}
icon={downloadIcon}
>
{t("imageExportDialog.button.exportToSvg")}
</FilledButton>
{(probablySupportsClipboardBlob || isFirefox) && (
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")}
status={copyStatus}
onClick={async () => {
await onExportImage(
EXPORT_IMAGE_TYPES.clipboard,
exportedElements,
{
exportingFrame,
},
);
onCopy();
}}
icon={copyIcon}
>
{t("imageExportDialog.button.copyPngToClipboard")}
</FilledButton>
)}
</div>
</div>
</div>
);
};
type ExportSettingProps = {
label: string;
children: React.ReactNode;
tooltip?: string;
name?: string;
};
const ExportSetting = ({
label,
children,
tooltip,
name,
}: ExportSettingProps) => {
return (
<div className="ImageExportModal__settings__setting" title={label}>
<label
htmlFor={name}
className="ImageExportModal__settings__setting__label"
>
{label}
{tooltip && (
<Tooltip label={tooltip} long={true}>
{helpIcon}
</Tooltip>
)}
</label>
<div className="ImageExportModal__settings__setting__content">
{children}
</div>
</div>
);
};
export const ImageExportDialog = ({
elements,
appState,
files,
actionManager,
onExportImage,
onCloseRequest,
name,
}: {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
onCloseRequest: () => void;
name: string;
}) => {
// we need to take a snapshot so that the exported state can't be modified
// while the dialog is open
const [{ appStateSnapshot, elementsSnapshot }] = useState(() => {
return {
appStateSnapshot: cloneJSON(appState),
elementsSnapshot: cloneJSON(elements),
};
});
return (
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
<ImageExportModal
elementsSnapshot={elementsSnapshot}
appStateSnapshot={appStateSnapshot}
files={files}
actionManager={actionManager}
onExportImage={onExportImage}
name={name}
exportWithDarkMode={appState.exportWithDarkMode}
/>
</Dialog>
);
};

View File

@@ -0,0 +1,31 @@
import React, { useEffect, useState } from "react";
import type { Theme } from "@excalidraw/element/types";
import { defaultLang, languages, setLanguage } from "../i18n";
import { LoadingMessage } from "./LoadingMessage";
import type { Language } from "../i18n";
interface Props {
langCode: Language["code"];
children: React.ReactElement;
theme?: Theme;
}
export const InitializeApp = (props: Props) => {
const [loading, setLoading] = useState(true);
useEffect(() => {
const updateLang = async () => {
await setLanguage(currentLang);
setLoading(false);
};
const currentLang =
languages.find((lang) => lang.code === props.langCode) || defaultLang;
updateLang();
}, [props.langCode]);
return loading ? <LoadingMessage theme={props.theme} /> : props.children;
};

View File

@@ -0,0 +1,26 @@
export const InlineIcon = ({
className,
icon,
size = "1em",
}: {
className?: string;
icon: React.ReactNode;
size?: string;
}) => {
return (
<span
className={className}
style={{
width: size,
height: "100%",
margin: "0 0.5ex 0 0.5ex",
display: "inline-flex",
lineHeight: 0,
verticalAlign: "middle",
flex: "0 0 auto",
}}
>
{icon}
</span>
);
};

View File

@@ -0,0 +1,15 @@
.excalidraw {
.Island {
--padding: 0;
box-sizing: border-box;
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
&.zen-mode {
box-shadow: none;
}
}
}

View File

@@ -0,0 +1,43 @@
import React from "react";
import clsx from "clsx";
import "./Island.scss";
import type { ViewportUIDock, ViewportUIName } from "../types";
type IslandProps = {
children: React.ReactNode;
padding?: number;
className?: string | boolean;
style?: object;
/** marks the island as a canvas-occluding UI surface measured by
* `getViewportOffsets` (see {@link ViewportUIDock}) */
"data-viewport-ui"?: ViewportUIDock;
/** identifies the surface so `getViewportOffsets` can reserve space for
* it while hidden (see {@link ViewportUIName}) */
"data-viewport-ui-name"?: ViewportUIName;
};
export const Island = React.forwardRef<HTMLDivElement, IslandProps>(
(
{
children,
padding,
className,
style,
"data-viewport-ui": viewportUI,
"data-viewport-ui-name": viewportUIName,
},
ref,
) => (
<div
className={clsx("Island", className)}
style={{ "--padding": padding, ...style }}
data-viewport-ui={viewportUI}
data-viewport-ui-name={viewportUIName}
ref={ref}
>
{children}
</div>
),
);

View File

@@ -0,0 +1,142 @@
import React from "react";
import { getFrame } from "@excalidraw/common";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { actionSaveFileToDisk } from "../actions/actionExport";
import { trackEvent } from "../analytics";
import { nativeFileSystemSupported } from "../data/filesystem";
import { t } from "../i18n";
import { Card } from "./Card";
import { Dialog } from "./Dialog";
import { IconButton } from "./IconButton";
import { exportToFileIcon, LinkIcon } from "./icons";
import "./ExportDialog.scss";
import type { ActionManager } from "../actions/manager";
import type { ExportOpts, BinaryFiles, UIAppState } from "../types";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
) => void;
const JSONExportModal = ({
elements,
appState,
setAppState,
files,
actionManager,
exportOpts,
canvas,
onCloseRequest,
}: {
appState: UIAppState;
setAppState: React.Component<any, UIAppState>["setState"];
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionManager;
onCloseRequest: () => void;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement;
}) => {
const { onExportToBackend } = exportOpts;
return (
<div className="ExportDialog ExportDialog--json">
<div className="ExportDialog-cards">
{exportOpts.saveFileToDisk && (
<Card color="lime">
<div className="Card-icon">{exportToFileIcon}</div>
<h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details">
{t("exportDialog.disk_details")}
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
</div>
<IconButton
className="Card-button"
type="button"
title={t("exportDialog.disk_button")}
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk, "ui");
}}
/>
</Card>
)}
{onExportToBackend && (
<Card color="pink">
<div className="Card-icon">{LinkIcon}</div>
<h2>{t("exportDialog.link_title")}</h2>
<div className="Card-details">{t("exportDialog.link_details")}</div>
<IconButton
className="Card-button"
type="button"
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={async () => {
try {
trackEvent("export", "link", `ui (${getFrame()})`);
await onExportToBackend(elements, appState, files);
onCloseRequest();
} catch (error: any) {
setAppState({ errorMessage: error.message });
}
}}
/>
</Card>
)}
{exportOpts.renderCustomUI &&
exportOpts.renderCustomUI(elements, appState, files, canvas)}
</div>
</div>
);
};
export const JSONExportDialog = ({
elements,
appState,
files,
actionManager,
exportOpts,
canvas,
setAppState,
}: {
elements: readonly NonDeletedExcalidrawElement[];
appState: UIAppState;
files: BinaryFiles;
actionManager: ActionManager;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement;
setAppState: React.Component<any, UIAppState>["setState"];
}) => {
const handleClose = React.useCallback(() => {
setAppState({ openDialog: null });
}, [setAppState]);
return (
<>
{appState.openDialog?.name === "jsonExport" && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal
elements={elements}
appState={appState}
setAppState={setAppState}
files={files}
actionManager={actionManager}
onCloseRequest={handleClose}
exportOpts={exportOpts}
canvas={canvas}
/>
</Dialog>
)}
</>
);
};

View File

@@ -0,0 +1,29 @@
import clsx from "clsx";
import { IconButton } from "./IconButton";
import { laserPointerToolIcon } from "./icons";
type LaserPointerButtonProps = {
title?: string;
checked: boolean;
onChange?(): void;
isMobile?: boolean;
};
export const LaserPointerButton = (props: LaserPointerButtonProps) => {
return (
<IconButton
className={clsx("ToolIcon__LaserPointer", {
"is-mobile": props.isMobile,
})}
type="toggle"
size="small"
icon={laserPointerToolIcon}
checked={props.checked}
title={`${props.title}`}
aria-label={`${props.title}`}
data-testid="toolbar-LaserPointer"
onSelect={() => props.onChange?.()}
/>
);
};

View File

@@ -0,0 +1,195 @@
@use "../css/variables.module" as *;
.excalidraw {
.layer-ui__wrapper.animate {
transition: width 0.1s ease-in-out;
}
.layer-ui__wrapper {
// when the rightside sidebar is docked, we need to resize the UI by its
// width, making the nested UI content shift to the left. To do this,
// we need the UI container to actually have dimensions set, but
// then we also need to disable pointer events else the canvas below
// wouldn't be interactive.
position: absolute;
width: 100%;
height: 100%;
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;
:root[dir="ltr"] &.transition-left {
transform: translate(-999px, 0);
}
:root[dir="ltr"] &.transition-right {
transform: translate(999px, 0);
}
:root[dir="rtl"] &.transition-left {
transform: translate(999px, 0);
}
:root[dir="rtl"] &.transition-right {
transform: translate(-999px, 0);
}
&.layer-ui__wrapper__footer-left--transition-bottom {
transform: translate(0, 92px);
}
}
.disable-zen-mode {
padding: 10px;
position: absolute;
bottom: 0;
[dir="ltr"] & {
right: 1rem;
}
[dir="rtl"] & {
left: 1rem;
}
opacity: 0;
visibility: hidden;
transition: visibility 0s linear 0s, opacity 0.5s;
font-family: var(--ui-font);
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
border-radius: var(--border-radius-lg);
border: 1px solid var(--default-border-color);
background-color: var(--island-bg-color);
color: var(--text-primary-color);
&:hover {
background-color: var(--button-hover-bg);
}
&:active {
border-color: var(--color-primary);
}
&--visible {
opacity: 1;
visibility: visible;
transition: visibility 0s linear 300ms, opacity 0.5s;
transition-delay: 0.8s;
pointer-events: var(--ui-pointerEvents);
}
}
.layer-ui__wrapper__footer-left,
.footer-center,
.layer-ui__wrapper__footer-right {
& > * {
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 {
// NOTE: not `justify-self: center` — that centers against the grid
// column's track width, which (see above) can be inflated by unrelated
// content, visibly shifting this button off from the icon stack above
// it. Nudge by hand instead, against the fixed-width compact actions
// column (see .selected-shape-actions-container--compact's min-width).
justify-self: start;
margin-left: calc((48px - var(--lg-button-size)) / 2);
.ToolIcon__icon {
width: var(--lg-button-size);
height: var(--lg-button-size);
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
}
// no shadow while pen mode is active (the active fill is enough)
&.ToolIcon--checked .ToolIcon__icon {
box-shadow: none;
}
}
.disable-view-mode {
display: flex;
justify-content: center;
cursor: pointer;
align-items: center;
border: 1px solid var(--color-primary);
padding: 0.5rem;
border-radius: var(--border-radius-lg);
background-color: var(--island-bg-color);
text-decoration: none !important;
font-family: var(--ui-font);
font-size: 0.8333rem;
box-sizing: border-box;
width: var(--mobile-action-button-size, var(--default-button-size));
height: var(--mobile-action-button-size, var(--default-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;
}
&:active {
background-color: var(--color-primary-darker);
}
svg {
width: 20px;
height: 20px;
}
}
.theme--dark {
.plus-banner {
&:hover {
color: black !important;
}
}
}
}

View File

@@ -0,0 +1,758 @@
import clsx from "clsx";
import React from "react";
import {
CLASSES,
DEFAULT_SIDEBAR,
TOOL_TYPE,
arrayToMap,
capitalizeString,
isShallowEqual,
} from "@excalidraw/common";
import { getColorUpdate, mutateElement } from "@excalidraw/element";
import { showSelectedShapeActions } from "@excalidraw/element";
import { ShapeCache } from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { actionToggleStats } from "../actions";
import { trackEvent } from "../analytics";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
import { UIAppStateContext } from "../context/ui-appState";
import { useAtom, useAtomValue } from "../editor-jotai";
import { t } from "../i18n";
import { getScrollToContentState, getSelectedElements } from "../scene";
import {
getColorTargetAppStateUpdates,
resolveColorTarget,
type ColorDefaultKey,
} from "../actions/colorTargets";
import { SelectedShapeActions, CompactShapeActions } from "./Actions";
import { LoadingMessage } from "./LoadingMessage";
import { MobileMenu } from "./MobileMenu";
import { PasteChartDialog } from "./PasteChartDialog";
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";
import { useAppProps, useEditorInterface, useStylesPanelMode } from "./App";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
import { sidebarRightIcon } from "./icons";
import { DefaultSidebar } from "./DefaultSidebar";
import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats";
import ElementLinkDialog from "./ElementLinkDialog";
import { ErrorDialog } from "./ErrorDialog";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import { FixedSideContainer } from "./FixedSideContainer";
import { HelpDialog } from "./HelpDialog";
import { ImageExportDialog } from "./ImageExportDialog";
import { Island } from "./Island";
import { JSONExportDialog } from "./JSONExportDialog";
import { LaserPointerButton } from "./LaserPointerButton";
import { Toast } from "./Toast";
import { Toolbar } from "./Toolbar";
import {
ViewportStatusBadge,
ViewportStatusBorder,
} from "./ViewportStatusFrame/ViewportStatusFrame";
import "./LayerUI.scss";
import "./Toolbar.scss";
import type { ActionManager } from "../actions/manager";
import type { Language } from "../i18n";
import type {
AppProps,
AppState,
ExcalidrawProps,
BinaryFiles,
UIAppState,
AppClassProperties,
} from "../types";
interface LayerUIProps {
actionManager: ActionManager;
appState: UIAppState;
files: BinaryFiles;
canvas: HTMLCanvasElement;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onPenModeToggle: AppClassProperties["togglePenMode"];
showExitZenModeBtn: boolean;
langCode: Language["code"];
renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
UIOptions: AppProps["UIOptions"];
onExportImage: AppClassProperties["onExportImage"];
renderWelcomeScreen: boolean;
children?: React.ReactNode;
app: AppClassProperties;
defaultUIEnabled: boolean;
zoomUIEnabled: boolean;
scrollBackToContentUIEnabled: boolean;
isCollaborating: boolean;
generateLinkForSelection?: AppProps["generateLinkForSelection"];
currentUserControls?: ExcalidrawProps["currentUserControls"];
}
const DefaultMainMenu: React.FC<{
UIOptions: AppProps["UIOptions"];
}> = ({ UIOptions }) => {
return (
<MainMenu __fallback>
<MainMenu.DefaultItems.LoadScene />
<MainMenu.DefaultItems.SaveToActiveFile />
{/* FIXME we should to test for this inside the item itself */}
{UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
{/* FIXME we should to test for this inside the item itself */}
{UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage />
)}
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials />
</MainMenu.Group>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme={false} />
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
);
};
const DefaultOverwriteConfirmDialog = () => {
return (
<OverwriteConfirmDialog __fallback>
<OverwriteConfirmDialog.Actions.SaveToDisk />
<OverwriteConfirmDialog.Actions.ExportToImage />
</OverwriteConfirmDialog>
);
};
const LayerUI = ({
actionManager,
appState,
files,
setAppState,
elements,
canvas,
onLockToggle,
onPenModeToggle,
showExitZenModeBtn,
renderTopLeftUI,
renderTopRightUI,
renderCustomStats,
UIOptions,
onExportImage,
renderWelcomeScreen,
children,
app,
defaultUIEnabled,
zoomUIEnabled,
scrollBackToContentUIEnabled,
isCollaborating,
generateLinkForSelection,
currentUserControls,
}: LayerUIProps) => {
const editorInterface = useEditorInterface();
const appProps = useAppProps();
const stylesPanelMode = useStylesPanelMode();
const isCompactStylesPanel = stylesPanelMode === "compact";
const tunnels = useInitializeTunnels();
const spacing = isCompactStylesPanel
? {
menuTopGap: 4,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 0.5,
islandPadding: 1,
collabMarginLeft: 8,
}
: {
menuTopGap: 6,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 1,
islandPadding: 1,
collabMarginLeft: 8,
};
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
}
return (
<JSONExportDialog
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export}
canvas={canvas}
setAppState={setAppState}
/>
);
};
const renderImageExportDialog = () => {
if (
!UIOptions.canvasActions.saveAsImage ||
appState.openDialog?.name !== "imageExport"
) {
return null;
}
return (
<ImageExportDialog
elements={elements}
appState={appState}
files={files}
actionManager={actionManager}
onExportImage={onExportImage}
onCloseRequest={() => setAppState({ openDialog: null })}
name={app.getName()}
/>
);
};
const renderCanvasActions = () => (
<div style={{ position: "relative" }}>
<div className="excalidraw-ui-top-left">
{renderTopLeftUI?.(false, appState)}
<tunnels.MainMenuTunnel.Out />
</div>
{renderWelcomeScreen && <tunnels.WelcomeScreenMenuHintTunnel.Out />}
</div>
);
const renderSelectedShapeActions = () => {
return (
<Section
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
>
{isCompactStylesPanel ? (
<Island
className={clsx("compact-shape-actions-island")}
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}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
setAppState={setAppState}
/>
</Island>
) : (
<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"
>
<SelectedShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Island>
)}
</Section>
);
};
const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions =
defaultUIEnabled && showSelectedShapeActions(appState, elements);
const shouldShowStats =
defaultUIEnabled &&
appState.stats.open &&
!appState.zenModeEnabled &&
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector";
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}
/>
)}
</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={appProps.userToFollow?.socketId || null}
currentUserControls={currentUserControls}
/>
)}
{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>
</div>
</FixedSideContainer>
);
};
const renderSidebars = () => {
if (!defaultUIEnabled) {
return null;
}
return (
<DefaultSidebar
__fallback
onDock={(docked) => {
trackEvent(
"sidebar",
`toggleDock (${docked ? "dock" : "undock"})`,
`(${
editorInterface.formFactor === "phone" ? "mobile" : "desktop"
})`,
);
}}
/>
);
};
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
const isSidebarDockedAndFits = !!(
appState.openSidebar &&
isSidebarDocked &&
editorInterface.canFitSidebar
);
const layerUIJSX = (
<>
{/* ------------------------- tunneled UI ---------------------------- */}
{/* make sure we render host app components first so that we can detect
them first on initial render to optimize layout shift */}
{children}
{/* Fallback entry points are the default UI. Host components above keep
rendering into the outlets below even when defaults are disabled. */}
{defaultUIEnabled && (
<>
<DefaultMainMenu UIOptions={UIOptions} />
<DefaultSidebar.Trigger
__fallback
icon={sidebarRightIcon}
title={capitalizeString(t("toolBar.library"))}
onToggle={(open) => {
if (open) {
trackEvent(
"sidebar",
`${DEFAULT_SIDEBAR.name} (open)`,
`button (${
editorInterface.formFactor === "phone"
? "mobile"
: "desktop"
})`,
);
}
}}
tab={DEFAULT_SIDEBAR.defaultTab}
/>
</>
)}
{/* Keep supporting surfaces available to host-supplied UI, including
MainMenu.DefaultItems. */}
<DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
{/* ------------------------------------------------------------------ */}
{defaultUIEnabled && appState.isLoading && <LoadingMessage delay={250} />}
{defaultUIEnabled && appState.errorMessage && (
<ErrorDialog onClose={() => setAppState({ errorMessage: null })}>
{appState.errorMessage}
</ErrorDialog>
)}
{defaultUIEnabled &&
eyeDropperState &&
editorInterface.formFactor !== "phone" && (
<EyeDropper
colorPickerType={eyeDropperState.colorPickerType}
onCancel={() => {
setEyeDropperState(null);
}}
onChange={(
colorPickerType,
color,
selectedElements,
{ altKey },
) => {
if (
colorPickerType !== "elementBackground" &&
colorPickerType !== "elementStroke"
) {
return;
}
const property =
altKey && eyeDropperState.swapPreviewOnAlt
? colorPickerType === "elementBackground"
? "strokeColor"
: "backgroundColor"
: colorPickerType === "elementBackground"
? "backgroundColor"
: "strokeColor";
if (selectedElements.length) {
const elementsMap = arrayToMap(elements);
// a note's visible text is its label, so stroke picks
// include bound labels
const targets = getSelectedElements(elements, appState, {
includeBoundTextElement: property === "strokeColor",
});
for (const element of targets) {
mutateElement(
element,
elementsMap,
getColorUpdate(element, property, color, elementsMap),
);
ShapeCache.delete(element);
}
app.scene.triggerUpdate();
} else {
setAppState(
getColorTargetAppStateUpdates(
resolveColorTarget(appState, elements, property),
color,
) as Pick<AppState, ColorDefaultKey>,
);
}
}}
onSelect={(color, event) => {
setEyeDropperState((state) => {
return state?.keepOpenOnAlt && event.altKey ? state : null;
});
eyeDropperState?.onSelect?.(color, event);
}}
/>
)}
{appState.openDialog?.name === "help" && (
<HelpDialog
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
<ActiveConfirmDialog />
{defaultUIEnabled && appState.openDialog?.name === "elementLinkSelector" && (
<ElementLinkDialog
sourceElementId={appState.openDialog.sourceElementId}
onClose={() => {
setAppState({
openDialog: null,
});
}}
scene={app.scene}
appState={appState}
generateLinkForSelection={generateLinkForSelection}
/>
)}
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{defaultUIEnabled && appState.openDialog?.name === "charts" && (
<PasteChartDialog
data={appState.openDialog.data}
rawText={appState.openDialog.rawText}
onClose={() =>
setAppState({
openDialog: null,
})
}
/>
)}
{editorInterface.formFactor === "phone" && (
<MobileMenu
app={app}
appState={appState}
elements={elements}
actionManager={actionManager}
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onPenModeToggle={onPenModeToggle}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI}
renderSidebars={renderSidebars}
renderWelcomeScreen={renderWelcomeScreen}
defaultUIEnabled={defaultUIEnabled}
scrollBackToContentUIEnabled={scrollBackToContentUIEnabled}
/>
)}
{editorInterface.formFactor !== "phone" && (
<>
{appProps.viewportStatusFrame?.border && (
<ViewportStatusBorder
border={appProps.viewportStatusFrame.border}
style={
isSidebarDockedAndFits
? {
// flush against the sidebar's own visible edge, not
// just the --right-sidebar-width column it reserves
// (which includes the sidebar's own outer margin)
right: `calc(var(--right-sidebar-width) - var(--space-factor) * 2)`,
}
: undefined
}
/>
)}
<div
className="layer-ui__wrapper"
style={
isSidebarDockedAndFits
? { width: `calc(100% - var(--right-sidebar-width))` }
: {}
}
>
{renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
{renderFixedSideContainer()}
<Footer
appState={appState}
actionManager={actionManager}
showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen}
defaultUIEnabled={defaultUIEnabled}
zoomUIEnabled={zoomUIEnabled}
/>
{(appState.toast ||
(scrollBackToContentUIEnabled && appState.scrolledOutside) ||
appProps.viewportStatusFrame?.label) && (
<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>
)}
{appProps.viewportStatusFrame?.label && (
<ViewportStatusBadge
label={appProps.viewportStatusFrame.label}
border={appProps.viewportStatusFrame.border}
/>
)}
</div>
)}
</div>
{renderSidebars()}
</>
)}
</>
);
return (
<UIAppStateContext.Provider value={appState}>
<TunnelsJotaiProvider>
<TunnelsContext.Provider value={tunnels}>
{layerUIJSX}
</TunnelsContext.Provider>
</TunnelsJotaiProvider>
</UIAppStateContext.Provider>
);
};
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
const {
cursorButton,
scrollX,
scrollY,
zoom,
shouldCacheIgnoreZoom,
snapLines,
originSnapOffset,
suggestedBinding,
frameToHighlight,
elementsToHighlight,
...ret
} = appState;
return ret;
};
const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
// short-circuit early
if (prevProps.children !== nextProps.children) {
return false;
}
const { canvas: _pC, appState: prevAppState, ...prev } = prevProps;
const { canvas: _nC, appState: nextAppState, ...next } = nextProps;
return (
isShallowEqual(
// asserting AppState because we're being passed the whole AppState
// but resolve to only the UI-relevant props
stripIrrelevantAppStateProps(prevAppState as AppState),
stripIrrelevantAppStateProps(nextAppState as AppState),
{
selectedElementIds: isShallowEqual,
selectedGroupIds: isShallowEqual,
},
) && isShallowEqual(prev, next)
);
};
export default React.memo(LayerUI, areEqual);

View File

@@ -0,0 +1,146 @@
@use "../css/variables.module" as *;
.excalidraw {
.layer-ui__library {
display: flex;
flex-direction: column;
flex: 1 1 auto;
}
.library-actions-counter {
background-color: var(--color-primary);
color: var(--color-primary-light);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
width: 1rem;
height: 1rem;
position: absolute;
bottom: -0.25rem;
right: -0.25rem;
font-size: 0.625rem;
pointer-events: none;
}
.layer-ui__library-message {
padding: 2rem;
min-width: 200px;
display: flex;
flex-direction: column;
align-items: center;
flex-grow: 1;
justify-content: center;
span {
font-size: 0.8em;
}
}
.publish-library-success {
.Dialog__content {
display: flex;
flex-direction: column;
}
&-close.ToolIcon_type_button {
background-color: $color-blue-6;
align-self: flex-end;
&:hover {
background-color: $color-blue-8;
}
.ToolIcon__icon {
width: auto;
font-size: 1rem;
color: #fff;
padding: 0 0.5rem;
}
}
}
.library-menu-control-buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
position: relative;
&--at-bottom::before {
content: "";
width: calc(100% - 1.5rem);
height: 1px;
position: absolute;
top: -1px;
background: var(--sidebar-border-color);
}
}
.library-menu-browse-button {
flex: 1;
height: var(--lg-button-size);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
border-radius: var(--border-radius-lg);
background-color: var(--color-primary);
color: #fff;
text-align: center;
white-space: nowrap;
text-decoration: none !important;
font-weight: 600;
font-size: 0.75rem;
&:hover {
background-color: var(--color-brand-hover);
}
&:active {
background-color: var(--color-brand-active);
}
}
&.theme--dark {
.library-menu-browse-button {
color: var(--color-gray-100);
}
}
&.excalidraw--mobile .library-menu-browse-button {
height: var(--default-button-size);
}
.layer-ui__library .dropdown-menu {
width: auto;
top: initial;
right: 0;
left: initial;
bottom: 100%;
margin-bottom: 0.625rem;
.dropdown-menu-container {
width: 196px;
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
--box-shadow: var(--library-dropdown-shadow);
}
}
.layer-ui__library .library-menu-dropdown-container {
z-index: 1;
position: relative;
&--in-heading {
margin-left: auto;
.dropdown-menu {
top: 100%;
}
}
}
}

View File

@@ -0,0 +1,349 @@
import React, {
useState,
useCallback,
useMemo,
useEffect,
memo,
useRef,
} from "react";
import {
LIBRARY_DISABLED_TYPES,
randomId,
isShallowEqual,
KEYS,
isWritableElement,
addEventListener,
EVENT,
CLASSES,
} from "@excalidraw/common";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import { trackEvent } from "../analytics";
import { useUIAppState } from "../context/ui-appState";
import {
distributeLibraryItemsOnSquareGrid,
libraryItemsAtom,
} from "../data/library";
import { atom, useAtom } from "../editor-jotai";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import {
useApp,
useAppProps,
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import LibraryMenuItems from "./LibraryMenuItems";
import Spinner from "./Spinner";
import "./LibraryMenu.scss";
import type {
LibraryItems,
LibraryItem,
ExcalidrawProps,
UIAppState,
AppClassProperties,
} from "../types";
import type Library from "../data/library";
export const isLibraryMenuOpenAtom = atom(false);
const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
return <div className="layer-ui__library">{children}</div>;
};
const LibraryMenuContent = memo(
({
onInsertLibraryItems,
pendingElements,
onAddToLibrary,
setAppState,
libraryReturnUrl,
library,
id,
theme,
selectedItems,
onSelectItems,
}: {
pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: () => void;
setAppState: React.Component<any, UIAppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library;
id: string;
theme: UIAppState["theme"];
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom);
const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => {
const addToLibrary = async (
processedElements: LibraryItem["elements"],
libraryItems: LibraryItems,
) => {
trackEvent("element", "addToLibrary", "ui");
for (const type of LIBRARY_DISABLED_TYPES) {
if (processedElements.some((element) => element.type === type)) {
return setAppState({
errorMessage: t(`errors.libraryElementTypeError.${type}`),
});
}
}
const nextItems: LibraryItems = [
{
status: "unpublished",
elements: processedElements,
id: randomId(),
created: Date.now(),
},
...libraryItems,
];
onAddToLibrary();
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
};
addToLibrary(elements, libraryItemsData.libraryItems);
},
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
);
const libraryItems = useMemo(
() => libraryItemsData.libraryItems,
[libraryItemsData],
);
if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return (
<LibraryMenuWrapper>
<div className="layer-ui__library-message">
<div>
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</div>
</LibraryMenuWrapper>
);
}
const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
return (
<LibraryMenuWrapper>
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItems}
onAddToLibrary={_onAddToLibrary}
onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/>
{showBtn && (
<LibraryMenuControlButtons
className="library-menu-control-buttons--at-bottom"
style={{ padding: "16px 12px 0 12px" }}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
)}
</LibraryMenuWrapper>
);
},
);
const getPendingElements = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElementIds: UIAppState["selectedElementIds"],
) => ({
elements,
pending: getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
),
selectedElementIds,
});
const usePendingElementsMemo = (
appState: UIAppState,
app: AppClassProperties,
) => {
const elements = useExcalidrawElements();
const [state, setState] = useState(() =>
getPendingElements(elements, appState.selectedElementIds),
);
const selectedElementVersions = useRef(
new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(),
);
useEffect(() => {
for (const element of state.pending) {
selectedElementVersions.current.set(element.id, element.version);
}
}, [state.pending]);
useEffect(() => {
if (
// Only update once pointer is released.
// Reading directly from app.state to make it clear it's not reactive
// (hence, there's potential for stale state)
app.state.cursorButton === "up" &&
app.state.activeTool.type === "selection"
) {
setState((prev) => {
// if selectedElementIds changed, we don't have to compare versions
// ---------------------------------------------------------------------
if (
!isShallowEqual(prev.selectedElementIds, appState.selectedElementIds)
) {
selectedElementVersions.current.clear();
return getPendingElements(elements, appState.selectedElementIds);
}
// otherwise we need to check whether selected elements changed
// ---------------------------------------------------------------------
const elementsMap = app.scene.getNonDeletedElementsMap();
for (const id of Object.keys(appState.selectedElementIds)) {
const currVersion = elementsMap.get(id)?.version;
if (
currVersion &&
currVersion !== selectedElementVersions.current.get(id)
) {
// we can't update the selectedElementVersions in here
// because of double render in StrictMode which would overwrite
// the state in the second pass with the old `prev` state.
// Thus, we update versions in a separate effect. May create
// a race condition since current effect is not fully reactive.
return getPendingElements(elements, appState.selectedElementIds);
}
}
// nothing changed
// ---------------------------------------------------------------------
return prev;
});
}
}, [
app,
app.state.cursorButton,
app.state.activeTool.type,
appState.selectedElementIds,
elements,
]);
return state.pending;
};
/**
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
* <DefaultSidebar/> or host apps Sidebar components.
*/
export const LibraryMenu = memo(() => {
const app = useApp();
const { onInsertElements } = app;
const appProps = useAppProps();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const memoizedLibrary = useMemo(() => app.library, [app.library]);
const pendingElements = usePendingElementsMemo(appState, app);
useEffect(() => {
const ownerDocument = app.ownerDocument;
const ownerWindow = app.ownerWindow;
return addEventListener(
ownerDocument,
EVENT.KEYDOWN,
(event) => {
if (
event.key === KEYS.ESCAPE &&
event.target instanceof ownerWindow.HTMLElement
) {
const target = event.target;
if (target.closest(`.${CLASSES.SIDEBAR}`)) {
// stop propagation so that we don't prevent it downstream
// (default browser behavior is to clear search input on ESC)
if (selectedItems.length > 0) {
event.stopPropagation();
setSelectedItems([]);
} else if (
target instanceof ownerWindow.HTMLInputElement &&
isWritableElement(target) &&
!target.value
) {
event.stopPropagation();
// if search input empty -> close library
// (maybe not a good idea?)
setAppState({ openSidebar: null });
app.focusContainer();
}
} else if (selectedItems.length > 0) {
const { x, y } = app.viewport.lastPosition;
const elementUnderCursor = ownerDocument.elementFromPoint(x, y);
// also deselect elements if sidebar doesn't have focus but the
// cursor is over it
if (elementUnderCursor?.closest(`.${CLASSES.SIDEBAR}`)) {
event.stopPropagation();
setSelectedItems([]);
}
}
}
},
{ capture: true },
);
}, [selectedItems, setAppState, app]);
const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
app.focusContainer();
},
[onInsertElements, app],
);
const deselectItems = useCallback(() => {
setAppState({
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
});
}, [setAppState]);
return (
<LibraryMenuContent
pendingElements={pendingElements}
onInsertLibraryItems={onInsertLibraryItems}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={appProps.libraryReturnUrl}
library={memoizedLibrary}
id={app.id}
theme={appState.theme}
selectedItems={selectedItems}
onSelectItems={setSelectedItems}
/>
);
});

View File

@@ -0,0 +1,33 @@
import { VERSIONS } from "@excalidraw/common";
import { t } from "../i18n";
import type { ExcalidrawProps, UIAppState } from "../types";
const LibraryMenuBrowseButton = ({
theme,
id,
libraryReturnUrl,
}: {
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"];
id: string;
}) => {
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
return (
<a
className="library-menu-browse-button"
href={`${import.meta.env.VITE_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
);
};
export default LibraryMenuBrowseButton;

View File

@@ -0,0 +1,35 @@
import clsx from "clsx";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
import type { ExcalidrawProps, UIAppState } from "../types";
export const LibraryMenuControlButtons = ({
libraryReturnUrl,
theme,
id,
style,
children,
className,
}: {
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"];
id: string;
style: React.CSSProperties;
children?: React.ReactNode;
className?: string;
}) => {
return (
<div
className={clsx("library-menu-control-buttons", className)}
style={style}
>
<LibraryMenuBrowseButton
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
{children}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More