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

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

2
packages/excalidraw/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
types

View File

@@ -0,0 +1,16 @@
[
{
"path": "dist/excalidraw.production.min.js",
"limit": "340 kB"
},
{
"path": "dist/excalidraw-assets/locales",
"name": "dist/excalidraw-assets/locales",
"limit": "290 kB"
},
{
"path": "dist/excalidraw-assets/vendor-*.js",
"name": "dist/excalidraw-assets/vendor*.js",
"limit": "900 kB"
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
# Excalidraw
**Excalidraw** is exported as a React component that you can embed directly in your app.
## Installation
Install the package together with its React peer dependencies.
```bash
npm install react react-dom @excalidraw/excalidraw
# or
yarn add react react-dom @excalidraw/excalidraw
```
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
## Quick start
The minimum working setup has two easy-to-miss requirements:
1. Import the package CSS:
```ts
import "@excalidraw/excalidraw/index.css";
```
2. Render Excalidraw inside a container with a non-zero height.
```tsx
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function App() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
## Next.js / SSR frameworks
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
```tsx
// app/components/ExcalidrawClient.tsx
"use client";
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function ExcalidrawClient() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
```tsx
// app/page.tsx
import dynamic from "next/dynamic";
const ExcalidrawClient = dynamic(
() => import("./components/ExcalidrawClient"),
{ ssr: false },
);
export default function Page() {
return <ExcalidrawClient />;
}
```
See the local examples for complete setups:
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
## LLM / agent tips
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
## Migrating to `@excalidraw/excalidraw@0.18.x`
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
| Old path | New path |
| --- | --- |
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
For example:
```ts
import { exportToSvg } from "@excalidraw/excalidraw";
```
## Self-hosting fonts
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
```html
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
</script>
```
## Demo
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
## Integration
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).

View File

@@ -0,0 +1,65 @@
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
import { deepCopyElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { t } from "../i18n";
import { register } from "./register";
export const actionAddToLibrary = register({
name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) {
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: {
...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`),
},
};
}
}
return app.library
.getLatestLibrary()
.then((items) => {
return app.library.setLibrary([
{
id: randomId(),
status: "unpublished",
elements: selectedElements.map(deepCopyElement),
created: Date.now(),
},
...items,
]);
})
.then(() => {
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: {
...appState,
toast: { message: t("toast.addedToLibrary") },
},
};
})
.catch((error) => {
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: {
...appState,
errorMessage: error.message,
},
};
});
},
label: "labels.addToLibrary",
});

View File

@@ -0,0 +1,273 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { KEYS, arrayToMap } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElementsByGroup } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element";
import { IconButton } from "../components/IconButton";
import {
AlignBottomIcon,
AlignLeftIcon,
AlignRightIcon,
AlignTopIcon,
CenterHorizontallyIcon,
CenterVerticallyIcon,
} from "../components/icons";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register";
import type { AppClassProperties, AppState, UIAppState } from "../types";
export const alignActionsPredicate = (
appState: UIAppState,
app: AppClassProperties,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
getSelectedElementsByGroup(
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 1 &&
// TODO enable aligning frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el))
);
};
const alignSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
alignment: Alignment,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = alignElements(
selectedElements,
alignment,
app.scene,
appState,
);
const updatedElementsMap = arrayToMap(updatedElements);
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
);
};
export const actionAlignTop = register({
name: "alignTop",
label: "labels.alignTop",
icon: AlignTopIcon,
trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
position: "start",
axis: "y",
}),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData, app }) => (
<IconButton
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={AlignTopIcon}
onClick={() => updateData(null)}
title={`${t("labels.alignTop")} — ${getShortcutKey(
"CtrlOrCmd+Shift+Up",
)}`}
aria-label={t("labels.alignTop")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const actionAlignBottom = register({
name: "alignBottom",
label: "labels.alignBottom",
icon: AlignBottomIcon,
trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
position: "end",
axis: "y",
}),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData, app }) => (
<IconButton
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={AlignBottomIcon}
onClick={() => updateData(null)}
title={`${t("labels.alignBottom")} — ${getShortcutKey(
"CtrlOrCmd+Shift+Down",
)}`}
aria-label={t("labels.alignBottom")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const actionAlignLeft = register({
name: "alignLeft",
label: "labels.alignLeft",
icon: AlignLeftIcon,
trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
position: "start",
axis: "x",
}),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData, app }) => (
<IconButton
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={AlignLeftIcon}
onClick={() => updateData(null)}
title={`${t("labels.alignLeft")} — ${getShortcutKey(
"CtrlOrCmd+Shift+Left",
)}`}
aria-label={t("labels.alignLeft")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const actionAlignRight = register({
name: "alignRight",
label: "labels.alignRight",
icon: AlignRightIcon,
trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
position: "end",
axis: "x",
}),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData, app }) => (
<IconButton
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={AlignRightIcon}
onClick={() => updateData(null)}
title={`${t("labels.alignRight")} — ${getShortcutKey(
"CtrlOrCmd+Shift+Right",
)}`}
aria-label={t("labels.alignRight")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
label: "labels.centerVertically",
icon: CenterVerticallyIcon,
trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
position: "center",
axis: "y",
}),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
<IconButton
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={CenterVerticallyIcon}
onClick={() => updateData(null)}
title={t("labels.centerVertically")}
aria-label={t("labels.centerVertically")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
label: "labels.centerHorizontally",
icon: CenterHorizontallyIcon,
trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => {
return {
appState,
elements: alignSelectedElements(elements, appState, app, {
position: "center",
axis: "x",
}),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
<IconButton
hidden={!alignActionsPredicate(appState, app)}
type="button"
icon={CenterHorizontallyIcon}
onClick={() => updateData(null)}
title={t("labels.centerHorizontally")}
aria-label={t("labels.centerHorizontally")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});

View File

@@ -0,0 +1,377 @@
import {
BOUND_TEXT_PADDING,
ROUNDNESS,
TEXT_ALIGN,
VERTICAL_ALIGN,
arrayToMap,
getFontString,
getStrokeWidthByKey,
isTransparent,
} from "@excalidraw/common";
import {
getOriginalContainerHeightFromCache,
isBoundToContainer,
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "@excalidraw/element";
import {
DEFAULT_BOUND_TEXT_LABEL_POSITION,
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getBoundTextElement,
normalizeStickyNoteFontSize,
redrawTextBoundingBox,
updateStickyNoteLayout,
} from "@excalidraw/element";
import {
hasBoundTextElement,
isArrowElement,
isStickyNoteElement,
isTextBindableContainer,
isTextElement,
isUsingAdaptiveRadius,
} from "@excalidraw/element";
import { measureText } from "@excalidraw/element";
import { syncMovedIndices } from "@excalidraw/element";
import { newElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "@excalidraw/element/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { register } from "./register";
import type { AppState } from "../types";
export const actionUnbindText = register({
name: "unbindText",
label: "labels.unbindText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return selectedElements.some((element) => hasBoundTextElement(element));
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
const elementsMap = app.scene.getNonDeletedElementsMap();
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { width, height } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
);
resetOriginalContainerCache(element.id);
const { x, y } = computeBoundTextPosition(
element,
boundTextElement,
elementsMap,
);
app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
text: boundTextElement.originalText,
baseFontSize: null,
x,
y,
labelPosition: null,
});
app.scene.mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
});
if (isStickyNoteElement(element)) {
// an empty note sits at its base height; bound arrows follow
updateStickyNoteLayout(element, app.scene);
} else {
app.scene.mutateElement(element, {
height: originalContainerHeight
? originalContainerHeight
: element.height,
});
}
}
});
return {
elements,
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
});
export const actionBindText = register({
name: "bindText",
label: "labels.bindText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.length === 2) {
const textElement =
isTextElement(selectedElements[0]) ||
isTextElement(selectedElements[1]);
let bindingContainer;
if (isTextBindableContainer(selectedElements[0])) {
bindingContainer = selectedElements[0];
} else if (isTextBindableContainer(selectedElements[1])) {
bindingContainer = selectedElements[1];
}
if (
textElement &&
bindingContainer &&
getBoundTextElement(
bindingContainer,
app.scene.getNonDeletedElementsMap(),
) === null
) {
return true;
}
}
return false;
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
let textElement: ExcalidrawTextElement;
let container: ExcalidrawTextContainer;
if (
isTextElement(selectedElements[0]) &&
isTextBindableContainer(selectedElements[1])
) {
textElement = selectedElements[0];
container = selectedElements[1];
} else {
textElement = selectedElements[1] as ExcalidrawTextElement;
container = selectedElements[0] as ExcalidrawTextContainer;
}
// a note and its label share one ink: the text the user styled wins,
// unless it is transparent (a note's label never is)
const stickyInk = isStickyNoteElement(container)
? isTransparent(textElement.strokeColor)
? container.strokeColor
: textElement.strokeColor
: null;
app.scene.mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
labelPosition: isArrowElement(container)
? DEFAULT_BOUND_TEXT_LABEL_POSITION
: null,
...(stickyInk
? {
baseFontSize: normalizeStickyNoteFontSize(
textElement.baseFontSize ?? textElement.fontSize,
),
strokeColor: stickyInk,
}
: null),
});
app.scene.mutateElement(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
// the footer paints with the note's ink
...(stickyInk ? { strokeColor: stickyInk } : null),
});
const originalContainerHeight = container.height;
redrawTextBoundingBox(textElement, container, app.scene);
// overwritting the cache with original container height so
// it can be restored when unbind
updateOriginalContainerCache(container.id, originalContainerHeight);
return {
elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
});
const pushTextAboveContainer = (
elements: readonly ExcalidrawElement[],
container: ExcalidrawElement,
textElement: ExcalidrawTextElement,
) => {
const updatedElements = elements.slice();
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
return updatedElements;
};
const pushContainerBelowText = (
elements: readonly ExcalidrawElement[],
container: ExcalidrawElement,
textElement: ExcalidrawTextElement,
) => {
const updatedElements = elements.slice();
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex, 1);
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 0, container);
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
return updatedElements;
};
export const actionWrapTextInContainer = register({
name: "wrapTextInContainer",
label: "labels.createContainerFromText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
const someTextElements = selectedElements.some(
(el) => isTextElement(el) && !isBoundToContainer(el),
);
return selectedElements.length > 0 && someTextElements;
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
for (const textElement of selectedElements) {
if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
const container = newElement({
type: "rectangle",
backgroundColor: appState.currentItemBackgroundColor,
boundElements: [
...(textElement.boundElements || []),
{ id: textElement.id, type: "text" },
],
angle: textElement.angle,
fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor,
roughness: appState.currentItemRoughness,
strokeWidth: getStrokeWidthByKey(
"rectangle",
appState.currentItemStrokeWidthKey,
),
strokeStyle: appState.currentItemStrokeStyle,
roundness:
appState.currentItemRoundness === "round"
? {
type: isUsingAdaptiveRadius("rectangle")
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
opacity: 100,
locked: false,
x: textElement.x - BOUND_TEXT_PADDING,
y: textElement.y - BOUND_TEXT_PADDING,
width: computeContainerDimensionForBoundText(
textElement.width,
"rectangle",
),
height: computeContainerDimensionForBoundText(
textElement.height,
"rectangle",
),
groupIds: textElement.groupIds,
frameId: textElement.frameId,
});
// update bindings
if (textElement.boundElements?.length) {
const linearElementIds = textElement.boundElements
.filter((ele) => ele.type === "arrow")
.map((el) => el.id);
const linearElements = updatedElements.filter((ele) =>
linearElementIds.includes(ele.id),
) as ExcalidrawLinearElement[];
linearElements.forEach((ele) => {
let startBinding = ele.startBinding;
let endBinding = ele.endBinding;
if (startBinding?.elementId === textElement.id) {
startBinding = {
...startBinding,
elementId: container.id,
};
}
if (endBinding?.elementId === textElement.id) {
endBinding = { ...endBinding, elementId: container.id };
}
if (startBinding || endBinding) {
app.scene.mutateElement(ele, {
startBinding,
endBinding,
});
}
});
}
app.scene.mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
});
redrawTextBoundingBox(textElement, container, app.scene);
updatedElements = pushContainerBelowText(
[...updatedElements, container],
container,
textElement,
);
containerIds[container.id] = true;
}
}
return {
elements: updatedElements,
appState: {
...appState,
selectedElementIds: containerIds,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
});

View File

@@ -0,0 +1,450 @@
import {
DEFAULT_CANVAS_BACKGROUND_PICKS,
MAX_ZOOM,
MIN_ZOOM,
THEME,
ZOOM_STEP,
CODES,
KEYS,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { getCommonBounds } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { Bounds } from "@excalidraw/common";
import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconButton } from "../components/IconButton";
import { Tooltip } from "../components/Tooltip";
import {
MoonIcon,
SunIcon,
TrashIcon,
zoomAreaIcon,
ZoomInIcon,
ZoomOutIcon,
ZoomResetIcon,
} from "../components/icons";
import { useAppStateValue } from "../hooks/useAppStateValue";
import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
import {
constrainScrollState,
getViewportForZoomWithScrollConstraints,
zoomToFitBounds,
} from "../viewport";
import { getShortcutKey } from "../shortcut";
import { register } from "./register";
import type { AppState } from "../types";
export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
name: "changeViewBackgroundColor",
label: "labels.canvasBackground",
trackEvent: false,
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.changeViewBackgroundColor &&
!appState.viewModeEnabled
);
},
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
captureUpdate: !!value?.viewBackgroundColor
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
return (
<ColorPicker
palette={null}
topPicks={DEFAULT_CANVAS_BACKGROUND_PICKS}
label={t("labels.canvasBackground")}
type="canvasBackground"
color={appState.viewBackgroundColor}
onChange={(color) => updateData({ viewBackgroundColor: color })}
data-testid="canvas-background-picker"
elements={elements}
appState={appState}
updateData={updateData}
/>
);
},
});
export const actionClearCanvas = register({
name: "clearCanvas",
label: "labels.clearCanvas",
icon: TrashIcon,
trackEvent: { category: "canvas" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.clearCanvas &&
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector"
);
},
perform: (elements, appState, _, app) => {
app.imageCache.clear();
return {
elements: elements.map((element) =>
newElementWith(element, { isDeleted: true }),
),
appState: {
...getDefaultAppState(),
files: {},
theme: appState.theme,
penMode: appState.penMode,
penDetected: appState.penDetected,
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
stats: appState.stats,
colorTopPicks: appState.colorTopPicks,
activeTool:
appState.activeTool.type === "image"
? {
...appState.activeTool,
type: app.state.preferredSelectionTool.type,
}
: appState.activeTool,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
});
export const actionZoomIn = register({
name: "zoomIn",
label: "buttons.zoomIn",
viewMode: true,
navigation: true,
icon: ZoomInIcon,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (_elements, appState, _, app) => {
app.requestUnfollow();
const nextState = {
...appState,
...getViewportForZoomWithScrollConstraints(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
},
appState,
),
};
return {
appState: nextState,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ updateData }) => {
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
return (
<IconButton
type="button"
className="zoom-in-button zoom-button"
icon={ZoomInIcon}
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")}
disabled={zoomValue >= MAX_ZOOM}
onClick={() => {
updateData(null);
}}
/>
);
},
keyTest: (event) =>
(event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
export const actionZoomOut = register({
name: "zoomOut",
label: "buttons.zoomOut",
icon: ZoomOutIcon,
viewMode: true,
navigation: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (_elements, appState, _, app) => {
app.requestUnfollow();
const nextState = {
...appState,
...getViewportForZoomWithScrollConstraints(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
},
appState,
),
};
return {
appState: nextState,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ updateData }) => {
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
return (
<IconButton
type="button"
className="zoom-out-button zoom-button"
icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")}
disabled={zoomValue <= MIN_ZOOM}
onClick={() => {
updateData(null);
}}
/>
);
},
keyTest: (event) =>
(event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
export const actionResetZoom = register({
name: "resetZoom",
label: "buttons.resetZoom",
icon: ZoomResetIcon,
viewMode: true,
navigation: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (_elements, appState, _, app) => {
app.requestUnfollow();
// reset to 100%, unless a zoom lock floors the zoom higher — then reset to
// the locked minimum zoom (the lock's resting zoom level)
const nextZoom = appState.scrollConstraints?.lockZoom
? appState.scrollConstraints.zoom
: 1;
const nextState = {
...appState,
...getViewportForZoomWithScrollConstraints(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(nextZoom),
},
appState,
),
};
return {
appState: nextState,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ updateData }) => {
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
return (
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
<IconButton
type="button"
className="reset-zoom-button zoom-button"
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
onClick={() => {
updateData(null);
}}
>
{(zoomValue * 100).toFixed(0)}%
</IconButton>
</Tooltip>
);
},
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
// under a viewport lock, zoom-to-fit targets the locked box rather than the
// scene elements
const getScrollConstraintsBounds = (
scrollConstraints: NonNullable<AppState["scrollConstraints"]>,
) => {
return [
scrollConstraints.x,
scrollConstraints.y,
scrollConstraints.x + scrollConstraints.width,
scrollConstraints.y + scrollConstraints.height,
] as Bounds;
};
// Note, this action differs from actionZoomToFitSelection in that it doesn't
// zoom beyond 100%. In other words, if the content is smaller than viewport
// size, it won't be zoomed in.
export const actionZoomToFitSelectionInViewport = register({
name: "zoomToFitSelectionInViewport",
label: "labels.zoomToFitViewport",
icon: zoomAreaIcon,
// with no selection (as is always the case in view mode & when
// non-interactive), fits all elements, or the locked viewport box when
// a viewport lock is active
viewMode: true,
navigation: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (elements, appState, _, app) => {
app.requestUnfollow();
const selectedElements = app.scene.getSelectedElements(appState);
const bounds = selectedElements.length
? getCommonBounds(getNonDeletedElements(selectedElements))
: appState.scrollConstraints
? getScrollConstraintsBounds(appState.scrollConstraints)
: getCommonBounds(getNonDeletedElements(elements));
const result = zoomToFitBounds({
bounds,
appState,
fit: "scale-down",
canvasOffsets: app.viewport.getOffsets(),
});
return {
...result,
// re-clamp so the fit can't escape an active scroll/zoom lock
appState: {
...result.appState,
...constrainScrollState(result.appState),
},
};
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFitSelection = register({
name: "zoomToFitSelection",
label: "helpDialog.zoomToSelection",
icon: zoomAreaIcon,
// with no selection (as is always the case in view mode & when
// non-interactive), fits all elements, or the locked viewport box when
// a viewport lock is active
viewMode: true,
navigation: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (elements, appState, _, app) => {
app.requestUnfollow();
const selectedElements = app.scene.getSelectedElements(appState);
const bounds = selectedElements.length
? getCommonBounds(getNonDeletedElements(selectedElements))
: appState.scrollConstraints
? getScrollConstraintsBounds(appState.scrollConstraints)
: getCommonBounds(getNonDeletedElements(elements));
const result = zoomToFitBounds({
bounds,
appState,
fit: "contain",
canvasOffsets: app.viewport.getOffsets(),
});
return {
...result,
// re-clamp so the fit can't escape an active scroll/zoom lock
appState: {
...result.appState,
...constrainScrollState(result.appState),
},
};
},
// NOTE this action should use shift-2 per figma, alas
keyTest: (event) =>
event.code === CODES.THREE &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFit = register({
name: "zoomToFit",
label: "helpDialog.zoomToFit",
icon: zoomAreaIcon,
viewMode: true,
navigation: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
perform: (elements, appState, _, app) => {
app.requestUnfollow();
// under a viewport lock, fits the locked box rather than the elements
const bounds = appState.scrollConstraints
? getScrollConstraintsBounds(appState.scrollConstraints)
: getCommonBounds(getNonDeletedElements(elements));
const result = zoomToFitBounds({
bounds,
appState,
fit: "scale-down",
canvasOffsets: app.viewport.getOffsets(),
});
return {
...result,
// re-clamp so the fit can't escape an active scroll/zoom lock
appState: {
...result.appState,
...constrainScrollState(result.appState),
},
};
},
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionToggleTheme = register<AppState["theme"]>({
name: "toggleTheme",
label: (_, appState) => {
return appState.theme === THEME.DARK
? "buttons.lightMode"
: "buttons.darkMode";
},
keywords: ["toggle", "dark", "light", "mode", "theme"],
icon: (appState, elements) =>
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_, appState, value, app) => {
const nextTheme =
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
if (app.props.onThemeChange) {
app.props.onThemeChange(nextTheme);
return false;
}
return {
appState: {
...appState,
theme: nextTheme,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D,
predicate: (elements, appState, props, app) => {
return !!app.props.UIOptions.canvasActions.toggleTheme;
},
});

View File

@@ -0,0 +1,285 @@
import { isTextElement } from "@excalidraw/element";
import { getTextFromElements } from "@excalidraw/element";
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import {
copyTextToSystemClipboard,
copyToClipboard,
createPasteEvent,
probablySupportsClipboardBlob,
probablySupportsClipboardWriteText,
readSystemClipboard,
} from "../clipboard";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { t } from "../i18n";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { register } from "./register";
export const actionCopy = register<ClipboardEvent | null>({
name: "copy",
label: "labels.copy",
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, event, app) => {
const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
try {
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: {
...appState,
errorMessage: error.message,
},
};
}
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionPaste = register({
name: "paste",
label: "labels.paste",
trackEvent: { category: "element" },
perform: async (elements, appState, data, app) => {
let types;
try {
types = await readSystemClipboard();
} catch (error: any) {
if (error.name === "AbortError" || error.name === "NotAllowedError") {
// user probably aborted the action. Though not 100% sure, it's best
// to not annoy them with an error message.
return false;
}
console.error(`actionPaste ${error.name}: ${error.message}`);
if (isFirefox) {
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
},
};
}
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
},
};
}
try {
app.pasteFromClipboard(createPasteEvent({ types }));
} catch (error: any) {
console.error(error);
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
},
};
}
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionCut = register<ClipboardEvent | null>({
name: "cut",
label: "labels.cut",
icon: cutIcon,
trackEvent: { category: "element" },
perform: (elements, appState, event, app) => {
actionCopy.perform(elements, appState, event, app);
return actionDeleteSelected.perform(elements, appState, null, app);
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
});
export const actionCopyAsSvg = register({
name: "copyAsSvg",
label: "labels.copyAsSvg",
icon: svgIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
true,
);
try {
await exportCanvas(
"clipboard-svg",
exportedElements,
appState,
app.files,
{
...appState,
exportingFrame,
name: app.getName(),
},
);
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
return {
appState: {
toast: {
message: t("toast.copyToClipboardAsSvg", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
} catch (error: any) {
console.error(error);
return {
appState: {
errorMessage: error.message,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
},
predicate: (elements) => {
return probablySupportsClipboardWriteText && elements.length > 0;
},
keywords: ["svg", "clipboard", "copy"],
});
export const actionCopyAsPng = register({
name: "copyAsPng",
label: "labels.copyAsPng",
icon: pngIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
true,
);
try {
await exportCanvas("clipboard", exportedElements, appState, app.files, {
...appState,
exportingFrame,
name: app.getName(),
});
return {
appState: {
...appState,
toast: {
message: t("toast.copyToClipboardAsPng", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
} catch (error: any) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
},
predicate: (elements) => {
return probablySupportsClipboardBlob && elements.length > 0;
},
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
keywords: ["png", "clipboard", "copy"],
});
export const copyText = register({
name: "copyText",
label: "labels.copyText",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
});
try {
copyTextToSystemClipboard(getTextFromElements(selectedElements));
} catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
}
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
predicate: (elements, appState, _, app) => {
return (
probablySupportsClipboardWriteText &&
app.scene
.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})
.some(isTextElement)
);
},
keywords: ["text", "clipboard", "copy"],
});

View File

@@ -0,0 +1,59 @@
import { isImageElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
import { IconButton } from "../components/IconButton";
import { cropIcon } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
export const actionToggleCropEditor = register({
name: "cropEditor",
label: "helpDialog.cropStart",
icon: cropIcon,
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["image", "crop"],
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawImageElement;
return {
appState: {
...appState,
isCropping: false,
croppingElementId: selectedElement.id,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.croppingElementId &&
selectedElements.length === 1 &&
isImageElement(selectedElements[0])
) {
return true;
}
return false;
},
PanelComponent: ({ appState, updateData, app }) => {
const label = t("helpDialog.cropStart");
return (
<IconButton
type="button"
icon={cropIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});

View File

@@ -0,0 +1,213 @@
import React from "react";
import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api";
import { act, assertElements, render } from "../tests/test-utils";
import { actionDeleteSelected } from "./actionDeleteSelected";
const { h } = window;
describe("deleting selected elements when frame selected should keep children + select them", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("frame only", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
API.setElements([f1, r1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
]);
});
it("frame + text container (text's frameId set)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: f1.id,
});
h.app.scene.mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + text container (text's frameId not set)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: null,
});
h.app.scene.mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + text container (text selected too)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: null,
});
h.app.scene.mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1, t1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + labeled arrow", async () => {
const f1 = API.createElement({
type: "frame",
});
const a1 = API.createElement({
type: "arrow",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: a1.id,
frameId: null,
});
h.app.scene.mutateElement(a1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, a1, t1]);
API.setSelectedElements([f1, t1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: a1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + children selected", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
API.setElements([f1, r1]);
API.setSelectedElements([f1, r1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
]);
});
});

View File

@@ -0,0 +1,329 @@
import {
KEYS,
MOBILE_ACTION_BUTTON_BG,
updateActiveTool,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { getContainerElement } from "@excalidraw/element";
import {
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "@excalidraw/element";
import { getFrameChildren } from "@excalidraw/element";
import {
getElementsInGroup,
selectGroupsForSelectedElements,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { TrashIcon } from "../components/icons";
import { IconButton } from "../components/IconButton";
import { useStylesPanelMode } from "../components/App";
import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)),
appState,
).map((el) => el.id),
);
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
const elementsMap = app.scene.getNonDeletedElementsMap();
const processedElements = new Set<ExcalidrawElement["id"]>();
for (const frameId of framesToBeDeleted) {
const frameChildren = getFrameChildren(elements, frameId);
for (const el of frameChildren) {
if (processedElements.has(el.id)) {
continue;
}
if (isBoundToContainer(el)) {
const containerElement = getContainerElement(el, elementsMap);
if (containerElement) {
selectedElementIds[containerElement.id] = true;
}
} else {
selectedElementIds[el.id] = true;
}
processedElements.add(el.id);
}
}
let shouldSelectEditingGroup = true;
const nextElements = elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
const boundElement = isBoundToContainer(el)
? getContainerElement(el, elementsMap)
: null;
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
shouldSelectEditingGroup = false;
selectedElementIds[el.id] = true;
return el;
}
if (
boundElement?.frameId &&
framesToBeDeleted.has(boundElement?.frameId)
) {
return el;
}
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
if (bound && isElbowArrow(bound)) {
app.scene.mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
});
}
});
}
return newElementWith(el, { isDeleted: true });
}
// if deleting a frame, remove the children from it and select them
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
shouldSelectEditingGroup = false;
if (!isBoundToContainer(el)) {
selectedElementIds[el.id] = true;
}
return newElementWith(el, { frameId: null });
}
if (isBoundToContainer(el) && appState.selectedElementIds[el.containerId]) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
let nextEditingGroupId = appState.editingGroupId;
// select next eligible element in currently editing group or supergroup
if (shouldSelectEditingGroup && appState.editingGroupId) {
const elems = getElementsInGroup(
nextElements,
appState.editingGroupId,
).filter((el) => !el.isDeleted);
if (elems.length > 1) {
if (elems[0]) {
selectedElementIds[elems[0].id] = true;
}
} else {
nextEditingGroupId = null;
if (elems[0]) {
selectedElementIds[elems[0].id] = true;
}
const lastElementInGroup = elems[0];
if (lastElementInGroup) {
const editingGroupIdx = lastElementInGroup.groupIds.findIndex(
(groupId) => {
return groupId === appState.editingGroupId;
},
);
const superGroupId = lastElementInGroup.groupIds[editingGroupIdx + 1];
if (superGroupId) {
const elems = getElementsInGroup(nextElements, superGroupId).filter(
(el) => !el.isDeleted,
);
if (elems.length > 1) {
nextEditingGroupId = superGroupId;
elems.forEach((el) => {
selectedElementIds[el.id] = true;
});
}
}
}
}
}
return {
elements: nextElements,
appState: {
...appState,
...selectGroupsForSelectedElements(
{
selectedElementIds,
editingGroupId: nextEditingGroupId,
},
getNonDeletedElements(nextElements),
appState,
null,
),
},
};
};
const handleGroupEditingState = (
appState: AppState,
elements: readonly ExcalidrawElement[],
): AppState => {
if (appState.editingGroupId) {
const siblingElements = getElementsInGroup(
getNonDeletedElements(elements),
appState.editingGroupId!,
);
if (siblingElements.length) {
return {
...appState,
selectedElementIds: { [siblingElements[0].id]: true },
};
}
}
return appState;
};
export const actionDeleteSelected = register({
name: "deleteSelectedElements",
label: "labels.delete",
icon: TrashIcon,
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => {
if (appState.selectedLinearElement?.isEditing) {
const { elementId, selectedPointsIndices } =
appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const linearElement = LinearElementEditor.getElement(
elementId,
elementsMap,
);
if (!linearElement) {
return false;
}
// case: no point selected → do nothing, as deleting the whole element
// is most likely a mistake, where you wanted to delete a specific point
// but failed to select it (or you thought it's selected, while it was
// only in a hover state)
if (selectedPointsIndices == null) {
return false;
}
// case: deleting all points
if (selectedPointsIndices.length >= linearElement.points.length) {
const nextElements = elements.map((el) => {
if (el.id === linearElement.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
const nextAppState = handleGroupEditingState(appState, nextElements);
return {
elements: nextElements,
appState: {
...nextAppState,
selectedLinearElement: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
LinearElementEditor.deletePoints(
linearElement,
app,
selectedPointsIndices,
);
return {
elements,
appState: {
...appState,
selectedLinearElement: {
...appState.selectedLinearElement,
selectedPointsIndices:
selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1]
: [0],
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState, app);
fixBindingsAfterDeletion(
nextElements,
nextElements.filter((el) => el.isDeleted),
);
nextAppState = handleGroupEditingState(nextAppState, nextElements);
return {
elements: nextElements,
appState: {
...nextAppState,
activeTool: updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
}),
multiElement: null,
newElement: null,
activeEmbeddable: null,
selectedLinearElement: null,
},
captureUpdate: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY,
};
},
keyTest: (event, appState, elements) =>
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
!event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ elements, appState, updateData, app }) => {
const isMobile = useStylesPanelMode() === "mobile";
return (
<IconButton
type="button"
icon={TrashIcon}
title={t("labels.delete")}
aria-label={t("labels.delete")}
onClick={() => updateData(null)}
disabled={
!isSomeElementSelected(getNonDeletedElements(elements), appState)
}
style={{
...(isMobile && appState.openPopup !== "compactOtherProperties"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/>
);
},
});

View File

@@ -0,0 +1,152 @@
import {
getElementsInGroup,
isSomeElementSelected,
makeNextSelectedElementIds,
selectGroupsForSelectedElements,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
import type { GroupId } from "@excalidraw/element/types";
import { TOGGLE_TOOLS } from "../components/Tools";
import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
const getNextActiveTool = (
appState: Readonly<AppState>,
app: AppClassProperties,
) => {
if (TOGGLE_TOOLS.includes(appState.activeTool.type)) {
return updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type,
}),
lastActiveTool: null,
});
}
return updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
});
};
const getParentEditingGroupId = (
appState: Readonly<AppState>,
app: AppClassProperties,
selectedElementIds: AppState["selectedElementIds"],
): GroupId | null => {
if (!appState.editingGroupId) {
return null;
}
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElements = app.scene.getSelectedElements({
selectedElementIds,
elements: nonDeletedElements,
});
const candidateElements = selectedElements.length
? selectedElements
: getElementsInGroup(nonDeletedElements, appState.editingGroupId);
for (const element of candidateElements) {
const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
return element.groupIds[editingGroupIndex + 1] as GroupId;
}
}
return null;
};
export const actionDeselect = register({
name: "deselect",
label: "",
trackEvent: false,
perform: (_elements, appState, _, app) => {
const activeTool = getNextActiveTool(appState, app);
app.cursor.applyForTool(activeTool);
if (appState.editingGroupId) {
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElementIds =
Object.keys(appState.selectedElementIds).length > 0
? appState.selectedElementIds
: getElementsInGroup(
nonDeletedElements,
appState.editingGroupId,
).reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<string, true>);
return {
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: getParentEditingGroupId(
appState,
app,
selectedElementIds,
),
selectedElementIds,
},
nonDeletedElements,
appState,
app,
),
activeEmbeddable: null,
activeTool,
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
hoveredArrowTextAnchor: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
return {
appState: {
...appState,
activeEmbeddable: null,
activeTool,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds({}, appState),
selectedGroupIds: {},
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
hoveredArrowTextAnchor: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event, appState, _, app) => {
if (event.key !== KEYS.ESCAPE) {
return false;
}
if (isWritableElement(event.target)) {
return false;
}
return (
!appState.newElement &&
appState.multiElement === null &&
!appState.selectedLinearElement?.isEditing &&
(appState.activeEmbeddable !== null ||
appState.activeTool.type !== app.state.preferredSelectionTool.type ||
!!appState.editingGroupId ||
!!appState.selectedLinearElement ||
isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
);
},
});

View File

@@ -0,0 +1,131 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { distributeElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElementsByGroup } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element";
import { IconButton } from "../components/IconButton";
import {
DistributeHorizontallyIcon,
DistributeVerticallyIcon,
} from "../components/icons";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register";
import type { AppClassProperties, AppState, UIAppState } from "../types";
const enableActionGroup = (appState: UIAppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
getSelectedElementsByGroup(
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState,
).length > 2 &&
// TODO enable distributing frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el))
);
};
const distributeSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
distribution: Distribution,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = distributeElements(
selectedElements,
app.scene.getNonDeletedElementsMap(),
distribution,
appState,
app.scene,
);
const updatedElementsMap = arrayToMap(updatedElements);
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
app,
);
};
export const distributeHorizontally = register({
name: "distributeHorizontally",
label: "labels.distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
appState,
elements: distributeSelectedElements(elements, appState, app, {
space: "between",
axis: "x",
}),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData, app }) => (
<IconButton
hidden={!enableActionGroup(appState, app)}
type="button"
icon={DistributeHorizontallyIcon}
onClick={() => updateData(null)}
title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
"Alt+H",
)}`}
aria-label={t("labels.distributeHorizontally")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const distributeVertically = register({
name: "distributeVertically",
label: "labels.distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
appState,
elements: distributeSelectedElements(elements, appState, app, {
space: "between",
axis: "y",
}),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData, app }) => (
<IconButton
hidden={!enableActionGroup(appState, app)}
type="button"
icon={DistributeVerticallyIcon}
onClick={() => updateData(null)}
title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
aria-label={t("labels.distributeVertically")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});

View File

@@ -0,0 +1,531 @@
import { ORIG_ID } from "@excalidraw/common";
import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api";
import {
act,
assertElements,
getCloneByOrigId,
render,
} from "../tests/test-utils";
import { actionDuplicateSelection } from "./actionDuplicateSelection";
const { h } = window;
describe("actionDuplicateSelection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
describe("duplicating frames", () => {
it("frame selected only", async () => {
const frame = API.createElement({
type: "frame",
});
const rectangle = API.createElement({
type: "rectangle",
frameId: frame.id,
});
API.setElements([frame, rectangle]);
API.setSelectedElements([frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: frame.id, selected: true },
]);
});
it("frame selected only (with text container)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{ [ORIG_ID]: frame.id, selected: true },
]);
});
it("frame + text container selected (order A)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([frame, rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{
[ORIG_ID]: rectangle.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{
[ORIG_ID]: frame.id,
selected: true,
},
]);
});
it("frame + text container selected (order B)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([text, rectangle, frame]);
API.setSelectedElements([rectangle, frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ id: frame.id },
{
type: "rectangle",
[ORIG_ID]: `${rectangle.id}`,
},
{
[ORIG_ID]: `${text.id}`,
type: "text",
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{ [ORIG_ID]: `${frame.id}`, type: "frame", selected: true },
]);
});
});
describe("duplicating frame children", () => {
it("frame child selected", () => {
const frame = API.createElement({
type: "frame",
});
const rectangle = API.createElement({
type: "rectangle",
frameId: frame.id,
});
API.setElements([frame, rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
]);
});
it("frame text container selected (rectangle selected)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
frameId: frame.id,
},
]);
});
it("frame bound text selected (container not selected)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
frameId: frame.id,
},
]);
});
it("frame text container selected (text not exists)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
]);
});
// shouldn't happen
it("frame bound text selected (container not exists)", () => {
const frame = API.createElement({
type: "frame",
});
const [, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: text.id, frameId: frame.id },
{ [ORIG_ID]: text.id, frameId: frame.id, selected: true },
]);
});
it("frame bound container selected (text has no frameId)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
label: { frameId: null },
});
API.setElements([frame, rectangle, text]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
},
]);
});
});
describe("duplicating multiple frames", () => {
it("multiple frames selected (no children)", () => {
const frame1 = API.createElement({
type: "frame",
});
const rect1 = API.createElement({
type: "rectangle",
frameId: frame1.id,
});
const frame2 = API.createElement({
type: "frame",
});
const rect2 = API.createElement({
type: "rectangle",
frameId: frame2.id,
});
const ellipse = API.createElement({
type: "ellipse",
});
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
API.setSelectedElements([frame1, frame2]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rect1.id, frameId: frame1.id },
{ id: frame1.id },
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
{ [ORIG_ID]: frame1.id, selected: true },
{ id: ellipse.id },
{ id: rect2.id, frameId: frame2.id },
{ id: frame2.id },
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
{ [ORIG_ID]: frame2.id, selected: true },
]);
});
it("multiple frames selected (no children) + unrelated element", () => {
const frame1 = API.createElement({
type: "frame",
});
const rect1 = API.createElement({
type: "rectangle",
frameId: frame1.id,
});
const frame2 = API.createElement({
type: "frame",
});
const rect2 = API.createElement({
type: "rectangle",
frameId: frame2.id,
});
const ellipse = API.createElement({
type: "ellipse",
});
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
API.setSelectedElements([frame1, ellipse, frame2]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rect1.id, frameId: frame1.id },
{ id: frame1.id },
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
{ [ORIG_ID]: frame1.id, selected: true },
{ id: ellipse.id },
{ [ORIG_ID]: ellipse.id, selected: true },
{ id: rect2.id, frameId: frame2.id },
{ id: frame2.id },
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
{ [ORIG_ID]: frame2.id, selected: true },
]);
});
});
describe("duplicating containers/bound elements", () => {
it("labeled arrow (arrow selected)", () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([arrow]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
]);
});
// shouldn't happen
it("labeled arrow (text selected)", () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
]);
});
});
describe("duplicating groups", () => {
it("duplicate group containing frame (children don't have groupIds set)", () => {
const frame = API.createElement({
type: "frame",
groupIds: ["A"],
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
});
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["A"],
});
API.setElements([rectangle, text, frame, ellipse]);
API.setSelectedElements([frame, ellipse]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, frameId: frame.id },
{ id: frame.id },
{ id: ellipse.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: frame.id, selected: true },
{ [ORIG_ID]: ellipse.id, selected: true },
]);
});
it("duplicate group containing frame (children have groupIds)", () => {
const frame = API.createElement({
type: "frame",
groupIds: ["A"],
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
groupIds: ["A"],
});
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["A"],
});
API.setElements([rectangle, text, frame, ellipse]);
API.setSelectedElements([frame, ellipse]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, frameId: frame.id },
{ id: frame.id },
{ id: ellipse.id },
{
[ORIG_ID]: rectangle.id,
frameId: getCloneByOrigId(frame.id)?.id,
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
selected: true,
},
{
[ORIG_ID]: text.id,
frameId: getCloneByOrigId(frame.id)?.id,
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
selected: true,
},
{ [ORIG_ID]: frame.id, selected: true },
{ [ORIG_ID]: ellipse.id, selected: true },
]);
});
it("duplicating element nested in group", () => {
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["B"],
});
const rect1 = API.createElement({
type: "rectangle",
groupIds: ["A", "B"],
});
const rect2 = API.createElement({
type: "rectangle",
groupIds: ["A", "B"],
});
API.setElements([ellipse, rect1, rect2]);
API.setSelectedElements([ellipse], "B");
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: ellipse.id },
{ [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true },
{ id: rect1.id, groupIds: ["A", "B"] },
{ id: rect2.id, groupIds: ["A", "B"] },
]);
});
});
});

View File

@@ -0,0 +1,135 @@
import {
DEFAULT_GRID_SIZE,
KEYS,
MOBILE_ACTION_BUTTON_BG,
arrayToMap,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import {
getSelectedElements,
getSelectionStateForElements,
} from "@excalidraw/element";
import { syncMovedIndices } from "@excalidraw/element";
import { duplicateElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { IconButton } from "../components/IconButton";
import { DuplicateIcon } from "../components/icons";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { useStylesPanelMode } from "../components/App";
import { register } from "./register";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
label: "labels.duplicateSelection",
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
if (appState.selectedElementsAreBeingDragged) {
return false;
}
// duplicate selected point(s) if editing a line
if (appState.selectedLinearElement?.isEditing) {
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try {
const newAppState = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene,
);
return {
elements,
appState: newAppState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
} catch {
return false;
}
}
let { duplicatedElements, elementsWithDuplicates } = duplicateElements({
type: "in-place",
elements,
idsOfElementsToDuplicate: arrayToMap(
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
),
appState,
randomizeSeed: true,
overrides: ({ origElement, origIdToDuplicateId }) => {
const duplicateFrameId =
origElement.frameId && origIdToDuplicateId.get(origElement.frameId);
return {
x: origElement.x + DEFAULT_GRID_SIZE / 2,
y: origElement.y + DEFAULT_GRID_SIZE / 2,
frameId: duplicateFrameId ?? origElement.frameId,
};
},
});
if (app.props.onDuplicate && elementsWithDuplicates) {
const mappedElements = app.props.onDuplicate(
elementsWithDuplicates,
elements,
);
if (mappedElements) {
elementsWithDuplicates = mappedElements;
}
}
return {
elements: syncMovedIndices(
elementsWithDuplicates,
arrayToMap(duplicatedElements),
),
appState: {
...appState,
...getSelectionStateForElements(
duplicatedElements,
getNonDeletedElements(elementsWithDuplicates),
appState,
),
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
PanelComponent: ({ elements, appState, updateData, app }) => {
const isMobile = useStylesPanelMode() === "mobile";
return (
<IconButton
type="button"
icon={DuplicateIcon}
title={`${t("labels.duplicateSelection")} — ${getShortcutKey(
"CtrlOrCmd+D",
)}`}
aria-label={t("labels.duplicateSelection")}
onClick={() => updateData(null)}
disabled={
!isSomeElementSelected(getNonDeletedElements(elements), appState)
}
style={{
...(isMobile && appState.openPopup !== "compactOtherProperties"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/>
);
},
});

View File

@@ -0,0 +1,113 @@
import {
canCreateLinkFromElements,
defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { copyTextToSystemClipboard } from "../clipboard";
import { copyIcon, elementLinkIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { register } from "./register";
export const actionCopyElementLink = register({
name: "copyElementLink",
label: "labels.copyElementLink",
icon: copyIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
try {
if (window.location) {
const idAndType = getLinkIdAndTypeFromSelection(
selectedElements,
appState,
);
if (idAndType) {
await copyTextToSystemClipboard(
app.props.generateLinkForSelection
? app.props.generateLinkForSelection(idAndType.id, idAndType.type)
: defaultGetElementLinkFromSelection(
idAndType.id,
idAndType.type,
),
);
return {
appState: {
toast: {
message: t("toast.elementLinkCopied"),
closable: true,
},
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
return {
appState,
elements,
app,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
} catch (error: any) {
console.error(error);
}
return {
appState,
elements,
app,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
predicate: (elements, appState) =>
canCreateLinkFromElements(getSelectedElements(elements, appState)),
});
export const actionLinkToElement = register({
name: "linkToElement",
label: "labels.linkToElement",
icon: elementLinkIcon,
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
if (
selectedElements.length !== 1 ||
!canCreateLinkFromElements(selectedElements)
) {
return {
elements,
appState,
app,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
return {
appState: {
...appState,
openDialog: {
name: "elementLinkSelector",
sourceElementId: getSelectedElements(elements, appState)[0].id,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
predicate: (elements, appState, appProps, app) => {
const selectedElements = getSelectedElements(elements, appState);
return (
appState.openDialog?.name !== "elementLinkSelector" &&
selectedElements.length === 1 &&
canCreateLinkFromElements(selectedElements)
);
},
trackEvent: false,
});

View File

@@ -0,0 +1,70 @@
import { queryByTestId, fireEvent } from "@testing-library/react";
import React from "react";
import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api";
import { Pointer, UI } from "../tests/helpers/ui";
import { render } from "../tests/test-utils";
const { h } = window;
const mouse = new Pointer("mouse");
describe("element locking", () => {
it("should not show unlockAllElements action in contextMenu if no elements locked", async () => {
await render(<Excalidraw />);
mouse.rightClickAt(0, 0);
const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements");
expect(item).toBe(null);
});
it("should unlock all elements and select them when using unlockAllElements action in contextMenu", async () => {
await render(
<Excalidraw
initialData={{
elements: [
API.createElement({
x: 100,
y: 100,
width: 100,
height: 100,
locked: true,
}),
API.createElement({
x: 100,
y: 100,
width: 100,
height: 100,
locked: true,
}),
API.createElement({
x: 100,
y: 100,
width: 100,
height: 100,
locked: false,
}),
],
}}
/>,
);
mouse.rightClickAt(0, 0);
expect(Object.keys(h.state.selectedElementIds).length).toBe(0);
expect(h.elements.map((el) => el.locked)).toEqual([true, true, false]);
const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements");
expect(item).not.toBe(null);
fireEvent.click(item!.querySelector("button")!);
expect(h.elements.map((el) => el.locked)).toEqual([false, false, false]);
// should select the unlocked elements
expect(h.state.selectedElementIds).toEqual({
[h.elements[0].id]: true,
[h.elements[1].id]: true,
});
});
});

View File

@@ -0,0 +1,219 @@
import { KEYS, arrayToMap, randomId } from "@excalidraw/common";
import {
elementsAreInSameGroup,
getNonDeletedElements,
newElementWith,
selectGroupsFromGivenElements,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { LockedIcon, UnlockedIcon } from "../components/icons";
import { getSelectedElements } from "../scene";
import { register } from "./register";
import type { AppState } from "../types";
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
elements.every((el) => !el.locked);
export const actionToggleElementLock = register({
name: "toggleElementLock",
label: (elements, appState, app) => {
const selected = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
return shouldLock(selected)
? "labels.elementLock.lock"
: "labels.elementLock.unlock";
},
icon: (appState, elements) => {
const selectedElements = getSelectedElements(elements, appState);
return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon;
},
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 0 &&
!selectedElements.some((element) => element.locked && element.frameId)
);
},
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
if (!selectedElements.length) {
return false;
}
const nextLockState = shouldLock(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements);
const isAGroup =
selectedElements.length > 1 && elementsAreInSameGroup(selectedElements);
const isASingleUnit = selectedElements.length === 1 || isAGroup;
const newGroupId = isASingleUnit ? null : randomId();
let nextLockedMultiSelections = { ...appState.lockedMultiSelections };
if (nextLockState) {
nextLockedMultiSelections = {
...appState.lockedMultiSelections,
...(newGroupId ? { [newGroupId]: true } : {}),
};
} else if (isAGroup) {
const groupId = selectedElements[0].groupIds.at(-1)!;
delete nextLockedMultiSelections[groupId];
}
const nextElements = elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
let nextGroupIds = element.groupIds;
// if locking together, add to group
// if unlocking, remove the temporary group
if (nextLockState) {
if (newGroupId) {
nextGroupIds = [...nextGroupIds, newGroupId];
}
} else {
nextGroupIds = nextGroupIds.filter(
(groupId) => !appState.lockedMultiSelections[groupId],
);
}
return newElementWith(element, {
locked: nextLockState,
// do not recreate the array unncessarily
groupIds:
nextGroupIds.length !== element.groupIds.length
? nextGroupIds
: element.groupIds,
});
});
const nextElementsMap = arrayToMap(nextElements);
const nextSelectedElementIds: AppState["selectedElementIds"] = nextLockState
? {}
: Object.fromEntries(selectedElements.map((el) => [el.id, true]));
const unlockedSelectedElements = selectedElements.map(
(el) => nextElementsMap.get(el.id) || el,
);
const nextSelectedGroupIds = nextLockState
? {}
: selectGroupsFromGivenElements(
getNonDeletedElements(unlockedSelectedElements),
appState,
);
const activeLockedId = nextLockState
? newGroupId
? newGroupId
: isAGroup
? selectedElements[0].groupIds.at(-1)!
: selectedElements[0].id
: null;
return {
elements: nextElements,
appState: {
...appState,
selectedElementIds: nextSelectedElementIds,
selectedGroupIds: nextSelectedGroupIds,
selectedLinearElement: nextLockState
? null
: appState.selectedLinearElement,
lockedMultiSelections: nextLockedMultiSelections,
activeLockedId,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event, appState, elements, app) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
}).length > 0
);
},
});
export const actionUnlockAllElements = register({
name: "unlockAllElements",
trackEvent: { category: "canvas" },
viewMode: false,
icon: UnlockedIcon,
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 0 &&
elements.some((element) => element.locked)
);
},
perform: (elements, appState) => {
const lockedElements = elements.filter((el) => el.locked);
const nextElements = elements.map((element) => {
if (element.locked) {
// remove the temporary groupId if it exists
const nextGroupIds = element.groupIds.filter(
(gid) => !appState.lockedMultiSelections[gid],
);
return newElementWith(element, {
locked: false,
groupIds:
// do not recreate the array unncessarily
element.groupIds.length !== nextGroupIds.length
? nextGroupIds
: element.groupIds,
});
}
return element;
});
const nextElementsMap = arrayToMap(nextElements);
const unlockedElements = lockedElements.map(
(el) => nextElementsMap.get(el.id) || el,
);
const unlockedNonDeletedElements = getNonDeletedElements(unlockedElements);
return {
elements: nextElements,
appState: {
...appState,
selectedElementIds: Object.fromEntries(
unlockedNonDeletedElements.map((el) => [el.id, true]),
),
selectedGroupIds: selectGroupsFromGivenElements(
unlockedNonDeletedElements,
appState,
),
lockedMultiSelections: {},
activeLockedId: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
label: "labels.elementLock.unlockAll",
});

View File

@@ -0,0 +1,462 @@
import { KEYS, THEME } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
import { useEditorInterface } from "../components/App";
import { CheckboxItem } from "../components/CheckboxItem";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { ProjectName } from "../components/ProjectName";
import { Toast } from "../components/Toast";
import { IconButton } from "../components/IconButton";
import { Tooltip } from "../components/Tooltip";
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
import { loadFromJSON, saveAsJSON } from "../data";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
import "../components/ToolIcon.scss";
import { register } from "./register";
import type { JSONExportData } from "../data/json";
import type {
AppClassProperties,
AppState,
BinaryFiles,
ExcalidrawProps,
OnExportProgress,
} from "../types";
export const actionChangeProjectName = register<AppState["name"]>({
name: "changeProjectName",
label: "labels.fileTitle",
trackEvent: false,
perform: (_elements, appState, value) => {
return {
appState: { ...appState, name: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
<ProjectName
label={t("labels.fileTitle")}
value={app.getName()}
onChange={(name: string) => updateData(name)}
ignoreFocus={data?.ignoreFocus ?? false}
/>
),
});
export const actionChangeExportScale = register<AppState["exportScale"]>({
name: "changeExportScale",
label: "imageExportDialog.scale",
trackEvent: { category: "export", action: "scale" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
});
export const actionChangeExportBackground = register<
AppState["exportBackground"]
>({
name: "changeExportBackground",
label: "imageExportDialog.label.withBackground",
trackEvent: { category: "export", action: "toggleBackground" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
checked={appState.exportBackground}
onChange={(checked) => updateData(checked)}
>
{t("imageExportDialog.label.withBackground")}
</CheckboxItem>
),
});
export const actionChangeExportEmbedScene = register<
AppState["exportEmbedScene"]
>({
name: "changeExportEmbedScene",
label: "imageExportDialog.tooltip.embedScene",
trackEvent: { category: "export", action: "embedScene" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
checked={appState.exportEmbedScene}
onChange={(checked) => updateData(checked)}
>
{t("imageExportDialog.label.embedScene")}
<Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}>
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
</Tooltip>
</CheckboxItem>
),
});
// ---------------------------------------------------------------------------
// onExport interception helpers
// ---------------------------------------------------------------------------
let onExportInProgress = false;
const onProgressToast = (
app: AppClassProperties,
progress: {
message?: OnExportProgress["message"];
progress?: number | null;
},
) => {
const message = progress.message ?? t("progressDialog.defaultMessage");
app.setAppState({
toast: {
message:
progress.progress != null ? (
<>
{message}
<Toast.ProgressBar progress={progress.progress} />
</>
) : (
message
),
duration: Infinity,
},
});
};
/** awaits host app's onExport result, and renders progress to the UI */
async function handleOnExportResult(
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
opts: {
signal: AbortSignal;
app: AppClassProperties;
},
): Promise<void> {
if (opts.app.state.isLoading) {
onProgressToast(opts.app, { progress: null });
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
}
if (
onExportResult != null &&
typeof onExportResult === "object" &&
Symbol.asyncIterator in onExportResult
) {
for await (const value of onExportResult) {
if (opts.signal.aborted) {
onExportResult.return();
return;
}
if (value.type === "progress") {
onProgressToast(opts.app, {
message: value.message,
progress: value.progress ?? null,
});
} else if (value.type === "done") {
return;
}
}
// Generator completed without explicit "done" message
return;
}
if (onExportResult instanceof Promise) {
onProgressToast(opts.app, { progress: null });
await onExportResult;
}
}
function prepareDataForJSONExport(
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
app: AppClassProperties,
): { abortController: AbortController; data: Promise<JSONExportData> } {
const abortController = new AbortController();
const signal = abortController.signal;
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
try {
if (app.props.onExport) {
await handleOnExportResult(
app.props.onExport(
"json",
{
elements,
appState,
files,
},
{
signal,
},
),
{
app,
signal,
},
);
}
} catch (error: any) {
if (error?.name === "AbortError") {
// if abort error, assume it's a reaction on the signal being aborted
console.warn(
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
);
} else {
// non-abort error
//
console.error("Error during props.onExport() handling", error);
}
// either way, we currently don't allow host apps to cancel save actions
// so we resolve to orig data
}
resolve({
elements,
appState,
// return latest files in case they finished loading during onExport
files: app.files,
});
});
return {
abortController,
data: dataPromise,
};
}
// ---------------------------------------------------------------------------
// Save actions
// ---------------------------------------------------------------------------
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
label: "buttons.save",
icon: ExportIcon,
trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.saveToActiveFile &&
!!appState.fileHandle &&
!appState.viewModeEnabled
);
},
perform: async (elements, appState, value, app) => {
if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const previousFileHandle = appState.fileHandle;
const filename = app.getName();
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try {
const { fileHandle } = isImageFileHandle(previousFileHandle)
? await resaveAsImageWithScene(
exportedDataPromise,
previousFileHandle,
filename,
)
: await saveAsJSON({
data: exportedDataPromise,
filename,
fileHandle: previousFileHandle,
});
return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
fileHandle,
toast: {
message:
previousFileHandle && fileHandle?.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
)
: t("toast.fileSaved"),
duration: 1500,
},
},
};
} catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") {
console.error(error);
} else {
console.warn(error);
}
return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
}
},
keyTest: (event) =>
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
});
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
label: "exportDialog.disk_title",
icon: ExportIcon,
viewMode: true,
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try {
const { fileHandle: savedFileHandle } = await saveAsJSON({
data: exportedDataPromise,
filename: app.getName(),
fileHandle: null,
});
return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
openDialog: null,
fileHandle: savedFileHandle,
toast: { message: t("toast.fileSaved"), duration: 3000 },
},
};
} catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") {
console.error(error);
} else {
console.warn(error);
}
return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
}
},
keyTest: (event) =>
event.key.toLowerCase() === KEYS.S &&
event.shiftKey &&
event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ updateData }) => (
<IconButton
type="button"
icon={saveAs}
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useEditorInterface().formFactor === "phone"}
hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)}
data-testid="save-as-button"
/>
),
});
export const actionLoadScene = register({
name: "loadScene",
label: "buttons.load",
trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled
);
},
perform: async (elements, appState, _, app) => {
try {
const {
elements: loadedElements,
appState: loadedAppState,
files,
} = await loadFromJSON(appState, elements);
return {
elements: loadedElements,
appState: loadedAppState,
files,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
} catch (error: any) {
if (error?.name === "AbortError") {
console.warn(error);
return false;
}
return {
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
});
export const actionExportWithDarkMode = register<
AppState["exportWithDarkMode"]
>({
name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value, app) => {
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
return {
appState: { ...appState, exportWithDarkMode: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ appState, updateData }) => (
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginTop: "-45px",
marginBottom: "10px",
}}
>
<DarkModeToggle
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
onChange={(theme: Theme) => {
updateData(theme === THEME.DARK);
}}
title={t("imageExportDialog.label.darkMode")}
/>
</div>
),
});

View File

@@ -0,0 +1,439 @@
import { pointFrom } from "@excalidraw/math";
import { bindOrUnbindBindingElement } from "@excalidraw/element/binding";
import {
isValidPolygon,
LinearElementEditor,
newElementWith,
} from "@excalidraw/element";
import {
isBindingElement,
isFreeDrawElement,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import {
KEYS,
arrayToMap,
invariant,
shouldRotateWithDiscreteAngle,
updateActiveTool,
} from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element";
import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
PointsPositionUpdates,
} from "@excalidraw/element/types";
import { t } from "../i18n";
import { done } from "../components/icons";
import { TOGGLE_TOOLS } from "../components/Tools";
import { IconButton } from "../components/IconButton";
import { register } from "./register";
import type { AppState } from "../types";
type FormData = {
event: PointerEvent;
sceneCoords: { x: number; y: number };
hitBoundText?: boolean;
};
export const actionFinalize = register<FormData>({
name: "finalize",
label: "",
trackEvent: false,
perform: (elements, appState, data, app) => {
let shouldCommit = true;
let newElements = elements;
const { focusContainer, scene } = app;
const elementsMap = scene.getNonDeletedElementsMap();
const isDrawShapeTool = appState.activeTool.type === "autoshape";
if (data && appState.selectedLinearElement) {
const { event, sceneCoords } = data;
const element = LinearElementEditor.getElement(
appState.selectedLinearElement.elementId,
elementsMap,
);
invariant(
element,
"Arrow element should exist if selectedLinearElement is set",
);
invariant(
sceneCoords,
"sceneCoords should be defined if actionFinalize is called with event",
);
const linearElementEditor = LinearElementEditor.handlePointerUp(
event,
appState.selectedLinearElement,
appState,
app.scene,
);
if (
isBindingElement(element) &&
!appState.selectedLinearElement.segmentMidPointHoveredCoords &&
// a label drag along the arrow moves no endpoint, so it must not
// rebind either of them
!data.hitBoundText
) {
const newArrow = !!appState.newElement;
const selectedPointsIndices =
newArrow || !appState.selectedLinearElement.selectedPointsIndices
? [element.points.length - 1] // New arrow creation
: appState.selectedLinearElement.selectedPointsIndices;
const angleLocked = shouldRotateWithDiscreteAngle(event);
const effectiveGridSize = event[KEYS.CTRL_OR_CMD]
? null
: app.getEffectiveGridSize();
const draggedPoints: PointsPositionUpdates =
selectedPointsIndices.reduce((map, index) => {
map.set(index, {
point: angleLocked
? element.points[index]
: LinearElementEditor.createPointAt(
element,
elementsMap,
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
effectiveGridSize,
),
});
return map;
}, new Map()) ?? new Map();
bindOrUnbindBindingElement(
element,
draggedPoints,
sceneCoords.x,
sceneCoords.y,
scene,
appState,
{
newArrow,
altKey: event.altKey,
angleLocked,
gridSize: app.getEffectiveGridSize(),
},
);
} else if (isLineElement(element)) {
if (
appState.selectedLinearElement?.isEditing &&
!appState.newElement &&
!isValidPolygon(element.points)
) {
scene.mutateElement(element, {
polygon: false,
});
}
}
if (linearElementEditor !== appState.selectedLinearElement) {
// `handlePointerUp()` updated the linear element instance,
// so filter out this element if it is too small,
// but do an update to all new elements anyway for undo/redo purposes.
if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, {
isDeleted: true,
});
}
return el;
});
}
const activeToolLocked = appState.activeTool?.locked;
return {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
? elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
})
: newElements,
appState: {
...appState,
cursorButton: "up",
selectedElementIds: isDrawShapeTool
? {}
: appState.selectedElementIds,
selectedLinearElement:
activeToolLocked || isDrawShapeTool
? null
: {
...linearElementEditor,
selectedPointsIndices: null,
isEditing: false,
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
pointerOffset: { x: 0, y: 0 },
},
selectionElement: null,
suggestedBinding: null,
hoveredArrowTextAnchor: null,
newElement: null,
multiElement: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
}
focusContainer();
// clean up pending gesture even if active tool is already not drawShape
const hadPendingSketch = app.drawShape.hasPendingGesture();
if (hadPendingSketch || isDrawShapeTool) {
app.drawShape.finalize();
if (hadPendingSketch) {
// finalize() inserts the recognized element via app.insertNewElement
// — re-read so the returned array includes it (replaceAllElements
// would otherwise drop it)
newElements = app.scene.getElementsIncludingDeleted();
}
}
let element: NonDeleted<ExcalidrawElement> | null = null;
if (appState.multiElement) {
element = appState.multiElement;
} else if (
// the drawShape preview in `newElement` is not a scene element and the
// sketch was finalized by app.drawShape.finalize() above — never treat
// the preview as an in-progress element here
!isDrawShapeTool &&
!hadPendingSketch &&
(appState.newElement?.type === "freedraw" ||
isBindingElement(appState.newElement))
) {
element = appState.newElement;
} else if (Object.keys(appState.selectedElementIds).length === 1) {
const candidate = elementsMap.get(
Object.keys(appState.selectedElementIds)[0],
) as NonDeleted<ExcalidrawLinearElement> | undefined;
if (candidate) {
element = candidate;
}
}
if (element) {
// pen and mouse have hover
if (
appState.selectedLinearElement &&
appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points } = element;
const { lastCommittedPoint } = appState.selectedLinearElement;
if (
!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint
) {
shouldCommit = false;
scene.mutateElement(element, {
points: element.points.slice(0, -1),
});
if (
isBindingElement(element) &&
element.endBinding &&
// after slicing the trailing point a <2-point arrow may be left
element.points.length > 1
) {
const newArrow = !!appState.newElement;
const draggedPoints: PointsPositionUpdates = new Map([
[
element.points.length - 1,
{
point: element.points[element.points.length - 1],
isDragging: false,
},
],
]);
const globalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
elementsMap,
);
bindOrUnbindBindingElement(
element,
draggedPoints,
globalPoint[0],
globalPoint[1],
scene,
appState,
{
newArrow,
},
);
}
}
}
if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.map((el) => {
if (el.id === element?.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
}
if (isLinearElement(element) || isFreeDrawElement(element)) {
// If the multi point line closes the loop,
// set the last point to first point.
// This ensures that loop remains closed at different scales.
const isLoop = isPathALoop(element.points, appState.zoom.value);
if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
const linePoints = element.points;
const firstPoint = linePoints[0];
const points: LocalPoint[] = linePoints.map((p, index) =>
index === linePoints.length - 1
? pointFrom(firstPoint[0], firstPoint[1])
: p,
);
if (isLineElement(element)) {
scene.mutateElement(element, {
points,
polygon: true,
});
} else {
scene.mutateElement(element, {
points,
});
}
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
});
}
}
}
let activeTool: AppState["activeTool"];
if (TOGGLE_TOOLS.includes(appState.activeTool.type)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type,
}),
lastActiveTool: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
});
}
// locked via the tool lock or host-forced (`props.activeTool`)
const isToolLocked = app.isToolLocked();
// the drawShape flow finalizes without a `newElement` (the sketch is
// converted separately), so it stays active regardless of `element`
const keepActiveTool =
appState.activeTool.type === "autoshape" ||
((isToolLocked || appState.activeTool.type === "freedraw") && !!element);
if (!keepActiveTool) {
// the active tool reverts (see the returned `appState.activeTool`) —
// apply the reverted tool's cursor
app.cursor.applyForTool(activeTool);
}
let selectedLinearElement =
element && isLinearElement(element)
? new LinearElementEditor(element, arrayToMap(newElements)) // To select the linear element when user has finished mutipoint editing
: appState.selectedLinearElement;
selectedLinearElement = selectedLinearElement
? {
...selectedLinearElement,
isEditing: appState.newElement
? false
: selectedLinearElement.isEditing,
initialState: {
...selectedLinearElement.initialState,
lastClickedPoint: -1,
origin: null,
},
}
: selectedLinearElement;
return {
elements: newElements,
appState: {
...appState,
cursorButton: "up",
activeTool: keepActiveTool ? appState.activeTool : activeTool,
activeEmbeddable: null,
newElement: null,
selectionElement: null,
multiElement: null,
editingTextElement: null,
suggestedBinding: null,
hoveredArrowTextAnchor: null,
frameToHighlight: null,
selectedElementIds: isDrawShapeTool
? {}
: element && !isToolLocked && appState.activeTool.type !== "freedraw"
? {
...appState.selectedElementIds,
[element.id]: true,
}
: appState.selectedElementIds,
selectedLinearElement: isDrawShapeTool ? null : selectedLinearElement,
},
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: shouldCommit
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.NEVER,
};
},
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => (
<IconButton
type="button"
icon={done}
title={t("buttons.done")}
aria-label={t("buttons.done")}
onClick={updateData}
visible={appState.multiElement != null}
size={data?.size || "medium"}
style={{ pointerEvents: "all" }}
/>
),
});

View File

@@ -0,0 +1,211 @@
import { pointFrom } from "@excalidraw/math";
import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api";
import { render } from "../tests/test-utils";
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
const { h } = window;
describe("flipping re-centers selection", () => {
it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
const elements = [
API.createElement({
type: "rectangle",
id: "rec1",
x: 100,
y: 100,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "rectangle",
id: "rec2",
x: 220,
y: 250,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "arrow",
id: "arr",
x: 149.9,
y: 95,
width: 156,
height: 239.9,
startBinding: {
elementId: "rec1",
fixedPoint: [0.49, -0.05],
mode: "orbit",
},
endBinding: {
elementId: "rec2",
fixedPoint: [-0.05, 0.49],
mode: "orbit",
},
startArrowhead: null,
endArrowhead: "arrow",
fixedSegments: null,
points: [
pointFrom(0, 0),
pointFrom(0, -35),
pointFrom(-90, -35),
pointFrom(-90, 204),
pointFrom(66, 204),
],
elbowed: true,
}),
];
await render(<Excalidraw initialData={{ elements }} />);
API.setSelectedElements(elements);
expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1.x).toBeCloseTo(100, 0);
expect(rec1.y).toBeCloseTo(101, 0);
const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0);
expect(rec2.y).toBeCloseTo(251, 0);
});
});
describe("flipping arrowheads", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("flipping bound arrow should flip arrowheads only", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
});
it("flipping bound arrow should flip arrowheads only 2", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const rect2 = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
startBinding: {
elementId: rect.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
endBinding: {
elementId: rect2.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
});
API.setElements([rect, rect2, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("circle");
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping unbound arrow shouldn't flip arrowheads", () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
});
API.setElements([arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([rect, arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
});
});

View File

@@ -0,0 +1,196 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { bindOrUnbindBindingElements } from "@excalidraw/element";
import { getCommonBoundingBox } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { deepCopyElement } from "@excalidraw/element";
import { resizeMultipleElements } from "@excalidraw/element";
import { isArrowElement, isElbowArrow } from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
NonDeleted,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import { getSelectedElements } from "../scene";
import { flipHorizontal, flipVertical } from "../components/icons";
import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
label: "labels.flipHorizontal",
icon: flipHorizontal,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(
elements,
app.scene.getNonDeletedElementsMap(),
appState,
"horizontal",
app,
),
appState,
app,
),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) => event.shiftKey && event.code === CODES.H,
});
export const actionFlipVertical = register({
name: "flipVertical",
label: "labels.flipVertical",
icon: flipVertical,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(
elements,
app.scene.getNonDeletedElementsMap(),
appState,
"vertical",
app,
),
appState,
app,
),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
});
const flipSelectedElements = (
elements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
appState: Readonly<AppState>,
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
const updatedElements = flipElements(
selectedElements,
elementsMap,
flipDirection,
app,
);
const updatedElementsMap = arrayToMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
);
};
const flipElements = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
): ExcalidrawElement[] => {
if (
selectedElements.every(
(element) =>
isArrowElement(element) && (element.startBinding || element.endBinding),
)
) {
return selectedElements.map((element) => {
const _element = element as ExcalidrawArrowElement;
return newElementWith(_element, {
startArrowhead: _element.endArrowhead,
endArrowhead: _element.startArrowhead,
});
});
}
const { midX, midY } = getCommonBoundingBox(selectedElements);
resizeMultipleElements(
selectedElements,
elementsMap,
"nw",
app.scene,
new Map(
Array.from(elementsMap.values()).map((element) => [
element.id,
deepCopyElement(element),
]),
),
{
flipByX: flipDirection === "horizontal",
flipByY: flipDirection === "vertical",
shouldResizeFromCenter: true,
shouldMaintainAspectRatio: true,
},
);
bindOrUnbindBindingElements(
selectedElements.filter(isArrowElement),
app.scene,
app.state,
);
// ---------------------------------------------------------------------------
// flipping arrow elements (and potentially other) makes the selection group
// "move" across the canvas because of how arrows can bump against the "wall"
// of the selection, so we need to center the group back to the original
// position so that repeated flips don't accumulate the offset
const { elbowArrows, otherElements } = selectedElements.reduce(
(
acc: {
elbowArrows: ExcalidrawElbowArrowElement[];
otherElements: ExcalidrawElement[];
},
element,
) =>
isElbowArrow(element)
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
: { ...acc, otherElements: acc.otherElements.concat(element) },
{ elbowArrows: [], otherElements: [] },
);
const { midX: newMidX, midY: newMidY } =
getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) =>
app.scene.mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
elbowArrows.forEach((element) =>
app.scene.mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
// ---------------------------------------------------------------------------
return selectedElements;
};

View File

@@ -0,0 +1,180 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { mutateElement } from "@excalidraw/element";
import { newFrameElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element";
import {
addElementsToFrame,
removeAllElementsFromFrame,
} from "@excalidraw/element";
import { getFrameChildren } from "@excalidraw/element";
import { getElementsInGroup } from "@excalidraw/element";
import { getCommonBounds } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register";
import type { AppClassProperties, UIAppState } from "../types";
const isSingleFrameSelected = (
appState: UIAppState,
app: AppClassProperties,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length === 1 && isFrameLikeElement(selectedElements[0])
);
};
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
label: "labels.selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElement =
app.scene.getSelectedElements(appState).at(0) || null;
if (isFrameLikeElement(selectedElement)) {
const elementsInFrame = getFrameChildren(
getNonDeletedElements(elements),
selectedElement.id,
).filter((element) => !(element.type === "text" && element.containerId));
return {
elements,
appState: {
...appState,
selectedElementIds: elementsInFrame.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
return {
elements,
appState,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
label: "labels.removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState, _, app) => {
const selectedElement =
app.scene.getSelectedElements(appState).at(0) || null;
if (isFrameLikeElement(selectedElement)) {
return {
elements: removeAllElementsFromFrame(elements, selectedElement),
appState: {
...appState,
selectedElementIds: {
[selectedElement.id]: true,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
return {
elements,
appState,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionupdateFrameRendering = register({
name: "updateFrameRendering",
label: "labels.updateFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
return {
elements,
appState: {
...appState,
frameRendering: {
...appState.frameRendering,
enabled: !appState.frameRendering.enabled,
},
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
checked: (appState) => appState.frameRendering.enabled,
});
export const actionWrapSelectionInFrame = register({
name: "wrapSelectionInFrame",
label: "labels.wrapSelectionInFrame",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length > 0 &&
!selectedElements.some((element) => isFrameLikeElement(element))
);
},
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
const elementsMap = app.scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2] = getCommonBounds(selectedElements, elementsMap);
const PADDING = 16;
const frame = newFrameElement({
x: x1 - PADDING,
y: y1 - PADDING,
width: x2 - x1 + PADDING * 2,
height: y2 - y1 + PADDING * 2,
});
// for a selected partial group, we want to remove it from the remainder of the group
if (appState.editingGroupId) {
const elementsInGroup = getElementsInGroup(
selectedElements,
appState.editingGroupId,
);
for (const elementInGroup of elementsInGroup) {
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
mutateElement(elementInGroup, elementsMap, {
groupIds: elementInGroup.groupIds.slice(0, index),
});
}
}
const nextElements = addElementsToFrame(
[...app.scene.getElementsIncludingDeleted(), frame],
selectedElements,
frame,
);
return {
elements: nextElements,
appState: {
selectedElementIds: { [frame.id]: true },
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
});

View File

@@ -0,0 +1,323 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { isBoundToContainer } from "@excalidraw/element";
import {
frameAndChildrenSelectedTogether,
getElementsInResizingFrame,
getFrameLikeElements,
getRootElements,
groupByFrameLikes,
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "@excalidraw/element";
import { KEYS, randomId, arrayToMap } from "@excalidraw/common";
import {
getSelectedGroupIds,
selectGroup,
selectGroupsForSelectedElements,
getElementsInGroup,
addToGroup,
removeFromSelectedGroups,
isElementInGroup,
} from "@excalidraw/element";
import { syncMovedIndices } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import { IconButton } from "../components/IconButton";
import { UngroupIcon, GroupIcon } from "../components/icons";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register";
import type { AppClassProperties, UIAppState } from "../types";
const allElementsInSameGroup = (
elements: readonly ExcalidrawElement[],
editingGroupId: UIAppState["editingGroupId"],
) => {
if (elements.length >= 2) {
const editingGroupIndex = editingGroupId
? elements[0].groupIds.indexOf(editingGroupId)
: -1;
const groupIds =
editingGroupIndex > -1
? elements[0].groupIds.slice(0, editingGroupIndex)
: elements[0].groupIds;
for (const groupId of groupIds) {
if (elements.every((element) => isElementInGroup(element, groupId))) {
return true;
}
}
}
return false;
};
const enableActionGroup = (appState: UIAppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
return (
selectedElements.length >= 2 &&
!allElementsInSameGroup(selectedElements, appState.editingGroupId) &&
!frameAndChildrenSelectedTogether(selectedElements)
);
};
export const actionGroup = register({
name: "group",
label: "labels.group",
icon: (appState) => <GroupIcon theme={appState.theme} />,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
if (!enableActionGroup(appState, app)) {
return false;
}
const selectedElements = getRootElements(
app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
}),
);
if (selectedElements.length < 2) {
// nothing to group
return {
appState,
elements,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
// if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState);
if (selectedGroupIds.length === 1) {
const selectedGroupId = selectedGroupIds[0];
const elementIdsInGroup = new Set(
getElementsInGroup(elements, selectedGroupId).map(
(element) => element.id,
),
);
const selectedElementIds = new Set(
selectedElements.map((element) => element.id),
);
const combinedSet = new Set([
...Array.from(elementIdsInGroup),
...Array.from(selectedElementIds),
]);
if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids
return {
appState,
elements,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
}
let nextElements = [...elements];
// this includes the case where we are grouping elements inside a frame
// and elements outside that frame
const groupingElementsFromDifferentFrames =
new Set(selectedElements.map((element) => element.frameId)).size > 1;
// when it happens, we want to remove elements that are in the frame
// and are going to be grouped from the frame (mouthful, I know)
if (groupingElementsFromDifferentFrames) {
const frameElementsMap = groupByFrameLikes(selectedElements);
frameElementsMap.forEach((elementsInFrame, frameId) => {
removeElementsFromFrame(
elementsInFrame,
app.scene.getNonDeletedElementsMap(),
);
});
}
const newGroupId = randomId();
const selectElementIds = arrayToMap(selectedElements);
nextElements = nextElements.map((element) => {
if (!selectElementIds.get(element.id)) {
return element;
}
return newElementWith(element, {
groupIds: addToGroup(
element.groupIds,
newGroupId,
appState.editingGroupId,
),
});
});
// keep the z order within the group the same, but move them
// to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = nextElements.lastIndexOf(
lastElementInGroup as OrderedExcalidrawElement,
);
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = nextElements
.slice(0, lastGroupElementIndex)
.filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
);
const reorderedElements = syncMovedIndices(
[...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup],
arrayToMap(elementsInGroup),
);
return {
appState: {
...appState,
...selectGroup(
newGroupId,
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
},
elements: reorderedElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
predicate: (elements, appState, _, app) => enableActionGroup(appState, app),
keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
PanelComponent: ({ elements, appState, updateData, app }) => (
<IconButton
hidden={!enableActionGroup(appState, app)}
type="button"
icon={<GroupIcon theme={appState.theme} />}
onClick={() => updateData(null)}
title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`}
aria-label={t("labels.group")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
></IconButton>
),
});
export const actionUngroup = register({
name: "ungroup",
label: "labels.ungroup",
icon: (appState) => <UngroupIcon theme={appState.theme} />,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const groupIds = getSelectedGroupIds(appState);
const elementsMap = arrayToMap(elements);
if (groupIds.length === 0) {
return {
appState,
elements,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
let nextElements = [...elements];
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
nextElements = nextElements.map((element) => {
if (isBoundToContainer(element)) {
boundTextElementIds.push(element.id);
}
const nextGroupIds = removeFromSelectedGroups(
element.groupIds,
appState.selectedGroupIds,
);
if (nextGroupIds.length === element.groupIds.length) {
return element;
}
return newElementWith(element, {
groupIds: nextGroupIds,
});
});
const updateAppState = selectGroupsForSelectedElements(
appState,
getNonDeletedElements(nextElements),
appState,
null,
);
const selectedElements = app.scene.getSelectedElements(appState);
const selectedElementFrameIds = new Set(
selectedElements
.filter((element) => element.frameId)
.map((element) => element.frameId!),
);
const targetFrames = getFrameLikeElements(elements).filter((frame) =>
selectedElementFrameIds.has(frame.id),
);
targetFrames.forEach((frame) => {
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,
getElementsInResizingFrame(
nextElements,
frame,
appState,
elementsMap,
),
frame,
);
}
});
// remove binded text elements from selection
updateAppState.selectedElementIds = Object.entries(
updateAppState.selectedElementIds,
).reduce(
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
if (selected && !boundTextElementIds.includes(id)) {
acc[id] = true;
}
return acc;
},
{},
);
return {
appState: { ...appState, ...updateAppState },
elements: nextElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
event.shiftKey &&
event[KEYS.CTRL_OR_CMD] &&
event.key === KEYS.G.toUpperCase(),
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
PanelComponent: ({ elements, appState, updateData }) => (
<IconButton
type="button"
hidden={getSelectedGroupIds(appState).length === 0}
icon={<UngroupIcon theme={appState.theme} />}
onClick={() => updateData(null)}
title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`}
aria-label={t("labels.ungroup")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
></IconButton>
),
});

View File

@@ -0,0 +1,146 @@
import {
KEYS,
matchKey,
arrayToMap,
MOBILE_ACTION_BUTTON_BG,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { orderByFractionalIndex } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { IconButton } from "../components/IconButton";
import { UndoIcon, RedoIcon } from "../components/icons";
import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n";
import { useStylesPanelMode } from "../components/App";
import type { History } from "../history";
import type { AppClassProperties, AppState } from "../types";
import type { Action, ActionResult } from "./types";
const executeHistoryAction = (
app: AppClassProperties,
appState: Readonly<AppState>,
updater: () => [SceneElementsMap, AppState] | void,
): ActionResult => {
if (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingTextElement &&
!appState.newElement &&
!appState.selectedElementsAreBeingDragged &&
!appState.selectionElement &&
!app.flowchart.isCreatingChart &&
// a drawShape sketch in progress isn't visible via `newElement` while
// the stroke is still unrecognized — block history mid-gesture the same
// way as for the other in-progress interactions above
!app.drawShape.hasPendingGesture()
) {
const result = updater();
if (!result) {
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
}
const [nextElementsMap, nextAppState] = result;
// order by fractional indices in case the map was accidently modified in the meantime
const nextElements = orderByFractionalIndex(
Array.from(nextElementsMap.values()),
);
return {
appState: nextAppState,
elements: nextElements,
captureUpdate: CaptureUpdateAction.NEVER,
};
}
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
};
type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
label: "buttons.undo",
icon: UndoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, value, app) =>
executeHistoryAction(app, appState, () =>
history.undo(arrayToMap(elements) as SceneElementsMap, appState),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
PanelComponent: ({ appState, updateData, data, app }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter,
new HistoryChangedEvent(
history.isUndoStackEmpty,
history.isRedoStackEmpty,
),
);
const isMobile = useStylesPanelMode() === "mobile";
return (
<IconButton
type="button"
icon={UndoIcon}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={isUndoStackEmpty}
data-testid="button-undo"
style={{
...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}),
}}
/>
);
},
});
export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
label: "buttons.redo",
icon: RedoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, __, app) =>
executeHistoryAction(app, appState, () =>
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ appState, updateData, data, app }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
new HistoryChangedEvent(
history.isUndoStackEmpty,
history.isRedoStackEmpty,
),
);
const isMobile = useStylesPanelMode() === "mobile";
return (
<IconButton
type="button"
icon={RedoIcon}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={isRedoStackEmpty}
data-testid="button-redo"
style={{
...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}),
}}
/>
);
},
});

View File

@@ -0,0 +1,212 @@
import {
isElbowArrow,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import { arrayToMap, invariant } from "@excalidraw/common";
import {
toggleLinePolygonState,
CaptureUpdateAction,
} from "@excalidraw/element";
import type {
ExcalidrawLinearElement,
ExcalidrawLineElement,
} from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { IconButton } from "../components/IconButton";
import { lineEditorIcon, polygonIcon } from "../components/icons";
import { t } from "../i18n";
import { newElementWith } from "../../element/src/mutateElement";
import { register } from "./register";
export const actionToggleLinearEditor = register({
name: "toggleLinearEditor",
category: DEFAULT_CATEGORIES.elements,
label: (elements, appState, app) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement | undefined;
return selectedElement?.type === "arrow"
? "labels.lineEditor.editArrow"
: "labels.lineEditor.edit";
},
keywords: ["line"],
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.selectedLinearElement?.isEditing &&
selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0])
) {
return true;
}
return false;
},
perform(elements, appState, _, app) {
invariant(
appState.selectedLinearElement,
"No selected linear element found",
);
const selectedElement = app.scene.getElement(
appState.selectedLinearElement.elementId,
) as ExcalidrawLinearElement | null;
invariant(selectedElement, "No selected element found");
const selectedLinearElement = {
...appState.selectedLinearElement,
isEditing: !appState.selectedLinearElement.isEditing,
};
return {
appState: {
...appState,
selectedLinearElement,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ appState, updateData, app }) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement;
if (!selectedElement) {
return null;
}
const label = t(
selectedElement.type === "arrow"
? "labels.lineEditor.editArrow"
: "labels.lineEditor.edit",
);
return (
<IconButton
type="button"
icon={lineEditorIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});
export const actionTogglePolygon = register({
name: "togglePolygon",
category: DEFAULT_CATEGORIES.elements,
icon: polygonIcon,
keywords: ["loop"],
label: (elements, appState, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
const allPolygons = !selectedElements.some(
(element) => !isLineElement(element) || !element.polygon,
);
return allPolygons
? "labels.polygon.breakPolygon"
: "labels.polygon.convertToPolygon";
},
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
return (
selectedElements.length > 0 &&
selectedElements.every(
(element) => isLineElement(element) && element.points.length >= 4,
)
);
},
perform(elements, appState, _, app) {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.some((element) => !isLineElement(element))) {
return false;
}
const targetElements = selectedElements as ExcalidrawLineElement[];
// if one element not a polygon, convert all to polygon
const nextPolygonState = targetElements.some((element) => !element.polygon);
const targetElementsMap = arrayToMap(targetElements);
return {
elements: elements.map((element) => {
if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
return element;
}
return newElementWith(element, {
backgroundColor: nextPolygonState
? element.backgroundColor
: "transparent",
...toggleLinePolygonState(element, nextPolygonState),
});
}),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ appState, updateData, app }) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
if (
selectedElements.length === 0 ||
selectedElements.some(
(element) =>
!isLineElement(element) ||
// only show polygon button if every selected element is already
// a polygon, effectively showing this button only to allow for
// disabling the polygon state
!element.polygon ||
element.points.length < 3,
)
) {
return null;
}
const allPolygon = selectedElements.every(
(element) => isLineElement(element) && element.polygon,
);
const label = t(
allPolygon
? "labels.polygon.breakPolygon"
: "labels.polygon.convertToPolygon",
);
return (
<IconButton
type="toggle"
icon={polygonIcon}
title={label}
aria-label={label}
checked={allPolygon}
onSelect={() => updateData(null)}
style={{ marginLeft: "auto" }}
/>
);
},
});

View File

@@ -0,0 +1,65 @@
import {
getNonDeletedElements,
isEmbeddableElement,
} from "@excalidraw/element";
import { KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { IconButton } from "../components/IconButton";
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register";
export const actionLink = register({
name: "hyperlink",
label: (elements, appState) =>
getContextMenuLabel(getNonDeletedElements(elements), appState),
icon: LinkIcon,
perform: (elements, appState) => {
if (appState.showHyperlinkPopup === "editor") {
return false;
}
return {
elements,
appState: {
...appState,
showHyperlinkPopup: "editor",
openMenu: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
trackEvent: { category: "hyperlink", action: "click" },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.length === 1;
},
PanelComponent: ({ elements, appState, updateData }) => {
const selectedElements = getSelectedElements(elements, appState);
return (
<IconButton
type="toggle"
icon={LinkIcon}
aria-label={t(
getContextMenuLabel(getNonDeletedElements(elements), appState),
)}
title={`${
isEmbeddableElement(elements[0])
? t("labels.link.labelEmbed")
: t("labels.link.label")
} - ${getShortcutKey("CtrlOrCmd+K")}`}
onSelect={() => updateData(null)}
checked={selectedElements.length === 1 && !!selectedElements[0].link}
/>
);
},
});

View File

@@ -0,0 +1,35 @@
import { KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { HelpIconThin } from "../components/icons";
import { register } from "./register";
export const actionShortcuts = register({
name: "toggleShortcuts",
label: "welcomeScreen.defaults.helpHint",
icon: HelpIconThin,
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog?.name === "help") {
focusContainer();
}
return {
appState: {
...appState,
openDialog:
appState.openDialog?.name === "help"
? null
: {
name: "help",
},
openMenu: null,
openPopup: null,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
});

View File

@@ -0,0 +1,151 @@
import clsx from "clsx";
import { CaptureUpdateAction } from "@excalidraw/element";
import { invariant } from "@excalidraw/common";
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import {
eyeIcon,
microphoneIcon,
microphoneMutedIcon,
} from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
import type { GoToCollaboratorComponentProps } from "../components/UserList";
import type { Collaborator } from "../types";
export const actionGoToCollaborator = register<Collaborator>({
name: "goToCollaborator",
label: "Go to a collaborator",
viewMode: true,
trackEvent: { category: "collab" },
perform: (_elements, appState, collaborator, app) => {
invariant(
collaborator,
"actionGoToCollaborator: collaborator should be defined when actionGoToCollaborator is called",
);
if (
!collaborator.socketId ||
app.props.userToFollow?.socketId === collaborator.socketId ||
collaborator.isCurrentUser
) {
app.requestUnfollow();
return {
appState,
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
app.emitUserFollowIntent({
userToFollow: {
socketId: collaborator.socketId,
username: collaborator.username || "",
},
action: "FOLLOW",
});
return {
appState: {
...appState,
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ updateData, data, appState }) => {
const { socketId, collaborator, withName, isBeingFollowed } =
data as GoToCollaboratorComponentProps;
const background = getClientColor(socketId, collaborator);
const statusClassNames = clsx({
"is-followed": isBeingFollowed,
"is-current-user": collaborator.isCurrentUser === true,
"is-speaking": collaborator.isSpeaking,
"is-in-call": collaborator.isInCall,
"is-muted": collaborator.isMuted,
});
const statusIconJSX = collaborator.isInCall ? (
collaborator.isSpeaking ? (
<div
className="UserList__collaborator-status-icon-speaking-indicator"
title={t("userList.hint.isSpeaking")}
>
<div />
<div />
<div />
</div>
) : collaborator.isMuted ? (
<div
className="UserList__collaborator-status-icon-microphone-muted"
title={t("userList.hint.micMuted")}
>
{microphoneMutedIcon}
</div>
) : (
<div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
)
) : null;
return withName ? (
<div
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
style={{ [`--avatar-size` as any]: "1.5rem" }}
onClick={
collaborator.isCurrentUser
? undefined
: () => updateData<Collaborator>(collaborator)
}
>
<Avatar
color={background}
onClick={() => {}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
className={statusClassNames}
/>
<div className="UserList__collaborator-name">
{collaborator.username}
</div>
<div className="UserList__collaborator-status-icons" aria-hidden>
{isBeingFollowed && (
<div
className="UserList__collaborator-status-icon-is-followed"
title={t("userList.hint.followStatus")}
>
{eyeIcon}
</div>
)}
{collaborator.isCurrentUser && ` (${t("labels.you")})`}
{statusIconJSX}
</div>
</div>
) : (
<div
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
>
<Avatar
color={background}
onClick={() => {
updateData(collaborator);
}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
className={statusClassNames}
/>
{statusIconJSX && (
<div className="UserList__collaborator-status-icon">
{statusIconJSX}
</div>
)}
</div>
);
},
});

View File

@@ -0,0 +1,255 @@
import { fireEvent, queryByTestId } from "@testing-library/react";
import {
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
FREEDRAW_STROKE_WIDTH,
FONT_FAMILY,
STROKE_WIDTH,
} from "@excalidraw/common";
import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api";
import { UI } from "../tests/helpers/ui";
import { render } from "../tests/test-utils";
describe("element locking", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
describe("properties when tool selected", () => {
it("should show active background top picks", () => {
UI.clickTool("rectangle");
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
API.setAppState({
currentItemBackgroundColor: color,
});
const activeColor = queryByTestId(
document.body,
`color-top-pick-${color}`,
);
expect(activeColor).toHaveClass("active");
});
it("should show fill style when background non-transparent", () => {
UI.clickTool("rectangle");
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
API.setAppState({
currentItemBackgroundColor: color,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toHaveClass("active");
API.setAppState({
currentItemFillStyle: "solid",
});
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
expect(solidFillStyle).toHaveClass("active");
});
it("should not show fill style when background transparent", () => {
UI.clickTool("rectangle");
API.setAppState({
currentItemBackgroundColor: COLOR_PALETTE.transparent,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toBe(null);
});
it("should not show fill style for sticky note tool", () => {
UI.clickTool("stickynote");
API.setAppState({
currentItemBackgroundColor: DEFAULT_ELEMENT_BACKGROUND_PICKS[1],
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toBe(null);
});
it("should show horizontal text align for text tool", () => {
UI.clickTool("text");
API.setAppState({
currentItemTextAlign: "right",
});
const centerTextAlign = queryByTestId(document.body, `align-right`);
expect(centerTextAlign).toBeChecked();
});
});
describe("properties when elements selected", () => {
it("should show active styles when single element selected", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: "red",
fillStyle: "cross-hatch",
});
API.setElements([rect]);
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
expect(crossHatchButton).toHaveClass("active");
});
it("should not show fill style selected element's background is transparent", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "cross-hatch",
});
API.setElements([rect]);
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
expect(crossHatchButton).toBe(null);
});
it("should not show fill style for selected sticky note", () => {
const stickyNote = API.createElement({
type: "stickynote",
backgroundColor: DEFAULT_ELEMENT_BACKGROUND_PICKS[1],
fillStyle: "hachure",
});
API.setElements([stickyNote]);
API.setSelectedElements([stickyNote]);
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toBe(null);
});
it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]);
const thinStrokeWidthButton = queryByTestId(
document.body,
`strokeWidth-thin`,
);
expect(thinStrokeWidthButton).toBeChecked();
});
it("should highlight common stroke width key across freedraw and non-freedraw elements", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
});
const freedraw = API.createElement({
type: "freedraw",
strokeWidth: FREEDRAW_STROKE_WIDTH.medium,
});
API.setElements([rect, freedraw]);
API.setSelectedElements([rect, freedraw]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
});
it("should apply stroke width by element type", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const freedraw = API.createElement({
type: "freedraw",
strokeWidth: FREEDRAW_STROKE_WIDTH.thin,
});
API.setElements([rect, freedraw]);
API.setSelectedElements([rect, freedraw]);
const boldStrokeWidthButton = queryByTestId(
document.body,
`strokeWidth-bold`,
);
expect(boldStrokeWidthButton).not.toBe(null);
fireEvent.click(boldStrokeWidthButton!);
const selectedElements = API.getSelectedElements();
const selectedRect = selectedElements.find(
(element) => element.type === "rectangle",
);
const selectedFreedraw = selectedElements.find(
(element) => element.type === "freedraw",
);
expect(selectedRect?.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(selectedFreedraw?.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
});
it("should create new elements with stroke width by element type", () => {
API.setAppState({ currentItemStrokeWidthKey: "bold" });
const rect = API.createElement({ type: "rectangle" });
const freedraw = API.createElement({ type: "freedraw" });
expect(rect.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(freedraw.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
});
it("should not highlight any stroke width button if no common style", () => {
const rect1 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
});
API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]);
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
expect(
queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-medium`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked();
});
it("should show properties of different element types when selected", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
});
const text = API.createElement({
type: "text",
fontFamily: FONT_FAMILY["Comic Shanns"],
});
API.setElements([rect, text]);
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active",
);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import { isLinearElement, isTextElement } from "@excalidraw/element";
import { arrayToMap, KEYS } from "@excalidraw/common";
import { selectGroupsForSelectedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "@excalidraw/element/types";
import { selectAllIcon } from "../components/icons";
import { register } from "./register";
export const actionSelectAll = register({
name: "selectAll",
label: "labels.selectAll",
icon: selectAllIcon,
trackEvent: { category: "canvas" },
viewMode: false,
perform: (elements, appState, value, app) => {
if (appState.selectedLinearElement?.isEditing) {
return false;
}
const selectedElementIds = elements
.filter(
(element) =>
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
!element.locked,
)
.reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
map[element.id] = true;
return map;
}, {});
return {
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: null,
selectedElementIds,
},
getNonDeletedElements(elements),
appState,
app,
),
selectedLinearElement:
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(
elements[0] as NonDeleted<ExcalidrawLinearElement>,
arrayToMap(elements),
)
: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
});

View File

@@ -0,0 +1,231 @@
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
CODES,
KEYS,
getLineHeight,
arrayToMap,
} from "@excalidraw/common";
import { newElementWith, syncStickyNoteInk } from "@excalidraw/element";
import {
normalizeStickyNote,
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
isFrameLikeElement,
isArrowElement,
isExcalidrawElement,
getColorUpdate,
isNonDeletedElement,
isStickyNoteBoundText,
isStickyNoteElement,
isTextElement,
getBaseFontSize,
getBaseFontSizeUpdate,
relayoutStickyNotes,
updateBoundElements,
} from "@excalidraw/element";
import {
getBoundTextElement,
redrawTextBoundingBox,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
import { paintIcon } from "../components/icons";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { register } from "./register";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
export const actionCopyStyles = register({
name: "copyStyles",
label: "labels.copyStyles",
icon: paintIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
const elementsCopied = [];
const element = elements.find((el) => appState.selectedElementIds[el.id]);
elementsCopied.push(element);
if (element && hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
elementsCopied.push(boundTextElement);
}
if (element) {
copiedStyles = JSON.stringify(elementsCopied);
}
return {
appState: {
...appState,
toast: { message: t("toast.copyStyles") },
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
});
export const actionPasteStyles = register({
name: "pasteStyles",
label: "labels.pasteStyles",
icon: paintIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
const elementsCopied = JSON.parse(copiedStyles);
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) {
return { elements, captureUpdate: CaptureUpdateAction.EVENTUALLY };
}
const selectedElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
const selectedElementIds = selectedElements.map((element) => element.id);
const elementsMap = arrayToMap(elements);
// whether the copied text was a sticky label is decided by the copied
// snapshot — its container may be gone from the live scene by now
const copiedElementsMap = arrayToMap(
(elementsCopied as unknown[]).filter(isExcalidrawElement),
);
const nextElements = relayoutStickyNotes(
// a restyled note and its label end up with one ink — the label's,
// when the copied styles carry two colors
syncStickyNoteInk(
elements.map((element) => {
if (selectedElementIds.includes(element.id)) {
let elementStylesToCopyFrom = pastedElement;
if (isTextElement(element) && element.containerId) {
elementStylesToCopyFrom = boundTextElement;
}
if (!elementStylesToCopyFrom) {
return element;
}
let newElement = newElementWith(element, {
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
strokeColor: elementStylesToCopyFrom?.strokeColor,
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
fillStyle: elementStylesToCopyFrom?.fillStyle,
opacity: elementStylesToCopyFrom?.opacity,
roughness: elementStylesToCopyFrom?.roughness,
roundness: elementStylesToCopyFrom.roundness
? canApplyRoundnessTypeToElement(
elementStylesToCopyFrom.roundness.type,
element,
)
? elementStylesToCopyFrom.roundness
: getDefaultRoundnessTypeForElement(element)
: null,
});
if (isTextElement(newElement)) {
const sourceText =
elementStylesToCopyFrom as ExcalidrawTextElement;
const fontSize =
(isTextElement(elementStylesToCopyFrom)
? getBaseFontSize(elementStylesToCopyFrom, copiedElementsMap)
: sourceText.fontSize) || DEFAULT_FONT_SIZE;
const fontFamily = sourceText.fontFamily || DEFAULT_FONT_FAMILY;
let container = null;
const containerId = newElement.containerId;
if (containerId) {
container =
selectedElements.find(
(element) => element.id === containerId,
) || null;
}
const newTextElement = newElementWith(newElement, {
...getBaseFontSizeUpdate(newElement, fontSize, elementsMap),
fontFamily,
textAlign: sourceText.textAlign || DEFAULT_TEXT_ALIGN,
lineHeight: sourceText.lineHeight || getLineHeight(fontFamily),
});
newElement = newTextElement;
if (isStickyNoteBoundText(newTextElement, elementsMap)) {
// the copied stroke may be transparent; a note's label never is
newElement = newElementWith(
newTextElement,
getColorUpdate(
newTextElement,
"strokeColor",
newTextElement.strokeColor,
elementsMap,
),
);
} else {
// sticky labels are laid out together with their (possibly
// also restyled) note in the post-pass below
redrawTextBoundingBox(newTextElement, container, app.scene);
}
}
if (
newElement.type === "arrow" &&
isArrowElement(elementStylesToCopyFrom)
) {
newElement = newElementWith(newElement, {
startArrowhead: elementStylesToCopyFrom.startArrowhead,
endArrowhead: elementStylesToCopyFrom.endArrowhead,
});
}
if (isFrameLikeElement(element)) {
newElement = newElementWith(newElement, {
roundness: null,
backgroundColor: "transparent",
});
}
if (isStickyNoteElement(newElement)) {
newElement = normalizeStickyNote(newElement);
}
return newElement;
}
return element;
}),
elementsMap,
),
new Set(selectedElementIds),
{ prevElementsMap: elementsMap },
);
// a restyled note may have grown or shrunk — arrows bound to it follow
for (const element of nextElements) {
const prev = elementsMap.get(element.id);
if (
isStickyNoteElement(element) &&
isNonDeletedElement(element) &&
prev &&
(prev.height !== element.height ||
prev.x !== element.x ||
prev.y !== element.y)
) {
updateBoundElements(element, app.scene);
}
}
return {
elements: nextElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
});

View File

@@ -0,0 +1,88 @@
import { getFontString } from "@excalidraw/common";
import {
getTextAnchorRatios,
isExcalidrawElement,
isNonDeletedElement,
newElementWith,
updateBoundElements,
} from "@excalidraw/element";
import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register";
export const actionTextAutoResize = register({
name: "autoResize",
label: "labels.autoResize",
icon: null,
trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
!selectedElements[0].autoResize
);
},
perform: (elements, appState, targetElement, app) => {
const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
const target = elements.find(
(element) => element.id === targetTextElement?.id,
);
if (!target || !isTextElement(target)) {
return false;
}
const metrics = measureText(
target.originalText,
getFontString(target),
target.lineHeight,
);
// unwrapping resizes the box, so keep the point the text's alignment pins
// — otherwise a right-aligned or centred text slides sideways, and one
// bound to an arrow endpoint drags the arrow with it
const anchor = getTextAnchorRatios(target);
const resized = newElementWith(target, {
autoResize: true,
width: metrics.width,
height: metrics.height,
x: target.x + (target.width - metrics.width) * anchor.x,
y: target.y + (target.height - metrics.height) * anchor.y,
text: target.originalText,
});
// the anchor only pins one point of the box, so any other arrow bound to
// this text still has to be re-routed to its new geometry. The scene holds
// the pre-resize text, hence handing the new one over explicitly.
if (isNonDeletedElement(resized)) {
updateBoundElements(resized, app.scene, {
changedElements: new Map([[resized.id, resized]]),
});
}
return {
appState,
elements: elements.map((element) =>
element.id === resized.id ? resized : element,
),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
});

View File

@@ -0,0 +1,26 @@
import { CaptureUpdateAction } from "@excalidraw/element";
import { register } from "./register";
export const actionToggleArrowBinding = register({
name: "arrowBinding",
label: "labels.arrowBinding",
viewMode: false,
trackEvent: {
category: "canvas",
predicate: (appState) => appState.bindingPreference === "disabled",
},
perform(elements, appState) {
const newPreference =
appState.bindingPreference === "enabled" ? "disabled" : "enabled";
return {
appState: {
...appState,
bindingPreference: newPreference,
isBindingEnabled: newPreference === "enabled",
},
captureUpdate: CaptureUpdateAction.NEVER,
};
},
checked: (appState) => appState.bindingPreference === "enabled",
});

View File

@@ -0,0 +1,34 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { gridIcon } from "../components/icons";
import { register } from "./register";
export const actionToggleGridMode = register({
name: "gridMode",
icon: gridIcon,
keywords: ["snap"],
label: "labels.toggleGrid",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => appState.gridModeEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
gridModeEnabled: !this.checked!(appState),
objectsSnapModeEnabled: false,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
checked: (appState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View File

@@ -0,0 +1,23 @@
import { CaptureUpdateAction } from "@excalidraw/element";
import { register } from "./register";
export const actionToggleMidpointSnapping = register({
name: "midpointSnapping",
label: "labels.midpointSnapping",
viewMode: false,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.isMidpointSnappingEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
isMidpointSnappingEnabled: !this.checked!(appState),
},
captureUpdate: CaptureUpdateAction.NEVER,
};
},
checked: (appState) => appState.isMidpointSnappingEnabled,
});

View File

@@ -0,0 +1,34 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { magnetIcon } from "../components/icons";
import { register } from "./register";
export const actionToggleObjectsSnapMode = register({
name: "objectsSnapMode",
label: "buttons.objectsSnapMode",
icon: magnetIcon,
viewMode: false,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.objectsSnapModeEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
objectsSnapModeEnabled: !this.checked!(appState),
gridModeEnabled: false,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
checked: (appState) => appState.objectsSnapModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.objectsSnapModeEnabled === "undefined";
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
});

View File

@@ -0,0 +1,58 @@
import {
KEYS,
CANVAS_SEARCH_TAB,
CLASSES,
DEFAULT_SIDEBAR,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { searchIcon } from "../components/icons";
import { register } from "./register";
export const actionToggleSearchMenu = register({
name: "searchMenu",
icon: searchIcon,
keywords: ["search", "find"],
label: "search.title",
viewMode: true,
trackEvent: {
category: "search_menu",
action: "toggle",
predicate: (appState) => appState.gridModeEnabled,
},
perform(elements, appState, _, app) {
if (appState.openDialog) {
return false;
}
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
const searchInput =
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
);
searchInput?.focus();
searchInput?.select();
return false;
}
return {
appState: {
...appState,
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
openDialog: null,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
checked: (appState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
});

View File

@@ -0,0 +1,35 @@
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import {
getConversionTypeFromElements,
convertElementTypePopupAtom,
} from "../components/ConvertElementTypePopup";
import { editorJotaiStore } from "../editor-jotai";
import { register } from "./register";
export const actionToggleShapeSwitch = register({
name: "toggleShapeSwitch",
label: "labels.shapeSwitch",
icon: () => null,
viewMode: true,
trackEvent: {
category: "shape_switch",
action: "toggle",
},
keywords: ["change", "switch", "swap"],
perform(elements, appState, _, app) {
editorJotaiStore.set(convertElementTypePopupAtom, {
type: "panel",
});
return {
captureUpdate: CaptureUpdateAction.NEVER,
};
},
checked: (appState) => appState.gridModeEnabled,
predicate: (elements, appState, props) =>
getConversionTypeFromElements(elements as ExcalidrawElement[]) !== null,
});

View File

@@ -0,0 +1,28 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { abacusIcon } from "../components/icons";
import { register } from "./register";
export const actionToggleStats = register({
name: "stats",
label: "stats.fullTitle",
icon: abacusIcon,
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["edit", "attributes", "customize"],
perform(elements, appState) {
return {
appState: {
...appState,
stats: { ...appState.stats, open: !this.checked!(appState) },
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
checked: (appState) => appState.stats.open,
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
});

View File

@@ -0,0 +1,36 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { eyeIcon } from "../components/icons";
import { register } from "./register";
export const actionToggleViewMode = register({
name: "viewMode",
label: "labels.viewMode",
icon: eyeIcon,
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.viewModeEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
viewModeEnabled: !this.checked!(appState),
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
checked: (appState) => appState.viewModeEnabled,
predicate: (elements, appState, appProps, app) => {
return (
typeof appProps.viewModeEnabled === "undefined" &&
app.isInteractionEnabled()
);
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
});

View File

@@ -0,0 +1,36 @@
import { CODES, KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { coffeeIcon } from "../components/icons";
import { register } from "./register";
export const actionToggleZenMode = register({
name: "zenMode",
label: "buttons.zenMode",
icon: coffeeIcon,
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.zenModeEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
zenModeEnabled: !this.checked!(appState),
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
checked: (appState) => appState.zenModeEnabled,
predicate: (elements, appState, appProps, app) => {
return (
app.editorInterface.formFactor !== "phone" &&
typeof appProps.zenModeEnabled === "undefined"
);
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
});

View File

@@ -0,0 +1,156 @@
import { KEYS, CODES, isDarwin } from "@excalidraw/common";
import {
moveOneLeft,
moveOneRight,
moveAllLeft,
moveAllRight,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import {
BringForwardIcon,
BringToFrontIcon,
SendBackwardIcon,
SendToBackIcon,
} from "../components/icons";
import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { register } from "./register";
export const actionSendBackward = register({
name: "sendBackward",
label: "labels.sendBackward",
keywords: ["move down", "zindex", "layer"],
icon: SendBackwardIcon,
trackEvent: { category: "element" },
perform: (elements, appState, value, app) => {
return {
elements: moveOneLeft(elements, appState, app.scene),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyPriority: 40,
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
event.code === CODES.BRACKET_LEFT,
PanelComponent: ({ updateData, appState }) => (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
>
{SendBackwardIcon}
</button>
),
});
export const actionBringForward = register({
name: "bringForward",
label: "labels.bringForward",
keywords: ["move up", "zindex", "layer"],
icon: BringForwardIcon,
trackEvent: { category: "element" },
perform: (elements, appState, value, app) => {
return {
elements: moveOneRight(elements, appState, app.scene),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyPriority: 40,
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
event.code === CODES.BRACKET_RIGHT,
PanelComponent: ({ updateData, appState }) => (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
>
{BringForwardIcon}
</button>
),
});
export const actionSendToBack = register({
name: "sendToBack",
label: "labels.sendToBack",
keywords: ["move down", "zindex", "layer"],
icon: SendToBackIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllLeft(elements, appState),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
isDarwin
? event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.code === CODES.BRACKET_LEFT
: event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.code === CODES.BRACKET_LEFT,
PanelComponent: ({ updateData, appState }) => (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.sendToBack")} — ${
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+[")
}`}
>
{SendToBackIcon}
</button>
),
});
export const actionBringToFront = register({
name: "bringToFront",
label: "labels.bringToFront",
keywords: ["move up", "zindex", "layer"],
icon: BringToFrontIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllRight(elements, appState),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event) =>
isDarwin
? event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.code === CODES.BRACKET_RIGHT
: event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.code === CODES.BRACKET_RIGHT,
PanelComponent: ({ updateData, appState }) => (
<button
type="button"
className="zIndexButton"
onClick={(event) => updateData(null)}
title={`${t("labels.bringToFront")} — ${
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]")
}`}
>
{BringToFrontIcon}
</button>
),
});

View File

@@ -0,0 +1,192 @@
import {
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
DEFAULT_ELEMENT_STROKE_PICKS,
STICKY_NOTE_BACKGROUND_PICKS,
STICKY_NOTE_STROKE_PICKS,
arrayToMap,
} from "@excalidraw/common";
import {
getColorTargetElement,
hasBackground,
hasStrokeColor,
isStickyNoteBoundText,
isStickyNoteElement,
isTextElement,
normalizeStickyNoteBackgroundColor,
normalizeStickyNoteStrokeColor,
} from "@excalidraw/element";
import type { ColorPaletteCustom, ColorTuple } from "@excalidraw/common";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene";
import type { AppState } from "../types";
export type ColorProperty = "strokeColor" | "backgroundColor";
/**
* Sticky notes are their own color domain: own defaults, own top picks, no
* transparent. A pick targets the regular domain, the sticky domain, or both
* ("mixed" selections write both defaults and use the regular picker).
*/
export type ColorTargetKind = "regular" | "sticky" | "mixed";
export type ColorDefaultKey =
| "currentItemStrokeColor"
| "currentItemBackgroundColor"
| "currentItemStickynoteStrokeColor"
| "currentItemStickynoteBackgroundColor";
export type ColorTargetAppState = Pick<
AppState,
"selectedElementIds" | "editingTextElement" | "activeTool" | ColorDefaultKey
>;
export type ColorTarget = {
kind: ColorTargetKind;
property: ColorProperty;
/** the current-item defaults a pick is written to (normalized per domain) */
appStateKeys: readonly ColorDefaultKey[];
/** the default shown when no element is targeted */
currentValue: string;
palette: ColorPaletteCustom;
topPicks: ColorTuple;
customizableTopPicks: keyof AppState["colorTopPicks"];
excludedColors: readonly string[] | undefined;
};
const DEFAULT_KEYS: Record<
ColorProperty,
Record<"regular" | "sticky", ColorDefaultKey>
> = {
strokeColor: {
regular: "currentItemStrokeColor",
sticky: "currentItemStickynoteStrokeColor",
},
backgroundColor: {
regular: "currentItemBackgroundColor",
sticky: "currentItemStickynoteBackgroundColor",
},
};
// transparent is hidden rather than removed from the palette so the remaining
// colors keep their usual hotkeys (same mechanism as the bucket fill picker);
// a module constant so the memoized picker's identity comparison holds
const STICKY_NOTE_EXCLUDED_COLORS: readonly string[] = [
COLOR_PALETTE.transparent,
];
const isStickyNoteColorTarget = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
) =>
isStickyNoteElement(element) ||
(isTextElement(element) && isStickyNoteBoundText(element, elementsMap));
/**
* Who a stroke/background pick targets. Resolve it from the state an action
* runs against, at execution time — never capture it in a render closure:
* the memoized picker keeps a stale `onChange`, and the always-visible top
* picks fire it without a re-render.
*
* Targets are the selected color-capable elements (for stroke incl. bound
* labels, since a note's visible text is its label) plus the text being
* edited (`handleTextWysiwyg` deselects while editing) — or, for a
* background pick on a note's label, the note (see `getColorTargetElement`).
* With no target, the active tool decides the domain.
*/
export const resolveColorTarget = (
appState: ColorTargetAppState,
elements: readonly ExcalidrawElement[],
property: ColorProperty,
): ColorTarget => {
const elementsMap = arrayToMap(elements);
const supports = (element: ExcalidrawElement) =>
property === "strokeColor"
? hasStrokeColor(element.type)
: hasBackground(element.type);
const targets: ExcalidrawElement[] = getSelectedElements(elements, appState, {
includeBoundTextElement: property === "strokeColor",
}).filter(supports);
const editingText =
appState.editingTextElement &&
elementsMap.get(appState.editingTextElement.id);
const editing =
editingText && getColorTargetElement(editingText, property, elementsMap);
if (
editing &&
!editing.isDeleted &&
supports(editing) &&
!targets.some((element) => element.id === editing.id)
) {
targets.push(editing);
}
let kind: ColorTargetKind;
if (!targets.length) {
kind = appState.activeTool.type === "stickynote" ? "sticky" : "regular";
} else {
const stickyCount = targets.filter((element) =>
isStickyNoteColorTarget(element, elementsMap),
).length;
kind =
stickyCount === 0
? "regular"
: stickyCount === targets.length
? "sticky"
: "mixed";
}
const keys = DEFAULT_KEYS[property];
const isStroke = property === "strokeColor";
return {
kind,
property,
appStateKeys: kind === "mixed" ? [keys.regular, keys.sticky] : [keys[kind]],
currentValue: appState[kind === "sticky" ? keys.sticky : keys.regular],
palette: isStroke
? DEFAULT_ELEMENT_STROKE_COLOR_PALETTE
: DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
topPicks:
kind === "sticky"
? isStroke
? STICKY_NOTE_STROKE_PICKS
: STICKY_NOTE_BACKGROUND_PICKS
: isStroke
? DEFAULT_ELEMENT_STROKE_PICKS
: DEFAULT_ELEMENT_BACKGROUND_PICKS,
customizableTopPicks:
kind === "sticky"
? isStroke
? "stickyNoteStroke"
: "stickyNoteBackground"
: isStroke
? "elementStroke"
: "elementBackground",
excludedColors: kind === "sticky" ? STICKY_NOTE_EXCLUDED_COLORS : undefined,
};
};
/** the current-item default updates for a picked color */
export const getColorTargetAppStateUpdates = (
target: ColorTarget,
color: string,
): Partial<AppState> => {
const updates: Partial<AppState> = {};
for (const key of target.appStateKeys) {
updates[key] =
key === "currentItemStickynoteStrokeColor"
? normalizeStickyNoteStrokeColor(color)
: key === "currentItemStickynoteBackgroundColor"
? normalizeStickyNoteBackgroundColor(color)
: color;
}
return updates;
};

View File

@@ -0,0 +1,94 @@
export { actionDeleteSelected } from "./actionDeleteSelected";
export {
actionBringForward,
actionBringToFront,
actionSendBackward,
actionSendToBack,
} from "./actionZindex";
export { actionSelectAll } from "./actionSelectAll";
export { actionDuplicateSelection } from "./actionDuplicateSelection";
export {
actionChangeStrokeColor,
actionChangeBackgroundColor,
actionChangeBucketFillBackgroundColor,
actionChangeStrokeWidth,
actionChangeFillStyle,
actionChangeSloppiness,
actionChangeFreedrawMode,
actionChangeOpacity,
actionChangeFontSize,
actionChangeFontFamily,
actionChangeTextAlign,
actionChangeVerticalAlign,
actionChangeArrowProperties,
} from "./actionProperties";
export {
actionChangeViewBackgroundColor,
actionClearCanvas,
actionZoomIn,
actionZoomOut,
actionResetZoom,
actionZoomToFit,
actionToggleTheme,
} from "./actionCanvas";
export { actionFinalize } from "./actionFinalize";
export { actionDeselect } from "./actionDeselect";
export {
actionChangeProjectName,
actionChangeExportBackground,
actionSaveToActiveFile,
actionSaveFileToDisk,
actionLoadScene,
} from "./actionExport";
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export { actionShortcuts } from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup";
export { actionGoToCollaborator } from "./actionNavigate";
export { actionAddToLibrary } from "./actionAddToLibrary";
export {
actionAlignTop,
actionAlignBottom,
actionAlignLeft,
actionAlignRight,
actionAlignVerticallyCentered,
actionAlignHorizontallyCentered,
} from "./actionAlign";
export {
distributeHorizontally,
distributeVertically,
} from "./actionDistribute";
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
export {
actionCopy,
actionCut,
actionCopyAsPng,
actionCopyAsSvg,
copyText,
} from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
export { actionToggleArrowBinding } from "./actionToggleArrowBinding";
export { actionToggleMidpointSnapping } from "./actionToggleMidpointSnapping";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "./actionLink";
export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
export { actionToggleCropEditor } from "./actionCropEditor";

View File

@@ -0,0 +1,246 @@
import React from "react";
import { isPromiseLike } from "@excalidraw/common";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import { trackEvent } from "../analytics";
import type { AppClassProperties, AppState } from "../types";
import type {
Action,
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
ActionSource,
} from "./types";
const trackAction = (
action: Action,
source: ActionSource,
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
value: any,
) => {
if (action.trackEvent) {
try {
if (typeof action.trackEvent === "object") {
const shouldTrack = action.trackEvent.predicate
? action.trackEvent.predicate(appState, elements, value)
: true;
if (shouldTrack) {
trackEvent(
action.trackEvent.category,
action.trackEvent.action || action.name,
`${source} (${
app.editorInterface.formFactor === "phone" ? "mobile" : "desktop"
})`,
);
}
}
} catch (error) {
console.error("error while logging action:", error);
}
}
};
export class ActionManager {
actions = {} as Record<ActionName, Action>;
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[];
app: AppClassProperties;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[],
app: AppClassProperties,
) {
this.updater = (actionResult) => {
if (isPromiseLike(actionResult)) {
actionResult.then((actionResult) => {
return updater(actionResult);
});
} else {
return updater(actionResult);
}
};
this.getAppState = getAppState;
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
this.app = app;
}
registerAction(action: Action) {
this.actions[action.name] = action;
}
registerAll(actions: readonly Action[]) {
actions.forEach((action) => this.registerAction(action));
}
private isActionBlockedByViewportTransition = (action: Action) =>
action.navigation === true && this.app.viewport.isLockedTransitionPending;
handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
if (!this.app.isInteractionEnabled() && !this.app.isNavigationEnabled()) {
return false;
}
const canvasActions = this.app.props.UIOptions.canvasActions;
const data = Object.values(this.actions)
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
.filter(
(action) =>
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
action.keyTest &&
action.keyTest(
event,
this.getAppState(),
this.getElementsIncludingDeleted(),
this.app,
),
);
if (data.length !== 1) {
if (data.length > 1) {
console.warn("Canceling as multiple actions match this shortcut", data);
}
return false;
}
const action = data[0];
// in the non-interactive editor, only navigation actions are allowed
// (when navigation itself is)
if (!this.app.isInteractionEnabled() && action.navigation !== true) {
return false;
}
if (this.getAppState().viewModeEnabled && action.viewMode !== true) {
return false;
}
if (this.isActionBlockedByViewportTransition(action)) {
event.preventDefault();
event.stopPropagation();
return true;
}
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const value = null;
trackAction(action, "keyboard", appState, elements, this.app, null);
event.preventDefault();
event.stopPropagation();
this.updater(data[0].perform(elements, appState, value, this.app));
return true;
}
executeAction<T extends Action>(
action: T,
source: ActionSource = "api",
value: Parameters<T["perform"]>[2] = null,
) {
// the user must not be able to affect a non-interactive editor
// (programmatic execution by the host remains allowed, as are
// navigation actions when navigation is)
if (
source !== "api" &&
!this.app.isInteractionEnabled() &&
!(this.app.isNavigationEnabled() && action.navigation === true)
) {
return;
}
if (this.isActionBlockedByViewportTransition(action)) {
return;
}
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
trackAction(action, source, appState, elements, this.app, value);
this.updater(action.perform(elements, appState, value, this.app));
}
/**
* @param data additional data sent to the PanelComponent
*/
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
this.actions[name] &&
"PanelComponent" in this.actions[name] &&
(name in canvasActions
? canvasActions[name as keyof typeof canvasActions]
: true)
) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
PanelComponent.displayName = "PanelComponent";
const updateData = (formState?: any) => {
if (this.isActionBlockedByViewportTransition(action)) {
return;
}
// read fresh state at call time — memoized panel children may invoke
// an `updateData` closure minted by an earlier render
trackAction(
action,
"ui",
this.getAppState(),
this.getElementsIncludingDeleted(),
this.app,
formState,
);
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
formState,
this.app,
),
);
};
return (
<PanelComponent
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}
appProps={this.app.props}
app={this.app}
data={data}
renderAction={this.renderAction}
/>
);
}
return null;
};
isActionEnabled = (action: Action) => {
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
return (
!action.predicate ||
action.predicate(elements, appState, this.app.props, this.app)
);
};
}

View File

@@ -0,0 +1,15 @@
import type { Action } from "./types";
export let actions: readonly Action[] = [];
export const register = <
TData extends any,
T extends Action<TData> = Action<TData>,
>(
action: T,
) => {
actions = actions.concat(action);
return action as T & {
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
};
};

View File

@@ -0,0 +1,124 @@
import { isDarwin } from "@excalidraw/common";
import type { SubtypeOf } from "@excalidraw/common/utility-types";
import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import type { ActionName } from "./types";
export type ShortcutName =
| SubtypeOf<
ActionName,
| "toggleTheme"
| "loadScene"
| "clearCanvas"
| "cut"
| "copy"
| "paste"
| "copyStyles"
| "pasteStyles"
| "selectAll"
| "deleteSelectedElements"
| "duplicateSelection"
| "sendBackward"
| "bringForward"
| "sendToBack"
| "bringToFront"
| "copyAsPng"
| "group"
| "ungroup"
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "stats"
| "addToLibrary"
| "viewMode"
| "flipHorizontal"
| "flipVertical"
| "hyperlink"
| "toggleElementLock"
| "resetZoom"
| "zoomOut"
| "zoomIn"
| "zoomToFit"
| "zoomToFitSelectionInViewport"
| "zoomToFitSelection"
| "saveFileToDisk"
| "saveToActiveFile"
| "toggleShortcuts"
| "wrapSelectionInFrame"
>
| "saveScene"
| "imageExport"
| "commandPalette"
| "searchMenu"
| "toolLock";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
saveScene: [getShortcutKey("CtrlOrCmd+S")],
loadScene: [getShortcutKey("CtrlOrCmd+O")],
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
commandPalette: [
getShortcutKey("CtrlOrCmd+/"),
getShortcutKey("CtrlOrCmd+Shift+P"),
],
cut: [getShortcutKey("CtrlOrCmd+X")],
copy: [getShortcutKey("CtrlOrCmd+C")],
paste: [getShortcutKey("CtrlOrCmd+V")],
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")],
deleteSelectedElements: [getShortcutKey("Delete")],
duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
],
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
bringForward: [getShortcutKey("CtrlOrCmd+]")],
sendToBack: [
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+["),
],
bringToFront: [
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]"),
],
copyAsPng: [getShortcutKey("Shift+Alt+C")],
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
objectsSnapMode: [getShortcutKey("Alt+S")],
stats: [getShortcutKey("Alt+/")],
addToLibrary: [],
flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
resetZoom: [getShortcutKey("CtrlOrCmd+0")],
zoomOut: [getShortcutKey("CtrlOrCmd+-")],
zoomIn: [getShortcutKey("CtrlOrCmd++")],
zoomToFitSelection: [getShortcutKey("Shift+3")],
zoomToFit: [getShortcutKey("Shift+1")],
zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")],
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [],
toolLock: [getShortcutKey("Q")],
};
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
const shortcuts = shortcutMap[name];
// if multiple shortcuts available, take the first one
return shortcuts && shortcuts.length > 0
? shortcuts[idx] || shortcuts[0]
: "";
};

View File

@@ -0,0 +1,222 @@
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type { CaptureUpdateActionType } from "@excalidraw/element";
import type {
AppClassProperties,
AppState,
ExcalidrawProps,
BinaryFiles,
UIAppState,
} from "../types";
import type React from "react";
export type ActionSource =
| "ui"
| "keyboard"
| "contextMenu"
| "api"
| "commandPalette";
/** if false, the action should be prevented */
export type ActionResult =
| {
elements?: readonly ExcalidrawElement[] | null;
appState?: Partial<AppState> | null;
files?: BinaryFiles | null;
captureUpdate: CaptureUpdateActionType;
replaceFiles?: boolean;
}
| false;
type ActionFn<TData = any> = (
elements: readonly OrderedExcalidrawElement[],
appState: Readonly<AppState>,
formData: TData | undefined,
app: AppClassProperties,
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void;
export type ActionName =
| "copy"
| "cut"
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "copyText"
| "sendBackward"
| "bringForward"
| "sendToBack"
| "bringToFront"
| "copyStyles"
| "selectAll"
| "pasteStyles"
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "arrowBinding"
| "midpointSnapping"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
| "changeBucketFillBackgroundColor"
| "changeFillStyle"
| "changeStrokeWidth"
| "changeSloppiness"
| "changeFreedrawMode"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
| "changeArrowProperties"
| "changeOpacity"
| "changeFontSize"
| "undo"
| "redo"
| "finalize"
| "changeProjectName"
| "changeExportBackground"
| "changeExportEmbedScene"
| "changeExportScale"
| "saveToActiveFile"
| "saveFileToDisk"
| "loadScene"
| "duplicateSelection"
| "deleteSelectedElements"
| "changeViewBackgroundColor"
| "clearCanvas"
| "zoomIn"
| "zoomOut"
| "resetZoom"
| "zoomToFit"
| "zoomToFitSelection"
| "zoomToFitSelectionInViewport"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
| "toggleFullScreen"
| "toggleShortcuts"
| "group"
| "ungroup"
| "goToCollaborator"
| "addToLibrary"
| "changeRoundness"
| "alignTop"
| "alignBottom"
| "alignLeft"
| "alignRight"
| "alignVerticallyCentered"
| "alignHorizontallyCentered"
| "distributeHorizontally"
| "distributeVertically"
| "flipHorizontal"
| "flipVertical"
| "deselect"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme"
| "increaseFontSize"
| "decreaseFontSize"
| "unbindText"
| "hyperlink"
| "bindText"
| "unlockAllElements"
| "toggleElementLock"
| "toggleLinearEditor"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "updateFrameRendering"
| "createContainerFromText"
| "wrapTextInContainer"
| "commandPalette"
| "autoResize"
| "elementStats"
| "searchMenu"
| "copyElementLink"
| "linkToElement"
| "cropEditor"
| "wrapSelectionInFrame"
| "toggleShapeSwitch"
| "togglePolygon";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
// UIAppState (not AppState) because PanelComponents only re-render when
// the UI does — reading UI-stripped props (zoom, scroll, …) would render
// stale values; subscribe via useAppStateValue for those instead
appState: UIAppState;
updateData: <T = any>(formData?: T) => void;
appProps: ExcalidrawProps;
data?: Record<string, any>;
app: AppClassProperties;
renderAction: (
name: ActionName,
data?: PanelComponentProps["data"],
) => React.JSX.Element | null;
};
export interface Action<TData = any> {
name: ActionName;
label:
| string
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<UIAppState>,
app: AppClassProperties,
) => string);
keywords?: string[];
icon?:
| React.ReactNode
| ((
appState: UIAppState,
elements: readonly ExcalidrawElement[],
) => React.ReactNode);
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn<TData>;
keyPriority?: number;
keyTest?: (
event: React.KeyboardEvent | KeyboardEvent,
appState: AppState,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
) => boolean;
predicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
) => boolean;
checked?: (appState: Readonly<UIAppState>) => boolean;
trackEvent:
| false
| {
category:
| "toolbar"
| "element"
| "canvas"
| "export"
| "history"
| "menu"
| "collab"
| "hyperlink"
| "search_menu"
| "shape_switch";
action?: string;
predicate?: (
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
value: any,
) => boolean;
};
/** if set to `true`, allow action to be performed in viewMode.
* Defaults to `false` */
viewMode?: boolean;
/** if set to `true`, the action counts as canvas navigation and remains
* available in the non-interactive editor when
* `interaction: { enabled: { navigation: true } }`. Defaults to `false` */
navigation?: boolean;
}

View File

@@ -0,0 +1,50 @@
// place here categories that you want to track. We want to track just a
import { isDevEnv } from "@excalidraw/common";
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = new Set([
"command_palette",
"export",
"ai",
]);
export const trackEvent = (
category: string,
action: string,
label?: string,
value?: number,
) => {
try {
if (
typeof window === "undefined" ||
import.meta.env.VITE_WORKER_ID ||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
) {
return;
}
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
return;
}
if (isDevEnv()) {
// comment out to debug in dev
return;
}
if (!import.meta.env.PROD) {
console.info("trackEvent", { category, action, label, value });
}
if (window.sa_event) {
window.sa_event(action, {
category,
label,
value,
});
}
} catch (error) {
console.error("error during analytics", error);
}
};

View File

@@ -0,0 +1,222 @@
import { LaserPointer } from "@excalidraw/laser-pointer";
import {
SVG_NS,
getSvgPathFromStroke,
sceneCoordsToViewportCoords,
} from "@excalidraw/common";
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimationController } from "./renderer/animation";
import type App from "./components/App";
import type { AppState } from "./types";
export interface Trail {
start(container: SVGSVGElement): void;
stop(): void;
startPath(x: number, y: number): void;
addPointToPath(x: number, y: number): void;
endPath(): void;
}
export interface AnimatedTrailOptions {
fill: (trail: AnimatedTrail) => string;
stroke?: (trail: AnimatedTrail) => string;
animateTrail?: boolean;
}
export class AnimatedTrail implements Trail {
private currentTrail?: LaserPointer;
private pastTrails: LaserPointer[] = [];
private container?: SVGSVGElement;
private trailElement: SVGPathElement;
private trailAnimation?: SVGAnimateElement;
private key: string;
private static counter = 0;
constructor(
protected app: App,
private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>,
) {
this.key = `animated-trail-${AnimatedTrail.counter++}`;
this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
// TODO: make this configurable
this.trailAnimation.setAttribute("attributeName", "stroke-dashoffset");
this.trailElement.setAttribute("stroke-dasharray", "7 7");
this.trailElement.setAttribute("stroke-dashoffset", "10");
this.trailAnimation.setAttribute("from", "0");
this.trailAnimation.setAttribute("to", `-14`);
this.trailAnimation.setAttribute("dur", "0.3s");
this.trailElement.appendChild(this.trailAnimation);
}
}
get hasCurrentTrail() {
return !!this.currentTrail;
}
hasLastPoint(x: number, y: number) {
if (this.currentTrail) {
const len = this.currentTrail.originalPoints.length;
return (
this.currentTrail.originalPoints[len - 1][0] === x &&
this.currentTrail.originalPoints[len - 1][1] === y
);
}
return false;
}
private cleanup() {
this.pastTrails = [];
this.currentTrail = undefined;
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
}
start(container?: SVGSVGElement) {
if (container) {
this.container = container;
}
if (this.trailElement.parentNode !== this.container && this.container) {
this.container.appendChild(this.trailElement);
}
if (!AnimationController.running(this.key)) {
AnimationController.start(this.key, () => {
const needsNext = this.onFrame();
if (needsNext) {
return { keep: true };
}
this.cleanup();
return null;
});
}
}
stop() {
AnimationController.cancel(this.key);
this.cleanup();
}
startPath(x: number, y: number) {
this.currentTrail = new LaserPointer(this.options);
this.currentTrail.addPoint([x, y, performance.now()]);
this.update();
}
addPointToPath(x: number, y: number) {
if (this.currentTrail) {
this.currentTrail.addPoint([x, y, performance.now()]);
this.update();
}
}
endPath() {
if (this.currentTrail) {
this.currentTrail.close();
this.currentTrail.options.keepHead = false;
this.pastTrails.push(this.currentTrail);
this.currentTrail = undefined;
this.update();
}
}
getCurrentTrail() {
return this.currentTrail;
}
clearTrails() {
this.pastTrails = [];
this.currentTrail = undefined;
this.update();
}
private update() {
this.start();
if (this.trailAnimation) {
this.trailAnimation.setAttribute("begin", "indefinite");
this.trailAnimation.setAttribute("repeatCount", "indefinite");
}
}
private onFrame() {
const paths: string[] = [];
for (const trail of this.pastTrails) {
paths.push(this.drawTrail(trail, this.app.state));
}
if (this.currentTrail) {
const currentPath = this.drawTrail(this.currentTrail, this.app.state);
paths.push(currentPath);
}
this.pastTrails = this.pastTrails.filter(
(t) =>
t.getStrokeOutline(t.options.size / this.app.state.zoom.value)
.length !== 0,
);
if (paths.length === 0) {
// Clean up the SVG path if there are no trails to render
this.trailElement.setAttribute("d", "");
return false;
}
const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths);
if (this.trailAnimation) {
this.trailElement.setAttribute(
"fill",
(this.options.fill ?? (() => "black"))(this),
);
this.trailElement.setAttribute(
"stroke",
(this.options.stroke ?? (() => "black"))(this),
);
} else {
this.trailElement.setAttribute(
"fill",
(this.options.fill ?? (() => "black"))(this),
);
}
return true;
}
private drawTrail(trail: LaserPointer, state: AppState): string {
const _stroke = trail
.getStrokeOutline(trail.options.size / state.zoom.value)
.map(([x, y]) => {
const result = sceneCoordsToViewportCoords(
{ sceneX: x, sceneY: y },
state,
);
return [result.x, result.y];
});
const stroke = this.trailAnimation
? _stroke.slice(0, _stroke.length / 2)
: _stroke;
return getSvgPathFromStroke(stroke, true);
}
}

View File

@@ -0,0 +1,351 @@
import {
COLOR_PALETTE,
ARROW_TYPE,
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
EXPORT_SCALES,
STATS_PANELS,
THEME,
DEFAULT_GRID_STEP,
isTestEnv,
DEFAULT_STICKY_NOTE_BG,
} from "@excalidraw/common";
import type { AppState, InputDevice, NormalizedZoomValue } from "./types";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio
: 1;
export const getDefaultAppState = (): Omit<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> => {
return {
showWelcomeScreen: false,
theme: THEME.LIGHT,
collaborators: new Map(),
currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
currentItemEndArrowhead: "arrow",
currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle,
currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStrokeVariability: "constant",
currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemStickynoteStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemStickynoteBackgroundColor: DEFAULT_STICKY_NOTE_BG,
currentItemRoundness: isTestEnv() ? "sharp" : "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidthKey: DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up",
activeEmbeddable: null,
newElement: null,
editingTextElement: null,
editingGroupId: null,
activeTool: {
type: "selection",
customType: null,
locked: DEFAULT_ELEMENT_PROPS.locked,
fromSelection: false,
lastActiveTool: null,
},
preferredSelectionTool: {
type: "selection",
initialized: false,
},
penMode: false,
penDetected: false,
errorMessage: null,
exportBackground: true,
exportScale: defaultExportScale,
exportEmbedScene: false,
exportWithDarkMode: false,
fileHandle: null,
gridSize: DEFAULT_GRID_SIZE,
gridStep: DEFAULT_GRID_STEP,
gridModeEnabled: false,
isBindingEnabled: true,
bindingPreference: "enabled",
isMidpointSnappingEnabled: true,
inputDevice: "auto",
defaultSidebarDockedPreference: false,
isLoading: false,
isResizing: false,
isRotating: false,
lastPointerDownWith: "mouse",
multiElement: null,
name: null,
contextMenu: null,
openMenu: null,
openPopup: null,
openSidebar: null,
openDialog: null,
previousSelectedElementIds: {},
resizingElement: null,
scrolledOutside: false,
scrollX: 0,
scrollY: 0,
scrollConstraints: null,
selectedElementIds: {},
hoveredElementIds: {},
selectedGroupIds: {},
selectedElementsAreBeingDragged: false,
selectionElement: null,
shouldCacheIgnoreZoom: false,
stats: {
open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
suggestedBinding: null,
hoveredArrowTextAnchor: null,
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
editingFrame: null,
elementsToHighlight: null,
toast: null,
viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false,
zoom: {
value: 1 as NormalizedZoomValue,
},
viewModeEnabled: false,
showHyperlinkPopup: false,
selectedLinearElement: null,
snapLines: [],
originSnapOffset: {
x: 0,
y: 0,
},
objectsSnapModeEnabled: false,
isCropping: false,
croppingElementId: null,
searchMatches: null,
lockedMultiSelections: {},
activeLockedId: null,
bindMode: "orbit",
boxSelectionMode: "contain",
colorTopPicks: {
elementStroke: null,
elementBackground: null,
bucketFill: null,
stickyNoteStroke: null,
stickyNoteBackground: null,
},
};
};
/**
* Config containing all AppState keys. Used to determine whether given state
* prop should be stripped when exporting to given storage type.
*/
const APP_STATE_STORAGE_CONF = (<
Values extends {
/** whether to keep when storing to browser storage (localStorage/IDB) */
browser: boolean;
/** whether to keep when exporting to file/database */
export: boolean;
/** server (shareLink/collab/...) */
server: boolean;
},
T extends Record<keyof AppState, Values>,
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
config)({
showWelcomeScreen: { browser: true, export: false, server: false },
theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false },
currentItemBackgroundColor: { browser: true, export: false, server: false },
currentItemEndArrowhead: { browser: true, export: false, server: false },
currentItemFillStyle: { browser: true, export: false, server: false },
currentItemFontFamily: { browser: true, export: false, server: false },
currentItemFontSize: { browser: true, export: false, server: false },
currentItemRoundness: {
browser: true,
export: false,
server: false,
},
currentItemArrowType: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStrokeVariability: {
browser: true,
export: false,
server: false,
},
currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStickynoteStrokeColor: {
browser: true,
export: false,
server: false,
},
currentItemStickynoteBackgroundColor: {
browser: true,
export: false,
server: false,
},
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidthKey: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
newElement: { browser: false, export: false, server: false },
editingTextElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
activeTool: { browser: true, export: false, server: false },
preferredSelectionTool: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false },
exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false },
exportScale: { browser: true, export: false, server: false },
exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true },
gridStep: { browser: true, export: true, server: true },
gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, export: false, server: false },
boxSelectionMode: { browser: true, export: false, server: false },
bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false },
inputDevice: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: {
browser: true,
export: false,
server: false,
},
isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false },
lastPointerDownWith: { browser: true, export: false, server: false },
multiElement: { browser: false, export: false, server: false },
name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false },
contextMenu: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false },
openSidebar: { browser: true, export: false, server: false },
openDialog: { browser: false, export: false, server: false },
previousSelectedElementIds: { browser: true, export: false, server: false },
resizingElement: { browser: false, export: false, server: false },
scrolledOutside: { browser: true, export: false, server: false },
scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false },
scrollConstraints: { browser: false, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
hoveredElementIds: { browser: false, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
selectedElementsAreBeingDragged: {
browser: false,
export: false,
server: false,
},
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false },
suggestedBinding: { browser: false, export: false, server: false },
hoveredArrowTextAnchor: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false },
elementsToHighlight: { browser: false, export: false, server: false },
toast: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false },
zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false },
snapLines: { browser: false, export: false, server: false },
originSnapOffset: { browser: false, export: false, server: false },
objectsSnapModeEnabled: { browser: true, export: false, server: false },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false },
bindMode: { browser: true, export: false, server: false },
colorTopPicks: { browser: true, export: false, server: false },
});
const _clearAppStateForStorage = <
ExportType extends "export" | "browser" | "server",
>(
appState: Partial<AppState>,
exportType: ExportType,
) => {
type ExportableKeys = {
[K in keyof typeof APP_STATE_STORAGE_CONF]: typeof APP_STATE_STORAGE_CONF[K][ExportType] extends true
? K
: never;
}[keyof typeof APP_STATE_STORAGE_CONF];
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
const propConfig = APP_STATE_STORAGE_CONF[key];
if (propConfig?.[exportType]) {
const nextValue = appState[key];
// https://github.com/microsoft/TypeScript/issues/31445
(stateForExport as any)[key] = nextValue;
}
}
return stateForExport;
};
export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "browser");
};
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "export");
};
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "server");
};
export const isEraserActive = ({
activeTool,
}: {
activeTool: AppState["activeTool"];
}) => activeTool.type === "eraser";
export const isHandToolActive = ({
activeTool,
}: {
activeTool: AppState["activeTool"];
}) => {
return activeTool.type === "hand";
};
/**
* The device the wheel mappings follow for the given preference.
*
* `auto` is meant to detect the device from the wheel events themselves
* (line vs. pixel delta modes, whole vs. fractional deltas, one vs. two axes
* moving, event cadence and momentum tails). That is not implemented yet, so
* it resolves to `trackpad` — the mapping the editor has always had, and the
* default to keep until detection exists.
*/
export const resolveInputDevice = (
inputDevice: InputDevice,
): Exclude<InputDevice, "auto"> =>
inputDevice === "auto" ? "trackpad" : inputDevice;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
import { isDevEnv } from "@excalidraw/common";
import { newElement } from "@excalidraw/element";
import { commonProps } from "./charts.constants";
import {
chartBaseElements,
chartXLabels,
createSeriesLegend,
getBackgroundColor,
getCartesianChartLayout,
getChartDimensions,
getColorOffset,
getRotatedTextElementBottom,
getSeriesColors,
} from "./charts.helpers";
import type { ChartElements, Spreadsheet } from "./charts.types";
export const renderBarChart = (
spreadsheet: Spreadsheet,
x: number,
y: number,
colorSeed?: number,
): ChartElements => {
const series = spreadsheet.series;
const layout = getCartesianChartLayout("bar", series.length);
const max = Math.max(
1,
...series.flatMap((seriesData) =>
seriesData.values.map((value) => Math.max(0, value)),
),
);
const colorOffset = getColorOffset(colorSeed);
const backgroundColor = getBackgroundColor(colorOffset);
const seriesColors = getSeriesColors(series.length, colorOffset);
const interBarGap =
series.length > 1
? Math.max(1, Math.floor(layout.gap / (series.length + 1)))
: 0;
const barWidth =
series.length > 1
? Math.max(
2,
(layout.slotWidth - interBarGap * (series.length - 1)) /
series.length,
)
: layout.slotWidth;
const clusterWidth =
series.length * barWidth + interBarGap * (series.length - 1);
const clusterOffset = (layout.slotWidth - clusterWidth) / 2;
const bars = series[0].values.flatMap((_, categoryIndex) =>
series.map((seriesData, seriesIndex) => {
const value = Math.max(0, seriesData.values[categoryIndex] ?? 0);
const barHeight = (value / max) * layout.chartHeight;
const barColor =
series.length > 1 ? seriesColors[seriesIndex] : backgroundColor;
return newElement({
backgroundColor: barColor,
...commonProps,
type: "rectangle",
fillStyle: series.length > 1 ? "solid" : commonProps.fillStyle,
strokeColor: series.length > 1 ? barColor : commonProps.strokeColor,
x:
x +
categoryIndex * (layout.slotWidth + layout.gap) +
layout.gap +
clusterOffset +
seriesIndex * (barWidth + interBarGap),
y: y - barHeight - layout.gap,
width: barWidth,
height: barHeight,
});
}),
);
const baseElements = chartBaseElements(
spreadsheet,
x,
y,
backgroundColor,
layout,
max,
isDevEnv(),
);
const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout);
const xLabelsBottomY = Math.max(
y + layout.gap / 2,
...xLabels.map((label) => getRotatedTextElementBottom(label)),
);
const { chartWidth } = getChartDimensions(spreadsheet, layout);
const seriesLegend = createSeriesLegend(
series,
seriesColors,
x + chartWidth / 2,
xLabelsBottomY,
y + layout.gap * 5,
backgroundColor,
);
return [...baseElements, ...bars, ...seriesLegend];
};

View File

@@ -0,0 +1,63 @@
import {
COLOR_PALETTE,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
VERTICAL_ALIGN,
} from "@excalidraw/common";
import type { Radians } from "@excalidraw/math";
export const CARTESIAN_BASE_SLOT_WIDTH = 44;
export const CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES = 22;
export const CARTESIAN_BAR_SLOT_EXTRA_MAX = 66;
export const CARTESIAN_LINE_SLOT_WIDTH = 48;
export const CARTESIAN_GAP = 14;
export const CARTESIAN_BAR_HEIGHT = 304;
export const CARTESIAN_LINE_HEIGHT = 320;
export const CARTESIAN_LABEL_ROTATION = 5.87 as Radians;
export const CARTESIAN_LABEL_MIN_WIDTH = 28;
export const CARTESIAN_LABEL_SLOT_PADDING = 4;
export const CARTESIAN_LABEL_AXIS_CLEARANCE = 2;
export const CARTESIAN_LABEL_MAX_WIDTH_BUFFER = 10;
export const CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER = 10;
export const CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER = 8;
export const BAR_GAP = 12;
export const BAR_HEIGHT = 256;
export const GRID_OPACITY = 10;
export const RADAR_GRID_LEVELS = 4;
export const RADAR_LABEL_OFFSET = BAR_GAP * 2;
export const RADAR_PADDING = BAR_GAP * 2;
export const RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD = 100;
export const RADAR_AXIS_LABEL_MAX_WIDTH = 140;
export const RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD = 0.35;
export const RADAR_AXIS_LABEL_CLEARANCE = BAR_GAP / 2;
export const RADAR_LEGEND_SWATCH_SIZE = 20;
export const RADAR_LEGEND_ITEM_GAP = BAR_GAP * 2;
export const RADAR_LEGEND_TEXT_GAP = BAR_GAP;
// Put all common chart element properties here so properties dialog
// shows stable values when selecting chart groups.
export const commonProps = {
fillStyle: "hachure",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: COLOR_PALETTE.black,
roundness: null,
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const;
export type CartesianChartType = "bar" | "line";
export type CartesianChartLayout = {
slotWidth: number;
gap: number;
chartHeight: number;
xLabelMaxWidth: number;
};

View File

@@ -0,0 +1,865 @@
import { pointFrom } from "@excalidraw/math";
import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
FONT_FAMILY,
FONT_SIZES,
ROUNDNESS,
DEFAULT_FONT_SIZE,
getAllColorsSpecificShade,
getFontString,
getLineHeight,
ROUGHNESS,
} from "@excalidraw/common";
import {
getApproxMinLineWidth,
measureText,
newElement,
newLinearElement,
newTextElement,
wrapText,
} from "@excalidraw/element";
import type {
ChartType,
ExcalidrawTextElement,
} from "@excalidraw/element/types";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import {
BAR_GAP,
CARTESIAN_BAR_HEIGHT,
CARTESIAN_BASE_SLOT_WIDTH,
CARTESIAN_BAR_SLOT_EXTRA_MAX,
CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES,
CARTESIAN_GAP,
CARTESIAN_LABEL_AXIS_CLEARANCE,
CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
CARTESIAN_LABEL_MIN_WIDTH,
CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER,
CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER,
CARTESIAN_LABEL_ROTATION,
CARTESIAN_LABEL_SLOT_PADDING,
CARTESIAN_LINE_HEIGHT,
CARTESIAN_LINE_SLOT_WIDTH,
GRID_OPACITY,
RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD,
RADAR_AXIS_LABEL_CLEARANCE,
RADAR_AXIS_LABEL_MAX_WIDTH,
RADAR_LABEL_OFFSET,
RADAR_LEGEND_ITEM_GAP,
RADAR_LEGEND_SWATCH_SIZE,
RADAR_LEGEND_TEXT_GAP,
RADAR_PADDING,
RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD,
BAR_HEIGHT,
commonProps,
type CartesianChartLayout,
type CartesianChartType,
} from "./charts.constants";
import type {
ChartElements,
Spreadsheet,
SpreadsheetSeries,
} from "./charts.types";
const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
const getSpreadsheetDimensionCount = (spreadsheet: Spreadsheet) =>
spreadsheet.labels?.length ?? spreadsheet.series[0]?.values.length ?? 0;
export const isSpreadsheetValidForChartType = (
spreadsheet: Spreadsheet | null,
chartType: ChartType,
) => {
if (!spreadsheet) {
return false;
}
const dimensionCount = getSpreadsheetDimensionCount(spreadsheet);
if (dimensionCount < 2) {
return false;
}
if (chartType === "radar") {
return dimensionCount >= 3;
}
return true;
};
const getSeriesAwareSlotWidth = (
baseSlotWidth: number,
seriesCount: number,
) => {
const extraSlotWidth =
seriesCount <= 1
? 0
: Math.min(
CARTESIAN_BAR_SLOT_EXTRA_MAX,
(seriesCount - 1) * CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES,
);
return baseSlotWidth + extraSlotWidth;
};
export const getCartesianChartLayout = (
chartType: CartesianChartType,
seriesCount: number,
): CartesianChartLayout => {
if (chartType === "line") {
const slotWidth = getSeriesAwareSlotWidth(
CARTESIAN_LINE_SLOT_WIDTH,
seriesCount,
);
return {
slotWidth,
gap: CARTESIAN_GAP,
chartHeight: CARTESIAN_LINE_HEIGHT,
xLabelMaxWidth:
slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
};
}
const slotWidth = getSeriesAwareSlotWidth(
CARTESIAN_BASE_SLOT_WIDTH,
seriesCount,
);
return {
slotWidth,
gap: CARTESIAN_GAP,
chartHeight: CARTESIAN_BAR_HEIGHT,
xLabelMaxWidth:
slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
};
};
export const getChartDimensions = (
spreadsheet: Spreadsheet,
layout: CartesianChartLayout,
) => {
const chartWidth =
(layout.slotWidth + layout.gap) * spreadsheet.series[0].values.length +
layout.gap;
const chartHeight = layout.chartHeight + layout.gap * 2;
return { chartWidth, chartHeight };
};
export const getRadarDimensions = () => {
const chartWidth = BAR_HEIGHT + RADAR_PADDING * 2;
const chartHeight = BAR_HEIGHT + RADAR_PADDING * 2;
return { chartWidth, chartHeight };
};
const getCircularDistance = (
firstIndex: number,
secondIndex: number,
paletteSize: number,
) => {
const absoluteDistance = Math.abs(firstIndex - secondIndex);
return Math.min(absoluteDistance, paletteSize - absoluteDistance);
};
export const getSeriesColors = (
seriesCount: number,
colorOffset: number,
): readonly string[] => {
if (seriesCount <= 0 || bgColors.length === 0) {
return [];
}
const paletteSize = bgColors.length;
const startIndex = ((colorOffset % paletteSize) + paletteSize) % paletteSize;
const selectedIndices = [startIndex];
const maxUniqueColors = Math.min(seriesCount, paletteSize);
const availableIndices = new Set(
Array.from({ length: paletteSize }, (_, index) => index).filter(
(index) => index !== startIndex,
),
);
while (selectedIndices.length < maxUniqueColors) {
let bestIndex = -1;
let bestMinDistance = -1;
let bestAverageDistance = -1;
for (const candidateIndex of availableIndices) {
const distances = selectedIndices.map((selectedIndex) =>
getCircularDistance(candidateIndex, selectedIndex, paletteSize),
);
const minDistance = Math.min(...distances);
const averageDistance =
distances.reduce((total, distance) => total + distance, 0) /
distances.length;
if (
minDistance > bestMinDistance ||
(minDistance === bestMinDistance &&
averageDistance > bestAverageDistance)
) {
bestIndex = candidateIndex;
bestMinDistance = minDistance;
bestAverageDistance = averageDistance;
}
}
selectedIndices.push(bestIndex);
availableIndices.delete(bestIndex);
}
return Array.from(
{ length: seriesCount },
(_, index) => bgColors[selectedIndices[index % selectedIndices.length]],
);
};
export const getColorOffset = (colorSeed?: number) => {
if (bgColors.length === 0) {
return 0;
}
if (typeof colorSeed !== "number" || !Number.isFinite(colorSeed)) {
return Math.floor(Math.random() * bgColors.length);
}
const seedText = colorSeed.toString();
let hash = 0;
for (let index = 0; index < seedText.length; index++) {
hash = (hash * 31 + seedText.charCodeAt(index)) | 0;
}
return Math.abs(hash) % bgColors.length;
};
export const getBackgroundColor = (colorOffset: number) =>
bgColors[colorOffset];
export const getRadarValueScale = (
series: SpreadsheetSeries[],
_labelsLength: number,
) => {
const allValues = series.flatMap((s) =>
s.values.map((value) => Math.max(0, value)),
);
const positiveValues = allValues.filter((value) => value > 0);
const max = Math.max(1, ...allValues);
const minPositive =
positiveValues.length > 0 ? Math.min(...positiveValues) : 1;
const useLogScale =
series.length === 1 &&
minPositive > 0 &&
max / minPositive >= RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD;
return {
renderSteps: false,
normalize: (value: number, _axisIndex: number) => {
const safeValue = Math.max(0, value);
return useLogScale
? Math.log10(safeValue + 1) / Math.log10(max + 1)
: safeValue / max;
},
};
};
const shouldWrapRadarText = (text: string) => /\s/.test(text.trim());
export const getRadarDisplayText = (
text: string,
fontString: ReturnType<typeof getFontString>,
maxWidth: number,
) => {
return shouldWrapRadarText(text)
? wrapText(text, fontString, maxWidth)
: text;
};
export const createRadarAxisLabels = (
labels: readonly string[],
angles: readonly number[],
centerX: number,
centerY: number,
radius: number,
backgroundColor: string,
): {
axisLabels: ChartElements;
axisLabelTopY: number;
axisLabelBottomY: number;
} => {
const fontFamily = FONT_FAMILY.Excalifont;
const fontSize = FONT_SIZES.sm;
const lineHeight = getLineHeight(fontFamily);
const fontString = getFontString({ fontFamily, fontSize });
const baseLabelWidth = Math.min(
RADAR_AXIS_LABEL_MAX_WIDTH,
radius * (labels.length > 8 ? 0.56 : 0.72),
);
const minLabelWidth = getApproxMinLineWidth(fontString, lineHeight);
const axisLabels = labels.map((label, index) => {
const angle = angles[index];
const longestWordWidth = Math.max(
0,
...label
.trim()
.split(/\s+/)
.filter(Boolean)
.map((word) => measureText(word, fontString, lineHeight).width),
);
const maxLabelWidth = Math.max(
minLabelWidth,
baseLabelWidth,
longestWordWidth,
);
const displayLabel = getRadarDisplayText(label, fontString, maxLabelWidth);
const metrics = measureText(displayLabel, fontString, lineHeight);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const textAlign: "left" | "center" | "right" =
cos > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
? "left"
: cos < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
? "right"
: "center";
// Keep labels outside the radar ring by projecting text extents
// onto the axis direction.
const centerAlignedXExtent = textAlign === "center" ? metrics.width / 2 : 0;
const projectedExtent =
Math.abs(cos) * centerAlignedXExtent +
Math.abs(sin) * (metrics.height / 2);
const radialOffset =
RADAR_LABEL_OFFSET + projectedExtent + RADAR_AXIS_LABEL_CLEARANCE;
const anchorX = centerX + cos * (radius + radialOffset);
const anchorY = centerY + sin * (radius + radialOffset);
const yNudge =
sin > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
? BAR_GAP / 3
: sin < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
? -BAR_GAP / 3
: 0;
return newTextElement({
backgroundColor,
...commonProps,
text: displayLabel,
originalText: label,
x: anchorX,
y: anchorY + yNudge,
fontFamily,
fontSize,
lineHeight,
textAlign,
verticalAlign: "middle",
});
});
const axisLabelTopY = Math.min(...axisLabels.map((axisLabel) => axisLabel.y));
const axisLabelBottomY = Math.max(
...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height),
);
return { axisLabels, axisLabelTopY, axisLabelBottomY };
};
export const createSeriesLegend = (
series: SpreadsheetSeries[],
seriesColors: readonly string[],
centerX: number,
minLegendTopY: number,
fallbackLegendY: number,
backgroundColor: string,
): ChartElements => {
if (series.length <= 1) {
return [];
}
const fontFamily = FONT_FAMILY["Lilita One"];
const fontSize = FONT_SIZES.lg;
const lineHeight = getLineHeight(fontFamily);
const fontString = getFontString({ fontFamily, fontSize });
const legendItems = series.map((seriesItem, index) => {
const label = seriesItem.title?.trim() || `Series ${index + 1}`;
const displayLabel = getRadarDisplayText(label, fontString, BAR_HEIGHT);
const metrics = measureText(displayLabel, fontString, lineHeight);
const itemWidth =
RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP + metrics.width;
return {
label,
displayLabel,
color: seriesColors[index],
width: itemWidth,
height: metrics.height,
};
});
const maxLegendHalfHeight = Math.max(
RADAR_LEGEND_SWATCH_SIZE / 2,
...legendItems.map((item) => item.height / 2),
);
const legendY = Math.max(
fallbackLegendY,
minLegendTopY + maxLegendHalfHeight + RADAR_LABEL_OFFSET,
);
const pillPaddingX = RADAR_LEGEND_ITEM_GAP;
const pillPaddingY = RADAR_LEGEND_SWATCH_SIZE * 0.6;
const totalLegendWidth =
legendItems.reduce((total, item) => total + item.width, 0) +
RADAR_LEGEND_ITEM_GAP * Math.max(0, legendItems.length - 1);
const pillWidth = totalLegendWidth + pillPaddingX * 2;
const pillHeight = maxLegendHalfHeight * 2 + pillPaddingY * 2;
const legendElements: NonDeletedExcalidrawElement[] = [];
// rounded pill background
legendElements.push(
newElement({
...commonProps,
backgroundColor: "transparent",
type: "rectangle",
fillStyle: "solid",
strokeColor: COLOR_PALETTE.black,
x: centerX - pillWidth / 2,
y: legendY - pillHeight / 2,
width: pillWidth,
height: pillHeight,
roughness: ROUGHNESS.architect,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
}),
);
let cursorX = centerX - totalLegendWidth / 2;
legendItems.forEach((item) => {
// solid filled swatch
legendElements.push(
newElement({
...commonProps,
backgroundColor: item.color,
type: "rectangle",
x: cursorX,
y: legendY - RADAR_LEGEND_SWATCH_SIZE / 2,
width: RADAR_LEGEND_SWATCH_SIZE,
height: RADAR_LEGEND_SWATCH_SIZE,
fillStyle: "solid",
strokeColor: item.color,
roughness: ROUGHNESS.architect,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
}),
);
// label in default (black) color
legendElements.push(
newTextElement({
...commonProps,
text: item.displayLabel,
originalText: item.label,
autoResize: false,
x: cursorX + RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP,
y: legendY,
fontFamily,
fontSize,
lineHeight,
textAlign: "left",
verticalAlign: "middle",
}),
);
cursorX += item.width + RADAR_LEGEND_ITEM_GAP;
});
return legendElements;
};
const ellipsifyTextToWidth = (
text: string,
maxWidth: number,
fontString: ReturnType<typeof getFontString>,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
if (measureText(text, fontString, lineHeight).width <= maxWidth) {
return text;
}
let end = text.length;
while (end > 1) {
const candidate = `${text.slice(0, end)}...`;
if (measureText(candidate, fontString, lineHeight).width <= maxWidth) {
return candidate;
}
end--;
}
return text[0] ? `${text[0]}...` : text;
};
const wrapOrEllipsifyTextToWidth = (
text: string,
maxWidth: number,
fontString: ReturnType<typeof getFontString>,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
if (measureText(text, fontString, lineHeight).width <= maxWidth) {
return { wrapped: false, text };
}
const words = text.trim().split(/\s+/).filter(Boolean);
if (words.length > 1) {
const hasLongWord = words.some((word) => {
return measureText(word, fontString, lineHeight).width > maxWidth;
});
if (
!hasLongWord &&
maxWidth >= getApproxMinLineWidth(fontString, lineHeight)
) {
return { wrapped: true, text: wrapText(text, fontString, maxWidth) };
}
}
return {
wrapped: false,
text: ellipsifyTextToWidth(text, maxWidth, fontString, lineHeight),
};
};
const getRotatedBoundingBox = (
width: number,
height: number,
angle: number,
) => {
const cos = Math.abs(Math.cos(angle));
const sin = Math.abs(Math.sin(angle));
return {
width: width * cos + height * sin,
height: width * sin + height * cos,
};
};
type CartesianAxisLabelSpec = {
originalText: string;
text: string;
wrapped: boolean;
metrics: ReturnType<typeof measureText>;
rotatedWidth: number;
rotatedHeight: number;
};
const isEllipsifiedLabel = (text: string) => text.includes("...");
const getCartesianAxisLabelSpec = (
label: string,
maxLabelWidth: number,
maxRotatedWidth: number,
fontString: ReturnType<typeof getFontString>,
lineHeight: ExcalidrawTextElement["lineHeight"],
): CartesianAxisLabelSpec => {
const minWidth = Math.max(
CARTESIAN_LABEL_MIN_WIDTH,
Math.ceil(getApproxMinLineWidth(fontString, lineHeight)),
);
const maxWidth = Math.max(minWidth, Math.floor(maxLabelWidth));
const candidateWidths: number[] = [];
for (let width = maxWidth; width >= minWidth; width -= 4) {
candidateWidths.push(width);
}
if (candidateWidths[candidateWidths.length - 1] !== minWidth) {
candidateWidths.push(minWidth);
}
const getRank = (spec: CartesianAxisLabelSpec) => {
const ellipsified = isEllipsifiedLabel(spec.text);
const visibleChars = spec.text
.replace(/\.\.\./g, "")
.replace(/\n/g, "").length;
const lineCount = spec.text.split("\n").length;
return {
ellipsified,
visibleChars,
lineCount,
};
};
const shouldPrefer = (
candidate: CartesianAxisLabelSpec,
current: CartesianAxisLabelSpec,
) => {
const candidateRank = getRank(candidate);
const currentRank = getRank(current);
if (candidateRank.ellipsified !== currentRank.ellipsified) {
return !candidateRank.ellipsified;
}
if (candidateRank.visibleChars !== currentRank.visibleChars) {
return candidateRank.visibleChars > currentRank.visibleChars;
}
if (candidateRank.lineCount !== currentRank.lineCount) {
return candidateRank.lineCount < currentRank.lineCount;
}
return candidate.rotatedHeight < current.rotatedHeight;
};
let bestFit: CartesianAxisLabelSpec | null = null;
let bestOverflowAny: {
overflow: number;
spec: CartesianAxisLabelSpec;
} | null = null;
let bestOverflowNonEllipsified: {
overflow: number;
spec: CartesianAxisLabelSpec;
} | null = null;
for (const width of candidateWidths) {
const { wrapped, text } = wrapOrEllipsifyTextToWidth(
label,
width,
fontString,
lineHeight,
);
const metrics = measureText(text, fontString, lineHeight);
const rotated = getRotatedBoundingBox(
metrics.width,
metrics.height,
CARTESIAN_LABEL_ROTATION,
);
const spec = {
originalText: label,
text,
metrics,
rotatedWidth: rotated.width,
rotatedHeight: rotated.height,
wrapped,
};
const overflow = rotated.width - maxRotatedWidth;
if (overflow <= 0) {
if (!bestFit || shouldPrefer(spec, bestFit)) {
bestFit = spec;
}
continue;
}
if (
!bestOverflowAny ||
overflow < bestOverflowAny.overflow ||
(overflow === bestOverflowAny.overflow &&
shouldPrefer(spec, bestOverflowAny.spec))
) {
bestOverflowAny = { overflow, spec };
}
if (
!isEllipsifiedLabel(spec.text) &&
(!bestOverflowNonEllipsified ||
overflow < bestOverflowNonEllipsified.overflow ||
(overflow === bestOverflowNonEllipsified.overflow &&
shouldPrefer(spec, bestOverflowNonEllipsified.spec)))
) {
bestOverflowNonEllipsified = { overflow, spec };
}
}
if (bestFit) {
return bestFit;
}
if (
bestOverflowNonEllipsified &&
bestOverflowAny &&
bestOverflowNonEllipsified.overflow <=
bestOverflowAny.overflow + CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER
) {
return bestOverflowNonEllipsified.spec;
}
return bestOverflowAny!.spec;
};
export const getRotatedTextElementBottom = (
element: NonDeletedExcalidrawElement,
) => {
if (element.type !== "text") {
return element.y + element.height;
}
const rotated = getRotatedBoundingBox(
element.width,
element.height,
element.angle,
);
return element.y + element.height / 2 + rotated.height / 2;
};
export const chartXLabels = (
spreadsheet: Spreadsheet,
x: number,
y: number,
backgroundColor: string,
layout: CartesianChartLayout,
): ChartElements => {
const fontFamily = commonProps.fontFamily;
const fontSize = FONT_SIZES.sm;
const lineHeight = getLineHeight(fontFamily);
const fontString = getFontString({ fontFamily, fontSize });
const maxRotatedWidth = Math.max(
1,
layout.slotWidth +
layout.gap -
CARTESIAN_LABEL_SLOT_PADDING * 2 +
CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER,
);
const axisY = y;
return (
spreadsheet.labels?.map((label, index) => {
const labelSpec = getCartesianAxisLabelSpec(
label,
layout.xLabelMaxWidth,
maxRotatedWidth,
fontString,
lineHeight,
);
const centerX =
x +
index * (layout.slotWidth + layout.gap) +
layout.gap +
layout.slotWidth / 2;
const labelY =
axisY +
CARTESIAN_LABEL_AXIS_CLEARANCE +
(labelSpec.rotatedHeight - labelSpec.metrics.height) / 2;
return newTextElement({
backgroundColor,
...commonProps,
text: labelSpec.text,
originalText: labelSpec.wrapped ? label : labelSpec.text,
autoResize: !labelSpec.wrapped,
x: centerX,
y: labelY,
angle: CARTESIAN_LABEL_ROTATION,
fontSize,
lineHeight,
textAlign: "center",
verticalAlign: "top",
});
}) || []
);
};
const chartYLabels = (
spreadsheet: Spreadsheet,
x: number,
y: number,
backgroundColor: string,
layout: CartesianChartLayout,
maxValue = Math.max(...spreadsheet.series[0].values),
): ChartElements => {
const minYLabel = newTextElement({
backgroundColor,
...commonProps,
x: x - layout.gap,
y: y - layout.gap,
text: "0",
textAlign: "right",
});
const maxYLabel = newTextElement({
backgroundColor,
...commonProps,
x: x - layout.gap,
y: y - layout.chartHeight - minYLabel.height / 2,
text: maxValue.toLocaleString(),
textAlign: "right",
});
return [minYLabel, maxYLabel];
};
const chartLines = (
spreadsheet: Spreadsheet,
x: number,
y: number,
backgroundColor: string,
layout: CartesianChartLayout,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout);
const xLine = newLinearElement({
backgroundColor,
...commonProps,
type: "line",
x,
y,
width: chartWidth,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
const yLine = newLinearElement({
backgroundColor,
...commonProps,
type: "line",
x,
y,
height: chartHeight,
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
});
const maxLine = newLinearElement({
backgroundColor,
...commonProps,
type: "line",
x,
y: y - layout.chartHeight - layout.gap,
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
return [xLine, yLine, maxLine];
};
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
export const chartBaseElements = (
spreadsheet: Spreadsheet,
x: number,
y: number,
backgroundColor: string,
layout: CartesianChartLayout,
maxValue = Math.max(...spreadsheet.series[0].values),
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout);
const title = spreadsheet.title
? newTextElement({
backgroundColor,
...commonProps,
text: spreadsheet.title,
x: x + chartWidth / 2,
y: y - layout.chartHeight - layout.gap * 2 - DEFAULT_FONT_SIZE,
roundness: null,
textAlign: "center",
fontSize: FONT_SIZES.xl,
fontFamily: FONT_FAMILY["Lilita One"],
})
: null;
const debugRect = debug
? newElement({
backgroundColor,
...commonProps,
type: "rectangle",
x,
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
strokeColor: COLOR_PALETTE.black,
fillStyle: "solid",
opacity: 6,
})
: null;
return [
...(debugRect ? [debugRect] : []),
...(title ? [title] : []),
...chartXLabels(spreadsheet, x, y, backgroundColor, layout),
...chartYLabels(spreadsheet, x, y, backgroundColor, layout, maxValue),
...chartLines(spreadsheet, x, y, backgroundColor, layout),
];
};

View File

@@ -0,0 +1,130 @@
import { pointFrom } from "@excalidraw/math";
import { isDevEnv } from "@excalidraw/common";
import { newElement, newLinearElement } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import { GRID_OPACITY, commonProps } from "./charts.constants";
import {
chartBaseElements,
chartXLabels,
createSeriesLegend,
getBackgroundColor,
getCartesianChartLayout,
getChartDimensions,
getColorOffset,
getRotatedTextElementBottom,
getSeriesColors,
} from "./charts.helpers";
import type { ChartElements, Spreadsheet } from "./charts.types";
export const renderLineChart = (
spreadsheet: Spreadsheet,
x: number,
y: number,
colorSeed?: number,
): ChartElements => {
const series = spreadsheet.series;
const layout = getCartesianChartLayout("line", series.length);
const max = Math.max(1, ...series.flatMap((seriesData) => seriesData.values));
const colorOffset = getColorOffset(colorSeed);
const backgroundColor = getBackgroundColor(colorOffset);
const seriesColors = getSeriesColors(series.length, colorOffset);
const lines = series.map((seriesData, seriesIndex) => {
const points = seriesData.values.map((value, valueIndex) =>
pointFrom<LocalPoint>(
valueIndex * (layout.slotWidth + layout.gap),
-(value / max) * layout.chartHeight,
),
);
const maxX = Math.max(...points.map((point) => point[0]));
const maxY = Math.max(...points.map((point) => point[1]));
const minX = Math.min(...points.map((point) => point[0]));
const minY = Math.min(...points.map((point) => point[1]));
return newLinearElement({
backgroundColor: "transparent",
...commonProps,
type: "line",
x: x + layout.gap + layout.slotWidth / 2,
y: y - layout.gap,
height: maxY - minY,
width: maxX - minX,
strokeColor: seriesColors[seriesIndex],
strokeWidth: 2,
points,
});
});
const dots = series.flatMap((seriesData, seriesIndex) =>
seriesData.values.map((value, valueIndex) => {
const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2;
const cy = -(value / max) * layout.chartHeight + layout.gap / 2;
return newElement({
backgroundColor: seriesColors[seriesIndex],
...commonProps,
fillStyle: "solid",
strokeColor: seriesColors[seriesIndex],
strokeWidth: 2,
type: "ellipse",
x: x + cx + layout.slotWidth / 2,
y: y + cy - layout.gap * 2,
width: layout.gap,
height: layout.gap,
});
}),
);
const guideValues = series[0].values.map((_, valueIndex) =>
Math.max(
0,
...series.map((seriesData) => seriesData.values[valueIndex] ?? 0),
),
);
const guides = guideValues.map((value, valueIndex) => {
const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2;
const cy = (value / max) * layout.chartHeight + layout.gap / 2 + layout.gap;
return newLinearElement({
backgroundColor,
...commonProps,
type: "line",
x: x + cx + layout.slotWidth / 2 + layout.gap / 2,
y: y - cy,
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(0, cy)],
});
});
const baseElements = chartBaseElements(
spreadsheet,
x,
y,
backgroundColor,
layout,
max,
isDevEnv(),
);
const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout);
const xLabelsBottomY = Math.max(
y + layout.gap / 2,
...xLabels.map((label) => getRotatedTextElementBottom(label)),
);
const { chartWidth } = getChartDimensions(spreadsheet, layout);
const seriesLegend = createSeriesLegend(
series,
seriesColors,
x + chartWidth / 2,
xLabelsBottomY,
y + layout.gap * 5,
backgroundColor,
);
return [...baseElements, ...lines, ...guides, ...dots, ...seriesLegend];
};

View File

@@ -0,0 +1,174 @@
import { type ParseSpreadsheetResult } from "./charts.types";
/**
* @private exported for testing
*/
export const tryParseNumber = (s: string): number | null => {
const match =
/^([-+]?)[$\u20AC\u00A3\u00A5\u20A9]?([-+]?)([\d.,]+)[%]?$/.exec(s);
if (!match) {
return null;
}
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
};
const isNumericColumn = (lines: string[][], columnIndex: number) =>
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
/**
* @private exported for testing
*/
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const numCols = cells[0].length;
if (numCols > 2) {
const hasHeader = cells[0].every((cell) => tryParseNumber(cell) === null);
const rows = hasHeader ? cells.slice(1) : cells;
if (rows.length < 1) {
return { ok: false, reason: "No data rows" };
}
const invalidNumericColumn = rows.some((row) =>
row.slice(1).some((value) => tryParseNumber(value) === null),
);
if (invalidNumericColumn) {
return { ok: false, reason: "Value is not numeric" };
}
// When there are more value columns than data rows, the data is in
// "wide" format — transpose so columns become labels (dimensions)
// and rows become series. This enables e.g. radar charts for wide data.
const numValueCols = numCols - 1;
if (numValueCols > rows.length) {
const labels = hasHeader ? cells[0].slice(1).map((h) => h.trim()) : null;
const series = rows.map((row) => ({
title: row[0]?.trim() || null,
values: row.slice(1).map((v) => tryParseNumber(v)!),
}));
const title =
series.length === 1
? series[0].title
: hasHeader
? cells[0][0].trim() || null
: null;
return {
ok: true,
data: { title, labels, series },
};
}
const series = cells[0].slice(1).map((seriesTitle, index) => {
const valueColumnIndex = index + 1;
const fallbackTitle = `Series ${valueColumnIndex}`;
return {
title: hasHeader ? seriesTitle.trim() || fallbackTitle : fallbackTitle,
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
};
});
return {
ok: true,
data: {
title: hasHeader ? cells[0][0].trim() || null : null,
labels: rows.map((row) => row[0]),
series,
},
};
}
if (numCols === 1) {
if (!isNumericColumn(cells, 0)) {
return { ok: false, reason: "Value is not numeric" };
}
const hasHeader = tryParseNumber(cells[0][0]) === null;
const title = hasHeader ? cells[0][0] : null;
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
tryParseNumber(line[0]),
);
if (values.length < 2) {
return { ok: false, reason: "Less than two rows" };
}
return {
ok: true,
data: {
title,
labels: null,
series: [{ title, values: values as number[] }],
},
};
}
const hasHeader = tryParseNumber(cells[0][1]) === null;
const rows = hasHeader ? cells.slice(1) : cells;
if (rows.length < 2) {
return { ok: false, reason: "Less than 2 rows" };
}
const invalidNumericColumn = rows.some(
(row) => tryParseNumber(row[1]) === null,
);
if (invalidNumericColumn) {
return { ok: false, reason: "Value is not numeric" };
}
const title = hasHeader ? cells[0][1] : null;
return {
ok: true,
data: {
title,
labels: rows.map((row) => row[0]),
series: [{ title, values: rows.map((row) => tryParseNumber(row[1])!) }],
},
};
};
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
// Copy/paste from excel, spreadsheets, TSV, CSV, semicolon-separated.
const parseDelimitedLines = (delimiter: "\t" | "," | ";") =>
text
.replace(/\r\n?/g, "\n")
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => line.split(delimiter).map((cell) => cell.trim()));
// Score each delimiter: prefer consistent column counts with the most columns.
// A delimiter that produces all single-column rows likely isn't the right one.
const candidates = (["\t", ",", ";"] as const).map((delimiter) => {
const parsed = parseDelimitedLines(delimiter);
const numCols = parsed[0]?.length ?? 0;
const isConsistent =
parsed.length > 0 && parsed.every((line) => line.length === numCols);
return { delimiter, parsed, numCols, isConsistent };
});
// Prefer: consistent + most columns. Among ties, tab > comma > semicolon
// (the array order already encodes this priority).
const best =
candidates.find((c) => c.isConsistent && c.numCols > 1) ??
candidates.find((c) => c.isConsistent) ??
candidates[0];
const lines = best.parsed;
if (lines.length === 0) {
return { ok: false, reason: "No values" };
}
const numColsFirstLine = lines[0].length;
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
if (!isSpreadsheet) {
return {
ok: false,
reason: "All rows don't have same number of columns",
};
}
return tryParseCells(lines);
};

View File

@@ -0,0 +1,199 @@
import { pointFrom } from "@excalidraw/math";
import {
FONT_FAMILY,
FONT_SIZES,
getFontString,
getLineHeight,
ROUGHNESS,
} from "@excalidraw/common";
import {
measureText,
newLinearElement,
newTextElement,
} from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import {
BAR_GAP,
BAR_HEIGHT,
GRID_OPACITY,
RADAR_GRID_LEVELS,
RADAR_LABEL_OFFSET,
commonProps,
} from "./charts.constants";
import {
createRadarAxisLabels,
createSeriesLegend,
getBackgroundColor,
getColorOffset,
getRadarDimensions,
getRadarDisplayText,
getRadarValueScale,
getSeriesColors,
isSpreadsheetValidForChartType,
} from "./charts.helpers";
import type { ChartElements, Spreadsheet } from "./charts.types";
export const renderRadarChart = (
spreadsheet: Spreadsheet,
x: number,
y: number,
colorSeed?: number,
): ChartElements | null => {
if (!isSpreadsheetValidForChartType(spreadsheet, "radar")) {
return null;
}
const labels =
spreadsheet.labels ??
spreadsheet.series[0].values.map((_, index) => `Value ${index + 1}`);
const series = spreadsheet.series;
const { normalize, renderSteps } = getRadarValueScale(series, labels.length);
const colorOffset = getColorOffset(colorSeed);
const backgroundColor = getBackgroundColor(colorOffset);
const seriesColors = getSeriesColors(series.length, colorOffset);
const { chartWidth, chartHeight } = getRadarDimensions();
const centerX = x + chartWidth / 2;
const centerY = y - chartHeight / 2;
const radius = BAR_HEIGHT / 2;
const angles = labels.map(
(_, index) => -Math.PI / 2 + (Math.PI * 2 * index) / labels.length,
);
const { axisLabels, axisLabelTopY, axisLabelBottomY } = createRadarAxisLabels(
labels,
angles,
centerX,
centerY,
radius,
backgroundColor,
);
const titleFontFamily = FONT_FAMILY["Lilita One"];
const titleFontSize = FONT_SIZES.xl;
const titleLineHeight = getLineHeight(titleFontFamily);
const titleFontString = getFontString({
fontFamily: titleFontFamily,
fontSize: titleFontSize,
});
const titleText = spreadsheet.title
? getRadarDisplayText(
spreadsheet.title,
titleFontString,
chartWidth + RADAR_LABEL_OFFSET * 2,
)
: null;
const titleTextMetrics = titleText
? measureText(titleText, titleFontString, titleLineHeight)
: null;
const title = titleText
? newTextElement({
backgroundColor,
...commonProps,
text: titleText,
originalText: spreadsheet.title ?? titleText,
x: x + chartWidth / 2,
y: axisLabelTopY - RADAR_LABEL_OFFSET - titleTextMetrics!.height / 2,
fontFamily: titleFontFamily,
fontSize: titleFontSize,
lineHeight: titleLineHeight,
textAlign: "center",
})
: null;
const radarGridLines = renderSteps
? Array.from({ length: RADAR_GRID_LEVELS }, (_, levelIndex) => {
const levelRatio = (levelIndex + 1) / RADAR_GRID_LEVELS;
const levelRadius = radius * levelRatio;
const points = angles.map((angle) =>
pointFrom<LocalPoint>(
Math.cos(angle) * levelRadius,
Math.sin(angle) * levelRadius,
),
);
points.push(pointFrom(points[0][0], points[0][1]));
return newLinearElement({
backgroundColor: "transparent",
...commonProps,
type: "line",
x: centerX,
y: centerY,
width: levelRadius * 2,
height: levelRadius * 2,
strokeStyle: "solid",
roughness: ROUGHNESS.architect,
opacity: GRID_OPACITY,
polygon: true,
points,
});
})
: [];
const spokes = angles.map((angle) => {
const px = Math.cos(angle) * radius;
const py = Math.sin(angle) * radius;
return newLinearElement({
backgroundColor: "transparent",
...commonProps,
type: "line",
x: centerX,
y: centerY,
width: Math.abs(px),
height: Math.abs(py),
strokeStyle: "solid",
roughness: ROUGHNESS.architect,
opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(px, py)],
});
});
const seriesPolygons = series.map((seriesData, index) => {
const points = angles.map((angle, axisIndex) => {
const value = seriesData.values[axisIndex] ?? 0;
const pointRadius = normalize(value, axisIndex) * radius;
return pointFrom<LocalPoint>(
Math.cos(angle) * pointRadius,
Math.sin(angle) * pointRadius,
);
});
points.push(pointFrom(points[0][0], points[0][1]));
return newLinearElement({
backgroundColor: "transparent",
...commonProps,
type: "line",
x: centerX,
y: centerY,
width: radius * 2,
height: radius * 2,
strokeColor: seriesColors[index],
strokeWidth: 2,
polygon: true,
points,
});
});
const seriesLegend = createSeriesLegend(
series,
seriesColors,
centerX,
axisLabelBottomY,
y + BAR_GAP * 5,
backgroundColor,
);
return [
...(title ? [title] : []),
...axisLabels,
...radarGridLines,
...spokes,
...seriesPolygons,
...seriesLegend,
];
};

View File

@@ -0,0 +1,18 @@
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
export type ChartElements = readonly NonDeletedExcalidrawElement[];
export interface Spreadsheet {
title: string | null;
labels: string[] | null;
series: SpreadsheetSeries[];
}
export interface SpreadsheetSeries {
title: string | null;
values: number[];
}
export type ParseSpreadsheetResult =
| { ok: false; reason: string }
| { ok: true; data: Spreadsheet };

View File

@@ -0,0 +1,38 @@
import type { ChartType } from "@excalidraw/element/types";
import { renderBarChart } from "./charts.bar";
import { renderLineChart } from "./charts.line";
import {
tryParseCells,
tryParseNumber,
tryParseSpreadsheet,
} from "./charts.parse";
import { renderRadarChart } from "./charts.radar";
import type { ChartElements, Spreadsheet } from "./charts.types";
export {
type ParseSpreadsheetResult,
type Spreadsheet,
type SpreadsheetSeries,
type ChartElements,
} from "./charts.types";
export { isSpreadsheetValidForChartType } from "./charts.helpers";
export { tryParseCells, tryParseNumber, tryParseSpreadsheet };
export const renderSpreadsheet = (
chartType: ChartType,
spreadsheet: Spreadsheet,
x: number,
y: number,
colorSeed?: number,
): ChartElements | null => {
if (chartType === "line") {
return renderLineChart(spreadsheet, x, y, colorSeed);
}
if (chartType === "radar") {
return renderRadarChart(spreadsheet, x, y, colorSeed);
}
return renderBarChart(spreadsheet, x, y, colorSeed);
};

View File

@@ -0,0 +1,258 @@
import {
COLOR_CHARCOAL_BLACK,
COLOR_VOICE_CALL,
COLOR_WHITE,
UserIdleState,
} from "@excalidraw/common";
import { roundRect } from "./renderer/roundRect";
import type { InteractiveCanvasRenderConfig } from "./scene/types";
import type {
Collaborator,
InteractiveCanvasAppState,
SocketId,
} from "./types";
function hashToInteger(id: string) {
let hash = 0;
if (id.length === 0) {
return hash;
}
for (let i = 0; i < id.length; i++) {
const char = id.charCodeAt(i);
hash = (hash << 5) - hash + char;
}
return hash;
}
export const getClientColor = (
socketId: SocketId,
collaborator: Collaborator | undefined,
) => {
// to get more even distribution in case `id` is not uniformly distributed to
// begin with, we hash it
const hash = Math.abs(hashToInteger(collaborator?.id || socketId));
// we want to get a multiple of 10 number in the range of 0-360 (in other
// words a hue value of step size 10). There are 37 such values including 0.
const hue = (hash % 37) * 10;
const saturation = 100;
const lightness = 83;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
/**
* returns first char, capitalized
*/
export const getNameInitial = (name?: string | null) => {
// first char can be a surrogate pair, hence using codePointAt
const firstCodePoint = name?.trim()?.codePointAt(0);
return (
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
).toUpperCase();
};
export const renderRemoteCursors = ({
context,
renderConfig,
appState,
normalizedWidth,
normalizedHeight,
}: {
context: CanvasRenderingContext2D;
renderConfig: InteractiveCanvasRenderConfig;
appState: InteractiveCanvasAppState;
normalizedWidth: number;
normalizedHeight: number;
}) => {
// Paint remote pointers
for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) {
let { x, y } = pointer;
const collaborator = appState.collaborators.get(socketId);
x -= appState.offsetLeft;
y -= appState.offsetTop;
const width = 11;
const height = 14;
const isOutOfBounds =
x < 0 ||
x > normalizedWidth - width ||
y < 0 ||
y > normalizedHeight - height;
x = Math.max(x, 0);
x = Math.min(x, normalizedWidth - width);
y = Math.max(y, 0);
y = Math.min(y, normalizedHeight - height);
const background = getClientColor(socketId, collaborator);
context.save();
context.strokeStyle = background;
context.fillStyle = background;
const userState = renderConfig.remotePointerUserStates.get(socketId);
const isInactive =
isOutOfBounds ||
userState === UserIdleState.IDLE ||
userState === UserIdleState.AWAY;
if (isInactive) {
context.globalAlpha = 0.3;
}
if (renderConfig.remotePointerButton.get(socketId) === "down") {
context.beginPath();
context.arc(x, y, 15, 0, 2 * Math.PI, false);
context.lineWidth = 3;
context.strokeStyle = "#ffffff88";
context.stroke();
context.closePath();
context.beginPath();
context.arc(x, y, 15, 0, 2 * Math.PI, false);
context.lineWidth = 1;
context.strokeStyle = background;
context.stroke();
context.closePath();
}
const IS_SPEAKING_COLOR = COLOR_VOICE_CALL;
const isSpeaking = collaborator?.isSpeaking;
if (isSpeaking) {
// cursor outline for currently speaking user
context.fillStyle = IS_SPEAKING_COLOR;
context.strokeStyle = IS_SPEAKING_COLOR;
context.lineWidth = 10;
context.lineJoin = "round";
context.beginPath();
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.stroke();
context.fill();
}
// Background (white outline) for arrow
context.fillStyle = COLOR_WHITE;
context.strokeStyle = COLOR_WHITE;
context.lineWidth = 6;
context.lineJoin = "round";
context.beginPath();
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.stroke();
context.fill();
// Arrow
context.fillStyle = background;
context.strokeStyle = background;
context.lineWidth = 2;
context.lineJoin = "round";
context.beginPath();
if (isInactive) {
context.moveTo(x - 1, y - 1);
context.lineTo(x - 1, y + 15);
context.lineTo(x + 5, y + 10);
context.lineTo(x + 12, y + 9);
context.closePath();
context.fill();
} else {
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.fill();
context.stroke();
}
const username = renderConfig.remotePointerUsernames.get(socketId) || "";
if (!isOutOfBounds && username) {
context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
const offsetX = (isSpeaking ? x + 0 : x) + width / 2;
const offsetY = (isSpeaking ? y + 0 : y) + height + 2;
const paddingHorizontal = 5;
const paddingVertical = 3;
const measure = context.measureText(username);
const measureHeight =
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
const finalHeight = Math.max(measureHeight, 12);
const boxX = offsetX - 1;
const boxY = offsetY - 1;
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
if (context.roundRect) {
context.beginPath();
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
context.fillStyle = background;
context.fill();
context.strokeStyle = COLOR_WHITE;
context.stroke();
if (isSpeaking) {
context.beginPath();
context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8);
context.strokeStyle = IS_SPEAKING_COLOR;
context.stroke();
}
} else {
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE);
}
context.fillStyle = COLOR_CHARCOAL_BLACK;
context.fillText(
username,
offsetX + paddingHorizontal + 1,
offsetY +
paddingVertical +
measure.actualBoundingBoxAscent +
Math.floor((finalHeight - measureHeight) / 2) +
2,
);
// draw three vertical bars signalling someone is speaking
if (isSpeaking) {
context.fillStyle = IS_SPEAKING_COLOR;
const barheight = 8;
const margin = 8;
const gap = 5;
context.fillRect(
boxX + boxWidth + margin,
boxY + (boxHeight / 2 - barheight / 2),
2,
barheight,
);
context.fillRect(
boxX + boxWidth + margin + gap,
boxY + (boxHeight / 2 - (barheight * 2) / 2),
2,
barheight * 2,
);
context.fillRect(
boxX + boxWidth + margin + gap * 2,
boxY + (boxHeight / 2 - barheight / 2),
2,
barheight,
);
}
}
context.restore();
context.closePath();
}
};

View File

@@ -0,0 +1,158 @@
import {
createPasteEvent,
parseClipboard,
parseDataTransferEvent,
serializeAsClipboardJSON,
} from "./clipboard";
import { API } from "./tests/helpers/api";
describe("parseClipboard()", () => {
it("should parse JSON as plaintext if not excalidraw-api/clipboard data", async () => {
let text;
let clipboardData;
// -------------------------------------------------------------------------
text = "123";
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
);
expect(clipboardData.text).toBe(text);
// -------------------------------------------------------------------------
text = "[123]";
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
);
expect(clipboardData.text).toBe(text);
// -------------------------------------------------------------------------
text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
);
expect(clipboardData.text).toBe(text);
});
it("should parse valid excalidraw JSON if inside text/plain", async () => {
const rect = API.createElement({ type: "rectangle" });
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": json,
},
}),
),
);
expect(clipboardData.elements).toEqual([rect]);
});
it("should parse valid excalidraw JSON if inside text/html", async () => {
const rect = API.createElement({ type: "rectangle" });
let json;
let clipboardData;
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": json,
},
}),
),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<div> ${json}</div>`,
},
}),
),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
});
it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<img src="https://example.com/image.png" />`,
},
}),
),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "imageUrl",
value: "https://example.com/image.png",
},
]);
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
},
}),
),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "imageUrl",
value: "https://example.com/image.png",
},
{
type: "imageUrl",
value: "https://example.com/image2.png",
},
]);
});
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
},
}),
),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "text",
// trimmed
value: "hello",
},
{
type: "imageUrl",
value: "https://example.com/image.png",
},
{
type: "text",
value: "my friend!",
},
]);
});
});

View File

@@ -0,0 +1,691 @@
import {
ALLOWED_PASTE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
arrayToMap,
isMemberOf,
isPromiseLike,
EVENT,
} from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element";
import { deepCopyElement } from "@excalidraw/element";
import {
isFrameLikeElement,
isInitializedImageElement,
} from "@excalidraw/element";
import { getContainingFrame } from "@excalidraw/element";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import { ExcalidrawError } from "./errors";
import {
createFile,
getFileHandle,
isSupportedImageFileType,
normalizeFile,
} from "./data/blob";
import type { BinaryFiles } from "./types";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | undefined;
};
export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
export interface ClipboardData {
elements?: readonly ExcalidrawElement[];
files?: BinaryFiles;
text?: string;
mixedContent?: PastedMixedContent;
errorMessage?: string;
programmaticAPI?: boolean;
}
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
type ParsedClipboardEventTextData =
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent };
export const probablySupportsClipboardReadText =
"clipboard" in navigator && "readText" in navigator.clipboard;
export const probablySupportsClipboardWriteText =
"clipboard" in navigator && "writeText" in navigator.clipboard;
export const probablySupportsClipboardBlob =
"clipboard" in navigator &&
"write" in navigator.clipboard &&
"ClipboardItem" in window &&
"toBlob" in HTMLCanvasElement.prototype;
const clipboardContainsElements = (
contents: any,
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
if (
[
EXPORT_DATA_TYPES.excalidraw,
EXPORT_DATA_TYPES.excalidrawClipboard,
EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
].includes(contents?.type) &&
Array.isArray(contents.elements)
) {
return true;
}
return false;
};
export const createPasteEvent = ({
types,
files,
}: {
types?: { [key in AllowedPasteMimeTypes]?: string | File };
files?: File[];
}) => {
if (!types && !files) {
console.warn("createPasteEvent: no types or files provided");
}
const event = new ClipboardEvent(EVENT.PASTE, {
clipboardData: new DataTransfer(),
});
if (types) {
for (const [type, value] of Object.entries(types)) {
if (typeof value !== "string") {
files = files || [];
files.push(value);
continue;
}
try {
event.clipboardData?.items.add(value, type);
if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`);
}
} catch (error: any) {
throw new Error(error.message);
}
}
}
if (files) {
let idx = -1;
for (const file of files) {
idx++;
try {
event.clipboardData?.items.add(file);
if (event.clipboardData?.files[idx] !== file) {
throw new Error(
`Failed to set file "${file.name}" as clipboardData item`,
);
}
} catch (error: any) {
throw new Error(error.message);
}
}
}
return event;
};
export const serializeAsClipboardJSON = ({
elements,
files,
}: {
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}) => {
const elementsMap = arrayToMap(elements);
const framesToCopy = new Set<ExcalidrawFrameLikeElement>(
elements.filter((element) => isFrameLikeElement(element)),
);
let foundFile = false;
const _files = elements.reduce((acc, element) => {
if (isInitializedImageElement(element)) {
foundFile = true;
if (files && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
}
return acc;
}, {} as BinaryFiles);
if (foundFile && !files) {
console.warn(
"copyToClipboard: attempting to file element(s) without providing associated `files` object.",
);
}
// select bound text elements when copying
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: elements.map((element) => {
if (
getContainingFrame(element, elementsMap) &&
!framesToCopy.has(getContainingFrame(element, elementsMap)!)
) {
const copiedElement = deepCopyElement(element);
mutateElement(copiedElement, elementsMap, {
frameId: null,
});
return copiedElement;
}
return element;
}),
files: files ? _files : undefined,
};
return JSON.stringify(contents);
};
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
files: BinaryFiles | null,
/** supply if available to make the operation more certain to succeed */
clipboardEvent?: ClipboardEvent | null,
) => {
const json = serializeAsClipboardJSON({ elements, files });
await copyTextToSystemClipboard(
{
[MIME_TYPES.excalidrawClipboard]: json,
[MIME_TYPES.text]: json,
},
clipboardEvent,
);
};
/** internal, specific to parsing paste events. Do not reuse. */
function parseHTMLTree(el: ChildNode) {
let result: PastedMixedContent = [];
for (const node of el.childNodes) {
if (node.nodeType === 3) {
const text = node.textContent?.trim();
if (text) {
result.push({ type: "text", value: text });
}
} else if (node instanceof HTMLImageElement) {
const url = node.getAttribute("src");
if (url && url.startsWith("http")) {
result.push({ type: "imageUrl", value: url });
}
} else {
result = result.concat(parseHTMLTree(node));
}
}
return result;
}
const maybeParseHTMLDataItem = (
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = dataItem.value;
try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
const content = parseHTMLTree(doc.body);
if (content.length) {
return { type: "mixedContent", value: content };
}
} catch (error: any) {
console.error(`error in parseHTMLFromPaste: ${error.message}`);
}
return null;
};
/**
* Reads OS clipboard programmatically. May not work on all browsers.
* Will prompt user for permission if not granted.
*/
export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
let clipboardItems: ClipboardItems;
try {
clipboardItems = await navigator.clipboard?.read();
} catch (error: any) {
try {
if (navigator.clipboard?.readText) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
const readText = await navigator.clipboard?.readText();
if (readText) {
return { [MIME_TYPES.text]: readText };
}
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
}
throw error;
}
}
throw error;
}
for (const item of clipboardItems) {
for (const type of item.types) {
if (!isMemberOf(ALLOWED_PASTE_MIME_TYPES, type)) {
continue;
}
try {
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
types[type] = await (await item.getType(type)).text();
} else if (isSupportedImageFileType(type)) {
const imageBlob = await item.getType(type);
const file = createFile(imageBlob, type, undefined);
types[type] = file;
} else {
throw new ExcalidrawError(`Unsupported clipboard type: ${type}`);
}
} catch (error: any) {
console.warn(
error instanceof ExcalidrawError
? error.message
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
);
}
}
}
if (Object.keys(types).length === 0) {
console.warn("No clipboard data found from clipboard.read().");
return types;
}
return types;
};
/**
* Parses "paste" ClipboardEvent.
*/
const parseClipboardEventTextData = async (
dataList: ParsedDataTranferList,
isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => {
try {
const htmlItem = dataList.findByType(MIME_TYPES.html);
const mixedContent =
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) {
return {
type: "text",
value:
dataList.getData(MIME_TYPES.text) ??
mixedContent.value
.map((item) => item.value)
.join("\n")
.trim(),
};
}
return mixedContent;
}
return {
type: "text",
value: (dataList.getData(MIME_TYPES.text) || "").trim(),
};
} catch {
return { type: "text", value: "" };
}
};
type AllowedParsedDataTransferItem =
| {
type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file";
file: File;
fileHandle: FileSystemFileHandle | null;
}
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
type ParsedDataTransferItem =
| {
type: string;
kind: "file";
file: File;
fileHandle: FileSystemFileHandle | null;
}
| { type: string; kind: "string"; value: string };
type ParsedDataTransferItemType<
T extends AllowedParsedDataTransferItem["type"],
> = AllowedParsedDataTransferItem & { type: T };
export type ParsedDataTransferFile = Extract<
AllowedParsedDataTransferItem,
{ kind: "file" }
>;
export type ParsedDataTranferList = ParsedDataTransferItem[] & {
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
findByType: typeof findDataTransferItemType;
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
getData: typeof getDataTransferItemData;
getFiles: typeof getDataTransferFiles;
};
const findDataTransferItemType = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
return (
this.find(
(item): item is ParsedDataTransferItemType<T> => item.type === type,
) || null
);
};
const getDataTransferItemData = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(
this: ParsedDataTranferList,
type: T,
):
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
| null {
const item = this.find(
(
item,
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
item.type === type,
);
return item?.value ?? null;
};
const getDataTransferFiles = function (
this: ParsedDataTranferList,
): ParsedDataTransferFile[] {
return this.filter(
(item): item is ParsedDataTransferFile => item.kind === "file",
);
};
/** @returns list of MIME types, synchronously */
export const parseDataTransferEventMimeTypes = (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Set<string> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
items = event.dataTransfer?.items;
}
const types: Set<string> = new Set();
for (const item of Array.from(items || [])) {
if (!types.has(item.type)) {
types.add(item.type);
}
}
return types;
};
export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
items = event.dataTransfer?.items;
}
const dataItems = (
await Promise.all(
Array.from(items || []).map(
async (item): Promise<ParsedDataTransferItem | null> => {
if (item.kind === "file") {
let file = item.getAsFile();
if (file) {
const fileHandle = await getFileHandle(item);
file = await normalizeFile(file);
return {
type: file.type,
kind: "file",
file,
fileHandle,
};
}
} else if (item.kind === "string") {
const { type } = item;
let value: string;
if ("clipboardData" in event && event.clipboardData) {
value = event.clipboardData?.getData(type);
} else {
value = await new Promise<string>((resolve) => {
item.getAsString((str) => resolve(str));
});
}
return { type, kind: "string", value };
}
return null;
},
),
)
).filter((data): data is ParsedDataTransferItem => data != null);
return Object.assign(dataItems, {
findByType: findDataTransferItemType,
getData: getDataTransferItemData,
getFiles: getDataTransferFiles,
});
};
/**
* Attempts to parse clipboard event.
*/
export const parseClipboard = async (
dataList: ParsedDataTranferList,
isPlainPaste = false,
): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData(
dataList,
isPlainPaste,
);
if (parsedEventData.type === "mixedContent") {
return {
mixedContent: parsedEventData.value,
};
}
try {
const systemClipboardData = JSON.parse(parsedEventData.value);
const programmaticAPI =
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
if (clipboardContainsElements(systemClipboardData)) {
return {
elements: systemClipboardData.elements,
files: systemClipboardData.files,
text: isPlainPaste
? JSON.stringify(systemClipboardData.elements, null, 2)
: undefined,
programmaticAPI,
};
}
} catch {}
return { text: parsedEventData.value };
};
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
try {
// in Safari so far we need to construct the ClipboardItem synchronously
// (i.e. in the same tick) otherwise browser will complain for lack of
// user intent. Using a Promise ClipboardItem constructor solves this.
// https://bugs.webkit.org/show_bug.cgi?id=222262
//
// Note that Firefox (and potentially others) seems to support Promise
// ClipboardItem constructor, but throws on an unrelated MIME type error.
// So we need to await this and fallback to awaiting the blob if applicable.
await navigator.clipboard.write([
new ClipboardItem({
[MIME_TYPES.png]: blob,
}),
]);
} catch (error: any) {
// if we're using a Promise ClipboardItem, let's try constructing
// with resolution value instead
if (isPromiseLike(blob)) {
await navigator.clipboard.write([
new ClipboardItem({
[MIME_TYPES.png]: await blob,
}),
]);
} else {
throw error;
}
}
};
export const copyTextToSystemClipboard = async <
MimeType extends ValueOf<typeof STRING_MIME_TYPES>,
>(
text: string | { [K in MimeType]: string } | null,
clipboardEvent?: ClipboardEvent | null,
) => {
text = text || "";
const entries = Object.entries(
typeof text === "string" ? { [MIME_TYPES.text]: text } : text,
);
// (1) if we have clipboardEvent, try using it first as it's the most
// versatile
try {
if (clipboardEvent) {
for (const [mimeType, value] of entries) {
clipboardEvent.clipboardData?.setData(mimeType, value);
if (clipboardEvent.clipboardData?.getData(mimeType) !== value) {
throw new Error("Failed to setData on clipboardEvent");
}
}
return;
}
} catch (error: any) {
console.error(error);
}
const plainTextEntry = entries.find(
([mimeType]) => mimeType === MIME_TYPES.text,
);
// (2) if we don't have access to clipboardEvent, or that fails,
// at least try setting text/plain via navigator.clipboard.writeText
// (navigator.clipboard.write doesn't work with non-standard mime types)
if (probablySupportsClipboardWriteText && plainTextEntry) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(plainTextEntry[1]);
return;
} catch (error: any) {
console.error(error);
}
}
// (3) if previous fails, use document.execCommand
if (plainTextEntry && !copyTextViaExecCommand(plainTextEntry[1])) {
throw new Error("Error copying to clipboard.");
}
};
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
const copyTextViaExecCommand = (text: string | null) => {
// execCommand doesn't allow copying empty strings, so if we're
// clearing clipboard using this API, we must copy at least an empty char
if (!text) {
text = " ";
}
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const textarea = document.createElement("textarea");
textarea.style.border = "0";
textarea.style.padding = "0";
textarea.style.margin = "0";
textarea.style.position = "absolute";
textarea.style[isRTL ? "right" : "left"] = "-9999px";
const yPosition = window.pageYOffset || document.documentElement.scrollTop;
textarea.style.top = `${yPosition}px`;
// Prevent zooming on iOS
textarea.style.fontSize = "12pt";
textarea.setAttribute("readonly", "");
textarea.value = text;
document.body.appendChild(textarea);
let success = false;
try {
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
success = document.execCommand("copy");
} catch (error: any) {
console.error(error);
}
textarea.remove();
return success;
};
export const isClipboardEvent = (
event: React.SyntheticEvent | Event,
): event is ClipboardEvent => {
/** not using instanceof ClipboardEvent due to tests (jsdom) */
return (
event.type === EVENT.PASTE ||
event.type === EVENT.COPY ||
event.type === EVENT.CUT
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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