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

This commit is contained in:
upstream-mirror
2026-09-16 05:18:53 +05:00
commit 3faa0021f0
447 changed files with 109496 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);
};
}

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;
}
}
}
}