snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
208
packages/excalidraw/components/Actions.scss
Normal file
208
packages/excalidraw/components/Actions.scss
Normal 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);
|
||||
}
|
||||
945
packages/excalidraw/components/Actions.tsx
Normal file
945
packages/excalidraw/components/Actions.tsx
Normal 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>
|
||||
);
|
||||
36
packages/excalidraw/components/ActiveConfirmDialog.tsx
Normal file
36
packages/excalidraw/components/ActiveConfirmDialog.tsx
Normal 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;
|
||||
};
|
||||
352
packages/excalidraw/components/App.arrowText.ts
Normal file
352
packages/excalidraw/components/App.arrowText.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
301
packages/excalidraw/components/App.bucketFill.ts
Normal file
301
packages/excalidraw/components/App.bucketFill.ts
Normal 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);
|
||||
}
|
||||
238
packages/excalidraw/components/App.cursor.ts
Normal file
238
packages/excalidraw/components/App.cursor.ts
Normal 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`,
|
||||
);
|
||||
};
|
||||
}
|
||||
265
packages/excalidraw/components/App.drawshape.ts
Normal file
265
packages/excalidraw/components/App.drawshape.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
198
packages/excalidraw/components/App.flowchart.ts
Normal file
198
packages/excalidraw/components/App.flowchart.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
246
packages/excalidraw/components/App.toolDrag.ts
Normal file
246
packages/excalidraw/components/App.toolDrag.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
14276
packages/excalidraw/components/App.tsx
Normal file
14276
packages/excalidraw/components/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
828
packages/excalidraw/components/App.viewport.ts
Normal file
828
packages/excalidraw/components/App.viewport.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
184
packages/excalidraw/components/App.wheel.ts
Normal file
184
packages/excalidraw/components/App.wheel.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
208
packages/excalidraw/components/AppStateObserver.ts
Normal file
208
packages/excalidraw/components/AppStateObserver.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
7
packages/excalidraw/components/Avatar.scss
Normal file
7
packages/excalidraw/components/Avatar.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.Avatar {
|
||||
@include avatarStyles;
|
||||
}
|
||||
}
|
||||
42
packages/excalidraw/components/Avatar.tsx
Normal file
42
packages/excalidraw/components/Avatar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
43
packages/excalidraw/components/BraveMeasureTextError.tsx
Normal file
43
packages/excalidraw/components/BraveMeasureTextError.tsx
Normal 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;
|
||||
7
packages/excalidraw/components/Button.scss
Normal file
7
packages/excalidraw/components/Button.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@use "../css/theme" as *;
|
||||
|
||||
.excalidraw {
|
||||
.excalidraw-button {
|
||||
@include outlineButtonStyles;
|
||||
}
|
||||
}
|
||||
46
packages/excalidraw/components/Button.tsx
Normal file
46
packages/excalidraw/components/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
packages/excalidraw/components/ButtonIconCycle.tsx
Normal file
30
packages/excalidraw/components/ButtonIconCycle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
10
packages/excalidraw/components/ButtonSeparator.tsx
Normal file
10
packages/excalidraw/components/ButtonSeparator.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export const ButtonSeparator = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: "1rem",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
57
packages/excalidraw/components/Card.scss
Normal file
57
packages/excalidraw/components/Card.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
packages/excalidraw/components/Card.tsx
Normal file
38
packages/excalidraw/components/Card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
92
packages/excalidraw/components/CheckboxItem.scss
Normal file
92
packages/excalidraw/components/CheckboxItem.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
packages/excalidraw/components/CheckboxItem.tsx
Normal file
37
packages/excalidraw/components/CheckboxItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
155
packages/excalidraw/components/ColorPicker/ColorInput.tsx
Normal file
155
packages/excalidraw/components/ColorPicker/ColorInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
598
packages/excalidraw/components/ColorPicker/ColorPicker.scss
Normal file
598
packages/excalidraw/components/ColorPicker/ColorPicker.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
539
packages/excalidraw/components/ColorPicker/ColorPicker.tsx
Normal file
539
packages/excalidraw/components/ColorPicker/ColorPicker.tsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
27
packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx
Normal file
27
packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx
Normal 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;
|
||||
227
packages/excalidraw/components/ColorPicker/Picker.tsx
Normal file
227
packages/excalidraw/components/ColorPicker/Picker.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
132
packages/excalidraw/components/ColorPicker/PickerColorList.tsx
Normal file
132
packages/excalidraw/components/ColorPicker/PickerColorList.tsx
Normal 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const PickerHeading = ({ children }: { children: ReactNode }) => (
|
||||
<div className="color-picker__heading">{children}</div>
|
||||
);
|
||||
|
||||
export default PickerHeading;
|
||||
137
packages/excalidraw/components/ColorPicker/ShadeList.tsx
Normal file
137
packages/excalidraw/components/ColorPicker/ShadeList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
184
packages/excalidraw/components/ColorPicker/TopPicks.tsx
Normal file
184
packages/excalidraw/components/ColorPicker/TopPicks.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
107
packages/excalidraw/components/ColorPicker/colorPickerUtils.ts
Normal file
107
packages/excalidraw/components/ColorPicker/colorPickerUtils.ts
Normal 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";
|
||||
@@ -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;
|
||||
};
|
||||
572
packages/excalidraw/components/ColorPicker/topPicksDnD.tsx
Normal file
572
packages/excalidraw/components/ColorPicker/topPicksDnD.tsx
Normal 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],
|
||||
);
|
||||
};
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1043
packages/excalidraw/components/CommandPalette/CommandPalette.tsx
Normal file
1043
packages/excalidraw/components/CommandPalette/CommandPalette.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export {};
|
||||
25
packages/excalidraw/components/CommandPalette/types.ts
Normal file
25
packages/excalidraw/components/CommandPalette/types.ts
Normal 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;
|
||||
};
|
||||
11
packages/excalidraw/components/ConfirmDialog.scss
Normal file
11
packages/excalidraw/components/ConfirmDialog.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.confirm-dialog {
|
||||
&-buttons {
|
||||
display: flex;
|
||||
column-gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
81
packages/excalidraw/components/ConfirmDialog.tsx
Normal file
81
packages/excalidraw/components/ConfirmDialog.tsx
Normal 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;
|
||||
102
packages/excalidraw/components/ContextMenu.scss
Normal file
102
packages/excalidraw/components/ContextMenu.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
132
packages/excalidraw/components/ContextMenu.tsx
Normal file
132
packages/excalidraw/components/ContextMenu.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
18
packages/excalidraw/components/ConvertElementTypePopup.scss
Normal file
18
packages/excalidraw/components/ConvertElementTypePopup.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
954
packages/excalidraw/components/ConvertElementTypePopup.tsx
Normal file
954
packages/excalidraw/components/ConvertElementTypePopup.tsx
Normal 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;
|
||||
43
packages/excalidraw/components/CursorHint.scss
Normal file
43
packages/excalidraw/components/CursorHint.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
209
packages/excalidraw/components/CursorHint.tsx
Normal file
209
packages/excalidraw/components/CursorHint.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
packages/excalidraw/components/DarkModeToggle.tsx
Normal file
55
packages/excalidraw/components/DarkModeToggle.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
147
packages/excalidraw/components/DefaultSidebar.test.tsx
Normal file
147
packages/excalidraw/components/DefaultSidebar.test.tsx
Normal 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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
127
packages/excalidraw/components/DefaultSidebar.tsx
Normal file
127
packages/excalidraw/components/DefaultSidebar.tsx
Normal 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,
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
};
|
||||
54
packages/excalidraw/components/Dialog.scss
Normal file
54
packages/excalidraw/components/Dialog.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
137
packages/excalidraw/components/Dialog.tsx
Normal file
137
packages/excalidraw/components/Dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
packages/excalidraw/components/DialogActionButton.scss
Normal file
47
packages/excalidraw/components/DialogActionButton.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
packages/excalidraw/components/DialogActionButton.tsx
Normal file
49
packages/excalidraw/components/DialogActionButton.tsx
Normal 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;
|
||||
14
packages/excalidraw/components/ElementCanvasButtons.scss
Normal file
14
packages/excalidraw/components/ElementCanvasButtons.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
69
packages/excalidraw/components/ElementCanvasButtons.tsx
Normal file
69
packages/excalidraw/components/ElementCanvasButtons.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
87
packages/excalidraw/components/ElementLinkDialog.scss
Normal file
87
packages/excalidraw/components/ElementLinkDialog.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
180
packages/excalidraw/components/ElementLinkDialog.tsx
Normal file
180
packages/excalidraw/components/ElementLinkDialog.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { normalizeLink, KEYS } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
defaultGetElementLinkFromSelection,
|
||||
getLinkIdAndTypeFromSelection,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
import { TextField } from "./TextField";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { TrashIcon } from "./icons";
|
||||
|
||||
import "./ElementLinkDialog.scss";
|
||||
|
||||
import type { AppProps, AppState, UIAppState } from "../types";
|
||||
const ElementLinkDialog = ({
|
||||
sourceElementId,
|
||||
onClose,
|
||||
appState,
|
||||
scene,
|
||||
generateLinkForSelection = defaultGetElementLinkFromSelection,
|
||||
}: {
|
||||
sourceElementId: ExcalidrawElement["id"];
|
||||
appState: UIAppState;
|
||||
scene: Scene;
|
||||
onClose?: () => void;
|
||||
generateLinkForSelection: AppProps["generateLinkForSelection"];
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const originalLink = elementsMap.get(sourceElementId)?.link ?? null;
|
||||
|
||||
const [nextLink, setNextLink] = useState<string | null>(originalLink);
|
||||
const [linkEdited, setLinkEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedElements = getSelectedElements(elementsMap, appState);
|
||||
let nextLink = originalLink;
|
||||
|
||||
if (selectedElements.length > 0 && generateLinkForSelection) {
|
||||
const idAndType = getLinkIdAndTypeFromSelection(
|
||||
selectedElements,
|
||||
appState as AppState,
|
||||
);
|
||||
|
||||
if (idAndType) {
|
||||
nextLink = normalizeLink(
|
||||
generateLinkForSelection(idAndType.id, idAndType.type),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setNextLink(nextLink);
|
||||
}, [
|
||||
elementsMap,
|
||||
appState,
|
||||
appState.selectedElementIds,
|
||||
originalLink,
|
||||
generateLinkForSelection,
|
||||
]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) {
|
||||
const elementToLink = elementsMap.get(sourceElementId);
|
||||
elementToLink &&
|
||||
scene.mutateElement(elementToLink, {
|
||||
link: nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
if (!nextLink && linkEdited && sourceElementId) {
|
||||
const elementToLink = elementsMap.get(sourceElementId);
|
||||
elementToLink &&
|
||||
scene.mutateElement(elementToLink, {
|
||||
link: null,
|
||||
});
|
||||
}
|
||||
|
||||
onClose?.();
|
||||
}, [sourceElementId, nextLink, elementsMap, linkEdited, scene, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
appState.openDialog?.name === "elementLinkSelector" &&
|
||||
event.key === KEYS.ENTER
|
||||
) {
|
||||
handleConfirm();
|
||||
}
|
||||
|
||||
if (
|
||||
appState.openDialog?.name === "elementLinkSelector" &&
|
||||
event.key === KEYS.ESCAPE
|
||||
) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [appState, onClose, handleConfirm]);
|
||||
|
||||
return (
|
||||
<div className="ElementLinkDialog">
|
||||
<div className="ElementLinkDialog__header">
|
||||
<h2>{t("elementLink.title")}</h2>
|
||||
<p>{t("elementLink.desc")}</p>
|
||||
</div>
|
||||
|
||||
<div className="ElementLinkDialog__input">
|
||||
<TextField
|
||||
value={nextLink ?? ""}
|
||||
onChange={(value) => {
|
||||
if (!linkEdited) {
|
||||
setLinkEdited(true);
|
||||
}
|
||||
setNextLink(value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KEYS.ENTER) {
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
className="ElementLinkDialog__input-field"
|
||||
selectOnRender
|
||||
/>
|
||||
|
||||
{originalLink && nextLink && (
|
||||
<IconButton
|
||||
type="button"
|
||||
title={t("buttons.remove")}
|
||||
aria-label={t("buttons.remove")}
|
||||
label={t("buttons.remove")}
|
||||
onClick={() => {
|
||||
// removes the link from the input
|
||||
// but doesn't update the element
|
||||
|
||||
// when confirmed, will remove the link from the element
|
||||
setNextLink(null);
|
||||
setLinkEdited(true);
|
||||
}}
|
||||
className="ElementLinkDialog__remove"
|
||||
icon={TrashIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ElementLinkDialog__actions">
|
||||
<DialogActionButton
|
||||
label={t("buttons.cancel")}
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
style={{
|
||||
marginRight: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
<DialogActionButton
|
||||
label={t("buttons.confirm")}
|
||||
onClick={handleConfirm}
|
||||
actionType="primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElementLinkDialog;
|
||||
18
packages/excalidraw/components/Ellipsify.tsx
Normal file
18
packages/excalidraw/components/Ellipsify.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export const Ellipsify = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
{...rest}
|
||||
style={{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
...rest.style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
41
packages/excalidraw/components/ErrorDialog.tsx
Normal file
41
packages/excalidraw/components/ErrorDialog.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
export const ErrorDialog = ({
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(!!children);
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
// TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
|
||||
excalidrawContainer?.focus();
|
||||
}, [onClose, excalidrawContainer]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modalIsShown && (
|
||||
<Dialog
|
||||
size="small"
|
||||
onCloseRequest={handleClose}
|
||||
title={t("errorDialog.title")}
|
||||
>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{children}</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
87
packages/excalidraw/components/ExcalidrawLogo.scss
Normal file
87
packages/excalidraw/components/ExcalidrawLogo.scss
Normal file
@@ -0,0 +1,87 @@
|
||||
.excalidraw {
|
||||
.ExcalidrawLogo {
|
||||
--logo-icon--mobile: 1rem;
|
||||
--logo-text--mobile: 0.75rem;
|
||||
|
||||
--logo-icon--xs: 2rem;
|
||||
--logo-text--xs: 1.5rem;
|
||||
|
||||
--logo-icon--small: 2.5rem;
|
||||
--logo-text--small: 1.75rem;
|
||||
|
||||
--logo-icon--normal: 3rem;
|
||||
--logo-text--normal: 2.2rem;
|
||||
|
||||
--logo-icon--large: 90px;
|
||||
--logo-text--large: 65px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-icon {
|
||||
width: auto;
|
||||
color: var(--color-logo-icon);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
margin-left: 0.75rem;
|
||||
width: auto;
|
||||
color: var(--color-logo-text);
|
||||
}
|
||||
|
||||
&.is-mobile {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--mobile);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
height: var(--logo-text--mobile);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-xs {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--xs);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
height: var(--logo-text--xs);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--small);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
height: var(--logo-text--small);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-normal {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--normal);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
height: var(--logo-text--normal);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-large {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--large);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
height: var(--logo-text--large);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
packages/excalidraw/components/ExcalidrawLogo.tsx
Normal file
69
packages/excalidraw/components/ExcalidrawLogo.tsx
Normal file
File diff suppressed because one or more lines are too long
129
packages/excalidraw/components/ExportDialog.scss
Normal file
129
packages/excalidraw/components/ExportDialog.scss
Normal file
@@ -0,0 +1,129 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.ExportDialog__preview {
|
||||
--preview-padding: calc(var(--space-factor) * 4);
|
||||
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
text-align: center;
|
||||
padding: var(--preview-padding);
|
||||
margin-bottom: calc(var(--space-factor) * 3);
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ExportDialog__preview canvas {
|
||||
max-width: calc(100% - var(--preview-padding) * 2);
|
||||
max-height: 25rem;
|
||||
}
|
||||
|
||||
&.theme--dark .ExportDialog__preview canvas {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.ExportDialog__actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
grid-gap: calc(var(--space-factor) * 2);
|
||||
align-items: top;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
.ExportDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ExportDialog__actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ExportDialog__actions > * {
|
||||
margin-bottom: calc(var(--space-factor) * 3);
|
||||
}
|
||||
|
||||
.ExportDialog__preview canvas {
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
.ExportDialog__dialog,
|
||||
.ExportDialog__dialog .Island {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ExportDialog__dialog .Island {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ExportDialog--json {
|
||||
.ExportDialog-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
justify-items: center;
|
||||
row-gap: 2em;
|
||||
|
||||
@media (max-width: 460px) {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
.Card-details {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.ProjectName {
|
||||
width: fit-content;
|
||||
margin: 1em auto;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.TextInput {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ProjectName-label {
|
||||
margin: 0.625em 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.ExportDialog-imageExportButton {
|
||||
border: 0;
|
||||
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin: 0 0.2em;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border-radius: 1rem;
|
||||
background-color: var(--button-color);
|
||||
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
|
||||
0 6px 10px 0 rgba(0, 0, 0, 0.14);
|
||||
|
||||
font-family: Cascadia;
|
||||
font-size: 1.8em;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-color-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--button-color-darkest);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
packages/excalidraw/components/EyeDropper.scss
Normal file
50
packages/excalidraw/components/EyeDropper.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
.excalidraw {
|
||||
.excalidraw-eye-dropper-container,
|
||||
.excalidraw-eye-dropper-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: var(--zIndex-eyeDropperBackdrop);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.excalidraw-eye-dropper-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.excalidraw-eye-dropper-backdrop {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.excalidraw-eye-dropper-preview {
|
||||
pointer-events: none;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
position: absolute;
|
||||
z-index: var(--zIndex-eyeDropperPreview);
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--eye-dropper-preview-border-color, #222);
|
||||
box-shadow: 2px 2px 0 0 #4949497c;
|
||||
}
|
||||
|
||||
.excalidraw-eye-dropper-trigger {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-right: -4px;
|
||||
margin-left: -2px;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--icon-fill-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
&.selected {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
284
packages/excalidraw/components/EyeDropper.tsx
Normal file
284
packages/excalidraw/components/EyeDropper.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import {
|
||||
EVENT,
|
||||
isColorDark,
|
||||
KEYS,
|
||||
MIME_TYPES,
|
||||
THEME,
|
||||
removeDarkModeFilter,
|
||||
rgbToHex,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { atom } from "../editor-jotai";
|
||||
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
||||
import { useOutsideClick } from "../hooks/useOutsideClick";
|
||||
import { useStable } from "../hooks/useStable";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
|
||||
import { eyeDropperIconSvgPaths } from "./icons";
|
||||
import { positionElementBesideCursor } from "./positionElementBesideCursor";
|
||||
|
||||
import "./EyeDropper.scss";
|
||||
|
||||
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||
|
||||
const eyeDropperCursorPaths = eyeDropperIconSvgPaths
|
||||
.map((path, idx) => `<path fill="${idx === 0 ? `#fff` : ``}" d="${path}" />`)
|
||||
.join("");
|
||||
const eyeDropperCursor = `url(data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
`<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke-linecap="round" stroke-linejoin="round"><g stroke="#fff" stroke-width="5">${eyeDropperCursorPaths}</g><g stroke="#1b1b1f" stroke-width="1.25">${eyeDropperCursorPaths}</g></svg>`,
|
||||
)}) 2 21, auto`;
|
||||
|
||||
export type EyeDropperProperties = {
|
||||
keepOpenOnAlt: boolean;
|
||||
swapPreviewOnAlt?: boolean;
|
||||
/** called when user picks color (on pointerup) */
|
||||
onSelect: (color: string, event: PointerEvent) => void;
|
||||
/**
|
||||
* property of selected elements to update live when alt-dragging.
|
||||
* Supply `null` if not applicable (e.g. updating the canvas bg instead of
|
||||
* elements)
|
||||
**/
|
||||
colorPickerType: ColorPickerType;
|
||||
};
|
||||
|
||||
export const activeEyeDropperAtom = atom<null | EyeDropperProperties>(null);
|
||||
|
||||
export const EyeDropper: React.FC<{
|
||||
onCancel: () => void;
|
||||
onSelect: EyeDropperProperties["onSelect"];
|
||||
/** called when color changes, on pointerdown for preview */
|
||||
onChange: (
|
||||
type: ColorPickerType,
|
||||
color: string,
|
||||
selectedElements: ExcalidrawElement[],
|
||||
event: { altKey: boolean },
|
||||
) => void;
|
||||
colorPickerType: EyeDropperProperties["colorPickerType"];
|
||||
}> = ({ onCancel, onChange, onSelect, colorPickerType }) => {
|
||||
const eyeDropperContainer = useCreatePortalContainer({
|
||||
className: "excalidraw-eye-dropper-backdrop",
|
||||
parentSelector: ".excalidraw-eye-dropper-container",
|
||||
});
|
||||
const appState = useUIAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
const app = useApp();
|
||||
const ownerWindow = app.ownerWindow;
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
const stableProps = useStable({
|
||||
app,
|
||||
onCancel,
|
||||
onChange,
|
||||
onSelect,
|
||||
selectedElements,
|
||||
});
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!eyeDropperContainer) {
|
||||
return;
|
||||
}
|
||||
eyeDropperContainer.style.cursor = eyeDropperCursor;
|
||||
return () => {
|
||||
eyeDropperContainer.style.cursor = "";
|
||||
};
|
||||
}, [eyeDropperContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
const colorPreviewDiv = ref.current;
|
||||
|
||||
if (!colorPreviewDiv || !app.canvas || !eyeDropperContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isHoldingPointerDown = false;
|
||||
|
||||
const ctx = app.canvas.getContext("2d")!;
|
||||
|
||||
const getCurrentColor = ({
|
||||
clientX,
|
||||
clientY,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}) => {
|
||||
const pixel = ctx.getImageData(
|
||||
(clientX - appState.offsetLeft) * ownerWindow.devicePixelRatio,
|
||||
(clientY - appState.offsetTop) * ownerWindow.devicePixelRatio,
|
||||
1,
|
||||
1,
|
||||
).data;
|
||||
|
||||
return rgbToHex(pixel[0], pixel[1], pixel[2]);
|
||||
};
|
||||
|
||||
const getColorToApply = (color: string) =>
|
||||
appState.theme === THEME.DARK ? removeDarkModeFilter(color) : color;
|
||||
|
||||
const mouseMoveListener = ({
|
||||
clientX,
|
||||
clientY,
|
||||
altKey,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
altKey: boolean;
|
||||
}) => {
|
||||
const { top, left } = positionElementBesideCursor({
|
||||
cursor: { x: clientX, y: clientY },
|
||||
element: {
|
||||
width: colorPreviewDiv.offsetWidth,
|
||||
height: colorPreviewDiv.offsetHeight,
|
||||
},
|
||||
container: eyeDropperContainer.getBoundingClientRect(),
|
||||
gap: 7,
|
||||
});
|
||||
|
||||
colorPreviewDiv.style.top = `${top}px`;
|
||||
colorPreviewDiv.style.left = `${left}px`;
|
||||
|
||||
const currentColor = getCurrentColor({ clientX, clientY });
|
||||
|
||||
if (isHoldingPointerDown) {
|
||||
stableProps.onChange(
|
||||
colorPickerType,
|
||||
getColorToApply(currentColor),
|
||||
stableProps.selectedElements,
|
||||
{ altKey },
|
||||
);
|
||||
}
|
||||
|
||||
colorPreviewDiv.style.background = currentColor;
|
||||
colorPreviewDiv.style.setProperty(
|
||||
"--eye-dropper-preview-border-color",
|
||||
isColorDark(currentColor) ? "#fff" : "#222",
|
||||
);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
stableProps.onCancel();
|
||||
};
|
||||
|
||||
const onSelect: Required<EyeDropperProperties>["onSelect"] = (
|
||||
color,
|
||||
event,
|
||||
) => {
|
||||
stableProps.onSelect(color, event);
|
||||
};
|
||||
|
||||
const pointerDownListener = (event: PointerEvent) => {
|
||||
isHoldingPointerDown = true;
|
||||
// NOTE we can't event.preventDefault() as that would stop
|
||||
// pointermove events
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const pointerUpListener = (event: PointerEvent) => {
|
||||
isHoldingPointerDown = false;
|
||||
|
||||
// since we're not preventing default on pointerdown, the focus would
|
||||
// goes back to `body` so we want to refocus the editor container instead
|
||||
excalidrawContainer?.focus();
|
||||
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
|
||||
onSelect(getColorToApply(getCurrentColor(event)), event);
|
||||
};
|
||||
|
||||
const keyDownListener = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eyeDropperContainer.tabIndex = -1;
|
||||
// focus container so we can listen on keydown events
|
||||
eyeDropperContainer.focus();
|
||||
|
||||
// init color preview else it would show only after the first mouse move
|
||||
mouseMoveListener({
|
||||
clientX: stableProps.app.viewport.lastPosition.x,
|
||||
clientY: stableProps.app.viewport.lastPosition.y,
|
||||
altKey: false,
|
||||
});
|
||||
|
||||
eyeDropperContainer.addEventListener(EVENT.KEYDOWN, keyDownListener);
|
||||
eyeDropperContainer.addEventListener(
|
||||
EVENT.POINTER_DOWN,
|
||||
pointerDownListener,
|
||||
);
|
||||
eyeDropperContainer.addEventListener(EVENT.POINTER_UP, pointerUpListener);
|
||||
ownerWindow.addEventListener("pointermove", mouseMoveListener, {
|
||||
passive: true,
|
||||
});
|
||||
ownerWindow.addEventListener(EVENT.BLUR, onCancel);
|
||||
|
||||
return () => {
|
||||
isHoldingPointerDown = false;
|
||||
eyeDropperContainer.removeEventListener(EVENT.KEYDOWN, keyDownListener);
|
||||
eyeDropperContainer.removeEventListener(
|
||||
EVENT.POINTER_DOWN,
|
||||
pointerDownListener,
|
||||
);
|
||||
eyeDropperContainer.removeEventListener(
|
||||
EVENT.POINTER_UP,
|
||||
pointerUpListener,
|
||||
);
|
||||
ownerWindow.removeEventListener("pointermove", mouseMoveListener);
|
||||
ownerWindow.removeEventListener(EVENT.BLUR, onCancel);
|
||||
};
|
||||
}, [
|
||||
stableProps,
|
||||
app.canvas,
|
||||
eyeDropperContainer,
|
||||
colorPickerType,
|
||||
excalidrawContainer,
|
||||
appState.offsetLeft,
|
||||
appState.offsetTop,
|
||||
appState.theme,
|
||||
ownerWindow,
|
||||
]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClick(
|
||||
ref,
|
||||
() => {
|
||||
onCancel();
|
||||
},
|
||||
(event) => {
|
||||
if (
|
||||
event.target.closest(
|
||||
".excalidraw-eye-dropper-trigger, .excalidraw-eye-dropper-backdrop",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// consider all other clicks as outside
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
if (!eyeDropperContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div ref={ref} className="excalidraw-eye-dropper-preview" />,
|
||||
eyeDropperContainer,
|
||||
);
|
||||
};
|
||||
330
packages/excalidraw/components/FilledButton.scss
Normal file
330
packages/excalidraw/components/FilledButton.scss
Normal file
@@ -0,0 +1,330 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
@keyframes successStatusAnimation {
|
||||
0% {
|
||||
transform: scale(0.35);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.ExcButton {
|
||||
--text-color: transparent;
|
||||
--border-color: transparent;
|
||||
--back-color: transparent;
|
||||
|
||||
color: var(--text-color);
|
||||
background-color: var(--back-color);
|
||||
border-color: var(--border-color);
|
||||
|
||||
border-radius: 0.5rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
|
||||
font-family: var(--font-family);
|
||||
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--color-surface-lowest);
|
||||
}
|
||||
|
||||
.ExcButton__statusIcon {
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
|
||||
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
|
||||
}
|
||||
|
||||
&.ExcButton--status-loading,
|
||||
&.ExcButton--status-success {
|
||||
pointer-events: none;
|
||||
background-color: var(--color-success);
|
||||
|
||||
.ExcButton__contents {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: not-allowed;
|
||||
|
||||
&.ExcButton--variant-filled,
|
||||
&:hover {
|
||||
--back-color: var(--color-surface-low) !important;
|
||||
--text-color: var(--color-on-surface-variant) !important;
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-on-surface-variant);
|
||||
--border-color: var(--color-surface-high);
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
&__contents {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
// needed because of .Spinner
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&--color-primary {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--color-surface-lowest);
|
||||
--back-color: var(--color-primary);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-brand-active);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-primary);
|
||||
--border-color: var(--color-primary);
|
||||
--back-color: transparent;
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-brand-hover);
|
||||
--border-color: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-brand-active);
|
||||
--border-color: var(--color-brand-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-danger {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--color-danger-text);
|
||||
--back-color: var(--color-danger-dark);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-danger-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-danger-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-danger);
|
||||
--border-color: var(--color-danger);
|
||||
--back-color: transparent;
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-danger-darkest);
|
||||
--border-color: var(--color-danger-darkest);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-danger-darker);
|
||||
--border-color: var(--color-danger-darker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-success {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--color-success-text);
|
||||
--back-color: var(--color-success);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--color-success);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-success-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-success-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-success-contrast);
|
||||
--border-color: var(--color-success-contrast);
|
||||
--back-color: transparent;
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--color-success-contrast);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-success-contrast-hover);
|
||||
--border-color: var(--color-success-contrast-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-success-contrast-active);
|
||||
--border-color: var(--color-success-contrast-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-muted {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--island-bg-color);
|
||||
--back-color: var(--color-gray-50);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-gray-60);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-gray-80);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-muted-background);
|
||||
--border-color: var(--color-muted);
|
||||
--back-color: var(--island-bg-color);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-muted-background-darker);
|
||||
--border-color: var(--color-muted-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-muted-background-darker);
|
||||
--border-color: var(--color-muted-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-warning {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: black;
|
||||
--back-color: var(--color-warning-dark);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-warning-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-warning-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-warning-dark);
|
||||
--border-color: var(--color-warning-dark);
|
||||
--back-color: var(--input-bg-color);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-warning-darker);
|
||||
--border-color: var(--color-warning-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-warning-darkest);
|
||||
--border-color: var(--color-warning-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--size-large {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
.ExcButton__contents {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--size-medium {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
letter-spacing: normal;
|
||||
|
||||
.ExcButton__contents {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--variant-icon {
|
||||
padding: 0.5rem 0.75rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
&--fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
119
packages/excalidraw/components/FilledButton.tsx
Normal file
119
packages/excalidraw/components/FilledButton.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import clsx from "clsx";
|
||||
import React, { forwardRef, useState } from "react";
|
||||
|
||||
import { isPromiseLike } from "@excalidraw/common";
|
||||
|
||||
import { AbortError } from "../errors";
|
||||
|
||||
import Spinner from "./Spinner";
|
||||
import { tablerCheckIcon } from "./icons";
|
||||
|
||||
import "./FilledButton.scss";
|
||||
|
||||
export type ButtonVariant = "filled" | "outlined" | "icon";
|
||||
export type ButtonColor =
|
||||
| "primary"
|
||||
| "danger"
|
||||
| "warning"
|
||||
| "muted"
|
||||
| "success";
|
||||
export type ButtonSize = "medium" | "large";
|
||||
|
||||
export type FilledButtonProps = {
|
||||
label?: string;
|
||||
|
||||
children?: React.ReactNode;
|
||||
onClick?: (event: React.MouseEvent) => void;
|
||||
status?: null | "loading" | "success";
|
||||
|
||||
variant?: ButtonVariant;
|
||||
color?: ButtonColor;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
icon,
|
||||
onClick,
|
||||
label,
|
||||
variant = "filled",
|
||||
color = "primary",
|
||||
size = "medium",
|
||||
fullWidth,
|
||||
className,
|
||||
status,
|
||||
disabled,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const _onClick = async (event: React.MouseEvent) => {
|
||||
const ret = onClick?.(event);
|
||||
|
||||
if (isPromiseLike(ret)) {
|
||||
// delay loading state to prevent flicker in case of quick response
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsLoading(true);
|
||||
}, 50);
|
||||
try {
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const _status = isLoading ? "loading" : status;
|
||||
color = _status === "success" ? "success" : color;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"ExcButton",
|
||||
`ExcButton--color-${color}`,
|
||||
`ExcButton--variant-${variant}`,
|
||||
`ExcButton--size-${size}`,
|
||||
`ExcButton--status-${_status}`,
|
||||
{ "ExcButton--fullWidth": fullWidth },
|
||||
className,
|
||||
)}
|
||||
onClick={_onClick}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
ref={ref}
|
||||
disabled={disabled || _status === "loading" || _status === "success"}
|
||||
>
|
||||
<div className="ExcButton__contents">
|
||||
{_status === "loading" ? (
|
||||
<Spinner className="ExcButton__statusIcon" />
|
||||
) : (
|
||||
_status === "success" && (
|
||||
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
|
||||
)
|
||||
)}
|
||||
{icon && (
|
||||
<div className="ExcButton__icon" aria-hidden>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{variant !== "icon" && (children ?? label)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
39
packages/excalidraw/components/FixedSideContainer.scss
Normal file
39
packages/excalidraw/components/FixedSideContainer.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.FixedSideContainer {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.FixedSideContainer > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top {
|
||||
left: var(--editor-container-padding);
|
||||
top: var(--editor-container-padding);
|
||||
right: var(--editor-container-padding);
|
||||
bottom: var(--editor-container-padding);
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top.zen-mode {
|
||||
right: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
/* TODO: if these are used, make sure to implement RTL support
|
||||
.FixedSideContainer_side_left {
|
||||
left: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
bottom: var(--space-factor);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_right {
|
||||
right: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
bottom: var(--space-factor);
|
||||
z-index: 3;
|
||||
}
|
||||
*/
|
||||
26
packages/excalidraw/components/FixedSideContainer.tsx
Normal file
26
packages/excalidraw/components/FixedSideContainer.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./FixedSideContainer.scss";
|
||||
|
||||
type FixedSideContainerProps = {
|
||||
children: React.ReactNode;
|
||||
side: "top" | "left" | "right";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const FixedSideContainer = ({
|
||||
children,
|
||||
side,
|
||||
className,
|
||||
}: FixedSideContainerProps) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"FixedSideContainer",
|
||||
`FixedSideContainer_side_${side}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
20
packages/excalidraw/components/FontPicker/FontPicker.scss
Normal file
20
packages/excalidraw/components/FontPicker/FontPicker.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
@use "../../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.FontPicker__container {
|
||||
display: grid;
|
||||
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
|
||||
align-items: center;
|
||||
|
||||
@include isMobile {
|
||||
max-width: calc(
|
||||
2rem + 4 * var(--default-button-size)
|
||||
); // 4 gaps + 4 buttons
|
||||
}
|
||||
|
||||
&--compact {
|
||||
display: block;
|
||||
grid-template-columns: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../..";
|
||||
import { Keyboard } from "../../tests/helpers/ui";
|
||||
import { act, render } from "../../tests/test-utils";
|
||||
|
||||
describe("FontPicker", () => {
|
||||
it("should be able to open font picker", async () => {
|
||||
(global as any).ResizeObserver =
|
||||
(global as any).ResizeObserver ||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
const { queryByTestId } = await render(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
|
||||
Keyboard.keyPress(KEYS.T);
|
||||
|
||||
const fontPickerTrigger = queryByTestId("font-family-show-fonts");
|
||||
|
||||
expect(fontPickerTrigger).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
fontPickerTrigger!.click();
|
||||
});
|
||||
});
|
||||
});
|
||||
130
packages/excalidraw/components/FontPicker/FontPicker.tsx
Normal file
130
packages/excalidraw/components/FontPicker/FontPicker.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Popover } from "radix-ui";
|
||||
import clsx from "clsx";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
|
||||
import { FONT_FAMILY } from "@excalidraw/common";
|
||||
|
||||
import type { FontFamilyValues } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { RadioSelection } from "../RadioSelection";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import {
|
||||
FontFamilyCodeIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FreedrawIcon,
|
||||
} from "../icons";
|
||||
|
||||
import { FontPickerList } from "./FontPickerList";
|
||||
import { FontPickerTrigger } from "./FontPickerTrigger";
|
||||
|
||||
import "./FontPicker.scss";
|
||||
|
||||
export const DEFAULT_FONTS = [
|
||||
{
|
||||
value: FONT_FAMILY.Excalifont,
|
||||
icon: FreedrawIcon,
|
||||
text: t("labels.handDrawn"),
|
||||
testId: "font-family-hand-drawn",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Nunito,
|
||||
icon: FontFamilyNormalIcon,
|
||||
text: t("labels.normal"),
|
||||
testId: "font-family-normal",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY["Comic Shanns"],
|
||||
icon: FontFamilyCodeIcon,
|
||||
text: t("labels.code"),
|
||||
testId: "font-family-code",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
|
||||
|
||||
export const isDefaultFont = (fontFamily: number | null) => {
|
||||
if (!fontFamily) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultFontFamilies.has(fontFamily);
|
||||
};
|
||||
|
||||
interface FontPickerProps {
|
||||
isOpened: boolean;
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
hoveredFontFamily: FontFamilyValues | null;
|
||||
onSelect: (fontFamily: FontFamilyValues) => void;
|
||||
onHover: (fontFamily: FontFamilyValues) => void;
|
||||
onLeave: () => void;
|
||||
onPopupChange: (open: boolean) => void;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
export const FontPicker = React.memo(
|
||||
({
|
||||
isOpened,
|
||||
selectedFontFamily,
|
||||
hoveredFontFamily,
|
||||
onSelect,
|
||||
onHover,
|
||||
onLeave,
|
||||
onPopupChange,
|
||||
compactMode = false,
|
||||
}: FontPickerProps) => {
|
||||
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
||||
const onSelectCallback = useCallback(
|
||||
(value: number | false) => {
|
||||
if (value) {
|
||||
onSelect(value);
|
||||
}
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={clsx("FontPicker__container", {
|
||||
"FontPicker__container--compact": compactMode,
|
||||
})}
|
||||
>
|
||||
{!compactMode && (
|
||||
<div className="buttonList">
|
||||
<RadioSelection<FontFamilyValues | false>
|
||||
type="button"
|
||||
options={defaultFonts}
|
||||
value={selectedFontFamily}
|
||||
onClick={onSelectCallback}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!compactMode && <ButtonSeparator />}
|
||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||
<FontPickerTrigger
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
isOpened={isOpened}
|
||||
compactMode={compactMode}
|
||||
/>
|
||||
{isOpened && (
|
||||
<FontPickerList
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={hoveredFontFamily}
|
||||
onSelect={onSelectCallback}
|
||||
onHover={onHover}
|
||||
onLeave={onLeave}
|
||||
onOpen={() => onPopupChange(true)}
|
||||
onClose={() => onPopupChange(false)}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.isOpened === next.isOpened &&
|
||||
prev.selectedFontFamily === next.selectedFontFamily &&
|
||||
prev.hoveredFontFamily === next.hoveredFontFamily,
|
||||
);
|
||||
413
packages/excalidraw/components/FontPicker/FontPickerList.tsx
Normal file
413
packages/excalidraw/components/FontPicker/FontPickerList.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type KeyboardEventHandler,
|
||||
} from "react";
|
||||
|
||||
import { type FontFamilyValues } from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
arrayToList,
|
||||
debounce,
|
||||
FONT_FAMILY,
|
||||
getFontFamilyString,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { Fonts } from "../../fonts";
|
||||
import { t } from "../../i18n";
|
||||
import {
|
||||
useApp,
|
||||
useAppProps,
|
||||
useExcalidrawContainer,
|
||||
useStylesPanelMode,
|
||||
} from "../App";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
import { QuickSearch } from "../QuickSearch";
|
||||
import { ScrollableList } from "../ScrollableList";
|
||||
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
|
||||
import {
|
||||
DropDownMenuItemBadgeType,
|
||||
DropDownMenuItemBadge,
|
||||
} from "../dropdownMenu/DropdownMenuItem";
|
||||
import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent";
|
||||
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
|
||||
import {
|
||||
FontFamilyCodeIcon,
|
||||
FontFamilyHeadingIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FreedrawIcon,
|
||||
} from "../icons";
|
||||
|
||||
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace";
|
||||
|
||||
export interface FontDescriptor {
|
||||
value: number;
|
||||
icon: JSX.Element;
|
||||
text: string;
|
||||
deprecated?: true;
|
||||
badge?: {
|
||||
type: ValueOf<typeof DropDownMenuItemBadgeType>;
|
||||
placeholder: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FontPickerListProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
hoveredFontFamily: FontFamilyValues | null;
|
||||
onSelect: (value: number) => void;
|
||||
onHover: (value: number) => void;
|
||||
onLeave: () => void;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => {
|
||||
switch (fontFamily) {
|
||||
case FONT_FAMILY.Excalifont:
|
||||
case FONT_FAMILY.Virgil:
|
||||
return FreedrawIcon;
|
||||
case FONT_FAMILY.Nunito:
|
||||
case FONT_FAMILY.Helvetica:
|
||||
return FontFamilyNormalIcon;
|
||||
case FONT_FAMILY["Lilita One"]:
|
||||
return FontFamilyHeadingIcon;
|
||||
case FONT_FAMILY["Comic Shanns"]:
|
||||
case FONT_FAMILY.Cascadia:
|
||||
return FontFamilyCodeIcon;
|
||||
default:
|
||||
return FontFamilyNormalIcon;
|
||||
}
|
||||
};
|
||||
|
||||
const getFontFamilyLabel = (
|
||||
fontFamily: FontFamilyValues,
|
||||
fontFaces: ExcalidrawFontFace[],
|
||||
) =>
|
||||
// prefer our config as the browser resolved names may be wrapped in quotes and such
|
||||
Object.entries(FONT_FAMILY).find(([, id]) => id === fontFamily)?.[0] ??
|
||||
fontFaces[0]?.fontFace?.family ??
|
||||
"Unknown";
|
||||
|
||||
export const FontPickerList = React.memo(
|
||||
({
|
||||
selectedFontFamily,
|
||||
hoveredFontFamily,
|
||||
onSelect,
|
||||
onHover,
|
||||
onLeave,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: FontPickerListProps) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const app = useApp();
|
||||
const { fonts } = app;
|
||||
const { showDeprecatedFonts } = useAppProps();
|
||||
const stylesPanelMode = useStylesPanelMode();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const allFonts = useMemo(
|
||||
() =>
|
||||
Array.from(Fonts.registered.entries())
|
||||
.filter(
|
||||
([_, { metadata }]) => !metadata.private && !metadata.fallback,
|
||||
)
|
||||
.map(([familyId, { metadata, fontFaces }]) => {
|
||||
const fontDescriptor = {
|
||||
value: familyId,
|
||||
icon: getFontFamilyIcon(familyId),
|
||||
text: getFontFamilyLabel(familyId, fontFaces),
|
||||
};
|
||||
|
||||
if (metadata.deprecated) {
|
||||
Object.assign(fontDescriptor, {
|
||||
deprecated: metadata.deprecated,
|
||||
badge: {
|
||||
type: DropDownMenuItemBadgeType.RED,
|
||||
placeholder: t("fontList.badge.old"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return fontDescriptor as FontDescriptor;
|
||||
})
|
||||
.sort((a, b) =>
|
||||
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const sceneFamilies = useMemo(
|
||||
() => new Set(fonts.getSceneFamilies()),
|
||||
// cache per selected font family, so hover re-render won't mess it up
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedFontFamily],
|
||||
);
|
||||
|
||||
const sceneFonts = useMemo(
|
||||
() => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
|
||||
[allFonts, sceneFamilies],
|
||||
);
|
||||
|
||||
const availableFonts = useMemo(
|
||||
() =>
|
||||
allFonts.filter(
|
||||
(font) =>
|
||||
!sceneFamilies.has(font.value) &&
|
||||
(showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
|
||||
),
|
||||
[allFonts, sceneFamilies, showDeprecatedFonts],
|
||||
);
|
||||
|
||||
const filteredFonts = useMemo(
|
||||
() =>
|
||||
arrayToList(
|
||||
[...sceneFonts, ...availableFonts].filter((font) =>
|
||||
font.text?.toLowerCase().includes(searchTerm),
|
||||
),
|
||||
),
|
||||
[sceneFonts, availableFonts, searchTerm],
|
||||
);
|
||||
|
||||
const hoveredFont = useMemo(() => {
|
||||
let font;
|
||||
|
||||
if (hoveredFontFamily) {
|
||||
font = filteredFonts.find((font) => font.value === hoveredFontFamily);
|
||||
} else if (selectedFontFamily) {
|
||||
font = filteredFonts.find((font) => font.value === selectedFontFamily);
|
||||
}
|
||||
|
||||
if (!font && searchTerm) {
|
||||
if (filteredFonts[0]?.value) {
|
||||
// hover first element on search
|
||||
onHover(filteredFonts[0].value);
|
||||
} else {
|
||||
// re-render cache on no results
|
||||
onLeave();
|
||||
}
|
||||
}
|
||||
|
||||
return font;
|
||||
}, [
|
||||
hoveredFontFamily,
|
||||
selectedFontFamily,
|
||||
searchTerm,
|
||||
filteredFonts,
|
||||
onHover,
|
||||
onLeave,
|
||||
]);
|
||||
|
||||
// Create a wrapped onSelect function that preserves caret position
|
||||
const wrappedOnSelect = useCallback(
|
||||
(fontFamily: FontFamilyValues) => {
|
||||
// Save caret position before font selection if editing text
|
||||
let savedSelection: { start: number; end: number } | null = null;
|
||||
if (app.state.editingTextElement) {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor) {
|
||||
savedSelection = {
|
||||
start: textEditor.selectionStart,
|
||||
end: textEditor.selectionEnd,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
onSelect(fontFamily);
|
||||
|
||||
// Restore caret position after font selection if editing text
|
||||
if (app.state.editingTextElement && savedSelection) {
|
||||
setTimeout(() => {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor && savedSelection) {
|
||||
textEditor.focus();
|
||||
textEditor.selectionStart = savedSelection.start;
|
||||
textEditor.selectionEnd = savedSelection.end;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[onSelect, app.state.editingTextElement],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
||||
(event) => {
|
||||
const handled = fontPickerKeyHandler({
|
||||
event,
|
||||
inputRef,
|
||||
hoveredFont,
|
||||
filteredFonts,
|
||||
onSelect: wrappedOnSelect,
|
||||
onHover,
|
||||
onClose,
|
||||
});
|
||||
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onOpen();
|
||||
|
||||
return () => {
|
||||
onClose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const sceneFilteredFonts = useMemo(
|
||||
() => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
|
||||
[filteredFonts, sceneFamilies],
|
||||
);
|
||||
|
||||
const availableFilteredFonts = useMemo(
|
||||
() => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
|
||||
[filteredFonts, sceneFamilies],
|
||||
);
|
||||
|
||||
const FontPickerListItem = ({
|
||||
font,
|
||||
order,
|
||||
}: {
|
||||
font: FontDescriptor;
|
||||
order: number;
|
||||
}) => {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const isHovered = font.value === hoveredFont?.value;
|
||||
const isSelected = font.value === selectedFontFamily;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHovered) {
|
||||
return;
|
||||
}
|
||||
if (order === 0) {
|
||||
// scroll into the first item differently, so it's visible what is above (i.e. group title)
|
||||
ref.current?.scrollIntoView?.({ block: "end" });
|
||||
} else {
|
||||
ref.current?.scrollIntoView?.({ block: "nearest" });
|
||||
}
|
||||
}, [isHovered, order]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
value={font.value}
|
||||
className={getDropdownMenuItemClassName("", isSelected, isHovered)}
|
||||
title={font.text}
|
||||
// allow to tab between search and selected font
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
wrappedOnSelect(Number(e.currentTarget.value));
|
||||
}}
|
||||
onMouseMove={() => {
|
||||
if (hoveredFont?.value !== font.value) {
|
||||
onHover(font.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItemContent
|
||||
icon={font.icon}
|
||||
badge={
|
||||
font.badge && (
|
||||
<DropDownMenuItemBadge type={font.badge.type}>
|
||||
{font.badge.placeholder}
|
||||
</DropDownMenuItemBadge>
|
||||
)
|
||||
}
|
||||
textStyle={{
|
||||
fontFamily: getFontFamilyString({ fontFamily: font.value }),
|
||||
}}
|
||||
>
|
||||
{font.text}
|
||||
</MenuItemContent>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const groups = [];
|
||||
|
||||
if (sceneFilteredFonts.length) {
|
||||
groups.push(
|
||||
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
|
||||
{sceneFilteredFonts.map((font, index) => (
|
||||
<FontPickerListItem key={font.value} font={font} order={index} />
|
||||
))}
|
||||
</DropdownMenuGroup>,
|
||||
);
|
||||
}
|
||||
|
||||
if (availableFilteredFonts.length) {
|
||||
groups.push(
|
||||
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
|
||||
{availableFilteredFonts.map((font, index) => (
|
||||
<FontPickerListItem
|
||||
key={font.value}
|
||||
font={font}
|
||||
order={index + sceneFilteredFonts.length}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuGroup>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertiesPopover
|
||||
className="properties-content"
|
||||
container={container}
|
||||
style={{ width: "15rem" }}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
|
||||
// Refocus text editor when font picker closes if we were editing text
|
||||
if (app.state.editingTextElement) {
|
||||
setTimeout(() => {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor) {
|
||||
textEditor.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={onLeave}
|
||||
onKeyDown={onKeyDown}
|
||||
preventAutoFocusOnTouch={!!app.state.editingTextElement}
|
||||
>
|
||||
{stylesPanelMode === "full" && (
|
||||
<QuickSearch
|
||||
ref={inputRef}
|
||||
placeholder={t("quickSearch.placeholder")}
|
||||
onChange={debounce(setSearchTerm, 20)}
|
||||
/>
|
||||
)}
|
||||
<ScrollableList
|
||||
className="dropdown-menu fonts manual-hover"
|
||||
placeholder={t("fontList.empty")}
|
||||
>
|
||||
{groups.length ? groups : null}
|
||||
</ScrollableList>
|
||||
</PropertiesPopover>
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.selectedFontFamily === next.selectedFontFamily &&
|
||||
prev.hoveredFontFamily === next.hoveredFontFamily,
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Popover } from "radix-ui";
|
||||
|
||||
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
|
||||
|
||||
import type { FontFamilyValues } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { TextIcon } from "../icons";
|
||||
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
|
||||
interface FontPickerTriggerProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
isOpened?: boolean;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
export const FontPickerTrigger = ({
|
||||
selectedFontFamily,
|
||||
isOpened = false,
|
||||
compactMode = false,
|
||||
}: FontPickerTriggerProps) => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const compactStyle = compactMode
|
||||
? {
|
||||
...MOBILE_ACTION_BUTTON_BG,
|
||||
width: "2rem",
|
||||
height: "2rem",
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
<div data-openpopup="fontFamily" className="properties-trigger">
|
||||
<IconButton
|
||||
type="toggle"
|
||||
icon={TextIcon}
|
||||
title={t("labels.showFonts")}
|
||||
aria-label={t("labels.showFonts")}
|
||||
className="standalone properties-trigger"
|
||||
data-testid="font-family-show-fonts"
|
||||
checked={isOpened}
|
||||
onSelect={() => {
|
||||
setAppState((appState) => ({
|
||||
openPopup:
|
||||
appState.openPopup === "fontFamily" ? null : appState.openPopup,
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
...compactStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import type { Node } from "@excalidraw/common";
|
||||
|
||||
import { type FontDescriptor } from "./FontPickerList";
|
||||
|
||||
interface FontPickerKeyNavHandlerProps {
|
||||
event: React.KeyboardEvent<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
hoveredFont: Node<FontDescriptor> | undefined;
|
||||
filteredFonts: Node<FontDescriptor>[];
|
||||
onClose: () => void;
|
||||
onSelect: (value: number) => void;
|
||||
onHover: (value: number) => void;
|
||||
}
|
||||
|
||||
export const fontPickerKeyHandler = ({
|
||||
event,
|
||||
inputRef,
|
||||
hoveredFont,
|
||||
filteredFonts,
|
||||
onClose,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: FontPickerKeyNavHandlerProps) => {
|
||||
if (
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.F
|
||||
) {
|
||||
// refocus input on the popup trigger shortcut
|
||||
inputRef.current?.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ENTER) {
|
||||
if (hoveredFont?.value) {
|
||||
onSelect(hoveredFont.value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ARROW_DOWN) {
|
||||
if (hoveredFont?.next) {
|
||||
onHover(hoveredFont.next.value);
|
||||
} else if (filteredFonts[0]?.value) {
|
||||
onHover(filteredFonts[0].value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ARROW_UP) {
|
||||
if (hoveredFont?.prev) {
|
||||
onHover(hoveredFont.prev.value);
|
||||
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
|
||||
onHover(filteredFonts[filteredFonts.length - 1].value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
23
packages/excalidraw/components/HelpButton.tsx
Normal file
23
packages/excalidraw/components/HelpButton.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { HelpIcon } from "./icons";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
type HelpButtonProps = {
|
||||
name?: string;
|
||||
id?: string;
|
||||
onClick?(): void;
|
||||
};
|
||||
|
||||
export const HelpButton = (props: HelpButtonProps) => (
|
||||
<Tooltip label={`${t("helpDialog.title")} — ?`}>
|
||||
<button
|
||||
className="help-icon"
|
||||
onClick={props.onClick}
|
||||
type="button"
|
||||
aria-label={t("helpDialog.title")}
|
||||
>
|
||||
{HelpIcon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
131
packages/excalidraw/components/HelpDialog.scss
Normal file
131
packages/excalidraw/components/HelpDialog.scss
Normal file
@@ -0,0 +1,131 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.HelpDialog {
|
||||
.Modal__content {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 1.5rem 0;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
--background: var(--color-surface-mid);
|
||||
|
||||
display: flex;
|
||||
column-gap: 0.5rem;
|
||||
align-items: center;
|
||||
background-color: var(--background);
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid var(--background);
|
||||
border-radius: var(--border-radius-lg);
|
||||
color: var(--text-primary-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
--background: var(--color-surface-high);
|
||||
&:hover {
|
||||
--background: #363541;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--background: var(--color-surface-high);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__link-icon {
|
||||
line-height: 0;
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__islands-container {
|
||||
display: grid;
|
||||
grid-column-gap: 1.5rem;
|
||||
grid-row-gap: 2rem;
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
&__island--tools {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
}
|
||||
&__island--view {
|
||||
grid-area: 2 / 1 / 3 / 2;
|
||||
}
|
||||
&__island--editor {
|
||||
grid-area: 1 / 2 / 3 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
&__island {
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
&-content {
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
border-bottom: 1px solid var(--dialog-border-color);
|
||||
padding: 0.375rem 0.75rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__key-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__key {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
font-size: 0.625rem;
|
||||
background-color: var(--color-primary-light);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 0.5rem;
|
||||
word-break: keep-all;
|
||||
align-items: center;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
521
packages/excalidraw/components/HelpDialog.tsx
Normal file
521
packages/excalidraw/components/HelpDialog.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import React from "react";
|
||||
|
||||
import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
|
||||
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { actionToggleTheme } from "../actions";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { useExcalidrawActionManager } from "./App";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
|
||||
|
||||
import "./HelpDialog.scss";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
const Header = () => (
|
||||
<div className="HelpDialog__header">
|
||||
<a
|
||||
className="HelpDialog__btn"
|
||||
href="https://docs.excalidraw.com"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
||||
{t("helpDialog.documentation")}
|
||||
</a>
|
||||
<a
|
||||
className="HelpDialog__btn"
|
||||
href="https://plus.excalidraw.com/blog"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
||||
{t("helpDialog.blog")}
|
||||
</a>
|
||||
<a
|
||||
className="HelpDialog__btn"
|
||||
href="https://github.com/excalidraw/excalidraw/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="HelpDialog__link-icon">{GithubIcon}</div>
|
||||
{t("helpDialog.github")}
|
||||
</a>
|
||||
<a
|
||||
className="HelpDialog__btn"
|
||||
href="https://youtube.com/@excalidraw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="HelpDialog__link-icon">{youtubeIcon}</div>
|
||||
YouTube
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Section = (props: { title: string; children: React.ReactNode }) => (
|
||||
<>
|
||||
<h3>{props.title}</h3>
|
||||
<div className="HelpDialog__islands-container">{props.children}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const ShortcutIsland = (props: {
|
||||
caption: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={`HelpDialog__island ${props.className}`}>
|
||||
<h4 className="HelpDialog__island-title">{props.caption}</h4>
|
||||
<div className="HelpDialog__island-content">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function* intersperse(as: JSX.Element[][], delim: string | null) {
|
||||
let first = true;
|
||||
for (const x of as) {
|
||||
if (!first) {
|
||||
yield delim;
|
||||
}
|
||||
first = false;
|
||||
yield x;
|
||||
}
|
||||
}
|
||||
|
||||
const upperCaseSingleChars = (str: string) => {
|
||||
return str.replace(/\b[a-z]\b/, (c) => c.toUpperCase());
|
||||
};
|
||||
|
||||
const Shortcut = ({
|
||||
label,
|
||||
shortcuts,
|
||||
isOr = true,
|
||||
}: {
|
||||
label: string;
|
||||
shortcuts: string[];
|
||||
isOr?: boolean;
|
||||
}) => {
|
||||
const splitShortcutKeys = shortcuts.map((shortcut) => {
|
||||
const keys = shortcut.endsWith("++")
|
||||
? [...shortcut.slice(0, -2).split("+"), "+"]
|
||||
: shortcut.split("+");
|
||||
|
||||
return keys.map((key) => (
|
||||
<ShortcutKey key={key}>{upperCaseSingleChars(key)}</ShortcutKey>
|
||||
));
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="HelpDialog__shortcut">
|
||||
<div>{label}</div>
|
||||
<div className="HelpDialog__key-container">
|
||||
{[...intersperse(splitShortcutKeys, isOr ? t("helpDialog.or") : "")]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ShortcutKey = (props: { children: React.ReactNode }) => (
|
||||
<kbd className="HelpDialog__key" {...props} />
|
||||
);
|
||||
|
||||
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
const handleClose = React.useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
onCloseRequest={handleClose}
|
||||
title={t("helpDialog.title")}
|
||||
className={"HelpDialog"}
|
||||
>
|
||||
<Header />
|
||||
<Section title={t("helpDialog.shortcuts")}>
|
||||
<ShortcutIsland
|
||||
className="HelpDialog__island--tools"
|
||||
caption={t("helpDialog.tools")}
|
||||
>
|
||||
<Shortcut label={t("toolBar.hand")} shortcuts={[KEYS.H]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.selection")}
|
||||
shortcuts={[KEYS.V, KEYS["1"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.rectangle")}
|
||||
shortcuts={[KEYS.R, KEYS["2"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.diamond")}
|
||||
shortcuts={[KEYS.D, KEYS["3"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.ellipse")}
|
||||
shortcuts={[KEYS.O, KEYS["4"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.arrow")}
|
||||
shortcuts={[KEYS.A, KEYS["5"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.line")}
|
||||
shortcuts={[KEYS.L, KEYS["6"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.freedraw")}
|
||||
shortcuts={[KEYS.P, KEYS["7"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.text")}
|
||||
shortcuts={[KEYS.T, KEYS["8"]]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.stickynote")} shortcuts={[KEYS.N]} />
|
||||
<Shortcut label={t("toolBar.image")} shortcuts={[KEYS["9"]]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.eraser")}
|
||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
|
||||
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
|
||||
<Shortcut label={t("toolBar.bucketfill")} shortcuts={[KEYS.B]} />
|
||||
<Shortcut
|
||||
label={t("labels.eyeDropper")}
|
||||
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.editLineArrowPoints")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Enter")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.editText")}
|
||||
shortcuts={[getShortcutKey("Enter")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.textNewLine")}
|
||||
shortcuts={[
|
||||
getShortcutKey("Enter"),
|
||||
getShortcutKey("Shift+Enter"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.textFinish")}
|
||||
shortcuts={[
|
||||
getShortcutKey("Esc"),
|
||||
getShortcutKey("CtrlOrCmd+Enter"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.curvedArrow")}
|
||||
shortcuts={[
|
||||
"A",
|
||||
t("helpDialog.click"),
|
||||
t("helpDialog.click"),
|
||||
t("helpDialog.click"),
|
||||
]}
|
||||
isOr={false}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.curvedLine")}
|
||||
shortcuts={[
|
||||
"L",
|
||||
t("helpDialog.click"),
|
||||
t("helpDialog.click"),
|
||||
t("helpDialog.click"),
|
||||
]}
|
||||
isOr={false}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.cropStart")}
|
||||
shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.cropFinish")}
|
||||
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
|
||||
<Shortcut
|
||||
label={t("helpDialog.preventBinding")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.link")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.convertElementType")}
|
||||
shortcuts={["Tab", "Shift+Tab"]}
|
||||
isOr={true}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
<ShortcutIsland
|
||||
className="HelpDialog__island--view"
|
||||
caption={t("helpDialog.view")}
|
||||
>
|
||||
<Shortcut
|
||||
label={t("buttons.zoomIn")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.zoomOut")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.resetZoom")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.zoomToFit")}
|
||||
shortcuts={["Shift+1"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.zoomToSelection")}
|
||||
shortcuts={["Shift+2"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.movePageUpDown")}
|
||||
shortcuts={["PgUp/PgDn"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.movePageLeftRight")}
|
||||
shortcuts={["Shift+PgUp/PgDn"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.zenMode")}
|
||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.objectsSnapMode")}
|
||||
shortcuts={[getShortcutKey("Alt+S")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.toggleGrid")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.viewMode")}
|
||||
shortcuts={[getShortcutKey("Alt+R")]}
|
||||
/>
|
||||
{actionManager.isActionEnabled(actionToggleTheme) && (
|
||||
<Shortcut
|
||||
label={t("labels.toggleTheme")}
|
||||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||
/>
|
||||
)}
|
||||
<Shortcut
|
||||
label={t("stats.fullTitle")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("search.title")}
|
||||
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("commandPalette.title")}
|
||||
shortcuts={
|
||||
isFirefox
|
||||
? [getShortcutFromShortcutName("commandPalette")]
|
||||
: [
|
||||
getShortcutFromShortcutName("commandPalette"),
|
||||
getShortcutFromShortcutName("commandPalette", 1),
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
<ShortcutIsland
|
||||
className="HelpDialog__island--editor"
|
||||
caption={t("helpDialog.editor")}
|
||||
>
|
||||
<Shortcut
|
||||
label={t("helpDialog.createFlowchart")}
|
||||
shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.navigateFlowchart")}
|
||||
shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.moveCanvas")}
|
||||
shortcuts={[
|
||||
getShortcutKey(`Space+${t("helpDialog.drag")}`),
|
||||
getShortcutKey(`Wheel+${t("helpDialog.drag")}`),
|
||||
]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.clearReset")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Delete")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.delete")}
|
||||
shortcuts={[getShortcutKey("Delete")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.cut")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.copy")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.paste")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.pasteAsPlaintext")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.selectAll")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.multiSelect")}
|
||||
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.deepSelect")}
|
||||
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`)]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.deepBoxSelect")}
|
||||
shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`)]}
|
||||
/>
|
||||
{/* firefox supports clipboard API under a flag, so we'll
|
||||
show users what they can do in the error message */}
|
||||
{(probablySupportsClipboardBlob || isFirefox) && (
|
||||
<Shortcut
|
||||
label={t("labels.copyAsPng")}
|
||||
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
||||
/>
|
||||
)}
|
||||
<Shortcut
|
||||
label={t("labels.copyStyles")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.pasteStyles")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.sendToBack")}
|
||||
shortcuts={[
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+[")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+["),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.bringToFront")}
|
||||
shortcuts={[
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+]")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.sendBackward")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.bringForward")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignTop")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignBottom")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignLeft")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignRight")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.duplicateSelection")}
|
||||
shortcuts={[
|
||||
getShortcutKey("CtrlOrCmd+D"),
|
||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.toggleElementLock")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.undo")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.redo")}
|
||||
shortcuts={
|
||||
isWindows
|
||||
? [
|
||||
getShortcutKey("CtrlOrCmd+Y"),
|
||||
getShortcutKey("CtrlOrCmd+Shift+Z"),
|
||||
]
|
||||
: [getShortcutKey("CtrlOrCmd+Shift+Z")]
|
||||
}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.group")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.ungroup")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.flipHorizontal")}
|
||||
shortcuts={[getShortcutKey("Shift+H")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.flipVertical")}
|
||||
shortcuts={[getShortcutKey("Shift+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showStroke")}
|
||||
shortcuts={[getShortcutKey("S")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showBackground")}
|
||||
shortcuts={[getShortcutKey("G")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showFonts")}
|
||||
shortcuts={[getShortcutKey("Shift+F")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.decreaseFontSize")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.increaseFontSize")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Section>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
51
packages/excalidraw/components/HintViewer.scss
Normal file
51
packages/excalidraw/components/HintViewer.scss
Normal file
@@ -0,0 +1,51 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
// this is loosely based on the longest hint text
|
||||
$wide-viewport-width: 1000px;
|
||||
|
||||
.excalidraw {
|
||||
.HintViewer {
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-gray-40);
|
||||
font-size: 0.75rem;
|
||||
|
||||
@include isMobile {
|
||||
position: static;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
> span {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
margin: 0 1px;
|
||||
font-family: monospace;
|
||||
border: 1px solid var(--color-gray-40);
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.HintViewer {
|
||||
color: var(--color-gray-60);
|
||||
kbd {
|
||||
border-color: var(--color-gray-60);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
305
packages/excalidraw/components/HintViewer.tsx
Normal file
305
packages/excalidraw/components/HintViewer.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
isArrowElement,
|
||||
isFlowchartNodeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isStickyNoteElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { isNodeInFlowchart } from "@excalidraw/element";
|
||||
|
||||
import type { EditorInterface } from "@excalidraw/common";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
import { isEraserActive } from "../appState";
|
||||
import { isGridModeEnabled } from "../snapping";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
|
||||
import type { AppClassProperties, UIAppState } from "../types";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: UIAppState;
|
||||
isMobile: boolean;
|
||||
editorInterface: EditorInterface;
|
||||
app: AppClassProperties;
|
||||
}
|
||||
|
||||
const getTaggedShortcutKey = (key: string | string[]) =>
|
||||
Array.isArray(key)
|
||||
? `<kbd>${key.map(getShortcutKey).join(" + ")}</kbd>`
|
||||
: `<kbd>${getShortcutKey(key)}</kbd>`;
|
||||
|
||||
const getHints = ({
|
||||
appState,
|
||||
isMobile,
|
||||
editorInterface,
|
||||
app,
|
||||
}: HintViewerProps): null | string | string[] => {
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (
|
||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
|
||||
appState.searchMatches?.matches.length
|
||||
) {
|
||||
return t("hints.dismissSearch", {
|
||||
shortcut: getTaggedShortcutKey("Escape"),
|
||||
});
|
||||
}
|
||||
|
||||
if (appState.openSidebar && !editorInterface.canFitSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
return t("hints.eraserRevert", {
|
||||
shortcut: getTaggedShortcutKey("Alt"),
|
||||
});
|
||||
}
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
// creating or dragging arrow point
|
||||
if (
|
||||
appState.selectedLinearElement?.isDragging &&
|
||||
selectedElements[0]?.type === "arrow"
|
||||
) {
|
||||
return t("hints.arrowBindModifiers", {
|
||||
shortcut_1: getTaggedShortcutKey("Ctrl"),
|
||||
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||
});
|
||||
}
|
||||
|
||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||
if (multiMode) {
|
||||
return t("hints.linearElementMulti", {
|
||||
shortcut_1: getTaggedShortcutKey("Escape"),
|
||||
shortcut_2: getTaggedShortcutKey("Enter"),
|
||||
});
|
||||
}
|
||||
if (activeTool.type === "arrow") {
|
||||
return t("hints.arrowTool", {
|
||||
shortcut: getTaggedShortcutKey("A"),
|
||||
});
|
||||
}
|
||||
return t("hints.linearElement");
|
||||
}
|
||||
|
||||
if (activeTool.type === "freedraw") {
|
||||
return t("hints.freeDraw");
|
||||
}
|
||||
|
||||
if (activeTool.type === "text") {
|
||||
return t("hints.text");
|
||||
}
|
||||
|
||||
if (activeTool.type === "embeddable") {
|
||||
return t("hints.embeddable");
|
||||
}
|
||||
|
||||
if (activeTool.type === "stickynote") {
|
||||
return t("hints.stickynote");
|
||||
}
|
||||
|
||||
if (activeTool.type === "autoshape") {
|
||||
return t("hints.autoshape");
|
||||
}
|
||||
|
||||
if (
|
||||
isResizing &&
|
||||
lastPointerDownWith === "mouse" &&
|
||||
selectedElements.length === 1
|
||||
) {
|
||||
const targetElement = selectedElements[0];
|
||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||
return t("hints.lockAngle", {
|
||||
shortcut: getTaggedShortcutKey("Shift"),
|
||||
});
|
||||
}
|
||||
if (
|
||||
isStickyNoteElement(targetElement) &&
|
||||
// a note's corners are proportional by default (Shift frees them); its
|
||||
// edges are free by default, so they get the generic hint below
|
||||
app.activeResizeHandle?.length === 2
|
||||
) {
|
||||
return t("hints.resizeStickyNote", {
|
||||
shortcut_1: getTaggedShortcutKey("Shift"),
|
||||
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||
});
|
||||
}
|
||||
return isImageElement(targetElement)
|
||||
? t("hints.resizeImage", {
|
||||
shortcut_1: getTaggedShortcutKey("Shift"),
|
||||
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||
})
|
||||
: t("hints.resize", {
|
||||
shortcut_1: getTaggedShortcutKey("Shift"),
|
||||
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||
});
|
||||
}
|
||||
|
||||
if (isRotating && lastPointerDownWith === "mouse") {
|
||||
return t("hints.rotate", {
|
||||
shortcut: getTaggedShortcutKey("Shift"),
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||
return t("hints.text_selected", {
|
||||
shortcut: getTaggedShortcutKey("Enter"),
|
||||
});
|
||||
}
|
||||
|
||||
if (appState.editingTextElement) {
|
||||
return t("hints.text_editing", {
|
||||
shortcut_1: getTaggedShortcutKey("Escape"),
|
||||
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
|
||||
});
|
||||
}
|
||||
|
||||
if (appState.croppingElementId) {
|
||||
return t("hints.leaveCropEditor", {
|
||||
shortcut_1: getTaggedShortcutKey("Enter"),
|
||||
shortcut_2: getTaggedShortcutKey("Escape"),
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
|
||||
return t("hints.enterCropEditor", {
|
||||
shortcut: getTaggedShortcutKey("Enter"),
|
||||
});
|
||||
}
|
||||
|
||||
if (activeTool.type === "selection") {
|
||||
if (
|
||||
appState.selectionElement &&
|
||||
!selectedElements.length &&
|
||||
!appState.editingTextElement &&
|
||||
!appState.selectedLinearElement?.isEditing
|
||||
) {
|
||||
return t("hints.deepBoxSelect", {
|
||||
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
|
||||
});
|
||||
}
|
||||
|
||||
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
|
||||
return t("hints.disableSnapping", {
|
||||
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
|
||||
});
|
||||
}
|
||||
|
||||
if (!selectedElements.length && !isMobile) {
|
||||
return t("hints.canvasPanning", {
|
||||
shortcut_1: getTaggedShortcutKey(t("keys.mmb")),
|
||||
shortcut_2: getTaggedShortcutKey("Space"),
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1) {
|
||||
if (isLinearElement(selectedElements[0])) {
|
||||
if (
|
||||
isArrowElement(selectedElements[0]) &&
|
||||
appState.selectedLinearElement?.elementId ===
|
||||
selectedElements[0].id &&
|
||||
(appState.selectedLinearElement.hoverPointIndex === 0 ||
|
||||
appState.selectedLinearElement.hoverPointIndex ===
|
||||
selectedElements[0].points.length - 1)
|
||||
) {
|
||||
return t("hints.toggleArrowhead");
|
||||
}
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return appState.selectedLinearElement.selectedPointsIndices
|
||||
? t("hints.lineEditor_pointSelected", {
|
||||
shortcut_1: getTaggedShortcutKey("Delete"),
|
||||
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "D"]),
|
||||
})
|
||||
: t("hints.lineEditor_nothingSelected", {
|
||||
shortcut_1: getTaggedShortcutKey("Shift"),
|
||||
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||
});
|
||||
}
|
||||
return isLineElement(selectedElements[0])
|
||||
? t("hints.lineEditor_line_info", {
|
||||
shortcut: getTaggedShortcutKey("Enter"),
|
||||
})
|
||||
: t("hints.lineEditor_info", {
|
||||
shortcut_1: getTaggedShortcutKey("CtrlOrCmd"),
|
||||
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!appState.newElement &&
|
||||
!appState.selectedElementsAreBeingDragged &&
|
||||
isTextBindableContainer(selectedElements[0])
|
||||
) {
|
||||
const bindTextToElement = t("hints.bindTextToElement", {
|
||||
shortcut: getTaggedShortcutKey("Enter"),
|
||||
});
|
||||
const createFlowchart = t("hints.createFlowchart", {
|
||||
shortcut: getTaggedShortcutKey(["CtrlOrCmd", "↑↓"]),
|
||||
});
|
||||
if (isFlowchartNodeElement(selectedElements[0])) {
|
||||
if (
|
||||
isNodeInFlowchart(
|
||||
selectedElements[0],
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
) {
|
||||
return [bindTextToElement, createFlowchart];
|
||||
}
|
||||
|
||||
return [bindTextToElement, createFlowchart];
|
||||
}
|
||||
|
||||
return bindTextToElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const HintViewer = ({
|
||||
appState,
|
||||
isMobile,
|
||||
editorInterface,
|
||||
app,
|
||||
}: HintViewerProps) => {
|
||||
const hints = getHints({
|
||||
appState,
|
||||
isMobile,
|
||||
editorInterface,
|
||||
app,
|
||||
});
|
||||
|
||||
if (!hints) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hint = Array.isArray(hints)
|
||||
? hints.map((hint) => hint.replace(/\. ?$/, "")).join(", ")
|
||||
: hints;
|
||||
|
||||
const hintJSX = hint.split(/(<kbd>[^<]+<\/kbd>)/g).map((part, index) => {
|
||||
if (index % 2 === 1) {
|
||||
const shortcutMatch =
|
||||
part[0] === "<" && part.match(/^<kbd>([^<]+)<\/kbd>$/);
|
||||
return <kbd key={index}>{shortcutMatch ? shortcutMatch[1] : part}</kbd>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="HintViewer">
|
||||
<span>{hintJSX}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
202
packages/excalidraw/components/IconButton.tsx
Normal file
202
packages/excalidraw/components/IconButton.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { isPromiseLike } from "@excalidraw/common";
|
||||
|
||||
import type { PointerType } from "@excalidraw/element/types";
|
||||
|
||||
import { AbortError } from "../errors";
|
||||
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export type IconButtonSize = "small" | "medium";
|
||||
|
||||
type IconButtonBaseProps = {
|
||||
icon?: React.ReactNode;
|
||||
"aria-label": string;
|
||||
"aria-keyshortcuts"?: string;
|
||||
"data-testid"?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
size?: IconButtonSize;
|
||||
keyBindingLabel?: string | null;
|
||||
showAriaLabel?: boolean;
|
||||
hidden?: boolean;
|
||||
visible?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
type IconButtonProps =
|
||||
| (IconButtonBaseProps & {
|
||||
type: "button";
|
||||
children?: React.ReactNode;
|
||||
onClick?(event: React.MouseEvent): void;
|
||||
})
|
||||
| (IconButtonBaseProps & {
|
||||
type: "icon";
|
||||
children?: React.ReactNode;
|
||||
onClick?(): void;
|
||||
})
|
||||
// a stateful (pressed/unpressed) tool button
|
||||
| (IconButtonBaseProps & {
|
||||
type: "toggle";
|
||||
checked: boolean;
|
||||
/**
|
||||
* Fired on activation — a completed pointer gesture (via `click`, so a
|
||||
* press canceled by sliding off the button doesn't select) or
|
||||
* keyboard/AT activation, in which case pointerType is null.
|
||||
* pointerType is captured on pointer-down, where it's reliable
|
||||
* (unlike the click event's own pointerType on iOS).
|
||||
*/
|
||||
onSelect?(data: { pointerType: PointerType | null }): void;
|
||||
/** the press that may become a click — e.g. to arm a drag out of the toolbar */
|
||||
onPointerDown?(event: React.PointerEvent<HTMLButtonElement>): void;
|
||||
});
|
||||
|
||||
export const IconButton = React.forwardRef(
|
||||
(
|
||||
{
|
||||
size = "medium",
|
||||
visible = true,
|
||||
className = "",
|
||||
...props
|
||||
}: IconButtonProps,
|
||||
ref,
|
||||
) => {
|
||||
const innerRef = React.useRef(null);
|
||||
React.useImperativeHandle(ref, () => innerRef.current);
|
||||
const sizeCn = `ToolIcon_size_${size}`;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const onClick = async (event: React.MouseEvent) => {
|
||||
const ret = "onClick" in props && props.onClick?.(event);
|
||||
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
||||
|
||||
if (props.type === "button" || props.type === "icon") {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"ToolIcon_type_button",
|
||||
sizeCn,
|
||||
className,
|
||||
visible && !props.hidden
|
||||
? "ToolIcon_type_button--show"
|
||||
: "ToolIcon_type_button--hide",
|
||||
{
|
||||
ToolIcon: !props.hidden,
|
||||
"ToolIcon--plain": props.type === "icon",
|
||||
},
|
||||
)}
|
||||
style={props.style}
|
||||
data-testid={props["data-testid"]}
|
||||
hidden={props.hidden}
|
||||
title={props.title}
|
||||
aria-label={props["aria-label"]}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
ref={innerRef}
|
||||
disabled={isLoading || props.isLoading || !!props.disabled}
|
||||
>
|
||||
{(props.icon || props.label) && (
|
||||
<div
|
||||
className="ToolIcon__icon"
|
||||
aria-hidden="true"
|
||||
aria-disabled={!!props.disabled}
|
||||
>
|
||||
{props.icon || props.label}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
{props.isLoading && <Spinner />}
|
||||
</div>
|
||||
)}
|
||||
{props.showAriaLabel && (
|
||||
<div className="ToolIcon__label">
|
||||
{props["aria-label"]} {isLoading && <Spinner />}
|
||||
</div>
|
||||
)}
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx("ToolIcon", "ToolIcon_type_toggle", sizeCn, className, {
|
||||
"ToolIcon--checked": props.checked,
|
||||
})}
|
||||
type="button"
|
||||
style={props.style}
|
||||
title={props.title}
|
||||
aria-label={props["aria-label"]}
|
||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||
aria-pressed={props.checked}
|
||||
data-testid={props["data-testid"]}
|
||||
disabled={!!props.disabled}
|
||||
aria-disabled={!!props.disabled}
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
props.onPointerDown?.(event);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
requestAnimationFrame(() => {
|
||||
lastPointerTypeRef.current = null;
|
||||
});
|
||||
}}
|
||||
onClick={() => {
|
||||
props.onSelect?.({ pointerType: lastPointerTypeRef.current });
|
||||
}}
|
||||
ref={innerRef}
|
||||
>
|
||||
<div className="ToolIcon__icon">
|
||||
{props.icon}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
IconButton.displayName = "IconButton";
|
||||
122
packages/excalidraw/components/IconPicker.scss
Normal file
122
packages/excalidraw/components/IconPicker.scss
Normal file
@@ -0,0 +1,122 @@
|
||||
@use "sass:color";
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.picker {
|
||||
padding: 0.5rem;
|
||||
background: var(--popup-bg-color);
|
||||
border: 0 solid color.adjust(#fff, $alpha: -0.75);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
border-radius: 4px;
|
||||
:root[dir="rtl"] & {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-sections,
|
||||
.picker-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.picker-container button,
|
||||
.picker button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:focus-visible {
|
||||
outline: transparent;
|
||||
background-color: var(--button-gray-2);
|
||||
& svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin: 0;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.picker button {
|
||||
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
||||
}
|
||||
|
||||
.picker-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, auto);
|
||||
grid-gap: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.picker-collapsible {
|
||||
font-size: 0.75rem;
|
||||
padding: 0;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.picker-section-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.picker-keybinding {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
font-size: 0.7em;
|
||||
color: var(--keybinding-color);
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 2px;
|
||||
}
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-type-canvasBackground .picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.picker-type-elementBackground .picker-keybinding {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.picker-type-elementStroke .picker-keybinding {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.picker-type-elementBackground .picker-keybinding {
|
||||
color: #000;
|
||||
}
|
||||
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
333
packages/excalidraw/components/IconPicker.tsx
Normal file
333
packages/excalidraw/components/IconPicker.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { Popover } from "radix-ui";
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
|
||||
import { isArrowKey, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
|
||||
import Collapsible from "./Stats/Collapsible";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
import "./IconPicker.scss";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
const moreOptionsAtom = atom(false);
|
||||
const PICKER_COLUMNS = 4;
|
||||
const DEFAULT_SECTION_NAME = "default";
|
||||
|
||||
type Option<T> = {
|
||||
value: T;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
keyBinding: string | null;
|
||||
};
|
||||
|
||||
type PickerSection<T> = {
|
||||
name: string;
|
||||
options: readonly Option<T>[];
|
||||
};
|
||||
|
||||
const flattenOptions = <T,>(sections: readonly PickerSection<T>[]) =>
|
||||
sections.flatMap((section) => section.options);
|
||||
|
||||
const findOption = <T,>(
|
||||
sections: readonly PickerSection<T>[],
|
||||
predicate: (option: Option<T>) => boolean,
|
||||
) => {
|
||||
for (const section of sections) {
|
||||
const option = section.options.find(predicate);
|
||||
if (option) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasOption = <T,>(
|
||||
sections: readonly PickerSection<T>[],
|
||||
predicate: (option: Option<T>) => boolean,
|
||||
) => sections.some((section) => section.options.some(predicate));
|
||||
|
||||
const getNavigationRows = <T,>(sections: readonly PickerSection<T>[]) =>
|
||||
sections.flatMap((section) =>
|
||||
Array.from(
|
||||
{ length: Math.ceil(section.options.length / PICKER_COLUMNS) },
|
||||
(_, index) =>
|
||||
section.options.slice(
|
||||
index * PICKER_COLUMNS,
|
||||
index * PICKER_COLUMNS + PICKER_COLUMNS,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
function Picker<T>({
|
||||
visibleSections,
|
||||
hiddenSections = [],
|
||||
value,
|
||||
label,
|
||||
onChange,
|
||||
onClose,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
visibleSections: readonly PickerSection<T>[];
|
||||
hiddenSections?: readonly PickerSection<T>[];
|
||||
onChange: (value: T) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
|
||||
const allSections = [...visibleSections, ...hiddenSections];
|
||||
const allOptions = flattenOptions(allSections);
|
||||
const navigationRows = getNavigationRows([
|
||||
...visibleSections,
|
||||
...(showMoreOptions ? hiddenSections : []),
|
||||
]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const pressedOption = allOptions.find(
|
||||
(option) => option.keyBinding === event.key.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||
// Keybinding navigation
|
||||
onChange(pressedOption.value);
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.TAB) {
|
||||
const index = allOptions.findIndex((option) => option.value === value);
|
||||
const nextIndex = event.shiftKey
|
||||
? (allOptions.length + index - 1) % allOptions.length
|
||||
: (index + 1) % allOptions.length;
|
||||
onChange(allOptions[nextIndex].value);
|
||||
} else if (isArrowKey(event.key)) {
|
||||
// Arrow navigation
|
||||
const isRTL = getLanguage().rtl;
|
||||
const index = allOptions.findIndex((option) => option.value === value);
|
||||
if (index !== -1) {
|
||||
const length = allOptions.length;
|
||||
let nextIndex = index;
|
||||
|
||||
switch (event.key) {
|
||||
// Select the next option
|
||||
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
||||
nextIndex = (index + 1) % length;
|
||||
break;
|
||||
// Select the previous option
|
||||
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
||||
nextIndex = (length + index - 1) % length;
|
||||
break;
|
||||
// Go the next row
|
||||
case KEYS.ARROW_DOWN: {
|
||||
const currentRowIndex = navigationRows.findIndex((row) =>
|
||||
row.some((option) => option.value === value),
|
||||
);
|
||||
const currentRow = navigationRows[currentRowIndex];
|
||||
|
||||
if (currentRowIndex !== -1 && currentRow) {
|
||||
const column = currentRow.findIndex(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
const nextRow =
|
||||
navigationRows[(currentRowIndex + 1) % navigationRows.length];
|
||||
const nextOption =
|
||||
nextRow[Math.min(column, nextRow.length - 1)] ??
|
||||
allOptions[index];
|
||||
|
||||
onChange(nextOption.value);
|
||||
event.preventDefault();
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Go the previous row
|
||||
case KEYS.ARROW_UP: {
|
||||
const currentRowIndex = navigationRows.findIndex((row) =>
|
||||
row.some((option) => option.value === value),
|
||||
);
|
||||
const currentRow = navigationRows[currentRowIndex];
|
||||
|
||||
if (currentRowIndex !== -1 && currentRow) {
|
||||
const column = currentRow.findIndex(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
const previousRow =
|
||||
navigationRows[
|
||||
(navigationRows.length + currentRowIndex - 1) %
|
||||
navigationRows.length
|
||||
];
|
||||
const previousOption =
|
||||
previousRow[Math.min(column, previousRow.length - 1)] ??
|
||||
allOptions[index];
|
||||
|
||||
onChange(previousOption.value);
|
||||
event.preventDefault();
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onChange(allOptions[nextIndex].value);
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||
// Close on escape or enter
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasOption(hiddenSections, (option) => option.value === value)) {
|
||||
setShowMoreOptions(true);
|
||||
}
|
||||
}, [value, hiddenSections, setShowMoreOptions]);
|
||||
|
||||
const renderOptions = (options: readonly Option<T>[]) => {
|
||||
return (
|
||||
<div className="picker-content">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("picker-option", {
|
||||
active: value === option.value,
|
||||
})}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
}}
|
||||
title={
|
||||
option.keyBinding
|
||||
? `${option.text} — ${option.keyBinding.toUpperCase()}`
|
||||
: option.text
|
||||
}
|
||||
aria-label={option.text || "none"}
|
||||
aria-keyshortcuts={option.keyBinding || undefined}
|
||||
key={option.text}
|
||||
ref={(ref) => {
|
||||
if (value === option.value) {
|
||||
// Use a timeout here to render focus properly
|
||||
setTimeout(() => {
|
||||
ref?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
{option.keyBinding && (
|
||||
<span className="picker-keybinding">{option.keyBinding}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSections = (sections: readonly PickerSection<T>[]) =>
|
||||
sections.map((section, index) =>
|
||||
section.name === DEFAULT_SECTION_NAME ? (
|
||||
<React.Fragment key={`${section.name}-${index}`}>
|
||||
{renderOptions(section.options)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div className="picker-section" key={`${section.name}-${index}`}>
|
||||
<div className="picker-section-label">{section.name}</div>
|
||||
{renderOptions(section.options)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover.Content
|
||||
className="picker"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
side={"bottom"}
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
alignOffset={12}
|
||||
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
|
||||
onKeyDown={handleKeyDown}
|
||||
collisionBoundary={container ?? undefined}
|
||||
>
|
||||
<div className="picker-sections">
|
||||
{renderSections(visibleSections)}
|
||||
|
||||
{hiddenSections.length > 0 && (
|
||||
<Collapsible
|
||||
label={t("labels.more_options")}
|
||||
open={showMoreOptions}
|
||||
openTrigger={() => {
|
||||
setShowMoreOptions((value) => !value);
|
||||
}}
|
||||
className="picker-collapsible"
|
||||
>
|
||||
<div className="picker-sections">
|
||||
{renderSections(hiddenSections)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPicker<T>({
|
||||
value,
|
||||
label,
|
||||
visibleSections,
|
||||
hiddenSections,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
visibleSections: readonly PickerSection<T>[];
|
||||
hiddenSections?: readonly PickerSection<T>[];
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
const [isActive, setActive] = React.useState(false);
|
||||
const selectedOption = useMemo(
|
||||
() =>
|
||||
findOption(visibleSections, (option) => option.value === value) ??
|
||||
findOption(hiddenSections ?? [], (option) => option.value === value),
|
||||
[visibleSections, hiddenSections, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => setActive(!isActive)}
|
||||
className={isActive ? "active" : ""}
|
||||
>
|
||||
{selectedOption?.icon}
|
||||
</Popover.Trigger>
|
||||
{isActive && (
|
||||
<Picker
|
||||
visibleSections={visibleSections}
|
||||
hiddenSections={hiddenSections}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
onClose={() => {
|
||||
setActive(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
packages/excalidraw/components/ImageExportDialog.scss
Normal file
175
packages/excalidraw/components/ImageExportDialog.scss
Normal file
@@ -0,0 +1,175 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
--ImageExportModal-preview-border: #d6d6d6;
|
||||
|
||||
&.theme--dark {
|
||||
--ImageExportModal-preview-border: #5c5c5c;
|
||||
}
|
||||
|
||||
.ImageExportModal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
user-select: none;
|
||||
|
||||
& h3 {
|
||||
font-family: "Assistant";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-size: 1.313rem;
|
||||
line-height: 130%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > h3 {
|
||||
display: none;
|
||||
|
||||
@include isMobile {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 5rem);
|
||||
}
|
||||
|
||||
&__preview {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 360px;
|
||||
width: 55%;
|
||||
|
||||
margin-right: 1.5rem;
|
||||
|
||||
@include isMobile {
|
||||
max-width: unset;
|
||||
margin-right: unset;
|
||||
|
||||
width: 100%;
|
||||
height: unset;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&__filename {
|
||||
& > input {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__canvas {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
|
||||
border: 1px solid var(--ImageExportModal-preview-border);
|
||||
border-radius: 12px;
|
||||
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
|
||||
& > canvas {
|
||||
max-width: calc(100% - 2rem);
|
||||
max-height: calc(100% - 2rem);
|
||||
|
||||
filter: none !important;
|
||||
|
||||
@include isMobile {
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
margin-top: 24px;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
|
||||
@include isMobile {
|
||||
margin-left: unset;
|
||||
margin-top: 1rem;
|
||||
flex-direction: row;
|
||||
gap: 6px 34px;
|
||||
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
&__setting {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@include isMobile {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
justify-content: unset;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
font-family: "Assistant";
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
line-height: 150%;
|
||||
|
||||
& svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
flex-grow: 1;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 11px;
|
||||
|
||||
align-items: flex-end;
|
||||
align-content: flex-end;
|
||||
|
||||
@include isMobile {
|
||||
padding-top: 32px;
|
||||
flex-basis: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
426
packages/excalidraw/components/ImageExportDialog.tsx
Normal file
426
packages/excalidraw/components/ImageExportDialog.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import { exportToCanvas } from "@excalidraw/utils/export";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
EXPORT_IMAGE_TYPES,
|
||||
isFirefox,
|
||||
EXPORT_SCALES,
|
||||
cloneJSON,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
actionExportWithDarkMode,
|
||||
actionChangeExportBackground,
|
||||
actionChangeExportEmbedScene,
|
||||
actionChangeExportScale,
|
||||
actionChangeProjectName,
|
||||
} from "../actions/actionExport";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { prepareElementsForExport } from "../data";
|
||||
import { canvasToBlob } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { useCopyStatus } from "../hooks/useCopiedIndicator";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
|
||||
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { RadioGroup } from "./RadioGroup";
|
||||
import { Switch } from "./Switch";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
|
||||
import "./ImageExportDialog.scss";
|
||||
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
|
||||
import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
|
||||
|
||||
export const ErrorCanvasPreview = () => {
|
||||
return (
|
||||
<div>
|
||||
<h3>{t("canvasError.cannotShowPreview")}</h3>
|
||||
<p>
|
||||
<span>{t("canvasError.canvasTooBig")}</span>
|
||||
</p>
|
||||
<em>({t("canvasError.canvasTooBigTip")})</em>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ImageExportModalProps = {
|
||||
appStateSnapshot: Readonly<UIAppState>;
|
||||
elementsSnapshot: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
name: string;
|
||||
exportWithDarkMode: boolean;
|
||||
};
|
||||
|
||||
const ImageExportModal = ({
|
||||
appStateSnapshot,
|
||||
elementsSnapshot,
|
||||
files,
|
||||
actionManager,
|
||||
onExportImage,
|
||||
name,
|
||||
exportWithDarkMode,
|
||||
}: ImageExportModalProps) => {
|
||||
const hasSelection = isSomeElementSelected(
|
||||
elementsSnapshot,
|
||||
appStateSnapshot,
|
||||
);
|
||||
|
||||
const [projectName, setProjectName] = useState(name);
|
||||
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
|
||||
const [exportWithBackground, setExportWithBackground] = useState(
|
||||
appStateSnapshot.exportBackground,
|
||||
);
|
||||
const [embedScene, setEmbedScene] = useState(
|
||||
appStateSnapshot.exportEmbedScene,
|
||||
);
|
||||
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const previewRenderRequestIdRef = useRef(0);
|
||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
|
||||
|
||||
useEffect(() => {
|
||||
// if user changes setting right after export to clipboard, reset the status
|
||||
// so they don't have to wait for the timeout to click the button again
|
||||
resetCopyStatus();
|
||||
}, [
|
||||
projectName,
|
||||
exportWithBackground,
|
||||
exportWithDarkMode,
|
||||
exportScale,
|
||||
embedScene,
|
||||
resetCopyStatus,
|
||||
]);
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elementsSnapshot,
|
||||
appStateSnapshot,
|
||||
exportSelectionOnly,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const previewNode = previewRef.current;
|
||||
if (!previewNode) {
|
||||
return;
|
||||
}
|
||||
const maxWidth = previewNode.offsetWidth;
|
||||
const maxHeight = previewNode.offsetHeight;
|
||||
if (!maxWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++previewRenderRequestIdRef.current;
|
||||
const isStaleRequest = () => {
|
||||
return requestId !== previewRenderRequestIdRef.current;
|
||||
};
|
||||
|
||||
exportToCanvas({
|
||||
elements: exportedElements,
|
||||
appState: {
|
||||
...appStateSnapshot,
|
||||
name: projectName,
|
||||
exportBackground: exportWithBackground,
|
||||
exportWithDarkMode,
|
||||
exportScale,
|
||||
exportEmbedScene: embedScene,
|
||||
},
|
||||
files,
|
||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
|
||||
exportingFrame,
|
||||
})
|
||||
.then(async (canvas) => {
|
||||
if (isStaleRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If converting to blob fails, there's some problem that will likely
|
||||
// prevent preview and export (e.g. canvas too big).
|
||||
try {
|
||||
await canvasToBlob(canvas);
|
||||
} catch (error: any) {
|
||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw new Error(t("canvasError.canvasTooBig"));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (isStaleRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRenderError(null);
|
||||
previewNode.replaceChildren(canvas);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isStaleRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
setRenderError(error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
previewRenderRequestIdRef.current += 1;
|
||||
};
|
||||
}, [
|
||||
appStateSnapshot,
|
||||
files,
|
||||
exportedElements,
|
||||
exportingFrame,
|
||||
projectName,
|
||||
exportWithBackground,
|
||||
exportWithDarkMode,
|
||||
exportScale,
|
||||
embedScene,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="ImageExportModal">
|
||||
<h3>{t("imageExportDialog.header")}</h3>
|
||||
<div className="ImageExportModal__preview">
|
||||
<div className="ImageExportModal__preview__canvas" ref={previewRef}>
|
||||
{renderError && <ErrorCanvasPreview />}
|
||||
</div>
|
||||
<div className="ImageExportModal__preview__filename">
|
||||
{!nativeFileSystemSupported && (
|
||||
<input
|
||||
type="text"
|
||||
className="TextInput"
|
||||
value={projectName}
|
||||
style={{ width: "30ch" }}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
actionManager.executeAction(
|
||||
actionChangeProjectName,
|
||||
"ui",
|
||||
event.target.value,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ImageExportModal__settings">
|
||||
<h3>{t("imageExportDialog.header")}</h3>
|
||||
{hasSelection && (
|
||||
<ExportSetting
|
||||
label={t("imageExportDialog.label.onlySelected")}
|
||||
name="exportOnlySelected"
|
||||
>
|
||||
<Switch
|
||||
name="exportOnlySelected"
|
||||
checked={exportSelectionOnly}
|
||||
onChange={(checked) => {
|
||||
setExportSelectionOnly(checked);
|
||||
}}
|
||||
/>
|
||||
</ExportSetting>
|
||||
)}
|
||||
<ExportSetting
|
||||
label={t("imageExportDialog.label.withBackground")}
|
||||
name="exportBackgroundSwitch"
|
||||
>
|
||||
<Switch
|
||||
name="exportBackgroundSwitch"
|
||||
checked={exportWithBackground}
|
||||
onChange={(checked) => {
|
||||
setExportWithBackground(checked);
|
||||
actionManager.executeAction(
|
||||
actionChangeExportBackground,
|
||||
"ui",
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ExportSetting>
|
||||
<ExportSetting
|
||||
label={t("imageExportDialog.label.darkMode")}
|
||||
name="exportDarkModeSwitch"
|
||||
>
|
||||
<Switch
|
||||
name="exportDarkModeSwitch"
|
||||
checked={exportWithDarkMode}
|
||||
onChange={(checked) => {
|
||||
actionManager.executeAction(
|
||||
actionExportWithDarkMode,
|
||||
"ui",
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ExportSetting>
|
||||
<ExportSetting
|
||||
label={t("imageExportDialog.label.embedScene")}
|
||||
tooltip={t("imageExportDialog.tooltip.embedScene")}
|
||||
name="exportEmbedSwitch"
|
||||
>
|
||||
<Switch
|
||||
name="exportEmbedSwitch"
|
||||
checked={embedScene}
|
||||
onChange={(checked) => {
|
||||
setEmbedScene(checked);
|
||||
actionManager.executeAction(
|
||||
actionChangeExportEmbedScene,
|
||||
"ui",
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ExportSetting>
|
||||
<ExportSetting
|
||||
label={t("imageExportDialog.label.scale")}
|
||||
name="exportScale"
|
||||
>
|
||||
<RadioGroup
|
||||
name="exportScale"
|
||||
value={exportScale}
|
||||
onChange={(scale) => {
|
||||
setExportScale(scale);
|
||||
actionManager.executeAction(actionChangeExportScale, "ui", scale);
|
||||
}}
|
||||
choices={EXPORT_SCALES.map((scale) => ({
|
||||
value: scale,
|
||||
label: `${scale}\u00d7`,
|
||||
}))}
|
||||
/>
|
||||
</ExportSetting>
|
||||
|
||||
<div className="ImageExportModal__settings__buttons">
|
||||
<FilledButton
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.exportToPng")}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
|
||||
exportingFrame,
|
||||
})
|
||||
}
|
||||
icon={downloadIcon}
|
||||
>
|
||||
{t("imageExportDialog.button.exportToPng")}
|
||||
</FilledButton>
|
||||
<FilledButton
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.exportToSvg")}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
|
||||
exportingFrame,
|
||||
})
|
||||
}
|
||||
icon={downloadIcon}
|
||||
>
|
||||
{t("imageExportDialog.button.exportToSvg")}
|
||||
</FilledButton>
|
||||
{(probablySupportsClipboardBlob || isFirefox) && (
|
||||
<FilledButton
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.copyPngToClipboard")}
|
||||
status={copyStatus}
|
||||
onClick={async () => {
|
||||
await onExportImage(
|
||||
EXPORT_IMAGE_TYPES.clipboard,
|
||||
exportedElements,
|
||||
{
|
||||
exportingFrame,
|
||||
},
|
||||
);
|
||||
onCopy();
|
||||
}}
|
||||
icon={copyIcon}
|
||||
>
|
||||
{t("imageExportDialog.button.copyPngToClipboard")}
|
||||
</FilledButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExportSettingProps = {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
tooltip?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const ExportSetting = ({
|
||||
label,
|
||||
children,
|
||||
tooltip,
|
||||
name,
|
||||
}: ExportSettingProps) => {
|
||||
return (
|
||||
<div className="ImageExportModal__settings__setting" title={label}>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className="ImageExportModal__settings__setting__label"
|
||||
>
|
||||
{label}
|
||||
{tooltip && (
|
||||
<Tooltip label={tooltip} long={true}>
|
||||
{helpIcon}
|
||||
</Tooltip>
|
||||
)}
|
||||
</label>
|
||||
<div className="ImageExportModal__settings__setting__content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImageExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
actionManager,
|
||||
onExportImage,
|
||||
onCloseRequest,
|
||||
name,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
onCloseRequest: () => void;
|
||||
name: string;
|
||||
}) => {
|
||||
// we need to take a snapshot so that the exported state can't be modified
|
||||
// while the dialog is open
|
||||
const [{ appStateSnapshot, elementsSnapshot }] = useState(() => {
|
||||
return {
|
||||
appStateSnapshot: cloneJSON(appState),
|
||||
elementsSnapshot: cloneJSON(elements),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
|
||||
<ImageExportModal
|
||||
elementsSnapshot={elementsSnapshot}
|
||||
appStateSnapshot={appStateSnapshot}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportImage={onExportImage}
|
||||
name={name}
|
||||
exportWithDarkMode={appState.exportWithDarkMode}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
31
packages/excalidraw/components/InitializeApp.tsx
Normal file
31
packages/excalidraw/components/InitializeApp.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { defaultLang, languages, setLanguage } from "../i18n";
|
||||
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
|
||||
import type { Language } from "../i18n";
|
||||
|
||||
interface Props {
|
||||
langCode: Language["code"];
|
||||
children: React.ReactElement;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
export const InitializeApp = (props: Props) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const updateLang = async () => {
|
||||
await setLanguage(currentLang);
|
||||
setLoading(false);
|
||||
};
|
||||
const currentLang =
|
||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
||||
updateLang();
|
||||
}, [props.langCode]);
|
||||
|
||||
return loading ? <LoadingMessage theme={props.theme} /> : props.children;
|
||||
};
|
||||
26
packages/excalidraw/components/InlineIcon.tsx
Normal file
26
packages/excalidraw/components/InlineIcon.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export const InlineIcon = ({
|
||||
className,
|
||||
icon,
|
||||
size = "1em",
|
||||
}: {
|
||||
className?: string;
|
||||
icon: React.ReactNode;
|
||||
size?: string;
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{
|
||||
width: size,
|
||||
height: "100%",
|
||||
margin: "0 0.5ex 0 0.5ex",
|
||||
display: "inline-flex",
|
||||
lineHeight: 0,
|
||||
verticalAlign: "middle",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
15
packages/excalidraw/components/Island.scss
Normal file
15
packages/excalidraw/components/Island.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
.excalidraw {
|
||||
.Island {
|
||||
--padding: 0;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
|
||||
&.zen-mode {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
packages/excalidraw/components/Island.tsx
Normal file
43
packages/excalidraw/components/Island.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./Island.scss";
|
||||
|
||||
import type { ViewportUIDock, ViewportUIName } from "../types";
|
||||
|
||||
type IslandProps = {
|
||||
children: React.ReactNode;
|
||||
padding?: number;
|
||||
className?: string | boolean;
|
||||
style?: object;
|
||||
/** marks the island as a canvas-occluding UI surface measured by
|
||||
* `getViewportOffsets` (see {@link ViewportUIDock}) */
|
||||
"data-viewport-ui"?: ViewportUIDock;
|
||||
/** identifies the surface so `getViewportOffsets` can reserve space for
|
||||
* it while hidden (see {@link ViewportUIName}) */
|
||||
"data-viewport-ui-name"?: ViewportUIName;
|
||||
};
|
||||
|
||||
export const Island = React.forwardRef<HTMLDivElement, IslandProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
padding,
|
||||
className,
|
||||
style,
|
||||
"data-viewport-ui": viewportUI,
|
||||
"data-viewport-ui-name": viewportUIName,
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<div
|
||||
className={clsx("Island", className)}
|
||||
style={{ "--padding": padding, ...style }}
|
||||
data-viewport-ui={viewportUI}
|
||||
data-viewport-ui-name={viewportUIName}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
142
packages/excalidraw/components/JSONExportDialog.tsx
Normal file
142
packages/excalidraw/components/JSONExportDialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
|
||||
import { getFrame } from "@excalidraw/common";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { actionSaveFileToDisk } from "../actions/actionExport";
|
||||
|
||||
import { trackEvent } from "../analytics";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { Card } from "./Card";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { exportToFileIcon, LinkIcon } from "./icons";
|
||||
|
||||
import "./ExportDialog.scss";
|
||||
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
|
||||
import type { ExportOpts, BinaryFiles, UIAppState } from "../types";
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
scale?: number,
|
||||
) => void;
|
||||
|
||||
const JSONExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
setAppState,
|
||||
files,
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
onCloseRequest,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionManager;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement;
|
||||
}) => {
|
||||
const { onExportToBackend } = exportOpts;
|
||||
return (
|
||||
<div className="ExportDialog ExportDialog--json">
|
||||
<div className="ExportDialog-cards">
|
||||
{exportOpts.saveFileToDisk && (
|
||||
<Card color="lime">
|
||||
<div className="Card-icon">{exportToFileIcon}</div>
|
||||
<h2>{t("exportDialog.disk_title")}</h2>
|
||||
<div className="Card-details">
|
||||
{t("exportDialog.disk_details")}
|
||||
{!nativeFileSystemSupported &&
|
||||
actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<IconButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("exportDialog.disk_button")}
|
||||
aria-label={t("exportDialog.disk_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionSaveFileToDisk, "ui");
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{onExportToBackend && (
|
||||
<Card color="pink">
|
||||
<div className="Card-icon">{LinkIcon}</div>
|
||||
<h2>{t("exportDialog.link_title")}</h2>
|
||||
<div className="Card-details">{t("exportDialog.link_details")}</div>
|
||||
<IconButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={async () => {
|
||||
try {
|
||||
trackEvent("export", "link", `ui (${getFrame()})`);
|
||||
await onExportToBackend(elements, appState, files);
|
||||
onCloseRequest();
|
||||
} catch (error: any) {
|
||||
setAppState({ errorMessage: error.message });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{exportOpts.renderCustomUI &&
|
||||
exportOpts.renderCustomUI(elements, appState, files, canvas)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const JSONExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
setAppState,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: UIAppState;
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
}) => {
|
||||
const handleClose = React.useCallback(() => {
|
||||
setAppState({ openDialog: null });
|
||||
}, [setAppState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{appState.openDialog?.name === "jsonExport" && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<JSONExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onCloseRequest={handleClose}
|
||||
exportOpts={exportOpts}
|
||||
canvas={canvas}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
29
packages/excalidraw/components/LaserPointerButton.tsx
Normal file
29
packages/excalidraw/components/LaserPointerButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import { IconButton } from "./IconButton";
|
||||
import { laserPointerToolIcon } from "./icons";
|
||||
|
||||
type LaserPointerButtonProps = {
|
||||
title?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
export const LaserPointerButton = (props: LaserPointerButtonProps) => {
|
||||
return (
|
||||
<IconButton
|
||||
className={clsx("ToolIcon__LaserPointer", {
|
||||
"is-mobile": props.isMobile,
|
||||
})}
|
||||
type="toggle"
|
||||
size="small"
|
||||
icon={laserPointerToolIcon}
|
||||
checked={props.checked}
|
||||
title={`${props.title}`}
|
||||
aria-label={`${props.title}`}
|
||||
data-testid="toolbar-LaserPointer"
|
||||
onSelect={() => props.onChange?.()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
195
packages/excalidraw/components/LayerUI.scss
Normal file
195
packages/excalidraw/components/LayerUI.scss
Normal file
@@ -0,0 +1,195 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__wrapper.animate {
|
||||
transition: width 0.1s ease-in-out;
|
||||
}
|
||||
.layer-ui__wrapper {
|
||||
// when the rightside sidebar is docked, we need to resize the UI by its
|
||||
// width, making the nested UI content shift to the left. To do this,
|
||||
// we need the UI container to actually have dimensions set, but
|
||||
// then we also need to disable pointer events else the canvas below
|
||||
// wouldn't be interactive.
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
&__top-right {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
pointer-events: none !important;
|
||||
|
||||
&--compact {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
& > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
width: 100%;
|
||||
|
||||
&-right {
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.zen-mode-transition {
|
||||
transition: transform 0.5s ease-in-out;
|
||||
|
||||
:root[dir="ltr"] &.transition-left {
|
||||
transform: translate(-999px, 0);
|
||||
}
|
||||
|
||||
:root[dir="ltr"] &.transition-right {
|
||||
transform: translate(999px, 0);
|
||||
}
|
||||
|
||||
:root[dir="rtl"] &.transition-left {
|
||||
transform: translate(999px, 0);
|
||||
}
|
||||
|
||||
:root[dir="rtl"] &.transition-right {
|
||||
transform: translate(-999px, 0);
|
||||
}
|
||||
|
||||
&.layer-ui__wrapper__footer-left--transition-bottom {
|
||||
transform: translate(0, 92px);
|
||||
}
|
||||
}
|
||||
|
||||
.disable-zen-mode {
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
[dir="ltr"] & {
|
||||
right: 1rem;
|
||||
}
|
||||
[dir="rtl"] & {
|
||||
left: 1rem;
|
||||
}
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: visibility 0s linear 0s, opacity 0.5s;
|
||||
|
||||
font-family: var(--ui-font);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--default-border-color);
|
||||
background-color: var(--island-bg-color);
|
||||
color: var(--text-primary-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
}
|
||||
&:active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&--visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: visibility 0s linear 300ms, opacity 0.5s;
|
||||
transition-delay: 0.8s;
|
||||
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-left,
|
||||
.footer-center,
|
||||
.layer-ui__wrapper__footer-right {
|
||||
& > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-right {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// on tablet, the pen mode button is rendered as a separate floating button
|
||||
// below the compact actions menu (see LayerUI.tsx)
|
||||
.App-menu_top__left > .ToolIcon__penMode {
|
||||
// NOTE: not `justify-self: center` — that centers against the grid
|
||||
// column's track width, which (see above) can be inflated by unrelated
|
||||
// content, visibly shifting this button off from the icon stack above
|
||||
// it. Nudge by hand instead, against the fixed-width compact actions
|
||||
// column (see .selected-shape-actions-container--compact's min-width).
|
||||
justify-self: start;
|
||||
margin-left: calc((48px - var(--lg-button-size)) / 2);
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
background-color: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
}
|
||||
|
||||
// no shadow while pen mode is active (the active fill is enough)
|
||||
&.ToolIcon--checked .ToolIcon__icon {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.disable-view-mode {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--island-bg-color);
|
||||
text-decoration: none !important;
|
||||
|
||||
font-family: var(--ui-font);
|
||||
font-size: 0.8333rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
width: var(--mobile-action-button-size, var(--default-button-size));
|
||||
height: var(--mobile-action-button-size, var(--default-button-size));
|
||||
|
||||
border: none;
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
background-color: var(--color-surface-low);
|
||||
color: var(--button-color, var(--color-on-surface)) !important;
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0 0 1px var(--color-brand-active);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white !important;
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.theme--dark {
|
||||
.plus-banner {
|
||||
&:hover {
|
||||
color: black !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
758
packages/excalidraw/components/LayerUI.tsx
Normal file
758
packages/excalidraw/components/LayerUI.tsx
Normal file
@@ -0,0 +1,758 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
CLASSES,
|
||||
DEFAULT_SIDEBAR,
|
||||
TOOL_TYPE,
|
||||
arrayToMap,
|
||||
capitalizeString,
|
||||
isShallowEqual,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getColorUpdate, mutateElement } from "@excalidraw/element";
|
||||
|
||||
import { showSelectedShapeActions } from "@excalidraw/element";
|
||||
|
||||
import { ShapeCache } from "@excalidraw/element";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { actionToggleStats } from "../actions";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
|
||||
import { UIAppStateContext } from "../context/ui-appState";
|
||||
import { useAtom, useAtomValue } from "../editor-jotai";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getScrollToContentState, getSelectedElements } from "../scene";
|
||||
import {
|
||||
getColorTargetAppStateUpdates,
|
||||
resolveColorTarget,
|
||||
type ColorDefaultKey,
|
||||
} from "../actions/colorTargets";
|
||||
|
||||
import { SelectedShapeActions, CompactShapeActions } from "./Actions";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { PasteChartDialog } from "./PasteChartDialog";
|
||||
import { Section } from "./Section";
|
||||
import Stack from "./Stack";
|
||||
import { UserList } from "./UserList";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import Footer from "./footer/Footer";
|
||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
import MainMenu from "./main-menu/MainMenu";
|
||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||
import { useAppProps, useEditorInterface, useStylesPanelMode } from "./App";
|
||||
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
|
||||
import { sidebarRightIcon } from "./icons";
|
||||
import { DefaultSidebar } from "./DefaultSidebar";
|
||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||
import { Stats } from "./Stats";
|
||||
import ElementLinkDialog from "./ElementLinkDialog";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { HelpDialog } from "./HelpDialog";
|
||||
import { ImageExportDialog } from "./ImageExportDialog";
|
||||
import { Island } from "./Island";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LaserPointerButton } from "./LaserPointerButton";
|
||||
import { Toast } from "./Toast";
|
||||
import { Toolbar } from "./Toolbar";
|
||||
import {
|
||||
ViewportStatusBadge,
|
||||
ViewportStatusBorder,
|
||||
} from "./ViewportStatusFrame/ViewportStatusFrame";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
|
||||
import type { Language } from "../i18n";
|
||||
import type {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
UIAppState,
|
||||
AppClassProperties,
|
||||
} from "../types";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
appState: UIAppState;
|
||||
files: BinaryFiles;
|
||||
canvas: HTMLCanvasElement;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onLockToggle: () => void;
|
||||
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||
showExitZenModeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"];
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
renderWelcomeScreen: boolean;
|
||||
children?: React.ReactNode;
|
||||
app: AppClassProperties;
|
||||
defaultUIEnabled: boolean;
|
||||
zoomUIEnabled: boolean;
|
||||
scrollBackToContentUIEnabled: boolean;
|
||||
isCollaborating: boolean;
|
||||
generateLinkForSelection?: AppProps["generateLinkForSelection"];
|
||||
currentUserControls?: ExcalidrawProps["currentUserControls"];
|
||||
}
|
||||
|
||||
const DefaultMainMenu: React.FC<{
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
}> = ({ UIOptions }) => {
|
||||
return (
|
||||
<MainMenu __fallback>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
<MainMenu.DefaultItems.SaveToActiveFile />
|
||||
{/* FIXME we should to test for this inside the item itself */}
|
||||
{UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
|
||||
{/* FIXME we should to test for this inside the item itself */}
|
||||
{UIOptions.canvasActions.saveAsImage && (
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
)}
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.Group title="Excalidraw links">
|
||||
<MainMenu.DefaultItems.Socials />
|
||||
</MainMenu.Group>
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme={false} />
|
||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||
</MainMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultOverwriteConfirmDialog = () => {
|
||||
return (
|
||||
<OverwriteConfirmDialog __fallback>
|
||||
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
||||
<OverwriteConfirmDialog.Actions.ExportToImage />
|
||||
</OverwriteConfirmDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
files,
|
||||
setAppState,
|
||||
elements,
|
||||
canvas,
|
||||
onLockToggle,
|
||||
onPenModeToggle,
|
||||
showExitZenModeBtn,
|
||||
renderTopLeftUI,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
UIOptions,
|
||||
onExportImage,
|
||||
renderWelcomeScreen,
|
||||
children,
|
||||
app,
|
||||
defaultUIEnabled,
|
||||
zoomUIEnabled,
|
||||
scrollBackToContentUIEnabled,
|
||||
isCollaborating,
|
||||
generateLinkForSelection,
|
||||
currentUserControls,
|
||||
}: LayerUIProps) => {
|
||||
const editorInterface = useEditorInterface();
|
||||
const appProps = useAppProps();
|
||||
const stylesPanelMode = useStylesPanelMode();
|
||||
const isCompactStylesPanel = stylesPanelMode === "compact";
|
||||
const tunnels = useInitializeTunnels();
|
||||
|
||||
const spacing = isCompactStylesPanel
|
||||
? {
|
||||
menuTopGap: 4,
|
||||
toolbarColGap: 4,
|
||||
toolbarRowGap: 1,
|
||||
toolbarInnerRowGap: 0.5,
|
||||
islandPadding: 1,
|
||||
collabMarginLeft: 8,
|
||||
}
|
||||
: {
|
||||
menuTopGap: 6,
|
||||
toolbarColGap: 4,
|
||||
toolbarRowGap: 1,
|
||||
toolbarInnerRowGap: 1,
|
||||
islandPadding: 1,
|
||||
collabMarginLeft: 8,
|
||||
};
|
||||
|
||||
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<JSONExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
exportOpts={UIOptions.canvasActions.export}
|
||||
canvas={canvas}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderImageExportDialog = () => {
|
||||
if (
|
||||
!UIOptions.canvasActions.saveAsImage ||
|
||||
appState.openDialog?.name !== "imageExport"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportImage={onExportImage}
|
||||
onCloseRequest={() => setAppState({ openDialog: null })}
|
||||
name={app.getName()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCanvasActions = () => (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div className="excalidraw-ui-top-left">
|
||||
{renderTopLeftUI?.(false, appState)}
|
||||
<tunnels.MainMenuTunnel.Out />
|
||||
</div>
|
||||
{renderWelcomeScreen && <tunnels.WelcomeScreenMenuHintTunnel.Out />}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectedShapeActions = () => {
|
||||
return (
|
||||
<Section
|
||||
heading="selectedShapeActions"
|
||||
className={clsx("selected-shape-actions zen-mode-transition", {
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{isCompactStylesPanel ? (
|
||||
<Island
|
||||
className={clsx("compact-shape-actions-island")}
|
||||
padding={0}
|
||||
data-viewport-ui="side"
|
||||
data-viewport-ui-name="stylesPanel"
|
||||
style={{
|
||||
// we want to make sure this doesn't overflow so subtracting the
|
||||
// approximate height of hamburgerMenu + footer
|
||||
maxHeight: `${appState.height - 166}px`,
|
||||
}}
|
||||
>
|
||||
<CompactShapeActions
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Island>
|
||||
) : (
|
||||
<Island
|
||||
className={CLASSES.SHAPE_ACTIONS_MENU}
|
||||
padding={2}
|
||||
style={{
|
||||
// we want to make sure this doesn't overflow so subtracting the
|
||||
// approximate height of hamburgerMenu + footer
|
||||
maxHeight: `${appState.height - 166}px`,
|
||||
}}
|
||||
data-viewport-ui="side"
|
||||
data-viewport-ui-name="stylesPanel"
|
||||
>
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
/>
|
||||
</Island>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFixedSideContainer = () => {
|
||||
const shouldRenderSelectedShapeActions =
|
||||
defaultUIEnabled && showSelectedShapeActions(appState, elements);
|
||||
|
||||
const shouldShowStats =
|
||||
defaultUIEnabled &&
|
||||
appState.stats.open &&
|
||||
!appState.zenModeEnabled &&
|
||||
!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector";
|
||||
|
||||
return (
|
||||
<FixedSideContainer side="top">
|
||||
<div className="App-menu App-menu_top">
|
||||
<Stack.Col
|
||||
gap={spacing.menuTopGap}
|
||||
className={clsx("App-menu_top__left")}
|
||||
>
|
||||
{renderCanvasActions()}
|
||||
{defaultUIEnabled && (
|
||||
<div
|
||||
className={clsx("selected-shape-actions-container", {
|
||||
"selected-shape-actions-container--compact":
|
||||
isCompactStylesPanel,
|
||||
})}
|
||||
>
|
||||
{shouldRenderSelectedShapeActions &&
|
||||
renderSelectedShapeActions()}
|
||||
</div>
|
||||
)}
|
||||
{/* in compact UI the pen mode button lives outside the toolbar, as
|
||||
a separate floating button below the compact actions menu
|
||||
(same as we render it on mobile); shown alongside the compact
|
||||
actions island, i.e. when a drawing tool or elements are
|
||||
selected */}
|
||||
{defaultUIEnabled &&
|
||||
isCompactStylesPanel &&
|
||||
!appState.viewModeEnabled &&
|
||||
shouldRenderSelectedShapeActions && (
|
||||
<PenModeButton
|
||||
checked={appState.penMode}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
title={t("toolBar.penMode")}
|
||||
isMobile
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
)}
|
||||
</Stack.Col>
|
||||
{defaultUIEnabled &&
|
||||
!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||
<Section heading="shapes" className="shapes-section">
|
||||
{(heading: React.ReactNode) => (
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderWelcomeScreen && (
|
||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||
)}
|
||||
<Stack.Col gap={spacing.toolbarColGap} align="start">
|
||||
<Stack.Row
|
||||
gap={spacing.toolbarRowGap}
|
||||
className={clsx("App-toolbar-container", {
|
||||
"zen-mode": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Toolbar
|
||||
app={app}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
UIOptions={UIOptions}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
onLockToggle={onLockToggle}
|
||||
heading={heading}
|
||||
/>
|
||||
{isCollaborating && (
|
||||
<Island
|
||||
style={{
|
||||
marginLeft: spacing.collabMarginLeft,
|
||||
alignSelf: "center",
|
||||
height: "fit-content",
|
||||
}}
|
||||
>
|
||||
<LaserPointerButton
|
||||
title={t("toolBar.laser")}
|
||||
checked={
|
||||
appState.activeTool.type === TOOL_TYPE.laser
|
||||
}
|
||||
onChange={() =>
|
||||
app.setActiveTool({ type: TOOL_TYPE.laser })
|
||||
}
|
||||
isMobile
|
||||
/>
|
||||
</Island>
|
||||
)}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||
{
|
||||
"transition-right": appState.zenModeEnabled,
|
||||
"layer-ui__wrapper__top-right--compact": isCompactStylesPanel,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{defaultUIEnabled && appState.collaborators.size > 0 && (
|
||||
<UserList
|
||||
collaborators={appState.collaborators}
|
||||
userToFollow={appProps.userToFollow?.socketId || null}
|
||||
currentUserControls={currentUserControls}
|
||||
/>
|
||||
)}
|
||||
{renderTopRightUI?.(
|
||||
editorInterface.formFactor === "phone",
|
||||
appState,
|
||||
)}
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||
// hide button when sidebar docked
|
||||
(!isSidebarDocked ||
|
||||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
||||
<tunnels.DefaultSidebarTriggerTunnel.Out />
|
||||
)}
|
||||
{shouldShowStats && (
|
||||
<Stats
|
||||
app={app}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSidebars = () => {
|
||||
if (!defaultUIEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultSidebar
|
||||
__fallback
|
||||
onDock={(docked) => {
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`toggleDock (${docked ? "dock" : "undock"})`,
|
||||
`(${
|
||||
editorInterface.formFactor === "phone" ? "mobile" : "desktop"
|
||||
})`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
|
||||
const isSidebarDockedAndFits = !!(
|
||||
appState.openSidebar &&
|
||||
isSidebarDocked &&
|
||||
editorInterface.canFitSidebar
|
||||
);
|
||||
|
||||
const layerUIJSX = (
|
||||
<>
|
||||
{/* ------------------------- tunneled UI ---------------------------- */}
|
||||
{/* make sure we render host app components first so that we can detect
|
||||
them first on initial render to optimize layout shift */}
|
||||
{children}
|
||||
{/* Fallback entry points are the default UI. Host components above keep
|
||||
rendering into the outlets below even when defaults are disabled. */}
|
||||
{defaultUIEnabled && (
|
||||
<>
|
||||
<DefaultMainMenu UIOptions={UIOptions} />
|
||||
<DefaultSidebar.Trigger
|
||||
__fallback
|
||||
icon={sidebarRightIcon}
|
||||
title={capitalizeString(t("toolBar.library"))}
|
||||
onToggle={(open) => {
|
||||
if (open) {
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`${DEFAULT_SIDEBAR.name} (open)`,
|
||||
`button (${
|
||||
editorInterface.formFactor === "phone"
|
||||
? "mobile"
|
||||
: "desktop"
|
||||
})`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
tab={DEFAULT_SIDEBAR.defaultTab}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Keep supporting surfaces available to host-supplied UI, including
|
||||
MainMenu.DefaultItems. */}
|
||||
<DefaultOverwriteConfirmDialog />
|
||||
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
{defaultUIEnabled && appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{defaultUIEnabled && appState.errorMessage && (
|
||||
<ErrorDialog onClose={() => setAppState({ errorMessage: null })}>
|
||||
{appState.errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
{defaultUIEnabled &&
|
||||
eyeDropperState &&
|
||||
editorInterface.formFactor !== "phone" && (
|
||||
<EyeDropper
|
||||
colorPickerType={eyeDropperState.colorPickerType}
|
||||
onCancel={() => {
|
||||
setEyeDropperState(null);
|
||||
}}
|
||||
onChange={(
|
||||
colorPickerType,
|
||||
color,
|
||||
selectedElements,
|
||||
{ altKey },
|
||||
) => {
|
||||
if (
|
||||
colorPickerType !== "elementBackground" &&
|
||||
colorPickerType !== "elementStroke"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const property =
|
||||
altKey && eyeDropperState.swapPreviewOnAlt
|
||||
? colorPickerType === "elementBackground"
|
||||
? "strokeColor"
|
||||
: "backgroundColor"
|
||||
: colorPickerType === "elementBackground"
|
||||
? "backgroundColor"
|
||||
: "strokeColor";
|
||||
|
||||
if (selectedElements.length) {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
// a note's visible text is its label, so stroke picks
|
||||
// include bound labels
|
||||
const targets = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: property === "strokeColor",
|
||||
});
|
||||
for (const element of targets) {
|
||||
mutateElement(
|
||||
element,
|
||||
elementsMap,
|
||||
getColorUpdate(element, property, color, elementsMap),
|
||||
);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
app.scene.triggerUpdate();
|
||||
} else {
|
||||
setAppState(
|
||||
getColorTargetAppStateUpdates(
|
||||
resolveColorTarget(appState, elements, property),
|
||||
color,
|
||||
) as Pick<AppState, ColorDefaultKey>,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onSelect={(color, event) => {
|
||||
setEyeDropperState((state) => {
|
||||
return state?.keepOpenOnAlt && event.altKey ? state : null;
|
||||
});
|
||||
eyeDropperState?.onSelect?.(color, event);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appState.openDialog?.name === "help" && (
|
||||
<HelpDialog
|
||||
onClose={() => {
|
||||
setAppState({ openDialog: null });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ActiveConfirmDialog />
|
||||
{defaultUIEnabled && appState.openDialog?.name === "elementLinkSelector" && (
|
||||
<ElementLinkDialog
|
||||
sourceElementId={appState.openDialog.sourceElementId}
|
||||
onClose={() => {
|
||||
setAppState({
|
||||
openDialog: null,
|
||||
});
|
||||
}}
|
||||
scene={app.scene}
|
||||
appState={appState}
|
||||
generateLinkForSelection={generateLinkForSelection}
|
||||
/>
|
||||
)}
|
||||
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
||||
{renderImageExportDialog()}
|
||||
{renderJSONExportDialog()}
|
||||
{defaultUIEnabled && appState.openDialog?.name === "charts" && (
|
||||
<PasteChartDialog
|
||||
data={appState.openDialog.data}
|
||||
rawText={appState.openDialog.rawText}
|
||||
onClose={() =>
|
||||
setAppState({
|
||||
openDialog: null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{editorInterface.formFactor === "phone" && (
|
||||
<MobileMenu
|
||||
app={app}
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
renderTopLeftUI={renderTopLeftUI}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderSidebars={renderSidebars}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
defaultUIEnabled={defaultUIEnabled}
|
||||
scrollBackToContentUIEnabled={scrollBackToContentUIEnabled}
|
||||
/>
|
||||
)}
|
||||
{editorInterface.formFactor !== "phone" && (
|
||||
<>
|
||||
{appProps.viewportStatusFrame?.border && (
|
||||
<ViewportStatusBorder
|
||||
border={appProps.viewportStatusFrame.border}
|
||||
style={
|
||||
isSidebarDockedAndFits
|
||||
? {
|
||||
// flush against the sidebar's own visible edge, not
|
||||
// just the --right-sidebar-width column it reserves
|
||||
// (which includes the sidebar's own outer margin)
|
||||
right: `calc(var(--right-sidebar-width) - var(--space-factor) * 2)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="layer-ui__wrapper"
|
||||
style={
|
||||
isSidebarDockedAndFits
|
||||
? { width: `calc(100% - var(--right-sidebar-width))` }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
|
||||
{renderFixedSideContainer()}
|
||||
<Footer
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
showExitZenModeBtn={showExitZenModeBtn}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
defaultUIEnabled={defaultUIEnabled}
|
||||
zoomUIEnabled={zoomUIEnabled}
|
||||
/>
|
||||
{(appState.toast ||
|
||||
(scrollBackToContentUIEnabled && appState.scrolledOutside) ||
|
||||
appProps.viewportStatusFrame?.label) && (
|
||||
<div className="floating-status-stack">
|
||||
{appState.toast && (
|
||||
<Toast
|
||||
message={appState.toast.message}
|
||||
onClose={() => setAppState({ toast: null })}
|
||||
duration={appState.toast.duration}
|
||||
closable={appState.toast.closable}
|
||||
/>
|
||||
)}
|
||||
{!appState.toast &&
|
||||
scrollBackToContentUIEnabled &&
|
||||
appState.scrolledOutside && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...getScrollToContentState(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
{appProps.viewportStatusFrame?.label && (
|
||||
<ViewportStatusBadge
|
||||
label={appProps.viewportStatusFrame.label}
|
||||
border={appProps.viewportStatusFrame.border}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderSidebars()}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<UIAppStateContext.Provider value={appState}>
|
||||
<TunnelsJotaiProvider>
|
||||
<TunnelsContext.Provider value={tunnels}>
|
||||
{layerUIJSX}
|
||||
</TunnelsContext.Provider>
|
||||
</TunnelsJotaiProvider>
|
||||
</UIAppStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
|
||||
const {
|
||||
cursorButton,
|
||||
scrollX,
|
||||
scrollY,
|
||||
zoom,
|
||||
shouldCacheIgnoreZoom,
|
||||
snapLines,
|
||||
originSnapOffset,
|
||||
suggestedBinding,
|
||||
frameToHighlight,
|
||||
elementsToHighlight,
|
||||
...ret
|
||||
} = appState;
|
||||
return ret;
|
||||
};
|
||||
|
||||
const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
|
||||
// short-circuit early
|
||||
if (prevProps.children !== nextProps.children) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { canvas: _pC, appState: prevAppState, ...prev } = prevProps;
|
||||
const { canvas: _nC, appState: nextAppState, ...next } = nextProps;
|
||||
|
||||
return (
|
||||
isShallowEqual(
|
||||
// asserting AppState because we're being passed the whole AppState
|
||||
// but resolve to only the UI-relevant props
|
||||
stripIrrelevantAppStateProps(prevAppState as AppState),
|
||||
stripIrrelevantAppStateProps(nextAppState as AppState),
|
||||
{
|
||||
selectedElementIds: isShallowEqual,
|
||||
selectedGroupIds: isShallowEqual,
|
||||
},
|
||||
) && isShallowEqual(prev, next)
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(LayerUI, areEqual);
|
||||
146
packages/excalidraw/components/LibraryMenu.scss
Normal file
146
packages/excalidraw/components/LibraryMenu.scss
Normal file
@@ -0,0 +1,146 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.library-actions-counter {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-light);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
position: absolute;
|
||||
bottom: -0.25rem;
|
||||
right: -0.25rem;
|
||||
font-size: 0.625rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer-ui__library-message {
|
||||
padding: 2rem;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-library-success {
|
||||
.Dialog__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&-close.ToolIcon_type_button {
|
||||
background-color: $color-blue-6;
|
||||
align-self: flex-end;
|
||||
&:hover {
|
||||
background-color: $color-blue-8;
|
||||
}
|
||||
.ToolIcon__icon {
|
||||
width: auto;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-control-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.625rem;
|
||||
position: relative;
|
||||
|
||||
&--at-bottom::before {
|
||||
content: "";
|
||||
width: calc(100% - 1.5rem);
|
||||
height: 1px;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
background: var(--sidebar-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-browse-button {
|
||||
flex: 1;
|
||||
|
||||
height: var(--lg-button-size);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--color-primary);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-decoration: none !important;
|
||||
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-hover);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--color-brand-active);
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.library-menu-browse-button {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
}
|
||||
|
||||
&.excalidraw--mobile .library-menu-browse-button {
|
||||
height: var(--default-button-size);
|
||||
}
|
||||
|
||||
.layer-ui__library .dropdown-menu {
|
||||
width: auto;
|
||||
top: initial;
|
||||
right: 0;
|
||||
left: initial;
|
||||
bottom: 100%;
|
||||
margin-bottom: 0.625rem;
|
||||
|
||||
.dropdown-menu-container {
|
||||
width: 196px;
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
--box-shadow: var(--library-dropdown-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__library .library-menu-dropdown-container {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
&--in-heading {
|
||||
margin-left: auto;
|
||||
.dropdown-menu {
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
349
packages/excalidraw/components/LibraryMenu.tsx
Normal file
349
packages/excalidraw/components/LibraryMenu.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
memo,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
LIBRARY_DISABLED_TYPES,
|
||||
randomId,
|
||||
isShallowEqual,
|
||||
KEYS,
|
||||
isWritableElement,
|
||||
addEventListener,
|
||||
EVENT,
|
||||
CLASSES,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
libraryItemsAtom,
|
||||
} from "../data/library";
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import {
|
||||
useApp,
|
||||
useAppProps,
|
||||
useExcalidrawElements,
|
||||
useExcalidrawSetAppState,
|
||||
} from "./App";
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
import "./LibraryMenu.scss";
|
||||
|
||||
import type {
|
||||
LibraryItems,
|
||||
LibraryItem,
|
||||
ExcalidrawProps,
|
||||
UIAppState,
|
||||
AppClassProperties,
|
||||
} from "../types";
|
||||
import type Library from "../data/library";
|
||||
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
|
||||
const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div className="layer-ui__library">{children}</div>;
|
||||
};
|
||||
|
||||
const LibraryMenuContent = memo(
|
||||
({
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
setAppState,
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
id,
|
||||
theme,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: () => void;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
theme: UIAppState["theme"];
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) => {
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||
|
||||
const _onAddToLibrary = useCallback(
|
||||
(elements: LibraryItem["elements"]) => {
|
||||
const addToLibrary = async (
|
||||
processedElements: LibraryItem["elements"],
|
||||
libraryItems: LibraryItems,
|
||||
) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (processedElements.some((element) => element.type === type)) {
|
||||
return setAppState({
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
const nextItems: LibraryItems = [
|
||||
{
|
||||
status: "unpublished",
|
||||
elements: processedElements,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
},
|
||||
...libraryItems,
|
||||
];
|
||||
onAddToLibrary();
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
};
|
||||
addToLibrary(elements, libraryItemsData.libraryItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
|
||||
);
|
||||
|
||||
const libraryItems = useMemo(
|
||||
() => libraryItemsData.libraryItems,
|
||||
[libraryItemsData],
|
||||
);
|
||||
|
||||
if (
|
||||
libraryItemsData.status === "loading" &&
|
||||
!libraryItemsData.isInitialized
|
||||
) {
|
||||
return (
|
||||
<LibraryMenuWrapper>
|
||||
<div className="layer-ui__library-message">
|
||||
<div>
|
||||
<Spinner size="2em" />
|
||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const showBtn =
|
||||
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
|
||||
|
||||
return (
|
||||
<LibraryMenuWrapper>
|
||||
<LibraryMenuItems
|
||||
isLoading={libraryItemsData.status === "loading"}
|
||||
libraryItems={libraryItems}
|
||||
onAddToLibrary={_onAddToLibrary}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
pendingElements={pendingElements}
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={theme}
|
||||
onSelectItems={onSelectItems}
|
||||
selectedItems={selectedItems}
|
||||
/>
|
||||
{showBtn && (
|
||||
<LibraryMenuControlButtons
|
||||
className="library-menu-control-buttons--at-bottom"
|
||||
style={{ padding: "16px 12px 0 12px" }}
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const getPendingElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
selectedElementIds: UIAppState["selectedElementIds"],
|
||||
) => ({
|
||||
elements,
|
||||
pending: getSelectedElements(
|
||||
elements,
|
||||
{ selectedElementIds },
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
),
|
||||
selectedElementIds,
|
||||
});
|
||||
|
||||
const usePendingElementsMemo = (
|
||||
appState: UIAppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const elements = useExcalidrawElements();
|
||||
const [state, setState] = useState(() =>
|
||||
getPendingElements(elements, appState.selectedElementIds),
|
||||
);
|
||||
|
||||
const selectedElementVersions = useRef(
|
||||
new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
for (const element of state.pending) {
|
||||
selectedElementVersions.current.set(element.id, element.version);
|
||||
}
|
||||
}, [state.pending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
// Only update once pointer is released.
|
||||
// Reading directly from app.state to make it clear it's not reactive
|
||||
// (hence, there's potential for stale state)
|
||||
app.state.cursorButton === "up" &&
|
||||
app.state.activeTool.type === "selection"
|
||||
) {
|
||||
setState((prev) => {
|
||||
// if selectedElementIds changed, we don't have to compare versions
|
||||
// ---------------------------------------------------------------------
|
||||
if (
|
||||
!isShallowEqual(prev.selectedElementIds, appState.selectedElementIds)
|
||||
) {
|
||||
selectedElementVersions.current.clear();
|
||||
return getPendingElements(elements, appState.selectedElementIds);
|
||||
}
|
||||
// otherwise we need to check whether selected elements changed
|
||||
// ---------------------------------------------------------------------
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
for (const id of Object.keys(appState.selectedElementIds)) {
|
||||
const currVersion = elementsMap.get(id)?.version;
|
||||
if (
|
||||
currVersion &&
|
||||
currVersion !== selectedElementVersions.current.get(id)
|
||||
) {
|
||||
// we can't update the selectedElementVersions in here
|
||||
// because of double render in StrictMode which would overwrite
|
||||
// the state in the second pass with the old `prev` state.
|
||||
// Thus, we update versions in a separate effect. May create
|
||||
// a race condition since current effect is not fully reactive.
|
||||
return getPendingElements(elements, appState.selectedElementIds);
|
||||
}
|
||||
}
|
||||
// nothing changed
|
||||
// ---------------------------------------------------------------------
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [
|
||||
app,
|
||||
app.state.cursorButton,
|
||||
app.state.activeTool.type,
|
||||
appState.selectedElementIds,
|
||||
elements,
|
||||
]);
|
||||
|
||||
return state.pending;
|
||||
};
|
||||
|
||||
/**
|
||||
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
|
||||
* <DefaultSidebar/> or host apps Sidebar components.
|
||||
*/
|
||||
export const LibraryMenu = memo(() => {
|
||||
const app = useApp();
|
||||
const { onInsertElements } = app;
|
||||
const appProps = useAppProps();
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const memoizedLibrary = useMemo(() => app.library, [app.library]);
|
||||
const pendingElements = usePendingElementsMemo(appState, app);
|
||||
|
||||
useEffect(() => {
|
||||
const ownerDocument = app.ownerDocument;
|
||||
const ownerWindow = app.ownerWindow;
|
||||
return addEventListener(
|
||||
ownerDocument,
|
||||
EVENT.KEYDOWN,
|
||||
(event) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
event.target instanceof ownerWindow.HTMLElement
|
||||
) {
|
||||
const target = event.target;
|
||||
if (target.closest(`.${CLASSES.SIDEBAR}`)) {
|
||||
// stop propagation so that we don't prevent it downstream
|
||||
// (default browser behavior is to clear search input on ESC)
|
||||
if (selectedItems.length > 0) {
|
||||
event.stopPropagation();
|
||||
setSelectedItems([]);
|
||||
} else if (
|
||||
target instanceof ownerWindow.HTMLInputElement &&
|
||||
isWritableElement(target) &&
|
||||
!target.value
|
||||
) {
|
||||
event.stopPropagation();
|
||||
// if search input empty -> close library
|
||||
// (maybe not a good idea?)
|
||||
setAppState({ openSidebar: null });
|
||||
app.focusContainer();
|
||||
}
|
||||
} else if (selectedItems.length > 0) {
|
||||
const { x, y } = app.viewport.lastPosition;
|
||||
const elementUnderCursor = ownerDocument.elementFromPoint(x, y);
|
||||
// also deselect elements if sidebar doesn't have focus but the
|
||||
// cursor is over it
|
||||
if (elementUnderCursor?.closest(`.${CLASSES.SIDEBAR}`)) {
|
||||
event.stopPropagation();
|
||||
setSelectedItems([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
}, [selectedItems, setAppState, app]);
|
||||
|
||||
const onInsertLibraryItems = useCallback(
|
||||
(libraryItems: LibraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
app.focusContainer();
|
||||
},
|
||||
[onInsertElements, app],
|
||||
);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
setAppState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}, [setAppState]);
|
||||
|
||||
return (
|
||||
<LibraryMenuContent
|
||||
pendingElements={pendingElements}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={appProps.libraryReturnUrl}
|
||||
library={memoizedLibrary}
|
||||
id={app.id}
|
||||
theme={appState.theme}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
/>
|
||||
);
|
||||
});
|
||||
33
packages/excalidraw/components/LibraryMenuBrowseButton.tsx
Normal file
33
packages/excalidraw/components/LibraryMenuBrowseButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { VERSIONS } from "@excalidraw/common";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import type { ExcalidrawProps, UIAppState } from "../types";
|
||||
|
||||
const LibraryMenuBrowseButton = ({
|
||||
theme,
|
||||
id,
|
||||
libraryReturnUrl,
|
||||
}: {
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
theme: UIAppState["theme"];
|
||||
id: string;
|
||||
}) => {
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
return (
|
||||
<a
|
||||
className="library-menu-browse-button"
|
||||
href={`${import.meta.env.VITE_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryMenuBrowseButton;
|
||||
35
packages/excalidraw/components/LibraryMenuControlButtons.tsx
Normal file
35
packages/excalidraw/components/LibraryMenuControlButtons.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
|
||||
|
||||
import type { ExcalidrawProps, UIAppState } from "../types";
|
||||
|
||||
export const LibraryMenuControlButtons = ({
|
||||
libraryReturnUrl,
|
||||
theme,
|
||||
id,
|
||||
style,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
theme: UIAppState["theme"];
|
||||
id: string;
|
||||
style: React.CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("library-menu-control-buttons", className)}
|
||||
style={style}
|
||||
>
|
||||
<LibraryMenuBrowseButton
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={theme}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user