1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-02 03:25:53 +01:00

Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax

This commit is contained in:
Daniel J. Geiger 2023-06-15 14:36:09 -05:00
commit 795176b256
221 changed files with 15664 additions and 8165 deletions

1
.npmrc

@ -1 +1,2 @@
save-exact=true
legacy-peer-deps=true

@ -20,6 +20,8 @@
},
"dependencies": {
"@excalidraw/extensions": "link:src/packages/extensions",
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
@ -27,6 +29,7 @@
"@testing-library/react": "12.1.5",
"@tldraw/vec": "1.7.1",
"browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
"fake-indexeddb": "3.1.7",
@ -34,7 +37,7 @@
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1",
"jotai": "1.6.4",
"jotai": "1.13.1",
"lodash.throttle": "4.1.1",
"nanoid": "3.3.3",
"open-color": "1.9.1",
@ -96,8 +99,17 @@
},
"homepage": ".",
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}"
],
"coveragePathIgnorePatterns": [
"<rootDir>/locales",
"<rootDir>/src/packages/excalidraw/dist/",
"<rootDir>/src/packages/excalidraw/types",
"<rootDir>/src/packages/excalidraw/example"
],
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access|canvas-roundrect-polyfill)/)"
],
"resetMocks": false
},
@ -128,6 +140,7 @@
"test:typecheck": "tsc",
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
"test": "yarn test:app",
"test:coverage": "react-scripts test --passWithNoTests --coverage --watchAll",
"autorelease": "node scripts/autorelease.js",
"prerelease": "node scripts/prerelease.js",
"release": "node scripts/release.js"

@ -12,7 +12,10 @@ export const actionAddToLibrary = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
if (selectedElements.some((element) => element.type === "image")) {
return {

@ -10,6 +10,7 @@ import {
import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
@ -17,10 +18,20 @@ import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const enableActionGroup = (
const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return (
selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly
!selectedElements.some((el) => el.type === "frame")
);
};
const alignSelectedElements = (
elements: readonly ExcalidrawElement[],
@ -36,14 +47,16 @@ const alignSelectedElements = (
const updatedElementsMap = arrayToMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
);
};
export const actionAlignTop = register({
name: "alignTop",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
return {
appState,
@ -58,7 +71,7 @@ export const actionAlignTop = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={AlignTopIcon}
onClick={() => updateData(null)}
@ -74,6 +87,7 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({
name: "alignBottom",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
return {
appState,
@ -88,7 +102,7 @@ export const actionAlignBottom = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={AlignBottomIcon}
onClick={() => updateData(null)}
@ -104,6 +118,7 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({
name: "alignLeft",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
return {
appState,
@ -118,7 +133,7 @@ export const actionAlignLeft = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={AlignLeftIcon}
onClick={() => updateData(null)}
@ -134,7 +149,7 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({
name: "alignRight",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
return {
appState,
@ -149,7 +164,7 @@ export const actionAlignRight = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={AlignRightIcon}
onClick={() => updateData(null)}
@ -165,7 +180,7 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
return {
appState,
@ -178,7 +193,7 @@ export const actionAlignVerticallyCentered = register({
},
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={CenterVerticallyIcon}
onClick={() => updateData(null)}
@ -192,6 +207,7 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => {
return {
appState,
@ -204,7 +220,7 @@ export const actionAlignHorizontallyCentered = register({
},
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
hidden={!alignActionsPredicate(elements, appState)}
type="button"
icon={CenterHorizontallyIcon}
onClick={() => updateData(null)}

@ -249,6 +249,7 @@ export const actionWrapTextInContainer = register({
"rectangle",
),
groupIds: textElement.groupIds,
frameId: textElement.frameId,
});
// update bindings

@ -1,4 +1,4 @@
import { ColorPicker } from "../components/ColorPicker";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
@ -19,6 +19,9 @@ import {
isEraserActive,
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { Bounds } from "../element/bounds";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@ -35,24 +38,21 @@ export const actionChangeViewBackgroundColor = register({
commitToHistory: !!value.viewBackgroundColor,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
PanelComponent: ({ elements, appState, updateData, appProps }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
return (
<div style={{ position: "relative" }}>
<ColorPicker
label={t("labels.canvasBackground")}
type="canvasBackground"
color={appState.viewBackgroundColor}
onChange={(color) => updateData({ viewBackgroundColor: color })}
isActive={appState.openPopup === "canvasColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "canvasColorPicker" : null })
}
data-testid="canvas-background-picker"
elements={elements}
appState={appState}
/>
</div>
<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}
/>
);
},
});
@ -208,7 +208,7 @@ export const actionResetZoom = register({
});
const zoomValueToFitBoundsOnViewport = (
bounds: [number, number, number, number],
bounds: Bounds,
viewportDimensions: { width: number; height: number },
) => {
const [x1, y1, x2, y2] = bounds;
@ -236,8 +236,10 @@ export const zoomToFitElements = (
const commonBounds =
zoomToSelection && selectedElements.length > 0
? getCommonBounds(selectedElements)
: getCommonBounds(nonDeletedElements);
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
: getCommonBounds(
excludeElementsInFramesFromSelection(nonDeletedElements),
);
const newZoom = {
value: zoomValueToFitBoundsOnViewport(commonBounds, {

@ -16,9 +16,12 @@ export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState, true);
const elementsToCopy = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
copyToClipboard(selectedElements, app.files);
copyToClipboard(elementsToCopy, app.files);
return {
commitToHistory: false,
@ -75,7 +78,10 @@ export const actionCopyAsSvg = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
try {
await exportCanvas(
@ -119,7 +125,10 @@ export const actionCopyAsPng = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
try {
await exportCanvas(
@ -172,7 +181,9 @@ export const copyText = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
{
includeBoundTextElement: true,
},
);
const text = selectedElements
@ -191,7 +202,9 @@ export const copyText = register({
predicate: (elements, appState) => {
return (
probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, true).some(isTextElement)
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).some(isTextElement)
);
},
contextItemLabel: "labels.copyText",

@ -1,4 +1,4 @@
import { isSomeElementSelected } from "../scene";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
@ -18,11 +18,23 @@ const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => el.type === "frame"),
appState,
).map((el) => el.id),
);
return {
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
return newElementWith(el, { isDeleted: true });
}
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
return newElementWith(el, { isDeleted: true });
}
if (
isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId]

@ -6,6 +6,7 @@ import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
@ -16,7 +17,17 @@ import { register } from "./register";
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return (
selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly
!selectedElements.some((el) => el.type === "frame")
);
};
const distributeSelectedElements = (
elements: readonly ExcalidrawElement[],
@ -32,8 +43,9 @@ const distributeSelectedElements = (
const updatedElementsMap = arrayToMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
return updateFrameMembershipOfSelectedElements(
elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
);
};

@ -2,7 +2,7 @@ import { KEYS } from "../keys";
import { register } from "./register";
import { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils";
@ -20,9 +20,17 @@ import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
} from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks";
import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
import {
bindElementsToFramesAfterDuplication,
getFrameElements,
} from "../frame";
import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
@ -94,8 +102,11 @@ const duplicateElements = (
return newElement;
};
const selectedElementIds = arrayToMap(
getSelectedElements(sortedElements, appState, true),
const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(sortedElements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
);
// Ids of elements that have already been processed so we don't push them
@ -129,12 +140,25 @@ const duplicateElements = (
}
const boundTextElement = getBoundTextElement(element);
if (selectedElementIds.get(element.id)) {
// if a group or a container/bound-text, duplicate atomically
if (element.groupIds.length || boundTextElement) {
const isElementAFrame = isFrameElement(element);
if (idsOfElementsToDuplicate.get(element.id)) {
// if a group or a container/bound-text or frame, duplicate atomically
if (element.groupIds.length || boundTextElement || isElementAFrame) {
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
const groupElements = getElementsInGroup(sortedElements, groupId);
// TODO:
// remove `.flatMap...`
// if the elements in a frame are grouped when the frame is grouped
const groupElements = getElementsInGroup(
sortedElements,
groupId,
).flatMap((element) =>
isFrameElement(element)
? [...getFrameElements(elements, element.id), element]
: [element],
);
elementsWithClones.push(
...markAsProcessed([
...groupElements,
@ -156,10 +180,34 @@ const duplicateElements = (
);
continue;
}
if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id);
elementsWithClones.push(
...markAsProcessed([
...elementsInFrame,
element,
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
duplicateAndOffsetElement(element),
]),
);
continue;
}
}
// since elements in frames have a lower z-index than the frame itself,
// they will be looped first and if their frames are selected as well,
// they will have been copied along with the frame atomically in the
// above branch, so we must skip those elements here
//
// now, for elements do not belong any frames or elements whose frames
// are selected (or elements that are left out from the above
// steps for whatever reason) we (should at least) duplicate them here
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
);
}
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
);
} else {
elementsWithClones.push(...markAsProcessed([element]));
}
@ -200,6 +248,14 @@ const duplicateElements = (
oldElements,
oldIdToDuplicatedId,
);
bindElementsToFramesAfterDuplication(
finalElements,
oldElements,
oldIdToDuplicatedId,
);
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
return {
elements: finalElements,
@ -207,7 +263,7 @@ const duplicateElements = (
{
...appState,
selectedGroupIds: {},
selectedElementIds: newElements.reduce(
selectedElementIds: nextElementsToSelect.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;

@ -11,8 +11,17 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({
name: "toggleElementLock",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return !selectedElements.some(
(element) => element.locked && element.frameId,
);
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true);
const selectedElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
if (!selectedElements.length) {
return false;
@ -38,8 +47,10 @@ export const actionToggleElementLock = register({
};
},
contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, false);
if (selected.length === 1) {
const selected = getSelectedElements(elements, appState, {
includeBoundTextElement: false,
});
if (selected.length === 1 && selected[0].type !== "frame") {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
@ -54,7 +65,9 @@ export const actionToggleElementLock = register({
event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
getSelectedElements(elements, appState, false).length > 0
getSelectedElements(elements, appState, {
includeBoundTextElement: false,
}).length > 0
);
},
});

@ -26,7 +26,7 @@ export const actionChangeProjectName = register({
perform: (_elements, appState, value) => {
return { appState: { ...appState, name: value }, commitToHistory: false };
},
PanelComponent: ({ appState, updateData, appProps }) => (
PanelComponent: ({ appState, updateData, appProps, data }) => (
<ProjectName
label={t("labels.fileTitle")}
value={appState.name || "Unnamed"}
@ -34,6 +34,7 @@ export const actionChangeProjectName = register({
isNameEditable={
typeof appProps.name === "undefined" && !appState.viewModeEnabled
}
ignoreFocus={data?.ignoreFocus ?? false}
/>
),
});

@ -1,57 +1,34 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
import { AppState } from "../types";
import { getTransformHandles } from "../element/transformHandles";
import { updateBoundElements } from "../element/binding";
import { resizeMultipleElements } from "../element/resizeElements";
import { AppState, PointerDownState } from "../types";
import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
import {
getElementAbsoluteCoords,
getElementPointsCoords,
} from "../element/bounds";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { KEYS } from "../keys";
const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const eligibleElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
};
const enableActionFlipVertical = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const eligibleElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return eligibleElements.length === 1;
};
bindOrUnbindSelectedElements,
isBindingEnabled,
unbindLinearElements,
} from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "horizontal"),
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "horizontal"),
appState,
),
appState,
commitToHistory: true,
};
},
keyTest: (event) => event.shiftKey && event.code === "KeyH",
keyTest: (event) => event.shiftKey && event.code === CODES.H,
contextItemLabel: "labels.flipHorizontal",
predicate: (elements, appState) =>
enableActionFlipHorizontal(elements, appState),
});
export const actionFlipVertical = register({
@ -59,16 +36,17 @@ export const actionFlipVertical = register({
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "vertical"),
elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "vertical"),
appState,
),
appState,
commitToHistory: true,
};
},
keyTest: (event) =>
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
contextItemLabel: "labels.flipVertical",
predicate: (elements, appState) =>
enableActionFlipVertical(elements, appState),
});
const flipSelectedElements = (
@ -79,13 +57,11 @@ const flipSelectedElements = (
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
{
includeElementsInFrames: true,
},
);
// remove once we allow for groups of elements to be flipped
if (selectedElements.length > 1) {
return elements;
}
const updatedElements = flipElements(
selectedElements,
appState,
@ -104,144 +80,20 @@ const flipElements = (
appState: AppState,
flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => {
elements.forEach((element) => {
flipElement(element, appState);
// If vertical flip, rotate an extra 180
if (flipDirection === "vertical") {
rotateElement(element, Math.PI);
}
});
const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements);
resizeMultipleElements(
{ originalElements: arrayToMap(elements) } as PointerDownState,
elements,
"nw",
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
);
(isBindingEnabled(appState)
? bindOrUnbindSelectedElements
: unbindLinearElements)(elements);
return elements;
};
const flipElement = (
element: NonDeleted<ExcalidrawElement>,
appState: AppState,
) => {
const originalX = element.x;
const originalY = element.y;
const width = element.width;
const height = element.height;
const originalAngle = normalizeAngle(element.angle);
// Rotate back to zero, if necessary
mutateElement(element, {
angle: normalizeAngle(0),
});
// Flip unrotated by pulling TransformHandle to opposite side
const transformHandles = getTransformHandles(element, appState.zoom);
let usingNWHandle = true;
let nHandle = transformHandles.nw;
if (!nHandle) {
// Use ne handle instead
usingNWHandle = false;
nHandle = transformHandles.ne;
if (!nHandle) {
mutateElement(element, {
angle: originalAngle,
});
return;
}
}
let finalOffsetX = 0;
if (isLinearElement(element) && element.points.length < 3) {
finalOffsetX =
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
element.width;
}
let initialPointsCoords;
if (isLinearElement(element)) {
initialPointsCoords = getElementPointsCoords(element, element.points);
}
const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
if (isLinearElement(element) && element.points.length < 3) {
for (let index = 1; index < element.points.length; index++) {
LinearElementEditor.movePoints(element, [
{
index,
point: [-element.points[index][0], element.points[index][1]],
},
]);
}
LinearElementEditor.normalizePoints(element);
} else {
const elWidth = initialPointsCoords
? initialPointsCoords[2] - initialPointsCoords[0]
: initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0];
const startPoint = initialPointsCoords
? [initialPointsCoords[0], initialPointsCoords[1]]
: [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]];
resizeSingleElement(
new Map().set(element.id, element),
false,
element,
usingNWHandle ? "nw" : "ne",
true,
usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth,
startPoint[1],
);
}
// Rotate by (360 degrees - original angle)
let angle = normalizeAngle(2 * Math.PI - originalAngle);
if (angle < 0) {
// check, probably unnecessary
angle = normalizeAngle(angle + 2 * Math.PI);
}
mutateElement(element, {
angle,
});
// Move back to original spot to appear "flipped in place"
mutateElement(element, {
x: originalX + finalOffsetX,
y: originalY,
width,
height,
});
updateBoundElements(element);
if (initialPointsCoords && isLinearElement(element)) {
// Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
// There's still room for improvement since when the line roughness is > 1
// we still have a small offset of the origin when fliipping the element.
const finalPointsCoords = getElementPointsCoords(element, element.points);
const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
const coordsDiff = topLeftCoordsDiff + topRightCoordDiff;
mutateElement(element, {
x: element.x + coordsDiff * 0.5,
y: element.y,
width,
height,
});
}
};
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
const originalX = element.x;
const originalY = element.y;
let angle = normalizeAngle(element.angle + rotationAngle);
if (angle < 0) {
// check, probably unnecessary
angle = normalizeAngle(2 * Math.PI + angle);
}
mutateElement(element, {
angle,
});
// Move back to original spot
mutateElement(element, {
x: originalX,
y: originalY,
});
};

140
src/actions/actionFrame.ts Normal file

@ -0,0 +1,140 @@
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils";
import { register } from "./register";
const isSingleFrameSelected = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return selectedElements.length === 1 && selectedElements[0].type === "frame";
};
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements(
getNonDeletedElements(elements),
selectedFrame.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>),
},
commitToHistory: false,
};
}
return {
elements,
appState,
commitToHistory: false,
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
});
export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
if (selectedFrame && selectedFrame.type === "frame") {
return {
elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
appState: {
...appState,
selectedElementIds: {
[selectedFrame.id]: true,
},
},
commitToHistory: true,
};
}
return {
elements,
appState,
commitToHistory: false,
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
});
export const actionToggleFrameRendering = register({
name: "toggleFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
return {
elements,
appState: {
...appState,
shouldRenderFrames: !appState.shouldRenderFrames,
},
commitToHistory: false,
};
},
contextItemLabel: "labels.toggleFrameRendering",
checked: (appState: AppState) => appState.shouldRenderFrames,
});
export const actionSetFrameAsActiveTool = register({
name: "setFrameAsActiveTool",
trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setCursorForShape(app.canvas, {
...appState,
activeTool: nextActiveTool,
});
return {
elements,
appState: {
...appState,
activeTool: updateActiveTool(appState, {
type: "frame",
}),
},
commitToHistory: false,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
!event.altKey &&
event.key.toLocaleLowerCase() === KEYS.F,
});

@ -17,9 +17,19 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
groupByFrames,
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
@ -45,7 +55,9 @@ const enableActionGroup = (
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
{
includeBoundTextElement: true,
},
);
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
@ -55,11 +67,13 @@ const enableActionGroup = (
export const actionGroup = register({
name: "group",
trackEvent: { category: "element" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
{
includeBoundTextElement: true,
},
);
if (selectedElements.length < 2) {
// nothing to group
@ -86,9 +100,31 @@ export const actionGroup = register({
return { appState, elements, commitToHistory: false };
}
}
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 = groupByFrames(selectedElements);
frameElementsMap.forEach((elementsInFrame, frameId) => {
nextElements = removeElementsFromFrame(
nextElements,
elementsInFrame,
appState,
);
});
}
const newGroupId = randomId();
const selectElementIds = arrayToMap(selectedElements);
const updatedElements = elements.map((element) => {
nextElements = nextElements.map((element) => {
if (!selectElementIds.get(element.id)) {
return element;
}
@ -102,17 +138,16 @@ export const actionGroup = register({
});
// 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(updatedElements, newGroupId);
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex =
updatedElements.lastIndexOf(lastElementInGroup);
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = updatedElements
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = nextElements
.slice(0, lastGroupElementIndex)
.filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
);
const updatedElementsInOrder = [
nextElements = [
...elementsBeforeGroup,
...elementsInGroup,
...elementsAfterGroup,
@ -122,9 +157,9 @@ export const actionGroup = register({
appState: selectGroup(
newGroupId,
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(updatedElementsInOrder),
getNonDeletedElements(nextElements),
),
elements: updatedElementsInOrder,
elements: nextElements,
commitToHistory: true,
};
},
@ -148,14 +183,23 @@ export const actionGroup = register({
export const actionUngroup = register({
name: "ungroup",
trackEvent: { category: "element" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false };
}
let nextElements = [...elements];
const selectedElements = getSelectedElements(nextElements, appState);
const frames = selectedElements
.filter((element) => element.frameId)
.map((element) =>
app.scene.getElement(element.frameId!),
) as ExcalidrawFrameElement[];
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
const nextElements = elements.map((element) => {
nextElements = nextElements.map((element) => {
if (isBoundToContainer(element)) {
boundTextElementIds.push(element.id);
}
@ -176,13 +220,23 @@ export const actionUngroup = register({
getNonDeletedElements(nextElements),
);
frames.forEach((frame) => {
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,
getElementsInResizingFrame(nextElements, frame, appState),
frame,
appState,
);
}
});
// remove binded text elements from selection
boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false),
);
return {
appState: updateAppState,
elements: nextElements,
commitToHistory: true,
};

@ -21,7 +21,9 @@ export const actionToggleLinearEditor = register({
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
{
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement;
const editingLinearElement =
@ -40,7 +42,9 @@ export const actionToggleLinearEditor = register({
const selectedElement = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
{
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit"

@ -67,7 +67,6 @@ export const actionFullScreen = register({
commitToHistory: false,
};
},
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
});
export const actionShortcuts = register({

@ -1,4 +1,4 @@
import { getClientColors } from "../clients";
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types";
@ -31,15 +31,14 @@ export const actionGoToCollaborator = register({
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData, data }) => {
PanelComponent: ({ updateData, data }) => {
const [clientId, collaborator] = data as [string, Collaborator];
const { background, stroke } = getClientColors(clientId, appState);
const background = getClientColor(clientId);
return (
<Avatar
color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)}
name={collaborator.username || ""}
src={collaborator.avatarUrl}

@ -1,7 +1,13 @@
import { AppState } from "../../src/types";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
DEFAULT_ELEMENT_STROKE_PICKS,
} from "../colors";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconPicker } from "../components/IconPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
@ -96,8 +102,11 @@ export const changeProperty = (
includeBoundText = false,
) => {
const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, includeBoundText),
getSelectedElements(elements, appState, {
includeBoundTextElement: includeBoundText,
}),
);
return elements.map((element) => {
if (
selectedElementIds.get(element.id) ||
@ -113,8 +122,8 @@ export const getFormValue = function <T>(
elements: readonly ExcalidrawElement[],
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T,
defaultValue?: T,
): T | null {
defaultValue: T,
): T {
const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements);
return (
@ -126,7 +135,7 @@ export const getFormValue = function <T>(
getAttribute,
)
: defaultValue) ??
null
defaultValue
);
};
@ -226,10 +235,12 @@ export const actionChangeStrokeColor = register({
commitToHistory: !!value.currentItemStrokeColor,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, appProps }) => (
<>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
type="elementStroke"
label={t("labels.stroke")}
color={getFormValue(
@ -239,12 +250,9 @@ export const actionChangeStrokeColor = register({
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "strokeColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
),
@ -269,10 +277,12 @@ export const actionChangeBackgroundColor = register({
commitToHistory: !!value.currentItemBackgroundColor,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, appProps }) => (
<>
<h3 aria-hidden="true">{t("labels.background")}</h3>
<ColorPicker
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
type="elementBackground"
label={t("labels.background")}
color={getFormValue(
@ -282,12 +292,9 @@ export const actionChangeBackgroundColor = register({
appState.currentItemBackgroundColor,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
isActive={appState.openPopup === "backgroundColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null })
}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
),
@ -807,6 +814,7 @@ export const actionChangeTextAlign = register({
);
},
});
export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign",
trackEvent: { category: "element" },
@ -861,16 +869,21 @@ export const actionChangeVerticalAlign = register({
testId: "align-bottom",
},
]}
value={getFormValue(elements, appState, (element) => {
if (isTextElement(element) && element.containerId) {
return element.verticalAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.verticalAlign;
}
return null;
})}
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element) && element.containerId) {
return element.verticalAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.verticalAlign;
}
return null;
},
VERTICAL_ALIGN.MIDDLE,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>

@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
export const actionSelectAll = register({
name: "selectAll",
@ -13,19 +14,18 @@ export const actionSelectAll = register({
if (appState.editingLinearElement) {
return false;
}
const selectedElementIds = elements.reduce(
(map: Record<ExcalidrawElement["id"], true>, element) => {
if (
const selectedElementIds = excludeElementsInFramesFromSelection(
elements.filter(
(element) =>
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
!element.locked
) {
map[element.id] = true;
}
return map;
},
{},
);
!element.locked,
),
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
map[element.id] = true;
return map;
}, {});
return {
appState: selectGroupsForSelectedElements(

@ -1,9 +1,14 @@
import ExcalidrawApp from "../excalidraw-app";
import { t } from "../i18n";
import { CODES } from "../keys";
import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { fireEvent, render, screen } from "../tests/test-utils";
import {
act,
fireEvent,
render,
screen,
togglePopover,
} from "../tests/test-utils";
import { copiedStyles } from "./actionStyles";
const { h } = window;
@ -14,7 +19,14 @@ describe("actionStyles", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
});
it("should copy & paste styles via keyboard", () => {
afterEach(async () => {
// https://github.com/floating-ui/floating-ui/issues/1908#issuecomment-1301553793
// affects node v16+
await act(async () => {});
});
it("should copy & paste styles via keyboard", async () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
@ -24,10 +36,10 @@ describe("actionStyles", () => {
mouse.up(20, 20);
// Change some styles of second rectangle
UI.clickLabeledElement("Stroke");
UI.clickLabeledElement(t("colors.c92a2a"));
UI.clickLabeledElement("Background");
UI.clickLabeledElement(t("colors.e64980"));
togglePopover("Stroke");
UI.clickOnTestId("color-red");
togglePopover("Background");
UI.clickOnTestId("color-blue");
// Fill style
fireEvent.click(screen.getByTitle("Cross-hatch"));
// Stroke width
@ -60,8 +72,8 @@ describe("actionStyles", () => {
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a");
expect(firstRect.backgroundColor).toBe("#e64980");
expect(firstRect.strokeColor).toBe("#e03131");
expect(firstRect.backgroundColor).toBe("#a5d8ff");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");

@ -20,6 +20,7 @@ import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
isFrameElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
@ -64,7 +65,9 @@ export const actionPasteStyles = register({
return { elements, commitToHistory: false };
}
const selectedElements = getSelectedElements(elements, appState, true);
const selectedElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
const selectedElementIds = selectedElements.map((element) => element.id);
return {
elements: elements.map((element) => {
@ -127,6 +130,13 @@ export const actionPasteStyles = register({
});
}
if (isFrameElement(element)) {
newElement = newElementWith(newElement, {
roundness: null,
backgroundColor: "transparent",
});
}
return newElement;
}
return element;

@ -150,10 +150,13 @@ export class ActionManager {
return true;
}
executeAction(action: Action, source: ActionSource = "api") {
executeAction(
action: Action,
source: ActionSource = "api",
value: any = null,
) {
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const value = null;
trackAction(action, source, appState, elements, this.app, value);

@ -125,6 +125,11 @@ export type ActionName =
| "toggleLinearEditor"
| "toggleEraserTool"
| "toggleHandTool"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "toggleFrameRendering"
| "setFrameAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer";
export type PanelComponentProps = {

@ -1,6 +1,6 @@
import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import { Box, getCommonBoundingBox } from "./element/bounds";
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups";
export interface Alignment {
@ -33,7 +33,7 @@ export const alignElements = (
const calculateTranslation = (
group: ExcalidrawElement[],
selectionBoundingBox: Box,
selectionBoundingBox: BoundingBox,
{ axis, position }: Alignment,
): { x: number; y: number } => {
const groupBoundingBox = getCommonBoundingBox(group);

@ -1,4 +1,4 @@
import oc from "open-color";
import { COLOR_PALETTE } from "./colors";
import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
@ -78,13 +78,18 @@ export const getDefaultAppState = (): Omit<
scrollY: 0,
selectedElementIds: {},
selectedGroupIds: {},
selectedElementsAreBeingDragged: false,
selectionElement: null,
shouldCacheIgnoreZoom: false,
showStats: false,
startBoundElement: null,
suggestedBindings: [],
shouldRenderFrames: true,
frameToHighlight: null,
editingFrame: null,
elementsToHighlight: null,
toast: null,
viewBackgroundColor: oc.white,
viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false,
zoom: {
value: 1 as NormalizedZoomValue,
@ -178,11 +183,20 @@ const APP_STATE_STORAGE_CONF = (<
scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, 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 },
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
shouldRenderFrames: { 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 },

20
src/assets/lock.svg Normal file

@ -0,0 +1,20 @@
<svg width="178" height="162" viewBox="0 0 178 162" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.3329 54.3823L38.5547 94.3134L39.7731 111.754L40.1282 118.907L41.0832 123.59L44.3502 131.942L48.9438 137.693L52.5472 143.333L58.5544 147.755L62.5364 150.239L72.3634 154.486L83.15 156.361L91.1212 158.708L101.174 157.525L110.808 156.719L115.983 154.049L124.511 151.377L129.276 148.71L133.701 143.947L139.666 135.877L142.001 128.136L145.746 118.192L145.188 111.065L145.489 94.3675L145.873 75.2546L143.227 59.7779L142.022 47.4695L138.595 46.8345L102.952 45.4703L56.9173 46.7498L46.0719 49.1207L41.9323 50.6825L39.5684 53.4297" fill="#E3E2FE"/>
<path d="M41.0014 54.2859C41.0861 64.8796 38.3765 102.581 40.9779 117.876C43.5793 133.17 48.2646 139.346 56.6121 146.047C64.9596 152.746 79.1214 157.662 91.0653 158.078C103.009 158.492 119.347 155.242 128.277 148.543C137.206 141.842 142.112 133.527 144.641 117.874C147.169 102.221 146.061 66.4132 143.446 54.6222C140.83 42.8289 143.97 48.2857 128.948 47.1238C113.925 45.9619 67.9608 46.477 53.3051 47.6483C38.6493 48.8197 43.2053 53.0675 41.0155 54.1518M40.5263 53.9801C40.5404 64.6138 37.9249 103.418 40.5921 118.587C43.257 133.755 48.147 138.325 56.5251 144.991C64.9008 151.655 78.7263 157.935 90.8536 158.577C102.981 159.221 120.212 155.413 129.289 148.844C138.368 142.277 142.872 134.995 145.321 119.168C147.767 103.343 146.805 65.8698 143.977 53.8837C141.148 41.8975 143.615 48.4292 128.348 47.2508C113.081 46.0724 67.14 45.6726 52.3737 46.8157C37.6074 47.9564 41.7776 53.091 39.7524 54.1024" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.7935 45.726L66.36 36.6964L65.5979 34.4501L67.3384 30.7668L70.4197 26.5048L74.8157 21.0598L81.9095 16.9131L89.9419 14.4951L95.6127 12.8534L97.7555 13.2133L103.819 15.269L106.552 18.5807L109.967 22.3793L114.45 27.2387L114.904 34.3937L117.153 38.9214L116.031 44.5005L116.765 45.5448L118.863 47.3559L126.164 47.6782L127.744 46.7797L128.76 44.7052L123.882 25.8227L120.641 20.4835L116.351 15.8758L112.809 11.0729L108.617 7.36601L101.352 5.43025L93.263 3.31104L84.2451 5.1433L77.7299 7.86229L76.0011 8.0975L62.1168 19.8532L59.5366 24.5597L55.7733 32.3215L55.0371 39.5282L56.0626 44.8933L56.5636 47.1725L59.6401 46.8644L66.1648 46.0388" fill="#E3E2FE"/>
<path d="M65.0037 46.0106C65.1166 43.8231 64.9237 36.8492 66.1421 33.2412C67.3605 29.6307 69.0799 27.2857 72.314 24.3527C75.5481 21.422 81.273 17.6093 85.5444 15.6453C89.8157 13.6813 94.3317 12.1148 97.9421 12.5664C101.555 13.0157 104.544 15.857 107.219 18.3478C109.893 20.8387 112.356 24.0869 113.986 27.5115C115.618 30.9338 116.389 35.8684 117.006 38.8861C117.622 41.9038 115.992 44.2888 117.69 45.6178C119.388 46.949 125.617 48.1721 127.195 46.8667C128.773 45.5613 127.717 41.1888 127.157 37.7877C126.597 34.3866 125.494 30.1247 123.838 26.4601C122.183 22.7956 119.746 18.9029 117.222 15.7982C114.698 12.6934 112.791 9.9086 108.696 7.83642C104.601 5.76189 97.8081 3.42863 92.6547 3.35337C87.5013 3.2781 81.5529 5.74308 77.7708 7.38717C73.991 9.03127 72.8879 10.6166 69.9666 13.218C67.043 15.8217 62.4306 19.6768 60.2384 23.0026C58.0486 26.3284 57.4818 29.252 56.8185 33.1753C56.1529 37.0962 54.6499 44.39 56.2517 46.5327C57.8511 48.6731 64.7756 45.98 66.4267 46.0247M65.9704 45.5096C65.9845 43.348 64.2652 37.5525 65.5423 33.8456C66.8172 30.1364 70.2959 26.4789 73.6264 23.259C76.9546 20.039 81.3177 16.3015 85.5208 14.5281C89.7216 12.7523 95.1079 11.7903 98.8383 12.6111C102.569 13.432 105.283 16.8072 107.903 19.4486C110.526 22.09 113.146 25.3029 114.567 28.4664C115.987 31.6276 116.03 35.4051 116.425 38.4204C116.82 41.4334 115.124 45.1426 116.937 46.5515C118.751 47.9604 125.539 48.2968 127.31 46.8761C129.081 45.4578 127.978 41.4428 127.562 38.0347C127.145 34.6265 126.501 30.1646 124.81 26.4296C123.116 22.6921 120.195 18.7594 117.413 15.6194C114.63 12.4818 112.247 9.39349 108.117 7.58945C103.987 5.78541 97.5776 5.02099 92.6335 4.79519C87.6895 4.56939 82.3503 4.78813 78.4505 6.23466C74.5484 7.68118 72.0882 10.6542 69.228 13.4696C66.3679 16.2851 63.4725 19.7873 61.2898 23.1319C59.1071 26.4789 56.9761 29.4896 56.1293 33.5469C55.285 37.6043 54.577 45.2132 56.2117 47.4759C57.8487 49.7409 64.2675 47.3418 65.9445 47.1301" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path d="M140.37 54.8958C137.884 58.1322 127.704 71.2286 125.185 74.5427M139.697 54.209C137.098 57.5466 127.005 71.7884 124.51 75.3565" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path d="M141.663 63.1765C139.661 66.0413 131.311 77.1501 129.077 79.9726M141.065 62.5908C139.021 65.2792 130.631 76.1364 128.717 78.8625" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path d="M141.888 72.9917C139.475 75.8589 130.268 86.8478 127.966 89.7455M141.02 72.726C138.503 75.6496 129.775 87.2476 127.58 90.3242" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path d="M141.948 82.215C139.815 85.1057 130.308 96.8214 127.961 99.7709M141.459 81.7375C139.298 84.4119 129.816 95.9888 127.479 98.8606" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path d="M141.357 91.7838C138.885 95.2484 128.808 108.535 126.428 111.76M142.474 91.4757C139.917 94.7921 128.38 107.493 125.781 110.883" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path d="M142.568 101.479C140.028 104.403 129.867 115.528 127.195 118.356M141.811 101.018C139.212 104.055 129.477 115.975 126.828 118.97" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path d="M141.023 112.172C138.591 114.775 128.028 125.905 125.422 128.664M140.51 113.465C138.008 116.147 127.36 125.233 124.742 127.582" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path d="M139.004 123.69C136.501 126.275 125.952 137.248 123.287 140.108M138.343 124.817C135.805 127.454 125.487 138.261 122.848 140.75" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path d="M132.192 139.862C129.854 141.624 120.87 148.168 118.574 150.012M131.39 139.496C128.97 141.333 120.524 148.89 118.322 150.621" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M82.6351 92.3124L78.2767 89.0148L78.6718 88.8784L75.6282 79.6865L74.4922 76.0525L75.0379 74.1074L78.6248 69.5444L83.6182 65.186L86.6924 64.0711L93.7768 63.9864L99.9181 63.9276L103.905 64.4215L106.038 66.068L109.333 67.6392L110.251 69.4479L112.438 73.1877L112.702 81.928L111.674 82.93L110.907 85.5573L107.828 89.2336L101.273 92.9193L102.785 120.401L99.5488 125.521L98.0059 127.838L96.1313 129.414L93.17 130.237L92.2198 130.033L90.1358 129.233L88.8328 126.594L87.8378 95.2549L88.9386 93.3215L86.0409 91.294L80.9533 91.1552" fill="white"/>
<path d="M82.8214 92.0607C82.0664 91.4327 79.291 90.7201 77.8539 88.2033C76.4167 85.6866 73.5284 80.4438 74.1964 76.9581C74.862 73.4723 78.6959 69.6384 81.8524 67.2887C85.0089 64.939 88.9227 63.1138 93.1353 62.8574C97.3478 62.6034 103.957 63.9888 107.132 65.7575C110.31 67.5263 111.416 70.5651 112.196 73.4747C112.977 76.3842 112.606 80.6626 111.82 83.2122C111.035 85.7642 109.078 87.1661 107.481 88.7749C105.883 90.3837 103.106 91.2751 102.233 92.8651C101.363 94.4551 102.327 95.3254 102.25 98.3125C102.172 101.3 101.76 107.227 101.767 110.788C101.772 114.349 102.487 116.981 102.285 119.676C102.085 122.374 101.52 125.126 100.556 126.965C99.5917 128.805 98.077 130.256 96.5011 130.715C94.9275 131.171 92.4485 130.36 91.1101 129.713C89.7742 129.066 89.0144 128.341 88.4805 126.836C87.9489 125.331 87.9678 123.964 87.9137 120.681C87.8596 117.397 88.1159 111.599 88.1583 107.14C88.203 102.68 89.2779 96.445 88.1724 93.9236C87.0693 91.4022 82.7791 92.4347 81.5325 92.0137M82.0194 91.6068C81.222 90.7624 78.4536 89.7886 77.3623 87.1567C76.2733 84.5247 74.6621 79.3125 75.4783 75.815C76.2921 72.3151 79.1428 68.3166 82.2522 66.1597C85.3617 64.0029 90.1693 63.062 94.1302 62.8739C98.0911 62.6857 102.925 63.0832 106.02 65.0331C109.118 66.9853 111.834 71.5836 112.705 74.5801C113.572 77.5743 111.949 80.7731 111.234 83.0076C110.519 85.2444 109.835 86.3711 108.417 87.9916C107.001 89.6146 103.738 90.9623 102.732 92.7358C101.725 94.5092 102.351 95.6382 102.377 98.6301C102.405 101.62 102.866 106.949 102.894 110.682C102.922 114.417 102.955 118.291 102.544 121.038C102.13 123.783 101.408 125.54 100.42 127.161C99.4318 128.781 98.1005 130.233 96.6163 130.759C95.1322 131.286 92.9353 130.893 91.51 130.322C90.0846 129.753 88.7769 128.889 88.0618 127.335C87.3468 125.78 87.0128 124.317 87.2198 120.998C87.4268 117.68 89.0874 112.046 89.299 107.422C89.5107 102.798 89.8494 95.9322 88.4946 93.2509C87.1398 90.5695 82.4804 91.4845 81.1679 91.3316" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
<path d="M28.1943 139.31C26.7936 139.432 25.332 140.402 23.8703 140.523C23.1395 140.766 22.5914 140.16 21.9824 139.735C21.5561 139.553 21.008 138.461 20.8253 138.219C20.5817 136.884 19.9118 134.276 20.0336 133.002C19.7291 131.364 21.5561 129.787 23.0786 129.727C23.2613 129.727 23.8094 129.787 23.8703 129.787C25.7583 130.151 27.5853 131.546 29.5341 131.728C29.595 131.728 29.6559 131.728 29.6559 131.668C30.4476 130.333 30.204 126.937 30.813 125.542C30.813 125.36 31.1784 123.54 31.1784 123.237C31.6048 122.327 32.1529 121.781 33.1273 122.084C33.7972 122.266 34.6498 122.388 34.6498 123.237V128.635C34.8325 129.242 36.1114 128.999 36.5986 128.999C38.7911 128.028 40.8617 127.422 43.3586 127.058C45.6729 127.179 46.7082 129.242 46.5864 131.304C46.6473 132.396 45.4293 133.245 44.6985 133.973C44.4549 134.094 43.4804 134.519 43.115 134.397C42.2624 133.791 41.1662 134.033 40.1309 134.094C40.1309 134.155 40.0091 134.337 40.07 134.397C41.288 135.853 43.5413 136.096 45.0639 137.066C46.1601 138.34 47.4999 138.643 47.1345 140.341C47.0736 141.191 47.1345 142.1 46.221 142.404C45.9774 142.586 44.5767 142.828 44.2722 142.828C43.9677 142.768 43.3586 142.343 43.115 142.04C40.9835 141.13 38.6693 140.402 36.2332 140.159V145.133C35.9896 146.468 35.6851 147.923 34.6498 148.955C34.2844 149.015 33.1273 149.015 32.7619 148.955C32.4574 148.773 31.4221 147.741 31.1784 147.438C30.5694 145.133 30.4476 142.404 29.6559 140.159C29.1687 139.553 28.986 139.25 28.1943 139.31Z" fill="#6965DB"/>
<path d="M59.5964 139.31C58.1956 139.432 56.734 140.402 55.2724 140.523C54.5416 140.766 53.9935 140.16 53.3845 139.735C52.9582 139.553 52.41 138.461 52.2273 138.219C51.9837 136.884 51.3138 134.276 51.4356 133.002C51.1311 131.364 52.9582 129.787 54.4807 129.727C54.6634 129.727 55.2115 129.787 55.2724 129.787C57.1603 130.151 58.9874 131.546 60.9362 131.728C60.9971 131.728 61.058 131.728 61.058 131.668C61.8497 130.333 61.6061 126.937 62.2151 125.542C62.2151 125.36 62.5805 123.54 62.5805 123.237C63.0068 122.327 63.5549 121.781 64.5293 122.084C65.1992 122.266 66.0519 122.388 66.0519 123.237V128.635C66.2346 129.242 67.5135 128.999 68.0007 128.999C70.1931 128.028 72.2638 127.422 74.7607 127.058C77.0749 127.179 78.1103 129.242 77.9885 131.304C78.0494 132.396 76.8313 133.245 76.1005 133.973C75.8569 134.094 74.8825 134.519 74.5171 134.397C73.6645 133.791 72.5683 134.033 71.5329 134.094C71.5329 134.155 71.4112 134.337 71.4721 134.397C72.6901 135.853 74.9434 136.096 76.4659 137.066C77.5621 138.34 78.902 138.643 78.5366 140.341C78.4757 141.191 78.5366 142.1 77.623 142.404C77.3794 142.586 75.9787 142.828 75.6742 142.828C75.3697 142.768 74.7607 142.343 74.5171 142.04C72.3856 141.13 70.0713 140.402 67.6353 140.159V145.133C67.3917 146.468 67.0872 147.923 66.0519 148.955C65.6865 149.015 64.5293 149.015 64.1639 148.955C63.8594 148.773 62.8241 147.741 62.5805 147.438C61.9715 145.133 61.8497 142.404 61.058 140.159C60.5708 139.553 60.3881 139.25 59.5964 139.31Z" fill="#6965DB"/>
<path d="M90.9984 139.31C89.5977 139.432 88.1361 140.402 86.6745 140.523C85.9436 140.766 85.3955 140.16 84.7865 139.735C84.3602 139.553 83.8121 138.461 83.6294 138.219C83.3858 136.884 82.7159 134.276 82.8377 133.002C82.5332 131.364 84.3602 129.787 85.8827 129.727C86.0654 129.727 86.6136 129.787 86.6745 129.787C88.5624 130.151 90.3894 131.546 92.3382 131.728C92.3991 131.728 92.46 131.728 92.46 131.668C93.2518 130.333 93.0082 126.937 93.6172 125.542C93.6172 125.36 93.9826 123.54 93.9826 123.237C94.4089 122.327 94.957 121.781 95.9314 122.084C96.6013 122.266 97.4539 122.388 97.4539 123.237V128.635C97.6366 129.242 98.9155 128.999 99.4028 128.999C101.595 128.028 103.666 127.422 106.163 127.058C108.477 127.179 109.512 129.242 109.391 131.304C109.451 132.396 108.233 133.245 107.503 133.973C107.259 134.094 106.285 134.519 105.919 134.397C105.067 133.791 103.97 134.033 102.935 134.094C102.935 134.155 102.813 134.337 102.874 134.397C104.092 135.853 106.345 136.096 107.868 137.066C108.964 138.34 110.304 138.643 109.939 140.341C109.878 141.191 109.939 142.1 109.025 142.404C108.782 142.586 107.381 142.828 107.076 142.828C106.772 142.768 106.163 142.343 105.919 142.04C103.788 141.13 101.473 140.402 99.0373 140.159V145.133C98.7937 146.468 98.4892 147.923 97.4539 148.955C97.0885 149.015 95.9314 149.015 95.566 148.955C95.2615 148.773 94.2262 147.741 93.9826 147.438C93.3736 145.133 93.2518 142.404 92.46 140.159C91.9728 139.553 91.7901 139.25 90.9984 139.31Z" fill="#6965DB"/>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

@ -1,5 +1,14 @@
import colors from "./colors";
import { DEFAULT_FONT_SIZE, ENV } from "./constants";
import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
getAllColorsSpecificShade,
} from "./colors";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
ENV,
VERTICAL_ALIGN,
} from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random";
@ -180,15 +189,22 @@ export const sortSpreadsheet = (spreadsheet: Spreadsheet) => {
return spreadsheet;
};
const bgColors = colors.elementBackground.slice(
2,
colors.elementBackground.length,
);
const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
// Put all the common properties here so when the whole chart is selected
// the properties dialog shows the correct selected values
const commonProps = {
strokeColor: colors.elementStroke[0],
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;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
@ -360,7 +376,7 @@ const chartBaseElements = (
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
strokeColor: colors.elementStroke[0],
strokeColor: COLOR_PALETTE.black,
fillStyle: "solid",
opacity: 6,
...selectSubtype(spreadsheet, "rectangle"),

@ -1,23 +1,31 @@
import colors from "./colors";
import { AppState } from "./types";
export const getClientColors = (clientId: string, appState: AppState) => {
if (appState?.collaborators) {
const currentUser = appState.collaborators.get(clientId);
if (currentUser?.color) {
return currentUser.color;
}
function hashToInteger(id: string) {
let hash = 0;
if (id.length === 0) {
return hash;
}
// Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
for (let i = 0; i < id.length; i++) {
const char = id.charCodeAt(i);
hash = (hash << 5) - hash + char;
}
return hash;
}
// Skip transparent & gray colors
const backgrounds = colors.elementBackground.slice(3);
const strokes = colors.elementStroke.slice(3);
return {
background: backgrounds[sum % backgrounds.length],
stroke: strokes[sum % strokes.length],
};
export const getClientColor = (
/**
* any uniquely identifying key, such as user id or socket id
*/
id: string,
) => {
// to get more even distribution in case `id` is not uniformly distributed to
// begin with, we hash it
const hash = Math.abs(hashToInteger(id));
// 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}%)`;
};
/**

@ -7,6 +7,9 @@ import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { isPromiseLike, isTestEnv } from "./utils";
type ElementsClipboard = {
@ -57,6 +60,9 @@ export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
files: BinaryFiles | null,
) => {
const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"),
);
let foundFile = false;
const _files = elements.reduce((acc, element) => {
@ -78,7 +84,20 @@ export const copyToClipboard = async (
// select binded text elements when copying
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements,
elements: elements.map((element) => {
if (
getContainingFrame(element) &&
!framesToCopy.has(getContainingFrame(element)!)
) {
const copiedElement = deepCopyElement(element);
mutateElement(copiedElement, {
frameId: null,
});
return copiedElement;
}
return element;
}),
files: files ? _files : undefined,
};
const json = JSON.stringify(contents);

@ -1,22 +1,170 @@
import oc from "open-color";
import { Merge } from "./utility-types";
const shades = (index: number) => [
oc.red[index],
oc.pink[index],
oc.grape[index],
oc.violet[index],
oc.indigo[index],
oc.blue[index],
oc.cyan[index],
oc.teal[index],
oc.green[index],
oc.lime[index],
oc.yellow[index],
oc.orange[index],
];
export default {
canvasBackground: [oc.white, oc.gray[0], oc.gray[1], ...shades(0)],
elementBackground: ["transparent", oc.gray[4], oc.gray[6], ...shades(6)],
elementStroke: [oc.black, oc.gray[8], oc.gray[7], ...shades(9)],
// FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
source: R,
keys: K,
) => {
return keys.reduce((acc, key: K[number]) => {
if (key in source) {
acc[key] = source[key];
}
return acc;
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
};
export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime">
| "transparent"
| "bronze";
export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{ black: string; white: string; transparent: string }
>;
// used general type instead of specific type (ColorPalette) to support custom colors
export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
export type ColorShadesIndexes = [number, number, number, number, number];
export const MAX_CUSTOM_COLORS_USED_IN_CANVAS = 5;
export const COLORS_PER_ROW = 5;
export const DEFAULT_CHART_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
export const ELEMENTS_PALETTE_SHADE_INDEXES = [0, 2, 4, 6, 8] as const;
export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
export const getSpecificColorShades = (
color: Exclude<
ColorPickerColor,
"transparent" | "white" | "black" | "bronze"
>,
indexArr: Readonly<ColorShadesIndexes>,
) => {
return indexArr.map((index) => oc[color][index]) as any as ColorTuple;
};
export const COLOR_PALETTE = {
transparent: "transparent",
black: "#1e1e1e",
white: "#ffffff",
// open-colors
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES),
grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES),
violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES),
blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES),
cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES),
teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES),
green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES),
yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES),
orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES),
// radix bronze shades 3,5,7,9,11
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
} as ColorPalette;
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"cyan",
"blue",
"violet",
"grape",
"pink",
"green",
"teal",
"yellow",
"orange",
"red",
]);
// -----------------------------------------------------------------------------
// quick picks defaults
// -----------------------------------------------------------------------------
// ORDER matters for positioning in quick picker
export const DEFAULT_ELEMENT_STROKE_PICKS = [
COLOR_PALETTE.black,
COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
] as ColorTuple;
// ORDER matters for positioning in quick picker
export const DEFAULT_ELEMENT_BACKGROUND_PICKS = [
COLOR_PALETTE.transparent,
COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
] as ColorTuple;
// ORDER matters for positioning in quick picker
export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
COLOR_PALETTE.white,
// radix slate2
"#f8f9fa",
// radix blue2
"#f5faff",
// radix yellow2
"#fffce8",
// radix bronze2
"#fdf8f6",
] as ColorTuple;
// -----------------------------------------------------------------------------
// palette defaults
// -----------------------------------------------------------------------------
export const DEFAULT_ELEMENT_STROKE_COLOR_PALETTE = {
// 1st row
transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray,
black: COLOR_PALETTE.black,
bronze: COLOR_PALETTE.bronze,
// rest
...COMMON_ELEMENT_SHADES,
} as const;
// ORDER matters for positioning in pallete (5x3 grid)s
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray,
black: COLOR_PALETTE.black,
bronze: COLOR_PALETTE.bronze,
...COMMON_ELEMENT_SHADES,
} as const;
// -----------------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------------
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
[
// 2nd row
COLOR_PALETTE.cyan[index],
COLOR_PALETTE.blue[index],
COLOR_PALETTE.violet[index],
COLOR_PALETTE.grape[index],
COLOR_PALETTE.pink[index],
// 3rd row
COLOR_PALETTE.green[index],
COLOR_PALETTE.teal[index],
COLOR_PALETTE.yellow[index],
COLOR_PALETTE.orange[index],
COLOR_PALETTE.red[index],
] as const;
export const rgbToHex = (r: number, g: number, b: number) =>
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
// -----------------------------------------------------------------------------

@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types";
@ -36,6 +36,9 @@ import {
} from "../element/textElement";
import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { extraToolsIcon, frameToolIcon } from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({
appState,
@ -90,7 +93,8 @@ export const SelectedShapeActions = ({
<div>
{((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image") ||
commonSelectedType !== "image" &&
commonSelectedType !== "frame") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
</div>
@ -222,28 +226,78 @@ export const ShapesSwitcher = ({
setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState;
}) => (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice();
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
}}
/>
);
})}
<div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
className={clsx("Shape", { fillable: false })}
type="radio"
icon={icon}
checked={activeTool.type === value}
icon={frameToolIcon}
checked={activeTool.type === "frame"}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
title={`${capitalizeString(
t("toolBar.frame"),
)} ${KEYS.F.toLocaleUpperCase()}`}
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
@ -253,31 +307,55 @@ export const ShapesSwitcher = ({
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
trackEvent("toolbar", "frame", "ui");
const nextActiveTool = updateActiveTool(appState, {
type: value,
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
}}
/>
);
})}
<SubtypeToggles />
</>
);
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className="App-toolbar__extra-tools-trigger"
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
>
{t("toolBar.frame")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
<SubtypeToggles />
</>
);
};
export const ZoomActions = ({
renderAction,

File diff suppressed because it is too large Load Diff

@ -10,10 +10,9 @@
display: flex;
justify-content: center;
align-items: center;
color: $oc-white;
cursor: pointer;
font-size: 0.625rem;
font-weight: 500;
font-size: 0.75rem;
font-weight: 800;
line-height: 1;
&-img {

@ -6,7 +6,6 @@ import { getNameInitial } from "../clients";
type AvatarProps = {
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string;
border: string;
name: string;
src?: string;
};

@ -1,430 +0,0 @@
import React from "react";
import { Popover } from "./Popover";
import { isTransparent } from "../utils";
import "./ColorPicker.scss";
import { isArrowKey, KEYS } from "../keys";
import { t, getLanguage } from "../i18n";
import { isWritableElement } from "../utils";
import colors from "../colors";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
const MAX_CUSTOM_COLORS = 5;
const MAX_DEFAULT_COLORS = 15;
export const getCustomColors = (
elements: readonly ExcalidrawElement[],
type: "elementBackground" | "elementStroke",
) => {
const customColors: string[] = [];
const updatedElements = elements
.filter((element) => !element.isDeleted)
.sort((ele1, ele2) => ele2.updated - ele1.updated);
let index = 0;
const elementColorTypeMap = {
elementBackground: "backgroundColor",
elementStroke: "strokeColor",
};
const colorType = elementColorTypeMap[type] as
| "backgroundColor"
| "strokeColor";
while (
index < updatedElements.length &&
customColors.length < MAX_CUSTOM_COLORS
) {
const element = updatedElements[index];
if (
customColors.length < MAX_CUSTOM_COLORS &&
isCustomColor(element[colorType], type) &&
!customColors.includes(element[colorType])
) {
customColors.push(element[colorType]);
}
index++;
}
return customColors;
};
const isCustomColor = (
color: string,
type: "elementBackground" | "elementStroke",
) => {
return !colors[type].includes(color);
};
const isValidColor = (color: string) => {
const style = new Option().style;
style.color = color;
return !!style.color;
};
const getColor = (color: string): string | null => {
if (isTransparent(color)) {
return color;
}
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is (incorrectly)
// considered valid
return isValidColor(`#${color}`)
? `#${color}`
: isValidColor(color)
? color
: null;
};
// This is a narrow reimplementation of the awesome react-color Twitter component
// https://github.com/casesandberg/react-color/blob/master/src/components/twitter/Twitter.js
// Unfortunately, we can't detect keyboard layout in the browser. So this will
// only work well for QWERTY but not AZERTY or others...
const keyBindings = [
["1", "2", "3", "4", "5"],
["q", "w", "e", "r", "t"],
["a", "s", "d", "f", "g"],
["z", "x", "c", "v", "b"],
].flat();
const Picker = ({
colors,
color,
onChange,
onClose,
label,
showInput = true,
type,
elements,
}: {
colors: string[];
color: string | null;
onChange: (color: string) => void;
onClose: () => void;
label: string;
showInput: boolean;
type: "canvasBackground" | "elementBackground" | "elementStroke";
elements: readonly ExcalidrawElement[];
}) => {
const firstItem = React.useRef<HTMLButtonElement>();
const activeItem = React.useRef<HTMLButtonElement>();
const gallery = React.useRef<HTMLDivElement>();
const colorInput = React.useRef<HTMLInputElement>();
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
return [];
}
return getCustomColors(elements, type);
});
React.useEffect(() => {
// After the component is first mounted focus on first input
if (activeItem.current) {
activeItem.current.focus();
} else if (colorInput.current) {
colorInput.current.focus();
} else if (gallery.current) {
gallery.current.focus();
}
}, []);
const handleKeyDown = (event: React.KeyboardEvent) => {
let handled = false;
if (isArrowKey(event.key)) {
handled = true;
const { activeElement } = document;
const isRTL = getLanguage().rtl;
let isCustom = false;
let index = Array.prototype.indexOf.call(
gallery.current!.querySelector(".color-picker-content--default")
?.children,
activeElement,
);
if (index === -1) {
index = Array.prototype.indexOf.call(
gallery.current!.querySelector(".color-picker-content--canvas-colors")
?.children,
activeElement,
);
if (index !== -1) {
isCustom = true;
}
}
const parentElement = isCustom
? gallery.current?.querySelector(".color-picker-content--canvas-colors")
: gallery.current?.querySelector(".color-picker-content--default");
if (parentElement && index !== -1) {
const length = parentElement.children.length - (showInput ? 1 : 0);
const nextIndex =
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
? (index + 1) % length
: event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
? (length + index - 1) % length
: !isCustom && event.key === KEYS.ARROW_DOWN
? (index + 5) % length
: !isCustom && event.key === KEYS.ARROW_UP
? (length + index - 5) % length
: index;
(parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
}
event.preventDefault();
} else if (
keyBindings.includes(event.key.toLowerCase()) &&
!event[KEYS.CTRL_OR_CMD] &&
!event.altKey &&
!isWritableElement(event.target)
) {
handled = true;
const index = keyBindings.indexOf(event.key.toLowerCase());
const isCustom = index >= MAX_DEFAULT_COLORS;
const parentElement = isCustom
? gallery?.current?.querySelector(
".color-picker-content--canvas-colors",
)
: gallery?.current?.querySelector(".color-picker-content--default");
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
(
parentElement?.children[actualIndex] as HTMLElement | undefined
)?.focus();
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
handled = true;
event.preventDefault();
onClose();
}
if (handled) {
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
}
};
const renderColors = (colors: Array<string>, custom: boolean = false) => {
return colors.map((_color, i) => {
const _colorWithoutHash = _color.replace("#", "");
const keyBinding = custom
? keyBindings[i + MAX_DEFAULT_COLORS]
: keyBindings[i];
const label = custom
? _colorWithoutHash
: t(`colors.${_colorWithoutHash}`);
return (
<button
className="color-picker-swatch"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(_color);
}}
title={`${label}${
!isTransparent(_color) ? ` (${_color})` : ""
} ${keyBinding.toUpperCase()}`}
aria-label={label}
aria-keyshortcuts={keyBindings[i]}
style={{ color: _color }}
key={_color}
ref={(el) => {
if (!custom && el && i === 0) {
firstItem.current = el;
}
if (el && _color === color) {
activeItem.current = el;
}
}}
onFocus={() => {
onChange(_color);
}}
>
{isTransparent(_color) ? (
<div className="color-picker-transparent"></div>
) : undefined}
<span className="color-picker-keybinding">{keyBinding}</span>
</button>
);
});
};
return (
<div
className={`color-picker color-picker-type-${type}`}
role="dialog"
aria-modal="true"
aria-label={t("labels.colorPicker")}
onKeyDown={handleKeyDown}
>
<div className="color-picker-triangle color-picker-triangle-shadow"></div>
<div className="color-picker-triangle"></div>
<div
className="color-picker-content"
ref={(el) => {
if (el) {
gallery.current = el;
}
}}
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>
<div className="color-picker-content--default">
{renderColors(colors)}
</div>
{!!customColors.length && (
<div className="color-picker-content--canvas">
<span className="color-picker-content--canvas-title">
{t("labels.canvasColors")}
</span>
<div className="color-picker-content--canvas-colors">
{renderColors(customColors, true)}
</div>
</div>
)}
{showInput && (
<ColorInput
color={color}
label={label}
onChange={(color) => {
onChange(color);
}}
ref={colorInput}
/>
)}
</div>
</div>
);
};
const ColorInput = React.forwardRef(
(
{
color,
onChange,
label,
}: {
color: string | null;
onChange: (color: string) => void;
label: string;
},
ref,
) => {
const [innerValue, setInnerValue] = React.useState(color);
const inputRef = React.useRef(null);
React.useEffect(() => {
setInnerValue(color);
}, [color]);
React.useImperativeHandle(ref, () => inputRef.current);
const changeColor = React.useCallback(
(inputValue: string) => {
const value = inputValue.toLowerCase();
const color = getColor(value);
if (color) {
onChange(color);
}
setInnerValue(value);
},
[onChange],
);
return (
<label className="color-input-container">
<div className="color-picker-hash">#</div>
<input
spellCheck={false}
className="color-picker-input"
aria-label={label}
onChange={(event) => changeColor(event.target.value)}
value={(innerValue || "").replace(/^#/, "")}
onBlur={() => setInnerValue(color)}
ref={inputRef}
/>
</label>
);
},
);
ColorInput.displayName = "ColorInput";
export const ColorPicker = ({
type,
color,
onChange,
label,
isActive,
setActive,
elements,
appState,
}: {
type: "canvasBackground" | "elementBackground" | "elementStroke";
color: string | null;
onChange: (color: string) => void;
label: string;
isActive: boolean;
setActive: (active: boolean) => void;
elements: readonly ExcalidrawElement[];
appState: AppState;
}) => {
const pickerButton = React.useRef<HTMLButtonElement>(null);
const coords = pickerButton.current?.getBoundingClientRect();
return (
<div>
<div className="color-picker-control-container">
<div className="color-picker-label-swatch-container">
<button
className="color-picker-label-swatch"
aria-label={label}
style={color ? { "--swatch-color": color } : undefined}
onClick={() => setActive(!isActive)}
ref={pickerButton}
/>
</div>
<ColorInput
color={color}
label={label}
onChange={(color) => {
onChange(color);
}}
/>
</div>
<React.Suspense fallback="">
{isActive ? (
<div
className="color-picker-popover-container"
style={{
position: "fixed",
top: coords?.top,
left: coords?.right,
zIndex: 1,
}}
>
<Popover
onCloseRequest={(event) =>
event.target !== pickerButton.current && setActive(false)
}
>
<Picker
colors={colors[type]}
color={color || null}
onChange={(changedColor) => {
onChange(changedColor);
}}
onClose={() => {
setActive(false);
pickerButton.current?.focus();
}}
label={label}
showInput={false}
type={type}
elements={elements}
/>
</Popover>
</div>
) : null}
</React.Suspense>
</div>
);
};

@ -0,0 +1,126 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker";
import { useAtom } from "jotai";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { eyeDropperIcon } from "../icons";
import { jotaiScope } from "../../jotai";
import { KEYS } from "../../keys";
import { activeEyeDropperAtom } from "../EyeDropper";
import clsx from "clsx";
import { t } from "../../i18n";
import { useDevice } from "../App";
import { getShortcutKey } from "../../utils";
interface ColorInputProps {
color: string;
onChange: (color: string) => void;
label: string;
}
export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
const device = useDevice();
const [innerValue, setInnerValue] = useState(color);
const [activeSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
useEffect(() => {
setInnerValue(color);
}, [color]);
const changeColor = useCallback(
(inputValue: string) => {
const value = inputValue.toLowerCase();
const color = getColor(value);
if (color) {
onChange(color);
}
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,
jotaiScope,
);
useEffect(() => {
return () => {
setEyeDropperState(null);
};
}, [setEyeDropperState]);
return (
<div className="color-picker__input-label">
<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}
onChange={(event) => {
changeColor(event.target.value);
}}
value={(innerValue || "").replace(/^#/, "")}
onBlur={() => {
setInnerValue(color);
}}
tabIndex={-1}
onFocus={() => setActiveColorPickerSection("hex")}
onKeyDown={(event) => {
if (event.key === KEYS.TAB) {
return;
} else if (event.key === KEYS.ESCAPE) {
eyeDropperTriggerRef.current?.focus();
}
event.stopPropagation();
}}
/>
{/* TODO reenable on mobile with a better UX */}
{!device.isMobile && (
<>
<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),
},
)
}
title={`${t(
"labels.eyeDropper",
)} ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `}
>
{eyeDropperIcon}
</div>
</>
)}
</div>
);
};

@ -1,6 +1,134 @@
@import "../css/variables.module";
@import "../../css/variables.module";
.excalidraw {
.focus-visible-none {
&:focus-visible {
outline: none !important;
}
}
.color-picker__heading {
padding: 0 0.5rem;
font-size: 0.75rem;
text-align: left;
}
.color-picker-container {
display: grid;
grid-template-columns: 1fr 20px 1.625rem;
padding: 0.25rem 0px;
align-items: center;
@include isMobile {
max-width: 175px;
}
}
.color-picker__top-picks {
display: flex;
justify-content: space-between;
}
.color-picker__button {
--radius: 0.25rem;
padding: 0;
margin: 0;
width: 1.35rem;
height: 1.35rem;
border: 1px solid var(--color-gray-30);
border-radius: var(--radius);
filter: var(--theme-filter);
background-color: var(--swatch-color);
background-position: left center;
position: relative;
font-family: inherit;
box-sizing: border-box;
&:hover {
&::after {
content: "";
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
box-shadow: 0 0 0 1px var(--color-gray-30);
border-radius: calc(var(--radius) + 1px);
filter: var(--theme-filter);
}
}
&.active {
.color-picker__button-outline {
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
box-shadow: 0 0 0 1px var(--color-primary-darkest);
z-index: 1; // due hover state so this has preference
border-radius: calc(var(--radius) + 1px);
filter: var(--theme-filter);
}
}
&: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: 0.5rem;
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;
}
}
.color-picker__button__hotkey-label {
position: absolute;
right: 4px;
bottom: 4px;
filter: none;
font-size: 11px;
}
.color-picker {
background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75);
@ -72,11 +200,18 @@
}
}
.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, auto);
grid-gap: 0.5rem;
grid-template-columns: repeat(5, 1.875rem);
grid-gap: 0.25rem;
border-radius: 4px;
&:focus {
@ -178,6 +313,27 @@
}
}
.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);
}
}
.color-picker__input-hash {
padding: 0 0.25rem;
}
.color-picker-input {
box-sizing: border-box;
width: 100%;

@ -0,0 +1,293 @@
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { TopPicks } from "./TopPicks";
import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai";
import {
activeColorPickerSectionAtom,
ColorPickerType,
} from "./colorPickerUtils";
import { useDevice, useExcalidrawContainer } from "../App";
import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
import PickerHeading from "./PickerHeading";
import { t } from "../../i18n";
import clsx from "clsx";
import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { useRef } from "react";
import { activeEyeDropperAtom } from "../EyeDropper";
import "./ColorPicker.scss";
const isValidColor = (color: string) => {
const style = new Option().style;
style.color = color;
return !!style.color;
};
export const getColor = (color: string): string | null => {
if (isTransparent(color)) {
return color;
}
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is (incorrectly)
// considered valid
return isValidColor(`#${color}`)
? `#${color}`
: isValidColor(color)
? color
: null;
};
interface ColorPickerProps {
type: ColorPickerType;
color: string;
onChange: (color: string) => void;
label: string;
elements: readonly ExcalidrawElement[];
appState: AppState;
palette?: ColorPaletteCustom | null;
topPicks?: ColorTuple;
updateData: (formData?: any) => void;
}
const ColorPickerPopupContent = ({
type,
color,
onChange,
label,
elements,
palette = COLOR_PALETTE,
updateData,
}: Pick<
ColorPickerProps,
| "type"
| "color"
| "onChange"
| "label"
| "elements"
| "palette"
| "updateData"
>) => {
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const { container } = useExcalidrawContainer();
const { isMobile, isLandscape } = useDevice();
const colorInputJSX = (
<div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
<ColorInput
color={color}
label={label}
onChange={(color) => {
onChange(color);
}}
/>
</div>
);
const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => {
popoverRef.current
?.querySelector<HTMLDivElement>(".color-picker-content")
?.focus();
};
return (
<Popover.Portal container={container}>
<Popover.Content
ref={popoverRef}
className="focus-visible-none"
data-prevent-outside-click
onFocusOutside={(event) => {
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();
}
}}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
container.focus();
}
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={isMobile && !isLandscape ? "bottom" : "right"}
align={isMobile && !isLandscape ? "center" : "start"}
alignOffset={-16}
sideOffset={20}
style={{
zIndex: 9999,
backgroundColor: "var(--popup-bg-color)",
maxWidth: "208px",
maxHeight: window.innerHeight,
padding: "12px",
borderRadius: "8px",
boxSizing: "border-box",
overflowY: "auto",
boxShadow:
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
}}
>
{palette ? (
<Picker
palette={palette}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
};
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else if (isWritableElement(event.target)) {
focusPickerContent();
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}}
/>
</Popover.Content>
</Popover.Portal>
);
};
const ColorPickerTrigger = ({
label,
color,
type,
}: {
color: string;
label: string;
type: ColorPickerType;
}) => {
return (
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color", {
"is-transparent": color === "transparent" || !color,
})}
aria-label={label}
style={color ? { "--swatch-color": color } : undefined}
title={
type === "elementStroke"
? t("labels.showStroke")
: t("labels.showBackground")
}
>
<div className="color-picker__button-outline" />
</Popover.Trigger>
);
};
export const ColorPicker = ({
type,
color,
onChange,
label,
elements,
palette = COLOR_PALETTE,
topPicks,
updateData,
appState,
}: ColorPickerProps) => {
return (
<div>
<div role="dialog" aria-modal="true" className="color-picker-container">
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
<div
style={{
width: 1,
height: "100%",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {
updateData({ openPopup: open ? type : null });
}}
>
{/* serves as an active color indicator as well */}
<ColorPickerTrigger color={color} label={label} type={type} />
{/* popup content */}
{appState.openPopup === type && (
<ColorPickerPopupContent
type={type}
color={color}
onChange={onChange}
label={label}
elements={elements}
palette={palette}
updateData={updateData}
/>
)}
</Popover.Root>
</div>
</div>
);
};

@ -0,0 +1,63 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
interface CustomColorListProps {
colors: string[];
color: string;
onChange: (color: string) => void;
label: string;
}
export const CustomColorList = ({
colors,
color,
onChange,
label,
}: CustomColorListProps) => {
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
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) => {
return (
<button
ref={color === c ? btnRef : undefined}
tabIndex={-1}
type="button"
className={clsx(
"color-picker__button color-picker__button--large",
{
active: color === c,
"is-transparent": c === "transparent" || !c,
},
)}
onClick={() => {
onChange(c);
setActiveColorPickerSection("custom");
}}
title={c}
aria-label={label}
style={{ "--swatch-color": c }}
key={i}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
</button>
);
})}
</div>
);
};

@ -0,0 +1,29 @@
import React from "react";
import { getContrastYIQ } from "./colorPickerUtils";
interface HotkeyLabelProps {
color: string;
keyLabel: string | number;
isCustomColor?: boolean;
isShade?: boolean;
}
const HotkeyLabel = ({
color,
keyLabel,
isCustomColor = false,
isShade = false,
}: HotkeyLabelProps) => {
return (
<div
className="color-picker__button__hotkey-label"
style={{
color: getContrastYIQ(color, isCustomColor),
}}
>
{isShade && "⇧"}
{keyLabel}
</div>
);
};
export default HotkeyLabel;

@ -0,0 +1,178 @@
import React, { useEffect, useState } from "react";
import { t } from "../../i18n";
import { ExcalidrawElement } from "../../element/types";
import { ShadeList } from "./ShadeList";
import PickerColorList from "./PickerColorList";
import { useAtom } from "jotai";
import { CustomColorList } from "./CustomColorList";
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
import PickerHeading from "./PickerHeading";
import {
ColorPickerType,
activeColorPickerSectionAtom,
getColorNameAndShadeFromColor,
getMostUsedCustomColors,
isCustomColor,
} from "./colorPickerUtils";
import {
ColorPaletteCustom,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
} from "../../colors";
import { KEYS } from "../../keys";
import { EVENT } from "../../constants";
interface PickerProps {
color: string;
onChange: (color: string) => void;
label: string;
type: ColorPickerType;
elements: readonly ExcalidrawElement[];
palette: ColorPaletteCustom;
updateData: (formData?: any) => void;
children?: React.ReactNode;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
}
export const Picker = ({
color,
onChange,
label,
type,
elements,
palette,
updateData,
children,
onEyeDropperToggle,
onEscape,
}: PickerProps) => {
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
return [];
}
return getMostUsedCustomColors(elements, type, palette);
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const colorObj = getColorNameAndShadeFromColor({
color,
palette,
});
useEffect(() => {
if (!activeColorPickerSection) {
const isCustom = isCustomColor({ color, palette });
const isCustomButNotInList = isCustom && !customColors.includes(color);
setActiveColorPickerSection(
isCustomButNotInList
? "hex"
: 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);
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,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}}
className="color-picker-content"
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>
{!!customColors.length && (
<div>
<PickerHeading>
{t("colorPicker.mostUsedCustomColors")}
</PickerHeading>
<CustomColorList
colors={customColors}
color={color}
label={t("colorPicker.mostUsedCustomColors")}
onChange={onChange}
/>
</div>
)}
<div>
<PickerHeading>{t("colorPicker.colors")}</PickerHeading>
<PickerColorList
color={color}
label={label}
palette={palette}
onChange={onChange}
activeShade={activeShade}
/>
</div>
<div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
<ShadeList hex={color} onChange={onChange} palette={palette} />
</div>
{children}
</div>
</div>
);
};

@ -0,0 +1,86 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
colorPickerHotkeyBindings,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { ColorPaletteCustom } from "../../colors";
import { t } from "../../i18n";
interface PickerColorListProps {
palette: ColorPaletteCustom;
color: string;
onChange: (color: string) => void;
label: string;
activeShade: number;
}
const PickerColorList = ({
palette,
color,
onChange,
label,
activeShade,
}: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
palette,
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
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";
const keybinding = colorPickerHotkeyBindings[index];
const label = t(`colors.${key.replace(/\d+/, "")}`, null, "");
return (
<button
ref={colorObj?.colorName === key ? btnRef : undefined}
tabIndex={-1}
type="button"
className={clsx(
"color-picker__button color-picker__button--large",
{
active: colorObj?.colorName === key,
"is-transparent": color === "transparent" || !color,
},
)}
onClick={() => {
onChange(color);
setActiveColorPickerSection("baseColors");
}}
title={`${label}${
color.startsWith("#") ? ` ${color}` : ""
} ${keybinding}`}
aria-label={`${label}${keybinding}`}
style={color ? { "--swatch-color": color } : undefined}
data-testid={`color-${key}`}
key={key}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={keybinding} />
</button>
);
})}
</div>
);
};
export default PickerColorList;

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

@ -0,0 +1,105 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { t } from "../../i18n";
import { ColorPaletteCustom } from "../../colors";
interface ShadeListProps {
hex: string;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
}
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color: hex || "transparent",
palette,
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
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) => (
<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": color } : undefined}
onClick={() => {
onChange(color);
setActiveColorPickerSection("shades");
}}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={color} 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>
);
};

@ -0,0 +1,64 @@
import clsx from "clsx";
import { ColorPickerType } from "./colorPickerUtils";
import {
DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS,
} from "../../colors";
interface TopPicksProps {
onChange: (color: string) => void;
type: ColorPickerType;
activeColor: string;
topPicks?: readonly string[];
}
export const TopPicks = ({
onChange,
type,
activeColor,
topPicks,
}: TopPicksProps) => {
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;
}
return (
<div className="color-picker__top-picks">
{colors.map((color: string) => (
<button
className={clsx("color-picker__button", {
active: color === activeColor,
"is-transparent": color === "transparent" || !color,
})}
style={{ "--swatch-color": color }}
key={color}
type="button"
title={color}
onClick={() => onChange(color)}
>
<div className="color-picker__button-outline" />
</button>
))}
</div>
);
};

@ -0,0 +1,136 @@
import { ExcalidrawElement } from "../../element/types";
import { atom } from "jotai";
import {
ColorPickerColor,
ColorPaletteCustom,
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
} from "../../colors";
export const getColorNameAndShadeFromColor = ({
palette,
color,
}: {
palette: ColorPaletteCustom;
color: string;
}): {
colorName: ColorPickerColor;
shade: number | null;
} | null => {
for (const [colorName, colorVal] of Object.entries(palette)) {
if (Array.isArray(colorVal)) {
const shade = colorVal.indexOf(color);
if (shade > -1) {
return { colorName: colorName as ColorPickerColor, shade };
}
} else if (colorVal === color) {
return { colorName: colorName as ColorPickerColor, shade: null };
}
}
return null;
};
export const colorPickerHotkeyBindings = [
["q", "w", "e", "r", "t"],
["a", "s", "d", "f", "g"],
["z", "x", "c", "v", "b"],
].flat();
export const isCustomColor = ({
color,
palette,
}: {
color: string;
palette: ColorPaletteCustom;
}) => {
const paletteValues = Object.values(palette).flat();
return !paletteValues.includes(color);
};
export const getMostUsedCustomColors = (
elements: readonly ExcalidrawElement[],
type: "elementBackground" | "elementStroke",
palette: ColorPaletteCustom,
) => {
const elementColorTypeMap = {
elementBackground: "backgroundColor",
elementStroke: "strokeColor",
};
const colors = elements.filter((element) => {
if (element.isDeleted) {
return false;
}
const color =
element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
return isCustomColor({ color, palette });
});
const colorCountMap = new Map<string, number>();
colors.forEach((element) => {
const color =
element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
if (colorCountMap.has(color)) {
colorCountMap.set(color, colorCountMap.get(color)! + 1);
} else {
colorCountMap.set(color, 1);
}
});
return [...colorCountMap.entries()]
.sort((a, b) => b[1] - a[1])
.map((c) => c[0])
.slice(0, MAX_CUSTOM_COLORS_USED_IN_CANVAS);
};
export type ActiveColorPickerSectionAtomType =
| "custom"
| "baseColors"
| "shades"
| "hex"
| null;
export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null);
const calculateContrast = (r: number, g: number, b: number) => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 160 ? "black" : "white";
};
// inspiration from https://stackoverflow.com/a/11868398
export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => {
if (isCustomColor) {
const style = new Option().style;
style.color = bgHex;
if (style.color) {
const rgb = style.color
.replace(/^(rgb|rgba)\(/, "")
.replace(/\)$/, "")
.replace(/\s/g, "")
.split(",");
const r = parseInt(rgb[0]);
const g = parseInt(rgb[1]);
const b = parseInt(rgb[2]);
return calculateContrast(r, g, b);
}
}
// TODO: ? is this wanted?
if (bgHex === "transparent") {
return "black";
}
const r = parseInt(bgHex.substring(1, 3), 16);
const g = parseInt(bgHex.substring(3, 5), 16);
const b = parseInt(bgHex.substring(5, 7), 16);
return calculateContrast(r, g, b);
};
export type ColorPickerType =
| "canvasBackground"
| "elementBackground"
| "elementStroke";

@ -0,0 +1,287 @@
import { KEYS } from "../../keys";
import {
ColorPickerColor,
ColorPalette,
ColorPaletteCustom,
COLORS_PER_ROW,
COLOR_PALETTE,
} from "../../colors";
import { ValueOf } from "../../utility-types";
import {
ActiveColorPickerSectionAtomType,
colorPickerHotkeyBindings,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
const arrowHandler = (
eventKey: string,
currentIndex: number | null,
length: number,
) => {
const rows = Math.ceil(length / COLORS_PER_ROW);
currentIndex = currentIndex ?? -1;
switch (eventKey) {
case "ArrowLeft": {
const prevIndex = currentIndex - 1;
return prevIndex < 0 ? length - 1 : prevIndex;
}
case "ArrowRight": {
return (currentIndex + 1) % length;
}
case "ArrowDown": {
const nextIndex = currentIndex + COLORS_PER_ROW;
return nextIndex >= length ? currentIndex % COLORS_PER_ROW : nextIndex;
}
case "ArrowUp": {
const prevIndex = currentIndex - COLORS_PER_ROW;
const newIndex =
prevIndex < 0 ? COLORS_PER_ROW * rows + prevIndex : prevIndex;
return newIndex >= length ? undefined : newIndex;
}
}
};
interface HotkeyHandlerProps {
e: React.KeyboardEvent;
colorObj: { colorName: ColorPickerColor; shade: number | null } | null;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
customColors: string[];
setActiveColorPickerSection: (
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
) => void;
activeShade: number;
}
/**
* @returns true if the event was handled
*/
const hotkeyHandler = ({
e,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
}: HotkeyHandlerProps): boolean => {
if (colorObj?.shade != null) {
// shift + numpad is extremely messed up on windows apparently
if (
["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].includes(e.code) &&
e.shiftKey
) {
const newShade = Number(e.code.slice(-1)) - 1;
onChange(palette[colorObj.colorName][newShade]);
setActiveColorPickerSection("shades");
return true;
}
}
if (["1", "2", "3", "4", "5"].includes(e.key)) {
const c = customColors[Number(e.key) - 1];
if (c) {
onChange(customColors[Number(e.key) - 1]);
setActiveColorPickerSection("custom");
return true;
}
}
if (colorPickerHotkeyBindings.includes(e.key)) {
const index = colorPickerHotkeyBindings.indexOf(e.key);
const paletteKey = Object.keys(palette)[index] as keyof ColorPalette;
const paletteValue = palette[paletteKey];
const r = Array.isArray(paletteValue)
? paletteValue[activeShade]
: paletteValue;
onChange(r);
setActiveColorPickerSection("baseColors");
return true;
}
return false;
};
interface ColorPickerKeyNavHandlerProps {
event: React.KeyboardEvent;
activeColorPickerSection: ActiveColorPickerSectionAtomType;
palette: ColorPaletteCustom;
color: string;
onChange: (color: string) => void;
customColors: string[];
setActiveColorPickerSection: (
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
) => void;
updateData: (formData?: any) => void;
activeShade: number;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
}
/**
* @returns true if the event was handled
*/
export const colorPickerKeyNavHandler = ({
event,
activeColorPickerSection,
palette,
color,
onChange,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
onEyeDropperToggle,
onEscape,
}: ColorPickerKeyNavHandlerProps): boolean => {
if (event[KEYS.CTRL_OR_CMD]) {
return false;
}
if (event.key === KEYS.ESCAPE) {
onEscape(event);
return true;
}
// checkt using `key` to ignore combos with Alt modifier
if (event.key === KEYS.ALT) {
onEyeDropperToggle(true);
return true;
}
if (event.key === KEYS.I) {
onEyeDropperToggle();
return true;
}
const colorObj = getColorNameAndShadeFromColor({ color, palette });
if (event.key === KEYS.TAB) {
const sectionsMap: Record<
NonNullable<ActiveColorPickerSectionAtomType>,
boolean
> = {
custom: !!customColors.length,
baseColors: true,
shades: colorObj?.shade != null,
hex: true,
};
const sections = Object.entries(sectionsMap).reduce((acc, [key, value]) => {
if (value) {
acc.push(key as ActiveColorPickerSectionAtomType);
}
return acc;
}, [] as ActiveColorPickerSectionAtomType[]);
const activeSectionIndex = sections.indexOf(activeColorPickerSection);
const indexOffset = event.shiftKey ? -1 : 1;
const nextSectionIndex =
activeSectionIndex + indexOffset > sections.length - 1
? 0
: activeSectionIndex + indexOffset < 0
? sections.length - 1
: activeSectionIndex + indexOffset;
const nextSection = sections[nextSectionIndex];
if (nextSection) {
setActiveColorPickerSection(nextSection);
}
if (nextSection === "custom") {
onChange(customColors[0]);
} else if (nextSection === "baseColors") {
const baseColorName = (
Object.entries(palette) as [string, ValueOf<ColorPalette>][]
).find(([name, shades]) => {
if (Array.isArray(shades)) {
return shades.includes(color);
} else if (shades === color) {
return name;
}
return null;
});
if (!baseColorName) {
onChange(COLOR_PALETTE.black);
}
}
event.preventDefault();
event.stopPropagation();
return true;
}
if (
hotkeyHandler({
e: event,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
})
) {
return true;
}
if (activeColorPickerSection === "shades") {
if (colorObj) {
const { shade } = colorObj;
const newShade = arrowHandler(event.key, shade, COLORS_PER_ROW);
if (newShade !== undefined) {
onChange(palette[colorObj.colorName][newShade]);
return true;
}
}
}
if (activeColorPickerSection === "baseColors") {
if (colorObj) {
const { colorName } = colorObj;
const colorNames = Object.keys(palette) as (keyof ColorPalette)[];
const indexOfColorName = colorNames.indexOf(colorName);
const newColorIndex = arrowHandler(
event.key,
indexOfColorName,
colorNames.length,
);
if (newColorIndex !== undefined) {
const newColorName = colorNames[newColorIndex];
const newColorNameValue = palette[newColorName];
onChange(
Array.isArray(newColorNameValue)
? newColorNameValue[activeShade]
: newColorNameValue,
);
return true;
}
}
}
if (activeColorPickerSection === "custom") {
const indexOfColor = customColors.indexOf(color);
const newColorIndex = arrowHandler(
event.key,
indexOfColor,
customColors.length,
);
if (newColorIndex !== undefined) {
const newColor = customColors[newColorIndex];
onChange(newColor);
return true;
}
}
return false;
};

@ -31,7 +31,7 @@ const ConfirmDialog = (props: Props) => {
return (
<Dialog
onCloseRequest={onCancel}
small={true}
size="small"
{...rest}
className={`confirm-dialog ${className}`}
>

@ -14,4 +14,33 @@
padding: 0 0 0.75rem;
margin-bottom: 1.5rem;
}
.Dialog__close {
color: var(--color-gray-40);
margin: 0;
position: absolute;
top: 0.75rem;
right: 0.5rem;
border: 0;
background-color: transparent;
line-height: 0;
cursor: pointer;
&:hover {
color: var(--color-gray-60);
}
&:active {
color: var(--color-gray-40);
}
@include isMobile {
top: 1.25rem;
right: 1.25rem;
}
svg {
width: 1.5rem;
height: 1.5rem;
}
}
}

@ -12,7 +12,6 @@ import "./Dialog.scss";
import { back, CloseIcon } from "./icons";
import { Island } from "./Island";
import { Modal } from "./Modal";
import { AppState } from "../types";
import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
@ -21,11 +20,10 @@ import { jotaiScope } from "../jotai";
export interface DialogProps {
children: React.ReactNode;
className?: string;
small?: boolean;
size?: "small" | "regular" | "wide";
onCloseRequest(): void;
title: React.ReactNode;
title: React.ReactNode | false;
autofocus?: boolean;
theme?: AppState["theme"];
closeOnClickOutside?: boolean;
}
@ -33,6 +31,7 @@ export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
const device = useDevice();
useEffect(() => {
if (!islandNode) {
@ -86,23 +85,26 @@ export const Dialog = (props: DialogProps) => {
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={props.small ? 550 : 800}
maxWidth={
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
}
onCloseRequest={onClose}
theme={props.theme}
closeOnClickOutside={props.closeOnClickOutside}
>
<Island ref={setIslandNode}>
<h2 id={`${id}-dialog-title`} className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
<button
className="Modal__close"
onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")}
>
{useDevice().isMobile ? back : CloseIcon}
</button>
</h2>
{props.title && (
<h2 id={`${id}-dialog-title`} className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
</h2>
)}
<button
className="Dialog__close"
onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")}
>
{device.isMobile ? back : CloseIcon}
</button>
<div className="Dialog__content">{props.children}</div>
</Island>
</Modal>

@ -28,7 +28,7 @@ export const ErrorDialog = ({
<>
{modalIsShown && (
<Dialog
small
size="small"
onCloseRequest={handleClose}
title={t("errorDialog.title")}
>

@ -0,0 +1,48 @@
.excalidraw {
.excalidraw-eye-dropper-container,
.excalidraw-eye-dropper-backdrop {
position: absolute;
width: 100%;
height: 100%;
z-index: 2;
touch-action: none;
}
.excalidraw-eye-dropper-container {
pointer-events: none;
}
.excalidraw-eye-dropper-backdrop {
pointer-events: all;
}
.excalidraw-eye-dropper-preview {
pointer-events: none;
width: 3rem;
height: 3rem;
position: fixed;
z-index: 999999;
border-radius: 1rem;
border: 1px solid var(--default-border-color);
filter: var(--theme-filter);
}
.excalidraw-eye-dropper-trigger {
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
padding: 4px;
margin-right: -4px;
margin-left: -2px;
border-radius: 0.5rem;
color: var(--icon-fill-color);
&:hover {
background: var(--button-hover-bg);
}
&.selected {
color: var(--color-primary);
background: var(--color-primary-light);
}
}
}

@ -0,0 +1,217 @@
import { atom } from "jotai";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { COLOR_PALETTE, rgbToHex } from "../colors";
import { EVENT } from "../constants";
import { useUIAppState } from "../context/ui-appState";
import { mutateElement } from "../element/mutateElement";
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
import { useOutsideClick } from "../hooks/useOutsideClick";
import { KEYS } from "../keys";
import { invalidateShapeForElement } from "../renderer/renderElement";
import { getSelectedElements } from "../scene";
import Scene from "../scene/Scene";
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
import "./EyeDropper.scss";
type EyeDropperProperties = {
keepOpenOnAlt: boolean;
swapPreviewOnAlt?: boolean;
onSelect?: (color: string, event: PointerEvent) => void;
previewType?: "strokeColor" | "backgroundColor";
};
export const activeEyeDropperAtom = atom<null | EyeDropperProperties>(null);
export const EyeDropper: React.FC<{
onCancel: () => void;
onSelect: Required<EyeDropperProperties>["onSelect"];
swapPreviewOnAlt?: EyeDropperProperties["swapPreviewOnAlt"];
previewType?: EyeDropperProperties["previewType"];
}> = ({
onCancel,
onSelect,
swapPreviewOnAlt,
previewType = "backgroundColor",
}) => {
const eyeDropperContainer = useCreatePortalContainer({
className: "excalidraw-eye-dropper-backdrop",
parentSelector: ".excalidraw-eye-dropper-container",
});
const appState = useUIAppState();
const elements = useExcalidrawElements();
const app = useApp();
const selectedElements = getSelectedElements(elements, appState);
const metaStuffRef = useRef({ selectedElements, app });
metaStuffRef.current.selectedElements = selectedElements;
metaStuffRef.current.app = app;
const { container: excalidrawContainer } = useExcalidrawContainer();
useEffect(() => {
const colorPreviewDiv = ref.current;
if (!colorPreviewDiv || !app.canvas || !eyeDropperContainer) {
return;
}
let currentColor = COLOR_PALETTE.black;
let isHoldingPointerDown = false;
const ctx = app.canvas.getContext("2d")!;
const mouseMoveListener = ({
clientX,
clientY,
altKey,
}: {
clientX: number;
clientY: number;
altKey: boolean;
}) => {
// FIXME swap offset when the preview gets outside viewport
colorPreviewDiv.style.top = `${clientY + 20}px`;
colorPreviewDiv.style.left = `${clientX + 20}px`;
const pixel = ctx.getImageData(
clientX * window.devicePixelRatio - appState.offsetLeft,
clientY * window.devicePixelRatio - appState.offsetTop,
1,
1,
).data;
currentColor = rgbToHex(pixel[0], pixel[1], pixel[2]);
if (isHoldingPointerDown) {
for (const element of metaStuffRef.current.selectedElements) {
mutateElement(
element,
{
[altKey && swapPreviewOnAlt
? previewType === "strokeColor"
? "backgroundColor"
: "strokeColor"
: previewType]: currentColor,
},
false,
);
invalidateShapeForElement(element);
}
Scene.getScene(
metaStuffRef.current.selectedElements[0],
)?.informMutation();
}
colorPreviewDiv.style.background = currentColor;
};
const pointerDownListener = (event: PointerEvent) => {
isHoldingPointerDown = true;
// NOTE we can't event.preventDefault() as that would stop
// pointermove events
event.stopImmediatePropagation();
};
const pointerUpListener = (event: PointerEvent) => {
isHoldingPointerDown = false;
// since we're not preventing default on pointerdown, the focus would
// goes back to `body` so we want to refocus the editor container instead
excalidrawContainer?.focus();
event.stopImmediatePropagation();
event.preventDefault();
onSelect(currentColor, event);
};
const keyDownListener = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
event.preventDefault();
event.stopImmediatePropagation();
onCancel();
}
};
// -------------------------------------------------------------------------
eyeDropperContainer.tabIndex = -1;
// focus container so we can listen on keydown events
eyeDropperContainer.focus();
// init color preview else it would show only after the first mouse move
mouseMoveListener({
clientX: metaStuffRef.current.app.lastViewportPosition.x,
clientY: metaStuffRef.current.app.lastViewportPosition.y,
altKey: false,
});
eyeDropperContainer.addEventListener(EVENT.KEYDOWN, keyDownListener);
eyeDropperContainer.addEventListener(
EVENT.POINTER_DOWN,
pointerDownListener,
);
eyeDropperContainer.addEventListener(EVENT.POINTER_UP, pointerUpListener);
window.addEventListener("pointermove", mouseMoveListener, {
passive: true,
});
window.addEventListener(EVENT.BLUR, onCancel);
return () => {
isHoldingPointerDown = false;
eyeDropperContainer.removeEventListener(EVENT.KEYDOWN, keyDownListener);
eyeDropperContainer.removeEventListener(
EVENT.POINTER_DOWN,
pointerDownListener,
);
eyeDropperContainer.removeEventListener(
EVENT.POINTER_UP,
pointerUpListener,
);
window.removeEventListener("pointermove", mouseMoveListener);
window.removeEventListener(EVENT.BLUR, onCancel);
};
}, [
app.canvas,
eyeDropperContainer,
onCancel,
onSelect,
swapPreviewOnAlt,
previewType,
excalidrawContainer,
appState.offsetLeft,
appState.offsetTop,
]);
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(
ref,
() => {
onCancel();
},
(event) => {
if (
event.target.closest(
".excalidraw-eye-dropper-trigger, .excalidraw-eye-dropper-backdrop",
)
) {
return true;
}
// consider all other clicks as outside
return false;
},
);
if (!eyeDropperContainer) {
return null;
}
return createPortal(
<div ref={ref} className="excalidraw-eye-dropper-preview" />,
eyeDropperContainer,
);
};

@ -0,0 +1,95 @@
@import "../css/variables.module";
.excalidraw {
.ExcButton {
&--color-primary {
color: var(--input-bg-color);
--accent-color: var(--color-primary);
--accent-color-hover: var(--color-primary-darker);
--accent-color-active: var(--color-primary-darkest);
}
&--color-danger {
color: var(--input-bg-color);
--accent-color: var(--color-danger);
--accent-color-hover: #d65550;
--accent-color-active: #d1413c;
}
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
flex-wrap: nowrap;
border-radius: 0.5rem;
font-family: "Assistant";
user-select: none;
transition: all 150ms ease-out;
&--size-large {
font-weight: 400;
font-size: 0.875rem;
height: 3rem;
padding: 0.5rem 1.5rem;
gap: 0.75rem;
letter-spacing: 0.4px;
}
&--size-medium {
font-weight: 600;
font-size: 0.75rem;
height: 2.5rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
letter-spacing: normal;
}
&--variant-filled {
background: var(--accent-color);
border: 1px solid transparent;
&:hover {
background: var(--accent-color-hover);
}
&:active {
background: var(--accent-color-active);
}
}
&--variant-outlined,
&--variant-icon {
border: 1px solid var(--accent-color);
color: var(--accent-color);
background: transparent;
&:hover {
border: 1px solid var(--accent-color-hover);
color: var(--accent-color-hover);
}
&:active {
border: 1px solid var(--accent-color-active);
color: var(--accent-color-active);
}
}
&--variant-icon {
padding: 0.5rem 0.75rem;
width: 3rem;
}
&__icon {
width: 1.25rem;
height: 1.25rem;
}
}
}

@ -0,0 +1,61 @@
import React, { forwardRef } from "react";
import clsx from "clsx";
import "./FilledButton.scss";
export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger";
export type ButtonSize = "medium" | "large";
export type FilledButtonProps = {
label: string;
children?: React.ReactNode;
onClick?: () => void;
variant?: ButtonVariant;
color?: ButtonColor;
size?: ButtonSize;
className?: string;
startIcon?: React.ReactNode;
};
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
(
{
children,
startIcon,
onClick,
label,
variant = "filled",
color = "primary",
size = "medium",
className,
},
ref,
) => {
return (
<button
className={clsx(
"ExcButton",
`ExcButton--color-${color}`,
`ExcButton--variant-${variant}`,
`ExcButton--size-${size}`,
className,
)}
onClick={onClick}
type="button"
aria-label={label}
ref={ref}
>
{startIcon && (
<div className="ExcButton__icon" aria-hidden>
{startIcon}
</div>
)}
{variant !== "icon" && (children ?? label)}
</button>
);
},
);

@ -164,6 +164,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("toolBar.eraser")}
shortcuts={[KEYS.E, KEYS["0"]]}
/>
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut
label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
/>
<Shortcut
label={t("helpDialog.editLineArrowPoints")}
shortcuts={[getShortcutKey("CtrlOrCmd+Enter")]}

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

@ -1,25 +1,39 @@
import React, { useEffect, useRef, useState } from "react";
import type { ActionManager } from "../actions/manager";
import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
import {
actionExportWithDarkMode,
actionChangeExportBackground,
actionChangeExportEmbedScene,
actionChangeExportScale,
actionChangeProjectName,
} from "../actions/actionExport";
import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppClassProperties, BinaryFiles, UIAppState } from "../types";
import { Dialog } from "./Dialog";
import { clipboard } from "./icons";
import Stack from "./Stack";
import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem";
import {
DEFAULT_EXPORT_PADDING,
EXPORT_IMAGE_TYPES,
isFirefox,
EXPORT_SCALES,
} from "../constants";
import { canvasToBlob } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../packages/utils";
import "./ExportDialog.scss";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog";
import { RadioGroup } from "./RadioGroup";
import { Switch } from "./Switch";
import { Tooltip } from "./Tooltip";
import "./ImageExportDialog.scss";
import { useAppProps } from "./App";
import { FilledButton } from "./FilledButton";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@ -36,55 +50,44 @@ export const ErrorCanvasPreview = () => {
);
};
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
) => void;
const ExportButton: React.FC<{
color: keyof OpenColor;
onClick: () => void;
title: string;
shade?: number;
children?: React.ReactNode;
}> = ({ children, title, onClick, color, shade = 6 }) => {
return (
<button
className="ExportDialog-imageExportButton"
style={{
["--button-color" as any]: OpenColor[color][shade],
["--button-color-darker" as any]: OpenColor[color][shade + 1],
["--button-color-darkest" as any]: OpenColor[color][shade + 2],
}}
title={title}
aria-label={title}
onClick={onClick}
>
{children}
</button>
);
};
const ImageExportModal = ({
elements,
appState,
files,
actionManager,
onExportImage,
}: {
type ImageExportModalProps = {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
}) => {
};
const ImageExportModal = ({
appState,
elements,
files,
actionManager,
onExportImage,
}: ImageExportModalProps) => {
const appProps = useAppProps();
const [projectName, setProjectName] = useState(appState.name);
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const [exportWithBackground, setExportWithBackground] = useState(
appState.exportBackground,
);
const [exportDarkMode, setExportDarkMode] = useState(
appState.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
const [exportScale, setExportScale] = useState(appState.exportScale);
const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected
? getSelectedElements(elements, appState, true)
? getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
})
: elements;
useEffect(() => {
@ -93,6 +96,7 @@ const ImageExportModal = ({
return;
}
const maxWidth = previewNode.offsetWidth;
const maxHeight = previewNode.offsetHeight;
if (!maxWidth) {
return;
}
@ -101,7 +105,7 @@ const ImageExportModal = ({
appState,
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: maxWidth,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
})
.then((canvas) => {
setRenderError(null);
@ -118,89 +122,190 @@ const ImageExportModal = ({
}, [appState, files, exportedElements]);
return (
<div className="ExportDialog">
<div className="ExportDialog__preview" ref={previewRef}>
{renderError && <ErrorCanvasPreview />}
</div>
{supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")}
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
// dunno why this is needed, but when the items wrap it creates
// an overflow
overflow: "hidden",
}}
>
{actionManager.renderAction("changeExportBackground")}
{someElementIsSelected && (
<CheckboxItem
checked={exportSelected}
onChange={(checked) => setExportSelected(checked)}
>
{t("labels.onlySelected")}
</CheckboxItem>
<div className="ImageExportModal">
<h3>{t("imageExportDialog.header")}</h3>
<div className="ImageExportModal__preview">
<div className="ImageExportModal__preview__canvas" ref={previewRef}>
{renderError && <ErrorCanvasPreview />}
</div>
<div className="ImageExportModal__preview__filename">
{!nativeFileSystemSupported && (
<input
type="text"
className="TextInput"
value={projectName}
style={{ width: "30ch" }}
disabled={
typeof appProps.name !== "undefined" || appState.viewModeEnabled
}
onChange={(event) => {
setProjectName(event.target.value);
actionManager.executeAction(
actionChangeProjectName,
"ui",
event.target.value,
);
}}
/>
)}
{actionManager.renderAction("changeExportEmbedScene")}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
<Stack.Row gap={2}>
{actionManager.renderAction("changeExportScale")}
</Stack.Row>
<p style={{ marginLeft: "1em", userSelect: "none" }}>
{t("buttons.scale")}
</p>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: ".6em 0",
}}
>
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
</div>
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
<ExportButton
color="indigo"
title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
}
>
PNG
</ExportButton>
<ExportButton
color="red"
title={t("buttons.exportToSvg")}
aria-label={t("buttons.exportToSvg")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
}
>
SVG
</ExportButton>
{/* firefox supports clipboard API under a flag,
so let's throw and tell people what they can do */}
{(probablySupportsClipboardBlob || isFirefox) && (
<ExportButton
title={t("buttons.copyPngToClipboard")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
}
color="gray"
shade={7}
<div className="ImageExportModal__settings">
<h3>{t("imageExportDialog.header")}</h3>
{someElementIsSelected && (
<ExportSetting
label={t("imageExportDialog.label.onlySelected")}
name="exportOnlySelected"
>
{clipboard}
</ExportButton>
<Switch
name="exportOnlySelected"
checked={exportSelected}
onChange={(checked) => {
setExportSelected(checked);
}}
/>
</ExportSetting>
)}
</Stack.Row>
<ExportSetting
label={t("imageExportDialog.label.withBackground")}
name="exportBackgroundSwitch"
>
<Switch
name="exportBackgroundSwitch"
checked={exportWithBackground}
onChange={(checked) => {
setExportWithBackground(checked);
actionManager.executeAction(
actionChangeExportBackground,
"ui",
checked,
);
}}
/>
</ExportSetting>
{supportsContextFilters && (
<ExportSetting
label={t("imageExportDialog.label.darkMode")}
name="exportDarkModeSwitch"
>
<Switch
name="exportDarkModeSwitch"
checked={exportDarkMode}
onChange={(checked) => {
setExportDarkMode(checked);
actionManager.executeAction(
actionExportWithDarkMode,
"ui",
checked,
);
}}
/>
</ExportSetting>
)}
<ExportSetting
label={t("imageExportDialog.label.embedScene")}
tooltip={t("imageExportDialog.tooltip.embedScene")}
name="exportEmbedSwitch"
>
<Switch
name="exportEmbedSwitch"
checked={embedScene}
onChange={(checked) => {
setEmbedScene(checked);
actionManager.executeAction(
actionChangeExportEmbedScene,
"ui",
checked,
);
}}
/>
</ExportSetting>
<ExportSetting
label={t("imageExportDialog.label.scale")}
name="exportScale"
>
<RadioGroup
name="exportScale"
value={exportScale}
onChange={(scale) => {
setExportScale(scale);
actionManager.executeAction(actionChangeExportScale, "ui", scale);
}}
choices={EXPORT_SCALES.map((scale) => ({
value: scale,
label: `${scale}\u00d7`,
}))}
/>
</ExportSetting>
<div className="ImageExportModal__settings__buttons">
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToPng")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
}
startIcon={downloadIcon}
>
{t("imageExportDialog.button.exportToPng")}
</FilledButton>
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToSvg")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
}
startIcon={downloadIcon}
>
{t("imageExportDialog.button.exportToSvg")}
</FilledButton>
{(probablySupportsClipboardBlob || isFirefox) && (
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
}
startIcon={copyIcon}
>
{t("imageExportDialog.button.copyPngToClipboard")}
</FilledButton>
)}
</div>
</div>
</div>
);
};
type ExportSettingProps = {
label: string;
children: React.ReactNode;
tooltip?: string;
name?: string;
};
const ExportSetting = ({
label,
children,
tooltip,
name,
}: ExportSettingProps) => {
return (
<div className="ImageExportModal__settings__setting" title={label}>
<label
htmlFor={name}
className="ImageExportModal__settings__setting__label"
>
{label}
{tooltip && (
<Tooltip label={tooltip} long={true}>
{helpIcon}
</Tooltip>
)}
</label>
<div className="ImageExportModal__settings__setting__content">
{children}
</div>
</div>
);
};
@ -225,7 +330,7 @@ export const ImageExportDialog = ({
}
return (
<Dialog onCloseRequest={onCloseRequest} title={t("buttons.exportImage")}>
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
<ImageExportModal
elements={elements}
appState={appState}

@ -38,7 +38,7 @@ import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai";
import { Provider, useAtomValue } from "jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { HandButton } from "./HandButton";
@ -47,6 +47,7 @@ import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
import { LibraryIcon } from "./icons";
import { UIAppStateContext } from "../context/ui-appState";
import { DefaultSidebar } from "./DefaultSidebar";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import "./LayerUI.scss";
import "./Toolbar.scss";
@ -120,6 +121,11 @@ const LayerUI = ({
const device = useDevice();
const tunnels = useInitializeTunnels();
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
@ -198,12 +204,7 @@ const LayerUI = ({
return (
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
<Stack.Col
gap={6}
className={clsx("App-menu_top__left", {
"disable-pointerEvents": appState.zenModeEnabled,
})}
>
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
{renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
@ -248,7 +249,7 @@ const LayerUI = ({
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider"></div>
<div className="App-toolbar__divider" />
<HandButton
checked={isHandToolActive(appState)}
@ -350,6 +351,21 @@ const LayerUI = ({
{appState.errorMessage}
</ErrorDialog>
)}
{eyeDropperState && !device.isMobile && (
<EyeDropper
swapPreviewOnAlt={eyeDropperState.swapPreviewOnAlt}
previewType={eyeDropperState.previewType}
onCancel={() => {
setEyeDropperState(null);
}}
onSelect={(color, event) => {
setEyeDropperState((state) => {
return state?.keepOpenOnAlt && event.altKey ? state : null;
});
eyeDropperState?.onSelect?.(color, event);
}}
/>
)}
{appState.openDialog === "help" && (
<HelpDialog
onClose={() => {
@ -371,7 +387,7 @@ const LayerUI = ({
}
/>
)}
{device.isMobile && (
{device.isMobile && !eyeDropperState && (
<MobileMenu
appState={appState}
elements={elements}

@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo, useRef } from "react";
import Library, {
distributeLibraryItemsOnSquareGrid,
libraryItemsAtom,
@ -27,6 +27,8 @@ import { useUIAppState } from "../context/ui-appState";
import "./LibraryMenu.scss";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { isShallowEqual } from "../utils";
import { NonDeletedExcalidrawElement } from "../element/types";
export const isLibraryMenuOpenAtom = atom(false);
@ -42,7 +44,7 @@ export const LibraryMenuContent = ({
libraryReturnUrl,
library,
id,
appState,
theme,
selectedItems,
onSelectItems,
}: {
@ -53,35 +55,47 @@ export const LibraryMenuContent = ({
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library;
id: string;
appState: UIAppState;
theme: UIAppState["theme"];
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const addToLibrary = useCallback(
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
trackEvent("element", "addToLibrary", "ui");
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => {
const addToLibrary = async (
processedElements: LibraryItem["elements"],
libraryItems: LibraryItems,
) => {
trackEvent("element", "addToLibrary", "ui");
if (processedElements.some((element) => element.type === "image")) {
return setAppState({
errorMessage:
"Support for adding images to the library coming soon!",
});
}
const nextItems: LibraryItems = [
{
status: "unpublished",
elements: processedElements,
id: randomId(),
created: Date.now(),
},
...libraryItems,
];
onAddToLibrary();
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
}
const nextItems: LibraryItems = [
{
status: "unpublished",
elements,
id: randomId(),
created: Date.now(),
},
...libraryItems,
];
onAddToLibrary();
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
};
addToLibrary(elements, libraryItemsData.libraryItems);
},
[onAddToLibrary, library, setAppState],
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
);
const libraryItems = useMemo(
() => libraryItemsData.libraryItems,
[libraryItemsData],
);
if (
@ -107,17 +121,15 @@ export const LibraryMenuContent = ({
<LibraryMenuWrapper>
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItemsData.libraryItems}
onAddToLibrary={(elements) =>
addToLibrary(elements, libraryItemsData.libraryItems)
}
libraryItems={libraryItems}
onAddToLibrary={_onAddToLibrary}
onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={appState.theme}
theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/>
{showBtn && (
<LibraryMenuControlButtons
@ -125,13 +137,40 @@ export const LibraryMenuContent = ({
style={{ padding: "16px 12px 0 12px" }}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={appState.theme}
theme={theme}
/>
)}
</LibraryMenuWrapper>
);
};
const usePendingElementsMemo = (
appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[],
) => {
const create = () =>
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const val = useRef(create());
const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements);
if (
!isShallowEqual(
appState.selectedElementIds,
prevAppState.current.selectedElementIds,
) ||
!isShallowEqual(elements, prevElements.current)
) {
val.current = create();
prevAppState.current = appState;
prevElements.current = elements;
}
return val.current;
};
/**
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
* <DefaultSidebar/> or host apps Sidebar components.
@ -142,8 +181,17 @@ export const LibraryMenu = () => {
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const memoizedLibrary = useMemo(() => library, [library]);
// BUG: pendingElements are still causing some unnecessary rerenders because clicking into canvas returns some ids even when no element is selected.
const pendingElements = usePendingElementsMemo(appState, elements);
const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
},
[onInsertElements],
);
const deselectItems = useCallback(() => {
setAppState({
@ -154,16 +202,14 @@ export const LibraryMenu = () => {
return (
<LibraryMenuContent
pendingElements={getSelectedElements(elements, appState, true)}
onInsertLibraryItems={(libraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
}}
pendingElements={pendingElements}
onInsertLibraryItems={onInsertLibraryItems}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={appProps.libraryReturnUrl}
library={library}
library={memoizedLibrary}
id={id}
appState={appState}
theme={appState.theme}
selectedItems={selectedItems}
onSelectItems={setSelectedItems}
/>

@ -24,6 +24,7 @@ import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { useUIAppState } from "../context/ui-appState";
import clsx from "clsx";
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
const getSelectedItems = (
libraryItems: LibraryItems,
@ -55,7 +56,7 @@ export const LibraryDropdownMenuButton: React.FC<{
jotaiScope,
);
const renderRemoveLibAlert = useCallback(() => {
const renderRemoveLibAlert = () => {
const content = selectedItems.length
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
: t("alerts.resetLibrary");
@ -80,7 +81,7 @@ export const LibraryDropdownMenuButton: React.FC<{
<p>{content}</p>
</ConfirmDialog>
);
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
};
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
@ -106,7 +107,7 @@ export const LibraryDropdownMenuButton: React.FC<{
onCloseRequest={() => setPublishLibSuccess(null)}
title={t("publishSuccessDialog.title")}
className="publish-library-success"
small={true}
size="small"
>
<p>
<Trans
@ -136,20 +137,20 @@ export const LibraryDropdownMenuButton: React.FC<{
);
}, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback(
(data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
nextLibItems.forEach((libItem) => {
if (selectedItems.includes(libItem.id)) {
libItem.status = "published";
}
});
library.setLibrary(nextLibItems);
},
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
);
const onPublishLibSuccess = (
data: { url: string; authorName: string },
libraryItems: LibraryItems,
) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
nextLibItems.forEach((libItem) => {
if (selectedItems.includes(libItem.id)) {
libItem.status = "published";
}
});
library.setLibrary(nextLibItems);
};
const onLibraryImport = async () => {
try {
@ -280,27 +281,29 @@ export const LibraryDropdownMenu = ({
className?: string;
}) => {
const { library } = useApp();
const { clearLibraryCache, deleteItemsFromLibraryCache } = useLibraryCache();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
onSelectItems([]);
},
[library, setAppState, selectedItems, onSelectItems],
);
const removeFromLibrary = async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
const resetLibrary = useCallback(() => {
deleteItemsFromLibraryCache(selectedItems);
onSelectItems([]);
};
const resetLibrary = () => {
library.resetLibrary();
}, [library]);
clearLibraryCache();
};
return (
<LibraryDropdownMenuButton

@ -73,6 +73,12 @@
}
}
&__grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-gap: 1rem;
}
.separator {
width: 100%;
display: flex;

@ -1,6 +1,11 @@
import React, { useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { serializeLibraryAsJSON } from "../data/json";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n";
import {
ExcalidrawProps,
@ -8,202 +13,71 @@ import {
LibraryItems,
UIAppState,
} from "../types";
import { arrayToMap, chunk } from "../utils";
import { LibraryUnit } from "./LibraryUnit";
import { arrayToMap } from "../utils";
import Stack from "./Stack";
import { MIME_TYPES } from "../constants";
import Spinner from "./Spinner";
import { duplicateElements } from "../element/newElement";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
import {
LibraryMenuSection,
LibraryMenuSectionGrid,
} from "./LibraryMenuSection";
import { useScrollPosition } from "../hooks/useScrollPosition";
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
import "./LibraryMenuItems.scss";
const CELLS_PER_ROW = 4;
// using an odd number of items per batch so the rendering creates an irregular
// pattern which looks more organic
const ITEMS_RENDERED_PER_BATCH = 17;
// when render outputs cached we can render many more items per batch to
// speed it up
const CACHED_ITEMS_RENDERED_PER_BATCH = 64;
const LibraryMenuItems = ({
export default function LibraryMenuItems({
isLoading,
libraryItems,
onAddToLibrary,
onInsertLibraryItems,
pendingElements,
selectedItems,
onSelectItems,
theme,
id,
libraryReturnUrl,
onSelectItems,
selectedItems,
}: {
isLoading: boolean;
libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"];
id: string;
}) => {
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) {
const libraryContainerRef = useRef<HTMLDivElement>(null);
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
const onItemSelectToggle = (
id: LibraryItem["id"],
event: React.MouseEvent,
) => {
const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems];
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = orderedItems.findIndex((item) => item.id === id);
if (rangeStart === -1 || rangeEnd === -1) {
onSelectItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = orderedItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
onSelectItems(nextSelectedIds);
} else {
onSelectItems([...selectedItems, id]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
onSelectItems(selectedItems.filter((_id) => _id !== id));
// This effect has to be called only on first render, therefore `scrollPosition` isn't in the dependency array
useEffect(() => {
if (scrollPosition > 0) {
libraryContainerRef.current?.scrollTo(0, scrollPosition);
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const getInsertedElements = (id: string) => {
let targetElements;
if (selectedItems.includes(id)) {
targetElements = libraryItems.filter((item) =>
selectedItems.includes(item.id),
);
} else {
targetElements = libraryItems.filter((item) => item.id === id);
}
return targetElements.map((item) => {
return {
...item,
// duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements, { randomizeSeed: true }),
};
});
};
const createLibraryItemCompo = (params: {
item:
| LibraryItem
| /* pending library item */ {
id: null;
elements: readonly NonDeleted<ExcalidrawElement>[];
}
| null;
onClick?: () => void;
key: string;
}) => {
return (
<Stack.Col key={params.key}>
<LibraryUnit
elements={params.item?.elements}
isPending={!params.item?.id && !!params.item?.elements}
onClick={params.onClick || (() => {})}
id={params.item?.id || null}
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
onToggle={onItemSelectToggle}
onDrag={(id, event) => {
event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
serializeLibraryAsJSON(getInsertedElements(id)),
);
}}
/>
</Stack.Col>
);
};
const renderLibrarySection = (
items: (
| LibraryItem
| /* pending library item */ {
id: null;
elements: readonly NonDeleted<ExcalidrawElement>[];
}
)[],
) => {
const _items = items.map((item) => {
if (item.id) {
return createLibraryItemCompo({
item,
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
key: item.id,
});
}
return createLibraryItemCompo({
key: "__pending__item__",
item,
onClick: () => onAddToLibrary(pendingElements),
});
});
// ensure we render all empty cells if no items are present
let rows = chunk(_items, CELLS_PER_ROW);
if (!rows.length) {
rows = [[]];
}
return rows.map((rowItems, index, rows) => {
if (index === rows.length - 1) {
// pad row with empty cells
rowItems = rowItems.concat(
new Array(CELLS_PER_ROW - rowItems.length)
.fill(null)
.map((_, index) => {
return createLibraryItemCompo({
key: `empty_${index}`,
item: null,
});
}),
);
}
return (
<Stack.Row
align="center"
key={index}
className="library-menu-items-container__row"
>
{rowItems}
</Stack.Row>
);
});
};
const unpublishedItems = libraryItems.filter(
(item) => item.status !== "published",
const { svgCache } = useLibraryCache();
const unpublishedItems = useMemo(
() => libraryItems.filter((item) => item.status !== "published"),
[libraryItems],
);
const publishedItems = libraryItems.filter(
(item) => item.status === "published",
const publishedItems = useMemo(
() => libraryItems.filter((item) => item.status === "published"),
[libraryItems],
);
const showBtn = !libraryItems.length && !pendingElements.length;
@ -213,6 +87,122 @@ const LibraryMenuItems = ({
!unpublishedItems.length &&
!publishedItems.length;
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
const onItemSelectToggle = useCallback(
(id: LibraryItem["id"], event: React.MouseEvent) => {
const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems];
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = orderedItems.findIndex((item) => item.id === id);
if (rangeStart === -1 || rangeEnd === -1) {
onSelectItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = orderedItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
onSelectItems(nextSelectedIds);
} else {
onSelectItems([...selectedItems, id]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
onSelectItems(selectedItems.filter((_id) => _id !== id));
}
},
[
lastSelectedItem,
onSelectItems,
publishedItems,
selectedItems,
unpublishedItems,
],
);
const getInsertedElements = useCallback(
(id: string) => {
let targetElements;
if (selectedItems.includes(id)) {
targetElements = libraryItems.filter((item) =>
selectedItems.includes(item.id),
);
} else {
targetElements = libraryItems.filter((item) => item.id === id);
}
return targetElements.map((item) => {
return {
...item,
// duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements, { randomizeSeed: true }),
};
});
},
[libraryItems, selectedItems],
);
const onItemDrag = useCallback(
(id: LibraryItem["id"], event: React.DragEvent) => {
event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
serializeLibraryAsJSON(getInsertedElements(id)),
);
},
[getInsertedElements],
);
const isItemSelected = useCallback(
(id: LibraryItem["id"] | null) => {
if (!id) {
return false;
}
return selectedItems.includes(id);
},
[selectedItems],
);
const onAddToLibraryClick = useCallback(() => {
onAddToLibrary(pendingElements);
}, [pendingElements, onAddToLibrary]);
const onItemClick = useCallback(
(id: LibraryItem["id"] | null) => {
if (id) {
onInsertLibraryItems(getInsertedElements(id));
}
},
[getInsertedElements, onInsertLibraryItems],
);
const itemsRenderedPerBatch =
svgCache.size >= libraryItems.length
? CACHED_ITEMS_RENDERED_PER_BATCH
: ITEMS_RENDERED_PER_BATCH;
return (
<div
className="library-menu-items-container"
@ -239,6 +229,7 @@ const LibraryMenuItems = ({
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
marginBottom: 0,
}}
ref={libraryContainerRef}
>
<>
{!isLibraryEmpty && (
@ -258,28 +249,41 @@ const LibraryMenuItems = ({
<Spinner />
</div>
)}
<div className="library-menu-items-private-library-container">
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
</div>
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
</div>
) : (
renderLibrarySection([
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...unpublishedItems,
])
)}
</div>
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
<LibraryMenuSectionGrid>
{pendingElements.length > 0 && (
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={[{ id: null, elements: pendingElements }]}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onAddToLibraryClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
)}
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={unpublishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
)}
</>
<>
@ -291,7 +295,17 @@ const LibraryMenuItems = ({
</div>
)}
{publishedItems.length > 0 ? (
renderLibrarySection(publishedItems)
<LibraryMenuSectionGrid>
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={publishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
) : unpublishedItems.length > 0 ? (
<div
style={{
@ -325,6 +339,4 @@ const LibraryMenuItems = ({
</Stack.Col>
</div>
);
};
export default LibraryMenuItems;
}

@ -0,0 +1,77 @@
import React, { memo, ReactNode, useEffect, useState } from "react";
import { EmptyLibraryUnit, LibraryUnit } from "./LibraryUnit";
import { LibraryItem } from "../types";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { SvgCache } from "../hooks/useLibraryItemSvg";
import { useTransition } from "../hooks/useTransition";
type LibraryOrPendingItem = (
| LibraryItem
| /* pending library item */ {
id: null;
elements: readonly NonDeleted<ExcalidrawElement>[];
}
)[];
interface Props {
items: LibraryOrPendingItem;
onClick: (id: LibraryItem["id"] | null) => void;
onItemSelectToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void;
isItemSelected: (id: LibraryItem["id"] | null) => boolean;
svgCache: SvgCache;
itemsRenderedPerBatch: number;
}
export const LibraryMenuSectionGrid = ({
children,
}: {
children: ReactNode;
}) => {
return <div className="library-menu-items-container__grid">{children}</div>;
};
export const LibraryMenuSection = memo(
({
items,
onItemSelectToggle,
onItemDrag,
isItemSelected,
onClick,
svgCache,
itemsRenderedPerBatch,
}: Props) => {
const [, startTransition] = useTransition();
const [index, setIndex] = useState(0);
useEffect(() => {
if (index < items.length) {
startTransition(() => {
setIndex(index + itemsRenderedPerBatch);
});
}
}, [index, items.length, startTransition, itemsRenderedPerBatch]);
return (
<>
{items.map((item, i) => {
return i < index ? (
<LibraryUnit
elements={item?.elements}
isPending={!item?.id && !!item?.elements}
onClick={onClick}
svgCache={svgCache}
id={item?.id}
selected={isItemSelected(item.id)}
onToggle={onItemSelectToggle}
onDrag={onItemDrag}
key={item?.id ?? i}
/>
) : (
<EmptyLibraryUnit key={i} />
);
})}
</>
);
},
);

@ -20,6 +20,27 @@
border-color: var(--color-primary);
border-width: 1px;
}
&--skeleton {
opacity: 0.5;
background: linear-gradient(
-45deg,
var(--color-gray-10),
var(--color-gray-20),
var(--color-gray-10)
);
background-size: 200% 200%;
animation: library-unit__skeleton-opacity-animation 0.2s linear;
}
}
&.theme--dark .library-unit--skeleton {
background-image: linear-gradient(
-45deg,
var(--color-gray-100),
var(--color-gray-80),
var(--color-gray-100)
);
}
.library-unit__dragger {
@ -142,4 +163,18 @@
transform: scale(0.85);
}
}
@keyframes library-unit__skeleton-opacity-animation {
0% {
opacity: 0;
}
75% {
opacity: 0;
}
100% {
opacity: 0.5;
}
}
}

@ -1,108 +1,107 @@
import clsx from "clsx";
import oc from "open-color";
import { useEffect, useRef, useState } from "react";
import { memo, useEffect, useRef, useState } from "react";
import { useDevice } from "../components/App";
import { exportToSvg } from "../packages/utils";
import { LibraryItem } from "../types";
import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem";
import { PlusIcon } from "./icons";
import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
export const LibraryUnit = ({
id,
elements,
isPending,
onClick,
selected,
onToggle,
onDrag,
}: {
id: LibraryItem["id"] | /** for pending item */ null;
elements?: LibraryItem["elements"];
isPending?: boolean;
onClick: () => void;
selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void;
onDrag: (id: string, event: React.DragEvent) => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const node = ref.current;
if (!node) {
return;
}
export const LibraryUnit = memo(
({
id,
elements,
isPending,
onClick,
selected,
onToggle,
onDrag,
svgCache,
}: {
id: LibraryItem["id"] | /** for pending item */ null;
elements?: LibraryItem["elements"];
isPending?: boolean;
onClick: (id: LibraryItem["id"] | null) => void;
selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void;
onDrag: (id: string, event: React.DragEvent) => void;
svgCache: SvgCache;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
const svg = useLibraryItemSvg(id, elements, svgCache);
(async () => {
if (!elements) {
useEffect(() => {
const node = ref.current;
if (!node) {
return;
}
const svg = await exportToSvg({
elements,
appState: {
exportBackground: false,
viewBackgroundColor: oc.white,
},
files: null,
});
svg.querySelector(".style-fonts")?.remove();
node.innerHTML = svg.outerHTML;
})();
return () => {
node.innerHTML = "";
};
}, [elements]);
if (svg) {
node.innerHTML = svg.outerHTML;
}
const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile;
const adder = isPending && (
<div className="library-unit__adder">{PlusIcon}</div>
);
return () => {
node.innerHTML = "";
};
}, [svg]);
return (
<div
className={clsx("library-unit", {
"library-unit__active": elements,
"library-unit--hover": elements && isHovered,
"library-unit--selected": selected,
})}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile;
const adder = isPending && (
<div className="library-unit__adder">{PlusIcon}</div>
);
return (
<div
className={clsx("library-unit__dragger", {
"library-unit__pulse": !!isPending,
className={clsx("library-unit", {
"library-unit__active": elements,
"library-unit--hover": elements && isHovered,
"library-unit--selected": selected,
"library-unit--skeleton": !svg,
})}
ref={ref}
draggable={!!elements}
onClick={
!!elements || !!isPending
? (event) => {
if (id && event.shiftKey) {
onToggle(id, event);
} else {
onClick();
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={clsx("library-unit__dragger", {
"library-unit__pulse": !!isPending,
})}
ref={ref}
draggable={!!elements}
onClick={
!!elements || !!isPending
? (event) => {
if (id && event.shiftKey) {
onToggle(id, event);
} else {
onClick(id);
}
}
}
: undefined
}
onDragStart={(event) => {
if (!id) {
event.preventDefault();
return;
: undefined
}
setIsHovered(false);
onDrag(id, event);
}}
/>
{adder}
{id && elements && (isHovered || isMobile || selected) && (
<CheckboxItem
checked={selected}
onChange={(checked, event) => onToggle(id, event)}
className="library-unit__checkbox"
onDragStart={(event) => {
if (!id) {
event.preventDefault();
return;
}
setIsHovered(false);
onDrag(id, event);
}}
/>
)}
</div>
);
};
{adder}
{id && elements && (isHovered || isMobile || selected) && (
<CheckboxItem
checked={selected}
onChange={(checked, event) => onToggle(id, event)}
className="library-unit__checkbox"
/>
)}
</div>
);
},
);
export const EmptyLibraryUnit = () => (
<div className="library-unit library-unit--skeleton" />
);

@ -24,13 +24,15 @@
}
.Modal__background {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
background-color: rgba(#121212, 0.2);
animation: Modal__background__fade-in 0.125s linear forwards;
}
.Modal__content {
@ -65,14 +67,23 @@
}
}
@keyframes Modal__content_fade-in {
@keyframes Modal__background__fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes Modal__content_fade-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}

@ -1,12 +1,11 @@
import "./Modal.scss";
import React, { useState, useLayoutEffect, useRef } from "react";
import React from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
import { KEYS } from "../keys";
import { useExcalidrawContainer, useDevice } from "./App";
import { AppState } from "../types";
import { THEME } from "../constants";
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
export const Modal: React.FC<{
className?: string;
@ -17,8 +16,10 @@ export const Modal: React.FC<{
theme?: AppState["theme"];
closeOnClickOutside?: boolean;
}> = (props) => {
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
const modalRoot = useBodyRoot(theme);
const { closeOnClickOutside = true } = props;
const modalRoot = useCreatePortalContainer({
className: "excalidraw-modal-container",
});
if (!modalRoot) {
return null;
@ -44,7 +45,7 @@ export const Modal: React.FC<{
<div
className="Modal__background"
onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
></div>
/>
<div
className="Modal__content"
style={{ "--max-width": `${props.maxWidth}px` }}
@ -56,43 +57,3 @@ export const Modal: React.FC<{
modalRoot,
);
};
const useBodyRoot = (theme: AppState["theme"]) => {
const [div, setDiv] = useState<HTMLDivElement | null>(null);
const device = useDevice();
const isMobileRef = useRef(device.isMobile);
isMobileRef.current = device.isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => {
if (div) {
div.classList.toggle("excalidraw--mobile", device.isMobile);
}
}, [div, device.isMobile]);
useLayoutEffect(() => {
const isDarkTheme =
!!excalidrawContainer?.classList.contains("theme--dark") ||
theme === "dark";
const div = document.createElement("div");
div.classList.add("excalidraw", "excalidraw-modal-container");
div.classList.toggle("excalidraw--mobile", isMobileRef.current);
if (isDarkTheme) {
div.classList.add("theme--dark");
div.classList.add("theme--dark-background-none");
}
document.body.appendChild(div);
setDiv(div);
return () => {
document.body.removeChild(div);
};
}, [excalidrawContainer, theme]);
return div;
};

@ -140,7 +140,7 @@ export const PasteChartDialog = ({
return (
<Dialog
small
size="small"
onCloseRequest={handleClose}
title={t("labels.pasteCharts")}
className={"PasteChartDialog"}

@ -5,12 +5,14 @@ import { focusNearestParent } from "../utils";
import "./ProjectName.scss";
import { useExcalidrawContainer } from "./App";
import { KEYS } from "../keys";
type Props = {
value: string;
onChange: (value: string) => void;
label: string;
isNameEditable: boolean;
ignoreFocus?: boolean;
};
export const ProjectName = (props: Props) => {
@ -18,7 +20,9 @@ export const ProjectName = (props: Props) => {
const [fileName, setFileName] = useState<string>(props.value);
const handleBlur = (event: any) => {
focusNearestParent(event.target);
if (!props.ignoreFocus) {
focusNearestParent(event.target);
}
const value = event.target.value;
if (value !== props.value) {
props.onChange(value);
@ -26,7 +30,7 @@ export const ProjectName = (props: Props) => {
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") {
if (event.key === KEYS.ENTER) {
event.preventDefault();
if (event.nativeEvent.isComposing || event.keyCode === 229) {
return;

@ -0,0 +1,100 @@
@import "../css/variables.module";
.excalidraw {
--RadioGroup-background: #ffffff;
--RadioGroup-border: var(--color-gray-30);
--RadioGroup-choice-color-off: var(--color-primary);
--RadioGroup-choice-color-off-hover: var(--color-primary-darkest);
--RadioGroup-choice-background-off: white;
--RadioGroup-choice-background-off-active: var(--color-gray-20);
--RadioGroup-choice-color-on: white;
--RadioGroup-choice-background-on: var(--color-primary);
--RadioGroup-choice-background-on-hover: var(--color-primary-darker);
--RadioGroup-choice-background-on-active: var(--color-primary-darkest);
&.theme--dark {
--RadioGroup-background: var(--color-gray-85);
--RadioGroup-border: var(--color-gray-70);
--RadioGroup-choice-background-off: var(--color-gray-85);
--RadioGroup-choice-background-off-active: var(--color-gray-70);
--RadioGroup-choice-color-on: var(--color-gray-85);
}
.RadioGroup {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 3px;
border-radius: 10px;
background: var(--RadioGroup-background);
border: 1px solid var(--RadioGroup-border);
&__choice {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 24px;
color: var(--RadioGroup-choice-color-off);
background: var(--RadioGroup-choice-background-off);
border-radius: 8px;
font-family: "Assistant";
font-style: normal;
font-weight: 600;
font-size: 0.75rem;
line-height: 100%;
user-select: none;
letter-spacing: 0.4px;
transition: all 75ms ease-out;
&:hover {
color: var(--RadioGroup-choice-color-off-hover);
}
&:active {
background: var(--RadioGroup-choice-background-off-active);
}
&.active {
color: var(--RadioGroup-choice-color-on);
background: var(--RadioGroup-choice-background-on);
&:hover {
background: var(--RadioGroup-choice-background-on-hover);
}
&:active {
background: var(--RadioGroup-choice-background-on-active);
}
}
& input {
z-index: 1;
position: absolute;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border-radius: 8px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
}
}
}
}

@ -0,0 +1,42 @@
import clsx from "clsx";
import "./RadioGroup.scss";
export type RadioGroupChoice<T> = {
value: T;
label: string;
};
export type RadioGroupProps<T> = {
choices: RadioGroupChoice<T>[];
value: T;
onChange: (value: T) => void;
name: string;
};
export const RadioGroup = function <T>({
onChange,
value,
choices,
name,
}: RadioGroupProps<T>) {
return (
<div className="RadioGroup">
{choices.map((choice) => (
<div
className={clsx("RadioGroup__choice", {
active: choice.value === value,
})}
key={choice.label}
>
<input
name={name}
type="radio"
checked={choice.value === value}
onChange={() => onChange(choice.value)}
/>
{choice.label}
</div>
))}
</div>
);
};

@ -6,7 +6,6 @@ import React, {
forwardRef,
useImperativeHandle,
useCallback,
RefObject,
} from "react";
import { Island } from ".././Island";
import { atom, useSetAtom } from "jotai";
@ -27,38 +26,10 @@ import { SidebarTabTriggers } from "./SidebarTabTriggers";
import { SidebarTabTrigger } from "./SidebarTabTrigger";
import { SidebarTabs } from "./SidebarTabs";
import { SidebarTab } from "./SidebarTab";
import { useUIAppState } from "../../context/ui-appState";
import { useOutsideClick } from "../../hooks/useOutsideClick";
import "./Sidebar.scss";
import { useUIAppState } from "../../context/ui-appState";
// FIXME replace this with the implem from ColorPicker once it's merged
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
if (
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event);
};
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
};
/**
* Flags whether the currently rendered Sidebar is docked or not, for use
@ -133,7 +104,7 @@ export const SidebarInner = forwardRef(
setAppState({ openSidebar: null });
}, [setAppState]);
useOnClickOutside(
useOutsideClick(
islandRef,
useCallback(
(event) => {

@ -15,6 +15,7 @@ $duration: 1.6s;
svg {
animation: rotate $duration linear infinite;
animation-delay: var(--spinner-delay);
transform-origin: center center;
}

@ -5,13 +5,26 @@ import "./Spinner.scss";
const Spinner = ({
size = "1em",
circleWidth = 8,
synchronized = false,
}: {
size?: string | number;
circleWidth?: number;
synchronized?: boolean;
}) => {
const mountTime = React.useRef(Date.now());
const mountDelay = -(mountTime.current % 1600);
return (
<div className="Spinner">
<svg viewBox="0 0 100 100" style={{ width: size, height: size }}>
<svg
viewBox="0 0 100 100"
style={{
width: size,
height: size,
// fix for remounting causing spinner flicker
["--spinner-delay" as any]: synchronized ? `${mountDelay}ms` : 0,
}}
>
<circle
cx="50"
cy="50"

@ -1,6 +1,6 @@
import "./Stack.scss";
import React from "react";
import React, { forwardRef } from "react";
import clsx from "clsx";
type StackProps = {
@ -10,53 +10,52 @@ type StackProps = {
justifyContent?: "center" | "space-around" | "space-between";
className?: string | boolean;
style?: React.CSSProperties;
ref: React.RefObject<HTMLDivElement>;
};
const RowStack = ({
children,
gap,
align,
justifyContent,
className,
style,
}: StackProps) => {
return (
<div
className={clsx("Stack Stack_horizontal", className)}
style={{
"--gap": gap,
alignItems: align,
justifyContent,
...style,
}}
>
{children}
</div>
);
};
const RowStack = forwardRef(
(
{ children, gap, align, justifyContent, className, style }: StackProps,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
return (
<div
className={clsx("Stack Stack_horizontal", className)}
style={{
"--gap": gap,
alignItems: align,
justifyContent,
...style,
}}
ref={ref}
>
{children}
</div>
);
},
);
const ColStack = ({
children,
gap,
align,
justifyContent,
className,
style,
}: StackProps) => {
return (
<div
className={clsx("Stack Stack_vertical", className)}
style={{
"--gap": gap,
justifyItems: align,
justifyContent,
...style,
}}
>
{children}
</div>
);
};
const ColStack = forwardRef(
(
{ children, gap, align, justifyContent, className, style }: StackProps,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
return (
<div
className={clsx("Stack Stack_vertical", className)}
style={{
"--gap": gap,
justifyItems: align,
justifyContent,
...style,
}}
ref={ref}
>
{children}
</div>
);
},
);
export default {
Row: RowStack,

116
src/components/Switch.scss Normal file

@ -0,0 +1,116 @@
@import "../css/variables.module";
.excalidraw {
--Switch-disabled-color: #d6d6d6;
--Switch-track-background: white;
--Switch-thumb-background: #3d3d3d;
&.theme--dark {
--Switch-disabled-color: #5c5c5c;
--Switch-track-background: #242424;
--Switch-thumb-background: #b8b8b8;
}
.Switch {
position: relative;
box-sizing: border-box;
width: 40px;
height: 20px;
border-radius: 12px;
transition-property: background, border;
transition-duration: 150ms;
transition-timing-function: ease-out;
background: var(--Switch-track-background);
border: 1px solid var(--Switch-disabled-color);
&:hover {
background: var(--Switch-track-background);
border: 1px solid #999999;
}
&.toggled {
background: var(--color-primary);
border: 1px solid var(--color-primary);
&:hover {
background: var(--color-primary-darker);
border: 1px solid var(--color-primary-darker);
}
}
&.disabled {
background: var(--Switch-track-background);
border: 1px solid var(--Switch-disabled-color);
&.toggled {
background: var(--Switch-disabled-color);
border: 1px solid var(--Switch-disabled-color);
}
}
&:before {
content: "";
box-sizing: border-box;
display: block;
pointer-events: none;
position: absolute;
border-radius: 100%;
transition: all 150ms ease-out;
width: 10px;
height: 10px;
top: 4px;
left: 4px;
background: var(--Switch-thumb-background);
}
&:active:before {
width: 12px;
}
&.toggled:before {
width: 14px;
height: 14px;
left: 22px;
top: 2px;
background: var(--Switch-track-background);
}
&.toggled:active:before {
width: 16px;
left: 20px;
}
&.disabled:before {
background: var(--Switch-disabled-color);
}
&.disabled.toggled:before {
background: var(--color-gray-50);
}
& input {
width: 100%;
height: 100%;
margin: 0;
border-radius: 12px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
&:disabled {
cursor: unset;
}
}
}
}

38
src/components/Switch.tsx Normal file

@ -0,0 +1,38 @@
import clsx from "clsx";
import "./Switch.scss";
export type SwitchProps = {
name: string;
checked: boolean;
title?: string;
onChange: (value: boolean) => void;
disabled?: boolean;
};
export const Switch = ({
title,
name,
checked,
onChange,
disabled = false,
}: SwitchProps) => {
return (
<div className={clsx("Switch", { toggled: checked, disabled })}>
<input
name={name}
id={name}
title={title}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => onChange(!checked)}
onKeyDown={(event) => {
if (event.key === " ") {
onChange(!checked);
}
}}
/>
</div>
);
};

@ -0,0 +1,118 @@
@import "../css/variables.module";
.excalidraw {
--ExcTextField--color: var(--color-gray-80);
--ExcTextField--label-color: var(--color-gray-80);
--ExcTextField--background: white;
--ExcTextField--readonly--background: var(--color-gray-10);
--ExcTextField--readonly--color: var(--color-gray-80);
--ExcTextField--border: var(--color-gray-40);
--ExcTextField--border-hover: var(--color-gray-50);
--ExcTextField--placeholder: var(--color-gray-40);
&.theme--dark {
--ExcTextField--color: var(--color-gray-10);
--ExcTextField--label-color: var(--color-gray-20);
--ExcTextField--background: var(--color-gray-85);
--ExcTextField--readonly--background: var(--color-gray-80);
--ExcTextField--readonly--color: var(--color-gray-40);
--ExcTextField--border: var(--color-gray-70);
--ExcTextField--border-hover: var(--color-gray-60);
--ExcTextField--placeholder: var(--color-gray-80);
}
.ExcTextField {
&--fullWidth {
width: 100%;
flex-grow: 1;
}
&__label {
font-family: "Assistant";
font-style: normal;
font-weight: 600;
font-size: 0.875rem;
line-height: 150%;
color: var(--ExcTextField--label-color);
margin-bottom: 0.25rem;
user-select: none;
}
&__input {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 1rem;
height: 3rem;
background: var(--ExcTextField--background);
border: 1px solid var(--ExcTextField--border);
border-radius: 0.5rem;
&:not(&--readonly) {
&:hover {
border-color: var(--ExcTextField--border-hover);
}
&:active,
&:focus-within {
border-color: var(--color-primary);
}
}
& input {
display: flex;
align-items: center;
border: none;
outline: none;
padding: 0;
margin: 0;
height: 1.5rem;
color: var(--ExcTextField--color);
font-family: "Assistant";
font-style: normal;
font-weight: 400;
font-size: 1rem;
line-height: 150%;
text-overflow: ellipsis;
background: transparent;
width: 100%;
&::placeholder {
color: var(--ExcTextField--placeholder);
}
&:not(:focus) {
&:hover {
background-color: initial;
}
}
&:focus {
outline: initial;
box-shadow: initial;
}
}
&--readonly {
background: var(--ExcTextField--readonly--background);
border-color: transparent;
& input {
color: var(--ExcTextField--readonly--color);
}
}
}
}
}

@ -0,0 +1,57 @@
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
import clsx from "clsx";
import "./TextField.scss";
export type TextFieldProps = {
value?: string;
onChange?: (value: string) => void;
onClick?: () => void;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
readonly?: boolean;
fullWidth?: boolean;
label?: string;
placeholder?: string;
};
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
(
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
ref,
) => {
const innerRef = useRef<HTMLInputElement | null>(null);
useImperativeHandle(ref, () => innerRef.current!);
return (
<div
className={clsx("ExcTextField", {
"ExcTextField--fullWidth": fullWidth,
})}
onClick={() => {
innerRef.current?.focus();
}}
>
<div className="ExcTextField__label">{label}</div>
<div
className={clsx("ExcTextField__input", {
"ExcTextField__input--readonly": readonly,
})}
>
<input
readOnly={readonly}
type="text"
value={value}
placeholder={placeholder}
ref={innerRef}
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown}
/>
</div>
</div>
);
},
);

@ -1,6 +1,6 @@
import "./ToolIcon.scss";
import React, { useEffect, useRef, useState } from "react";
import React, { CSSProperties, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { useExcalidrawContainer } from "./App";
import { AbortError } from "../errors";
@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
visible?: boolean;
selected?: boolean;
className?: string;
style?: CSSProperties;
isLoading?: boolean;
};
@ -114,6 +115,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title}

@ -15,7 +15,24 @@
height: 1.5rem;
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.5rem;
margin: 0 0.25rem;
}
}
.App-toolbar__extra-tools-trigger {
box-shadow: none;
border: 0;
&:active {
background-color: var(--button-hover-bg);
box-shadow: 0 0 0 1px
var(--button-active-border, var(--color-primary-darkest)) inset;
}
}
.App-toolbar__extra-tools-dropdown {
margin-top: 0.375rem;
right: 0;
min-width: 11.875rem;
}
}

@ -11,8 +11,6 @@
top: auto;
left: 0;
width: 100%;
display: flex;
flex-direction: column;
row-gap: 0.75rem;
.dropdown-menu-container {

@ -1,11 +1,10 @@
import { useOutsideClick } from "../../hooks/useOutsideClick";
import { Island } from "../Island";
import { useDevice } from "../App";
import clsx from "clsx";
import Stack from "../Stack";
import React from "react";
import React, { useRef } from "react";
import { DropdownMenuContentPropsContext } from "./common";
import { useOutsideClick } from "../../hooks/useOutsideClick";
const MenuContent = ({
children,
@ -24,7 +23,9 @@ const MenuContent = ({
style?: React.CSSProperties;
}) => {
const device = useDevice();
const menuRef = useOutsideClick(() => {
const menuRef = useRef<HTMLDivElement>(null);
useOutsideClick(menuRef, () => {
onClickOutside?.();
});
@ -48,7 +49,7 @@ const MenuContent = ({
<Island
className="dropdown-menu-container"
padding={2}
style={{ zIndex: 1 }}
style={{ zIndex: 2 }}
>
{children}
</Island>

@ -1,23 +1,23 @@
import clsx from "clsx";
import { useUIAppState } from "../../context/ui-appState";
import { useDevice } from "../App";
const MenuTrigger = ({
className = "",
children,
onToggle,
title,
...rest
}: {
className?: string;
children: React.ReactNode;
onToggle: () => void;
}) => {
const appState = useUIAppState();
title?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const device = useDevice();
const classNames = clsx(
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"transition-left": appState.zenModeEnabled,
"dropdown-menu-button--mobile": device.isMobile,
},
).trim();
@ -28,6 +28,8 @@ const MenuTrigger = ({
onClick={onToggle}
type="button"
data-testid="dropdown-menu-button"
title={title}
{...rest}
>
{children}
</button>

@ -0,0 +1,100 @@
import { render, queryAllByTestId } from "../../tests/test-utils";
import { Excalidraw, MainMenu } from "../../packages/excalidraw/index";
describe("Test internal component fallback rendering", () => {
it("should render only one menu per excalidraw instance (custom menu first scenario)", async () => {
const { container } = await render(
<div>
<Excalidraw>
<MainMenu>test</MainMenu>
</Excalidraw>
<Excalidraw />
</div>,
);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1);
});
it("should render only one menu per excalidraw instance (default menu first scenario)", async () => {
const { container } = await render(
<div>
<Excalidraw />
<Excalidraw>
<MainMenu>test</MainMenu>
</Excalidraw>
</div>,
);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1);
});
it("should render only one menu per excalidraw instance (two custom menus scenario)", async () => {
const { container } = await render(
<div>
<Excalidraw>
<MainMenu>test</MainMenu>
</Excalidraw>
<Excalidraw>
<MainMenu>test</MainMenu>
</Excalidraw>
</div>,
);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1);
});
it("should render only one menu per excalidraw instance (two default menus scenario)", async () => {
const { container } = await render(
<div>
<Excalidraw />
<Excalidraw />
</div>,
);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1);
});
});

@ -1,5 +1,5 @@
import { atom, useAtom } from "jotai";
import React, { useLayoutEffect } from "react";
import React, { useLayoutEffect, useRef } from "react";
import { useTunnels } from "../../context/tunnels";
export const withInternalFallback = <P,>(
@ -7,13 +7,6 @@ export const withInternalFallback = <P,>(
Component: React.FC<P>,
) => {
const renderAtom = atom(0);
// flag set on initial render to tell the fallback component to skip the
// render until mount counter are initialized. This is because the counter
// is initialized in an effect, and thus we could end rendering both
// components at the same time until counter is initialized.
let preferHost = false;
let counter = 0;
const WrapperComponent: React.FC<
P & {
@ -21,38 +14,52 @@ export const withInternalFallback = <P,>(
}
> = (props) => {
const { jotaiScope } = useTunnels();
const [, setRender] = useAtom(renderAtom, jotaiScope);
// for rerenders
const [, setCounter] = useAtom(renderAtom, jotaiScope);
// for initial & subsequent renders. Tracked as component state
// due to excalidraw multi-instance scanerios.
const metaRef = useRef({
// flag set on initial render to tell the fallback component to skip the
// render until mount counter are initialized. This is because the counter
// is initialized in an effect, and thus we could end rendering both
// components at the same time until counter is initialized.
preferHost: false,
counter: 0,
});
useLayoutEffect(() => {
setRender((c) => {
const meta = metaRef.current;
setCounter((c) => {
const next = c + 1;
counter = next;
meta.counter = next;
return next;
});
return () => {
setRender((c) => {
setCounter((c) => {
const next = c - 1;
counter = next;
meta.counter = next;
if (!next) {
preferHost = false;
meta.preferHost = false;
}
return next;
});
};
}, [setRender]);
}, [setCounter]);
if (!props.__fallback) {
preferHost = true;
metaRef.current.preferHost = true;
}
// ensure we don't render fallback and host components at the same time
if (
// either before the counters are initialized
(!counter && props.__fallback && preferHost) ||
(!metaRef.current.counter &&
props.__fallback &&
metaRef.current.preferHost) ||
// or after the counters are initialized, and both are rendered
// (this is the default when host renders as well)
(counter > 1 && props.__fallback)
(metaRef.current.counter > 1 && props.__fallback)
) {
return null;
}

@ -1550,3 +1550,90 @@ export const handIcon = createIcon(
</g>,
tablerIconProps,
);
export const downloadIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"></path>
<path d="M7 11l5 5l5 -5"></path>
<path d="M12 4l0 12"></path>
</>,
tablerIconProps,
);
export const copyIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"></path>
<path d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"></path>
</>,
tablerIconProps,
);
export const helpIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"></path>
<path d="M12 17l0 .01"></path>
<path d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"></path>
</>,
tablerIconProps,
);
export const playerPlayIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M7 4v16l13 -8z"></path>
</>,
tablerIconProps,
);
export const playerStopFilledIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M17 4h-10a3 3 0 0 0 -3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3 -3v-10a3 3 0 0 0 -3 -3z"
strokeWidth="0"
fill="currentColor"
></path>
</>,
tablerIconProps,
);
export const tablerCheckIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path>
</>,
tablerIconProps,
);
export const eyeDropperIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M11 7l6 6"></path>
<path d="M4 16l11.7 -11.7a1 1 0 0 1 1.4 0l2.6 2.6a1 1 0 0 1 0 1.4l-11.7 11.7h-4v-4z"></path>
</g>,
tablerIconProps,
);
export const extraToolsIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3l-4 7h8z"></path>
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"></path>
</g>,
tablerIconProps,
);
export const frameToolIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 7l16 0"></path>
<path d="M4 17l16 0"></path>
<path d="M7 4l0 16"></path>
<path d="M17 4l0 16"></path>
</g>,
tablerIconProps,
);

@ -42,6 +42,7 @@ const MainMenu = Object.assign(
openMenu: appState.openMenu === "canvas" ? null : "canvas",
});
}}
data-testid="main-menu-trigger"
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>

@ -1,7 +1,7 @@
import cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types";
import { ExcalidrawElement, FontFamilyValues } from "./element/types";
import oc from "open-color";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
@ -59,6 +59,7 @@ export enum EVENT {
GESTURE_START = "gesturestart",
GESTURE_CHANGE = "gesturechange",
POINTER_MOVE = "pointermove",
POINTER_DOWN = "pointerdown",
POINTER_UP = "pointerup",
STATE_CHANGE = "statechange",
WHEEL = "wheel",
@ -93,6 +94,17 @@ export const THEME = {
DARK: "dark",
};
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
roughness: 0 as ExcalidrawElement["roughness"],
roundness: null as ExcalidrawElement["roundness"],
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
radius: 8,
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const DEFAULT_FONT_SIZE = 20;
@ -272,8 +284,8 @@ export const DEFAULT_ELEMENT_PROPS: {
opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"];
} = {
strokeColor: oc.black,
backgroundColor: "transparent",
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",

@ -103,6 +103,8 @@
--color-danger: #db6965;
--color-promo: #e70078;
--color-success: #268029;
--color-success-lighter: #cafccc;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;

@ -22,6 +22,7 @@ import {
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
@ -115,6 +116,20 @@ class Library {
}
};
/** call on excalidraw instance unmount */
destroy = () => {
this.isInitialized = false;
this.updateQueue = [];
this.lastLibraryItems = [];
jotaiStore.set(libraryItemSvgsCache, new Map());
// TODO uncomment after/if we make jotai store scoped to each excal instance
// jotaiStore.set(libraryItemsAtom, {
// status: "loading",
// isInitialized: false,
// libraryItems: [],
// });
};
resetLibrary = () => {
return this.setLibrary([]);
};

@ -35,13 +35,13 @@ import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import { isValidSubtype } from "../subtypes";
import oc from "open-color";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getDefaultLineHeight,
measureTextElement,
} from "../element/textElement";
import { COLOR_PALETTE } from "../colors";
type RestoredAppState = Omit<
AppState,
@ -63,6 +63,7 @@ export const AllowedExcalidrawActiveTools: Record<
freedraw: true,
eraser: false,
custom: true,
frame: true,
hand: true,
};
@ -121,12 +122,13 @@ const restoreElementWithProperties = <
angle: element.angle || 0,
x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor || oc.black,
backgroundColor: element.backgroundColor || "transparent",
strokeColor: element.strokeColor || COLOR_PALETTE.black,
backgroundColor: element.backgroundColor || COLOR_PALETTE.transparent,
width: element.width || 0,
height: element.height || 0,
seed: element.seed ?? 1,
groupIds: element.groupIds ?? [],
frameId: element.frameId ?? null,
roundness: element.roundness
? element.roundness
: element.strokeSharpness === "round"
@ -273,6 +275,10 @@ const restoreElement = (
return restoreElementWithProperties(element, {});
case "diamond":
return restoreElementWithProperties(element, {});
case "frame":
return restoreElementWithProperties(element, {
name: element.name ?? null,
});
// Don't use default case so as to catch a missing an element type case.
// We also don't want to throw, but instead return void so we filter

@ -344,7 +344,7 @@ export const isPointHittingLinkIcon = (
if (
!isMobile &&
appState.viewModeEnabled &&
isPointHittingElementBoundingBox(element, [x, y], threshold)
isPointHittingElementBoundingBox(element, [x, y], threshold, null)
) {
return true;
}
@ -440,7 +440,9 @@ export const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box
if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
if (
isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
) {
return false;
}
const [x1, y1, x2] = getElementAbsoluteCoords(element);

@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
];
export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLCanvasElement>,
event: React.PointerEvent<HTMLElement>,
) => {
return !event[KEYS.CTRL_OR_CMD];
};

@ -6,7 +6,7 @@ import {
NonDeleted,
ExcalidrawTextElementWithContainer,
} from "./types";
import { distance2d, rotate } from "../math";
import { distance2d, rotate, rotatePoint } from "../math";
import rough from "roughjs/bin/rough";
import { Drawable, Op } from "roughjs/bin/core";
import { Point } from "../types";
@ -25,10 +25,101 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types";
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number];
export type RectangleBox = {
x: number;
y: number;
width: number;
height: number;
angle: number;
};
type MaybeQuadraticSolution = [number | null, number | null] | false;
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
export class ElementBounds {
private static boundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement) {
const cachedBounds = ElementBounds.boundsCache.get(element);
if (cachedBounds?.version && cachedBounds.version === element.version) {
return cachedBounds.bounds;
}
const bounds = ElementBounds.calculateBounds(element);
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
return bounds;
}
private static calculateBounds(element: ExcalidrawElement): Bounds {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
),
);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
const cos = Math.cos(element.angle);
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
}
return bounds;
}
}
// Scene -> Scene coords, but in x1,x2,y1,y2 format.
//
// If the element is created from right to left, the width is going to be negative
// This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = (
@ -69,6 +160,111 @@ export const getElementAbsoluteCoords = (
];
};
/**
* for a given element, `getElementLineSegments` returns line segments
* that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection
*/
export const getElementLineSegments = (
element: ExcalidrawElement,
): [Point, Point][] => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
const center: Point = [cx, cy];
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: [Point, Point][] = [];
let i = 0;
while (i < element.points.length - 1) {
segments.push([
rotatePoint(
[
element.points[i][0] + element.x,
element.points[i][1] + element.y,
] as Point,
center,
element.angle,
),
rotatePoint(
[
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
] as Point,
center,
element.angle,
),
]);
i++;
}
return segments;
}
const [nw, ne, sw, se, n, s, w, e] = (
[
[x1, y1],
[x2, y1],
[x1, y2],
[x2, y2],
[cx, y1],
[cx, y2],
[x1, cy],
[x2, cy],
] as Point[]
).map((point) => rotatePoint(point, center, element.angle));
if (element.type === "diamond") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
];
}
if (element.type === "ellipse") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
[n, w],
[n, e],
[s, w],
[s, e],
];
}
return [
[nw, ne],
[sw, se],
[nw, sw],
[ne, se],
[nw, e],
[sw, e],
[ne, w],
[se, w],
];
};
/**
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
*
* Rectangle here means any rectangular frame, not an excalidraw element.
*/
export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
return [
boxSceneCoords.x,
boxSceneCoords.y,
boxSceneCoords.x + boxSceneCoords.width,
boxSceneCoords.y + boxSceneCoords.height,
boxSceneCoords.x + boxSceneCoords.width / 2,
boxSceneCoords.y + boxSceneCoords.height / 2,
];
};
export const pointRelativeTo = (
element: ExcalidrawElement,
absoluteCoords: Point,
@ -454,64 +650,12 @@ const getLinearElementRotatedBounds = (
return coords;
};
// We could cache this stuff
export const getElementBounds = (
element: ExcalidrawElement,
): [number, number, number, number] => {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
),
);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
const cos = Math.cos(element.angle);
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
}
return bounds;
export const getElementBounds = (element: ExcalidrawElement): Bounds => {
return ElementBounds.getBounds(element);
};
export const getCommonBounds = (
elements: readonly ExcalidrawElement[],
): [number, number, number, number] => {
): Bounds => {
if (!elements.length) {
return [0, 0, 0, 0];
}
@ -608,7 +752,7 @@ export const getElementPointsCoords = (
export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[],
from: { x: number; y: number },
): [number, number, number, number] => {
): Bounds => {
if (!elements.length) {
return [0, 0, 0, 0];
}
@ -629,7 +773,7 @@ export const getClosestElementBounds = (
return getElementBounds(closestElement);
};
export interface Box {
export interface BoundingBox {
minX: number;
minY: number;
maxX: number;
@ -642,7 +786,7 @@ export interface Box {
export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
): Box => {
): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return {
minX,

@ -26,10 +26,16 @@ import {
ExcalidrawImageElement,
ExcalidrawLinearElement,
StrokeRoundness,
ExcalidrawFrameElement,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
import { Point } from "../types";
import {
getElementAbsoluteCoords,
getCurvePathOps,
getRectangleBoxAbsoluteCoords,
RectangleBox,
} from "./bounds";
import { FrameNameBoundsCache, Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
@ -61,6 +67,7 @@ const isElementDraggableFromInside = (
export const hitTest = (
element: NonDeletedExcalidrawElement,
appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache,
x: number,
y: number,
): boolean => {
@ -72,22 +79,39 @@ export const hitTest = (
isElementSelected(appState, element) &&
shouldShowBoundingBox([element], appState)
) {
return isPointHittingElementBoundingBox(element, point, threshold);
return isPointHittingElementBoundingBox(
element,
point,
threshold,
frameNameBoundsCache,
);
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
const isHittingBoundTextElement = hitTest(
boundTextElement,
appState,
frameNameBoundsCache,
x,
y,
);
if (isHittingBoundTextElement) {
return true;
}
}
return isHittingElementNotConsideringBoundingBox(element, appState, point);
return isHittingElementNotConsideringBoundingBox(
element,
appState,
frameNameBoundsCache,
point,
);
};
export const isHittingElementBoundingBoxWithoutHittingElement = (
element: NonDeletedExcalidrawElement,
appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache,
x: number,
y: number,
): boolean => {
@ -96,19 +120,33 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
// So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
// eg for linear elements text can be outside the element bounding box
const boundTextElement = getBoundTextElement(element);
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
if (
boundTextElement &&
hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
) {
return false;
}
return (
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
isPointHittingElementBoundingBox(element, [x, y], threshold)
!isHittingElementNotConsideringBoundingBox(
element,
appState,
frameNameBoundsCache,
[x, y],
) &&
isPointHittingElementBoundingBox(
element,
[x, y],
threshold,
frameNameBoundsCache,
)
);
};
export const isHittingElementNotConsideringBoundingBox = (
element: NonDeletedExcalidrawElement,
appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache | null,
point: Point,
): boolean => {
const threshold = 10 / appState.zoom.value;
@ -117,7 +155,13 @@ export const isHittingElementNotConsideringBoundingBox = (
: isElementDraggableFromInside(element)
? isInsideCheck
: isNearCheck;
return hitTestPointAgainstElement({ element, point, threshold, check });
return hitTestPointAgainstElement({
element,
point,
threshold,
check,
frameNameBoundsCache,
});
};
const isElementSelected = (
@ -129,7 +173,22 @@ export const isPointHittingElementBoundingBox = (
element: NonDeleted<ExcalidrawElement>,
[x, y]: Point,
threshold: number,
frameNameBoundsCache: FrameNameBoundsCache | null,
) => {
// frames needs be checked differently so as to be able to drag it
// by its frame, whether it has been selected or not
// this logic here is not ideal
// TODO: refactor it later...
if (element.type === "frame") {
return hitTestPointAgainstElement({
element,
point: [x, y],
threshold,
check: isInsideCheck,
frameNameBoundsCache,
});
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCenterX = (x1 + x2) / 2;
const elementCenterY = (y1 + y2) / 2;
@ -157,7 +216,13 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height);
const check = isOutsideCheck;
const point: Point = [x, y];
return hitTestPointAgainstElement({ element, point, threshold, check });
return hitTestPointAgainstElement({
element,
point,
threshold,
check,
frameNameBoundsCache: null,
});
};
export const maxBindingGap = (
@ -177,6 +242,7 @@ type HitTestArgs = {
point: Point;
threshold: number;
check: (distance: number, threshold: number) => boolean;
frameNameBoundsCache: FrameNameBoundsCache | null;
};
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
@ -208,6 +274,27 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
"This should not happen, we need to investigate why it does.",
);
return false;
case "frame": {
// check distance to frame element first
if (
args.check(
distanceToBindableElement(args.element, args.point),
args.threshold,
)
) {
return true;
}
const frameNameBounds = args.frameNameBoundsCache?.get(args.element);
if (frameNameBounds) {
return args.check(
distanceToRectangleBox(frameNameBounds, args.point),
args.threshold,
);
}
return false;
}
}
};
@ -219,6 +306,7 @@ export const distanceToBindableElement = (
case "rectangle":
case "image":
case "text":
case "frame":
return distanceToRectangle(element, point);
case "diamond":
return distanceToDiamond(element, point);
@ -248,7 +336,8 @@ const distanceToRectangle = (
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement,
| ExcalidrawImageElement
| ExcalidrawFrameElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@ -258,6 +347,14 @@ const distanceToRectangle = (
);
};
const distanceToRectangleBox = (box: RectangleBox, point: Point): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box);
return Math.max(
GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
);
};
const distanceToDiamond = (
element: ExcalidrawDiamondElement,
point: Point,
@ -457,8 +554,7 @@ const pointRelativeToElement = (
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
const center = coordsCenter(x1, y1, x2, y2);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const pointRotated = GATransform.apply(rotate, point);
@ -466,9 +562,26 @@ const pointRelativeToElement = (
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
const elementPos = GA.offset(element.x, element.y);
const pointRelToPos = GA.sub(pointRotated, elementPos);
const [ax, ay, bx, by] = elementCoords;
const halfWidth = (bx - ax) / 2;
const halfHeight = (by - ay) / 2;
const halfWidth = (x2 - x1) / 2;
const halfHeight = (y2 - y1) / 2;
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
};
const pointRelativeToDivElement = (
pointTuple: Point,
rectangle: RectangleBox,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle);
const center = coordsCenter(x1, y1, x2, y2);
const rotate = GATransform.rotation(center, rectangle.angle);
const pointRotated = GATransform.apply(rotate, point);
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
const elementPos = GA.offset(rectangle.x, rectangle.y);
const pointRelToPos = GA.sub(pointRotated, elementPos);
const halfWidth = (x2 - x1) / 2;
const halfHeight = (y2 - y1) / 2;
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
};
@ -490,7 +603,7 @@ const relativizationToElementCenter = (
element: ExcalidrawElement,
): GA.Transform => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
const center = coordsCenter(x1, y1, x2, y2);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const translate = GA.reverse(
@ -499,8 +612,13 @@ const relativizationToElementCenter = (
return GATransform.compose(rotate, translate);
};
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
return GA.point((ax + bx) / 2, (ay + by) / 2);
const coordsCenter = (
x1: number,
y1: number,
x2: number,
y2: number,
): GA.Point => {
return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
};
// The focus distance is the oriented ratio between the size of
@ -531,6 +649,7 @@ export const determineFocusDistance = (
case "rectangle":
case "image":
case "text":
case "frame":
return c / (hwidth * (nabs + q * mabs));
case "diamond":
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
@ -548,7 +667,7 @@ export const determineFocusPoint = (
): Point => {
if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
const center = coordsCenter(x1, y1, x2, y2);
return GAPoint.toTuple(center);
}
const relateToCenter = relativizationToElementCenter(element);
@ -563,6 +682,7 @@ export const determineFocusPoint = (
case "image":
case "text":
case "diamond":
case "frame":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break;
case "ellipse":
@ -613,6 +733,7 @@ const getSortedElementLineIntersections = (
case "image":
case "text":
case "diamond":
case "frame":
const corners = getCorners(element);
intersections = corners
.flatMap((point, i) => {
@ -646,7 +767,8 @@ const getCorners = (
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
| ExcalidrawTextElement
| ExcalidrawFrameElement,
scale: number = 1,
): GA.Point[] => {
const hx = (scale * element.width) / 2;
@ -655,6 +777,7 @@ const getCorners = (
case "rectangle":
case "image":
case "text":
case "frame":
return [
GA.point(hx, hy),
GA.point(hx, -hy),
@ -802,7 +925,8 @@ export const findFocusPointForRectangulars = (
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
| ExcalidrawTextElement
| ExcalidrawFrameElement,
// Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation.
relativeDistance: number,

@ -6,6 +6,8 @@ import { NonDeletedExcalidrawElement } from "./types";
import { AppState, PointerDownState } from "../types";
import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups";
import Scene from "../scene/Scene";
import { isFrameElement } from "./typeChecks";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@ -16,10 +18,31 @@ export const dragSelectedElements = (
distanceX: number = 0,
distanceY: number = 0,
appState: AppState,
scene: Scene,
) => {
const [x1, y1] = getCommonBounds(selectedElements);
const offset = { x: pointerX - x1, y: pointerY - y1 };
selectedElements.forEach((element) => {
// we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
selectedElements,
);
const frames = selectedElements
.filter((e) => isFrameElement(e))
.map((f) => f.id);
if (frames.length > 0) {
const elementsInFrames = scene
.getNonDeletedElements()
.filter((e) => e.frameId !== null)
.filter((e) => frames.includes(e.frameId!));
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
}
elementsToUpdate.forEach((element) => {
updateElementCoords(
lockDirection,
distanceX,
@ -38,7 +61,13 @@ export const dragSelectedElements = (
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
) {
const textElement = getBoundTextElement(element);
if (textElement) {
if (
textElement &&
// when container is added to a frame, so will its bound text
// so the text is already in `elementsToUpdate` and we should avoid
// updating its coords again
(!textElement.frameId || !frames.includes(textElement.frameId))
) {
updateElementCoords(
lockDirection,
distanceX,
@ -50,7 +79,7 @@ export const dragSelectedElements = (
}
}
updateBoundElements(element, {
simultaneouslyUpdated: selectedElements,
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
});
};

@ -2,6 +2,7 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawFrameElement,
} from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks";
@ -49,7 +50,11 @@ export {
getDragOffsetXY,
dragNewElement,
} from "./dragElements";
export { isTextElement, isExcalidrawElement } from "./typeChecks";
export {
isTextElement,
isExcalidrawElement,
isFrameElement,
} from "./typeChecks";
export { textWysiwyg } from "./textWysiwyg";
export { redrawTextBoundingBox } from "./textElement";
export {
@ -74,6 +79,13 @@ export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
(element) => !element.isDeleted,
) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedFrames = (
frames: readonly ExcalidrawFrameElement[],
) =>
frames.filter(
(frame) => !frame.isDeleted,
) as readonly NonDeleted<ExcalidrawFrameElement>[];
export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T,
): element is NonDeleted<T> => !element.isDeleted;

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