feat: color picker redesign (#6216)

Co-authored-by: Maielo <maielo.mv@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
This commit is contained in:
Barnabás Molnár 2023-05-18 16:06:27 +02:00 committed by GitHub
parent 6977c32631
commit 5b7596582f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 4010 additions and 2699 deletions

1
.npmrc
View File

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

View File

@ -19,6 +19,7 @@
]
},
"dependencies": {
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",

View File

@ -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,7 @@ import {
isEraserActive,
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@ -35,24 +36,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}
/>
);
},
});

View File

@ -14,7 +14,7 @@ import {
} from "../element/bounds";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { KEYS } from "../keys";
import { CODES, KEYS } from "../keys";
const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[],
@ -48,7 +48,7 @@ export const actionFlipHorizontal = register({
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),
@ -65,7 +65,7 @@ export const actionFlipVertical = register({
};
},
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),

View File

@ -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,
@ -226,10 +232,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 +247,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 +274,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 +289,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}
/>
</>
),

View File

@ -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");

View File

@ -1,4 +1,4 @@
import oc from "open-color";
import { COLOR_PALETTE } from "./colors";
import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
@ -84,7 +84,7 @@ export const getDefaultAppState = (): Omit<
startBoundElement: null,
suggestedBindings: [],
toast: null,
viewBackgroundColor: oc.white,
viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false,
zoom: {
value: 1 as NormalizedZoomValue,

View File

@ -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";
@ -153,15 +162,22 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
return result;
};
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) => {
@ -322,7 +338,7 @@ const chartBaseElements = (
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
strokeColor: colors.elementStroke[0],
strokeColor: COLOR_PALETTE.black,
fillStyle: "solid",
opacity: 6,
})

View File

@ -1,6 +1,17 @@
import colors from "./colors";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
getAllColorsSpecificShade,
} from "./colors";
import { AppState } from "./types";
const BG_COLORS = getAllColorsSpecificShade(
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
);
const STROKE_COLORS = getAllColorsSpecificShade(
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
);
export const getClientColors = (clientId: string, appState: AppState) => {
if (appState?.collaborators) {
const currentUser = appState.collaborators.get(clientId);
@ -11,12 +22,9 @@ export const getClientColors = (clientId: string, appState: AppState) => {
// Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
// 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],
background: BG_COLORS[sum % BG_COLORS.length],
stroke: STROKE_COLORS[sum % STROKE_COLORS.length],
};
};

View File

@ -1,22 +1,167 @@
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;
// -----------------------------------------------------------------------------

View File

@ -313,6 +313,7 @@ const deviceContextInitialValue = {
isMobile: false,
isTouchScreen: false,
canDeviceFitSidebar: false,
isLandscape: false,
};
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
DeviceContext.displayName = "DeviceContext";
@ -947,6 +948,7 @@ class App extends React.Component<AppProps, AppState> {
? this.props.UIOptions.dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
this.device = updateObject(this.device, {
isLandscape: width > height,
isSmScreen: width < MQ_SM_MAX_WIDTH,
isMobile:
width < MQ_MAX_WIDTH_PORTRAIT ||
@ -2323,11 +2325,11 @@ class App extends React.Component<AppProps, AppState> {
(hasBackground(this.state.activeTool.type) ||
selectedElements.some((element) => hasBackground(element.type)))
) {
this.setState({ openPopup: "backgroundColorPicker" });
this.setState({ openPopup: "elementBackground" });
event.stopPropagation();
}
if (event.key === KEYS.S) {
this.setState({ openPopup: "strokeColorPicker" });
this.setState({ openPopup: "elementStroke" });
event.stopPropagation();
}
}

View File

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

View File

@ -0,0 +1,75 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker";
import { useAtom } from "jotai";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { KEYS } from "../../keys";
interface ColorInputProps {
color: string | null;
onChange: (color: string) => void;
label: string;
}
export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
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 divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [activeSection]);
return (
<label 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={(e) => {
if (e.key === KEYS.TAB) {
return;
}
if (e.key === KEYS.ESCAPE) {
divRef.current?.focus();
}
e.stopPropagation();
}}
/>
</label>
);
};

View File

@ -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,17 @@
}
}
.color-picker-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.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 +312,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%;

View File

@ -0,0 +1,235 @@
import { isTransparent } 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 { ColorInput } from "./ColorInput";
import { t } from "../../i18n";
import clsx from "clsx";
import "./ColorPicker.scss";
import React from "react";
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;
};
export interface ColorPickerProps {
type: ColorPickerType;
color: string | null;
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"
| "label"
| "elements"
| "palette"
| "updateData"
>) => {
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
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>
);
return (
<Popover.Portal container={container}>
<Popover.Content
className="focus-visible-none"
data-prevent-outside-click
onCloseAutoFocus={(e) => {
// return focus to excalidraw container
if (container) {
container.focus();
}
e.preventDefault();
e.stopPropagation();
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 || null}
onChange={(changedColor) => {
onChange(changedColor);
}}
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 | null;
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>
);
};

View File

@ -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 | null;
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>
);
};

View File

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

View File

@ -0,0 +1,156 @@
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,
getColorNameAndShadeFromHex,
getMostUsedCustomColors,
isCustomColor,
} from "./colorPickerUtils";
import {
ColorPaletteCustom,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
} from "../../colors";
interface PickerProps {
color: string | null;
onChange: (color: string) => void;
label: string;
type: ColorPickerType;
elements: readonly ExcalidrawElement[];
palette: ColorPaletteCustom;
updateData: (formData?: any) => void;
children?: React.ReactNode;
}
export const Picker = ({
color,
onChange,
label,
type,
elements,
palette,
updateData,
children,
}: PickerProps) => {
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
return [];
}
return getMostUsedCustomColors(elements, type, palette);
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const colorObj = getColorNameAndShadeFromHex({
hex: color || "transparent",
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);
}
}, [colorObj]);
return (
<div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
<div
onKeyDown={(e) => {
e.preventDefault();
e.stopPropagation();
colorPickerKeyNavHandler({
e,
activeColorPickerSection,
palette,
hex: color,
onChange,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
});
}}
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>
);
};

View File

@ -0,0 +1,86 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
colorPickerHotkeyBindings,
getColorNameAndShadeFromHex,
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { ColorPaletteCustom } from "../../colors";
import { t } from "../../i18n";
interface PickerColorListProps {
palette: ColorPaletteCustom;
color: string | null;
onChange: (color: string) => void;
label: string;
activeShade: number;
}
const PickerColorList = ({
palette,
color,
onChange,
label,
activeShade,
}: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromHex({
hex: 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;

View File

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

View File

@ -0,0 +1,105 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
getColorNameAndShadeFromHex,
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { t } from "../../i18n";
import { ColorPaletteCustom } from "../../colors";
interface ShadeListProps {
hex: string | null;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
}
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromHex({
hex: 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>
);
};

View File

@ -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 | null;
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>
);
};

View File

@ -0,0 +1,139 @@
import { ExcalidrawElement } from "../../element/types";
import { atom } from "jotai";
import {
ColorPickerColor,
ColorPaletteCustom,
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
} from "../../colors";
export const getColorNameAndShadeFromHex = ({
palette,
hex,
}: {
palette: ColorPaletteCustom;
hex: string;
}): {
colorName: ColorPickerColor;
shade: number | null;
} | null => {
for (const [colorName, colorVal] of Object.entries(palette)) {
if (Array.isArray(colorVal)) {
const shade = colorVal.indexOf(hex);
if (shade > -1) {
return { colorName: colorName as ColorPickerColor, shade };
}
} else if (colorVal === hex) {
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 | null;
palette: ColorPaletteCustom;
}) => {
if (!color) {
return false;
}
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";

View File

@ -0,0 +1,249 @@
import {
ColorPickerColor,
ColorPalette,
ColorPaletteCustom,
COLORS_PER_ROW,
COLOR_PALETTE,
} from "../../colors";
import { KEYS } from "../../keys";
import { ValueOf } from "../../utility-types";
import {
ActiveColorPickerSectionAtomType,
colorPickerHotkeyBindings,
getColorNameAndShadeFromHex,
} 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;
}
const hotkeyHandler = ({
e,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
}: HotkeyHandlerProps) => {
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");
}
}
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");
}
}
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");
}
};
interface ColorPickerKeyNavHandlerProps {
e: React.KeyboardEvent;
activeColorPickerSection: ActiveColorPickerSectionAtomType;
palette: ColorPaletteCustom;
hex: string | null;
onChange: (color: string) => void;
customColors: string[];
setActiveColorPickerSection: (
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
) => void;
updateData: (formData?: any) => void;
activeShade: number;
}
export const colorPickerKeyNavHandler = ({
e,
activeColorPickerSection,
palette,
hex,
onChange,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
}: ColorPickerKeyNavHandlerProps) => {
if (e.key === KEYS.ESCAPE || !hex) {
updateData({ openPopup: null });
return;
}
const colorObj = getColorNameAndShadeFromHex({ hex, palette });
if (e.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 = e.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(hex);
} else if (shades === hex) {
return name;
}
return null;
});
if (!baseColorName) {
onChange(COLOR_PALETTE.black);
}
}
e.preventDefault();
e.stopPropagation();
return;
}
hotkeyHandler({
e,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
});
if (activeColorPickerSection === "shades") {
if (colorObj) {
const { shade } = colorObj;
const newShade = arrowHandler(e.key, shade, COLORS_PER_ROW);
if (newShade !== undefined) {
onChange(palette[colorObj.colorName][newShade]);
}
}
}
if (activeColorPickerSection === "baseColors") {
if (colorObj) {
const { colorName } = colorObj;
const colorNames = Object.keys(palette) as (keyof ColorPalette)[];
const indexOfColorName = colorNames.indexOf(colorName);
const newColorIndex = arrowHandler(
e.key,
indexOfColorName,
colorNames.length,
);
if (newColorIndex !== undefined) {
const newColorName = colorNames[newColorIndex];
const newColorNameValue = palette[newColorName];
onChange(
Array.isArray(newColorNameValue)
? newColorNameValue[activeShade]
: newColorNameValue,
);
}
}
}
if (activeColorPickerSection === "custom") {
const indexOfColor = customColors.indexOf(hex);
const newColorIndex = arrowHandler(
e.key,
indexOfColor,
customColors.length,
);
if (newColorIndex !== undefined) {
const newColor = customColors[newColorIndex];
onChange(newColor);
}
}
};

View File

@ -1,5 +1,4 @@
import clsx from "clsx";
import oc from "open-color";
import { useEffect, useRef, useState } from "react";
import { useDevice } from "../components/App";
import { exportToSvg } from "../packages/utils";
@ -7,6 +6,7 @@ import { LibraryItem } from "../types";
import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem";
import { PlusIcon } from "./icons";
import { COLOR_PALETTE } from "../colors";
export const LibraryUnit = ({
id,
@ -40,7 +40,7 @@ export const LibraryUnit = ({
elements,
appState: {
exportBackground: false,
viewBackgroundColor: oc.white,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
});

View File

@ -5,6 +5,7 @@ import { focusNearestParent } from "../utils";
import "./ProjectName.scss";
import { useExcalidrawContainer } from "./App";
import { KEYS } from "../keys";
type Props = {
value: string;
@ -26,7 +27,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;

View File

@ -48,7 +48,7 @@ const MenuContent = ({
<Island
className="dropdown-menu-container"
padding={2}
style={{ zIndex: 1 }}
style={{ zIndex: 2 }}
>
{children}
</Island>

View File

@ -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);
@ -272,8 +272,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",

View File

@ -34,13 +34,13 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import oc from "open-color";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getDefaultLineHeight,
measureBaseline,
} from "../element/textElement";
import { COLOR_PALETTE } from "../colors";
type RestoredAppState = Omit<
AppState,
@ -119,8 +119,8 @@ 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,

View File

@ -1521,7 +1521,7 @@ describe("textWysiwyg", () => {
roundness: {
type: 3,
},
strokeColor: "#000000",
strokeColor: "#1e1e1e",
strokeStyle: "solid",
strokeWidth: 1,
type: "rectangle",

View File

@ -636,20 +636,46 @@ export const textWysiwyg = ({
// in that same tick.
const target = event?.target;
const isTargetColorPicker =
target instanceof HTMLInputElement &&
target.closest(".color-picker-input") &&
isWritableElement(target);
const isTargetPickerTrigger =
target instanceof HTMLElement &&
target.classList.contains("active-color");
setTimeout(() => {
editable.onblur = handleSubmit;
if (target && isTargetColorPicker) {
target.onblur = () => {
editable.focus();
if (isTargetPickerTrigger) {
const callback = (
mutationList: MutationRecord[],
observer: MutationObserver,
) => {
const radixIsRemoved = mutationList.find(
(mutation) =>
mutation.removedNodes.length > 0 &&
(mutation.removedNodes[0] as HTMLElement).dataset
?.radixPopperContentWrapper !== undefined,
);
if (radixIsRemoved) {
// should work without this in theory
// and i think it does actually but radix probably somewhere,
// somehow sets the focus elsewhere
setTimeout(() => {
editable.focus();
});
observer.disconnect();
}
};
const observer = new MutationObserver(callback);
observer.observe(document.querySelector(".excalidraw-container")!, {
childList: true,
});
}
// case: clicking on the same property → no change → no update → no focus
if (!isTargetColorPicker) {
if (!isTargetPickerTrigger) {
editable.focus();
}
});
@ -657,16 +683,16 @@ export const textWysiwyg = ({
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
const isTargetColorPicker =
event.target instanceof HTMLInputElement &&
event.target.closest(".color-picker-input") &&
isWritableElement(event.target);
const isTargetPickerTrigger =
event.target instanceof HTMLElement &&
event.target.classList.contains("active-color");
if (
((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
!isWritableElement(event.target)) ||
isTargetColorPicker
isTargetPickerTrigger
) {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
@ -680,7 +706,7 @@ export const textWysiwyg = ({
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
updateWysiwygStyle();
const isColorPickerActive = !!document.activeElement?.closest(
".color-picker-input",
".color-picker-content",
);
if (!isColorPickerActive) {
editable.focus();

View File

@ -17,6 +17,7 @@ import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
import DialogActionButton from "../../components/DialogActionButton";
import { useI18n } from "../../i18n";
import { KEYS } from "../../keys";
const getShareIcon = () => {
const navigator = window.navigator as any;
@ -148,7 +149,9 @@ const RoomDialog = ({
value={username.trim() || ""}
className="RoomDialog-username TextInput"
onChange={(event) => onUsernameChange(event.target.value)}
onKeyPress={(event) => event.key === "Enter" && handleClose()}
onKeyPress={(event) =>
event.key === KEYS.ENTER && handleClose()
}
/>
</div>
<p>

View File

@ -124,7 +124,8 @@ const findPartsForData = (data: any, parts: string[]) => {
export const t = (
path: string,
replacement?: { [key: string]: string | number },
replacement?: { [key: string]: string | number } | null,
fallback?: string,
) => {
if (currentLang.code.startsWith(TEST_LANG_CODE)) {
const name = replacement
@ -136,9 +137,16 @@ export const t = (
const parts = path.split(".");
let translation =
findPartsForData(currentLangData, parts) ||
findPartsForData(fallbackLangData, parts);
findPartsForData(fallbackLangData, parts) ||
fallback;
if (translation === undefined) {
throw new Error(`Can't find translation for ${path}`);
const errorMessage = `Can't find translation for ${path}`;
// in production, don't blow up the app on a missing translation key
if (process.env.NODE_ENV === "production") {
console.warn(errorMessage);
return "";
}
throw new Error(errorMessage);
}
if (replacement) {

View File

@ -394,51 +394,21 @@
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor"
},
"colors": {
"ffffff": "White",
"f8f9fa": "Gray 0",
"f1f3f5": "Gray 1",
"fff5f5": "Red 0",
"fff0f6": "Pink 0",
"f8f0fc": "Grape 0",
"f3f0ff": "Violet 0",
"edf2ff": "Indigo 0",
"e7f5ff": "Blue 0",
"e3fafc": "Cyan 0",
"e6fcf5": "Teal 0",
"ebfbee": "Green 0",
"f4fce3": "Lime 0",
"fff9db": "Yellow 0",
"fff4e6": "Orange 0",
"transparent": "Transparent",
"ced4da": "Gray 4",
"868e96": "Gray 6",
"fa5252": "Red 6",
"e64980": "Pink 6",
"be4bdb": "Grape 6",
"7950f2": "Violet 6",
"4c6ef5": "Indigo 6",
"228be6": "Blue 6",
"15aabf": "Cyan 6",
"12b886": "Teal 6",
"40c057": "Green 6",
"82c91e": "Lime 6",
"fab005": "Yellow 6",
"fd7e14": "Orange 6",
"000000": "Black",
"343a40": "Gray 8",
"495057": "Gray 7",
"c92a2a": "Red 9",
"a61e4d": "Pink 9",
"862e9c": "Grape 9",
"5f3dc4": "Violet 9",
"364fc7": "Indigo 9",
"1864ab": "Blue 9",
"0b7285": "Cyan 9",
"087f5b": "Teal 9",
"2b8a3e": "Green 9",
"5c940d": "Lime 9",
"e67700": "Yellow 9",
"d9480f": "Orange 9"
"black": "Black",
"white": "White",
"red": "Red",
"pink": "Pink",
"grape": "Grape",
"violet": "Violet",
"gray": "Gray",
"blue": "Blue",
"cyan": "Cyan",
"teal": "Teal",
"green": "Green",
"yellow": "Yellow",
"orange": "Orange",
"bronze": "Bronze"
},
"welcomeScreen": {
"app": {
@ -452,5 +422,12 @@
"toolbarHint": "Pick a tool & Start drawing!",
"helpHint": "Shortcuts & help"
}
},
"colorPicker": {
"mostUsedCustomColors": "Most used custom colors",
"colors": "Colors",
"shades": "Shades",
"hexCode": "Hex code",
"noShades": "No shades available for this color"
}
}

View File

@ -30,6 +30,7 @@ import { NonDeletedExcalidrawElement } from "../../../element/types";
import { ImportedLibraryData } from "../../../data/types";
import CustomFooter from "./CustomFooter";
import MobileFooter from "./MobileFooter";
import { KEYS } from "../../../keys";
declare global {
interface Window {
@ -55,9 +56,9 @@ type PointerDownState = {
y: number;
};
};
// This is so that we use the bundled excalidraw.development.js file instead
// of the actual source code
const {
exportToCanvas,
exportToSvg,
@ -484,7 +485,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
}}
onBlur={saveComment}
onKeyDown={(event) => {
if (!event.shiftKey && event.key === "Enter") {
if (!event.shiftKey && event.key === KEYS.ENTER) {
event.preventDefault();
saveComment();
}
@ -521,9 +522,11 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
</MainMenu>
);
};
return (
<div className="App" ref={appRef}>
<h1>{appTitle}</h1>
{/* TODO fix type */}
<ExampleSidebar>
<div className="button-wrapper">
<button onClick={loadSceneOrLibrary}>Load Scene or Library</button>

View File

@ -63,7 +63,7 @@
"sass-loader": "13.0.2",
"terser-webpack-plugin": "5.3.3",
"ts-loader": "9.3.1",
"typescript": "4.7.4",
"typescript": "4.9.4",
"webpack": "5.76.0",
"webpack-bundle-analyzer": "4.5.0",
"webpack-cli": "4.10.0",

View File

@ -3678,10 +3678,10 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
typescript@4.7.4:
version "4.7.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
typescript@4.9.4:
version "4.9.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78"
integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.0"

View File

@ -29,6 +29,7 @@ describe("Test MobileMenu", () => {
expect(h.app.device).toMatchInlineSnapshot(`
Object {
"canDeviceFitSidebar": false,
"isLandscape": true,
"isMobile": true,
"isSmScreen": false,
"isTouchScreen": false,

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@ Object {
"seed": 337897,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "arrow",
@ -68,7 +68,7 @@ Object {
"type": 2,
},
"seed": 337897,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "diamond",
@ -101,7 +101,7 @@ Object {
"type": 2,
},
"seed": 337897,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "ellipse",
@ -147,7 +147,7 @@ Object {
"seed": 337897,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "line",
@ -180,7 +180,7 @@ Object {
"type": 3,
},
"seed": 337897,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",

View File

@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>

View File

@ -18,7 +18,7 @@ Object {
"type": 3,
},
"seed": 401146281,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
@ -49,7 +49,7 @@ Object {
"type": 3,
},
"seed": 337897,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
@ -80,7 +80,7 @@ Object {
"type": 3,
},
"seed": 337897,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
@ -116,7 +116,7 @@ Object {
"type": 3,
},
"seed": 337897,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
@ -152,7 +152,7 @@ Object {
"type": 3,
},
"seed": 449462985,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
@ -206,7 +206,7 @@ Object {
"focus": -0.6000000000000001,
"gap": 10,
},
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "line",

View File

@ -40,7 +40,7 @@ Object {
"seed": 337897,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "arrow",
@ -93,7 +93,7 @@ Object {
"seed": 337897,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "line",

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,7 @@ Object {
"seed": 337897,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "arrow",
@ -79,7 +79,7 @@ Object {
"seed": 337897,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "line",
@ -110,7 +110,7 @@ Object {
"type": 2,
},
"seed": 337897,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "diamond",
@ -141,7 +141,7 @@ Object {
"type": 2,
},
"seed": 337897,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "ellipse",
@ -172,7 +172,7 @@ Object {
"type": 3,
},
"seed": 337897,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",

View File

@ -9,6 +9,7 @@ import {
queryByText,
queryAllByText,
waitFor,
togglePopover,
} from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import * as Renderer from "../renderer/renderScene";
@ -19,7 +20,6 @@ import { ShortcutName } from "../actions/shortcuts";
import { copiedStyles } from "../actions/actionStyles";
import { API } from "./helpers/api";
import { setDateTimeForTests } from "../utils";
import { t } from "../i18n";
import { LibraryItem } from "../types";
const checkpoint = (name: string) => {
@ -303,10 +303,10 @@ describe("contextMenu element", () => {
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
@ -320,13 +320,20 @@ describe("contextMenu element", () => {
target: { value: "60" },
});
// closing the background popover as this blocks
// context menu from rendering after we started focussing
// the popover once rendered :/
togglePopover("Background");
mouse.reset();
// Copy styles of second rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
let contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu!, "Copy styles")!);
const secondRect = JSON.parse(copiedStyles)[0];
@ -344,8 +351,8 @@ describe("contextMenu element", () => {
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");

View File

@ -33,7 +33,7 @@ Object {
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "arrow",
@ -173,7 +173,7 @@ Object {
},
"seed": Any<Number>,
"simulatePressure": true,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "freedraw",
@ -219,7 +219,7 @@ Object {
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "line",
@ -265,7 +265,7 @@ Object {
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "line",
@ -302,7 +302,7 @@ Object {
"type": 3,
},
"seed": Any<Number>,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"text": "text",
@ -342,7 +342,7 @@ Object {
"type": 3,
},
"seed": Any<Number>,
"strokeColor": "#000000",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"text": "",

View File

@ -237,6 +237,15 @@ export class UI {
fireEvent.click(element);
};
static clickOnTestId = (testId: string) => {
const element = document.querySelector(`[data-testid='${testId}']`);
// const element = GlobalTestState.renderResult.queryByTestId(testId);
if (!element) {
throw new Error(`No element with testid "${testId}" found`);
}
fireEvent.click(element);
};
/**
* Creates an Excalidraw element, and returns a proxy that wraps it so that
* accessing props will return the latest ones from the object existing in

View File

@ -7,7 +7,7 @@ exports[`<Excalidraw/> <MainMenu/> should render main menu with host menu items
>
<div
class="Island dropdown-menu-container"
style="--padding: 2; z-index: 1;"
style="--padding: 2; z-index: 2;"
>
<button
class="dropdown-menu-item dropdown-menu-item-base"
@ -115,7 +115,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should render menu
>
<div
class="Island dropdown-menu-container"
style="--padding: 2; z-index: 1;"
style="--padding: 2; z-index: 2;"
>
<button
aria-label="Open"
@ -523,38 +523,84 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should render menu
<div
style="padding: 0px 0.625rem;"
>
<div
style="position: relative;"
>
<div>
<div>
<div
aria-modal="true"
class="color-picker-container"
role="dialog"
>
<div
class="color-picker-control-container"
class="color-picker__top-picks"
>
<div
class="color-picker-label-swatch-container"
>
<button
aria-label="Canvas background"
class="color-picker-label-swatch"
style="--swatch-color: #ffffff;"
/>
</div>
<label
class="color-input-container"
<button
class="color-picker__button active"
style="--swatch-color: #ffffff;"
title="#ffffff"
type="button"
>
<div
class="color-picker-hash"
>
#
</div>
<input
aria-label="Canvas background"
class="color-picker-input"
spellcheck="false"
value="ffffff"
class="color-picker__button-outline"
/>
</label>
</button>
<button
class="color-picker__button"
style="--swatch-color: #f8f9fa;"
title="#f8f9fa"
type="button"
>
<div
class="color-picker__button-outline"
/>
</button>
<button
class="color-picker__button"
style="--swatch-color: #f5faff;"
title="#f5faff"
type="button"
>
<div
class="color-picker__button-outline"
/>
</button>
<button
class="color-picker__button"
style="--swatch-color: #fffce8;"
title="#fffce8"
type="button"
>
<div
class="color-picker__button-outline"
/>
</button>
<button
class="color-picker__button"
style="--swatch-color: #fdf8f6;"
title="#fdf8f6"
type="button"
>
<div
class="color-picker__button-outline"
/>
</button>
</div>
<div
style="width: 1px; height: 100%; margin: 0px auto;"
/>
<button
aria-controls="radix-:r0:"
aria-expanded="false"
aria-haspopup="dialog"
aria-label="Canvas background"
class="color-picker__button active-color"
data-state="closed"
style="--swatch-color: #ffffff;"
title="Show background color picker"
type="button"
>
<div
class="color-picker__button-outline"
/>
</button>
</div>
</div>
</div>

View File

@ -20,7 +20,7 @@ Object {
"currentItemRoughness": 1,
"currentItemRoundness": "round",
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#000000",
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 1,
"currentItemTextAlign": "left",

View File

@ -12,11 +12,11 @@ import {
fireEvent,
render,
screen,
togglePopover,
waitFor,
} from "./test-utils";
import { defaultLang } from "../i18n";
import { FONT_FAMILY } from "../constants";
import { t } from "../i18n";
const { h } = window;
@ -42,7 +42,6 @@ const checkpoint = (name: string) => {
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
};
beforeEach(async () => {
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -159,13 +158,14 @@ describe("regression tests", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(10, 10);
togglePopover("Background");
UI.clickOnTestId("color-yellow");
UI.clickOnTestId("color-red");
UI.clickLabeledElement("Background");
UI.clickLabeledElement(t("colors.fa5252"));
UI.clickLabeledElement("Stroke");
UI.clickLabeledElement(t("colors.5f3dc4"));
expect(API.getSelectedElement().backgroundColor).toBe("#fa5252");
expect(API.getSelectedElement().strokeColor).toBe("#5f3dc4");
togglePopover("Stroke");
UI.clickOnTestId("color-blue");
expect(API.getSelectedElement().backgroundColor).toBe("#ffc9c9");
expect(API.getSelectedElement().strokeColor).toBe("#1971c2");
});
it("click on an element and drag it", () => {
@ -988,8 +988,8 @@ describe("regression tests", () => {
UI.clickTool("rectangle");
// change background color since default is transparent
// and transparent elements can't be selected by clicking inside of them
UI.clickLabeledElement("Background");
UI.clickLabeledElement(t("colors.fa5252"));
togglePopover("Background");
UI.clickOnTestId("color-red");
mouse.down();
mouse.up(1000, 1000);
@ -1088,15 +1088,14 @@ describe("regression tests", () => {
assertSelectedElements(rect3);
});
it("should show fill icons when element has non transparent background", () => {
it("should show fill icons when element has non transparent background", async () => {
UI.clickTool("rectangle");
expect(screen.queryByText(/fill/i)).not.toBeNull();
mouse.down();
mouse.up(10, 10);
expect(screen.queryByText(/fill/i)).toBeNull();
UI.clickLabeledElement("Background");
UI.clickLabeledElement(t("colors.fa5252"));
togglePopover("Background");
UI.clickOnTestId("color-red");
// select rectangle
mouse.reset();
mouse.click();

View File

@ -16,6 +16,7 @@ import { STORAGE_KEYS } from "../excalidraw-app/app_constants";
import { SceneData } from "../types";
import { getSelectedElements } from "../scene/selection";
import { ExcalidrawElement } from "../element/types";
import { UI } from "./helpers/ui";
const customQueries = {
...queries,
@ -186,11 +187,6 @@ export const assertSelectedElements = (
expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
};
export const toggleMenu = (container: HTMLElement) => {
// open menu
fireEvent.click(container.querySelector(".dropdown-menu-button")!);
};
export const createPasteEvent = (
text:
| string
@ -211,3 +207,24 @@ export const createPasteEvent = (
},
);
};
export const toggleMenu = (container: HTMLElement) => {
// open menu
fireEvent.click(container.querySelector(".dropdown-menu-button")!);
};
export const togglePopover = (label: string) => {
// Needed for radix-ui/react-popover as tests fail due to resize observer not being present
(global as any).ResizeObserver = class ResizeObserver {
constructor(cb: any) {
(this as any).cb = cb;
}
observe() {}
unobserve() {}
disconnect() {}
};
UI.clickLabeledElement(label);
};

View File

@ -163,11 +163,7 @@ export type AppState = {
isRotating: boolean;
zoom: Zoom;
openMenu: "canvas" | "shape" | null;
openPopup:
| "canvasColorPicker"
| "backgroundColorPicker"
| "strokeColorPicker"
| null;
openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog: "imageExport" | "help" | "jsonExport" | null;
/**
@ -542,4 +538,5 @@ export type Device = Readonly<{
isMobile: boolean;
isTouchScreen: boolean;
canDeviceFitSidebar: boolean;
isLandscape: boolean;
}>;

View File

@ -1,6 +1,5 @@
import oc from "open-color";
import colors from "./colors";
import { COLOR_PALETTE } from "./colors";
import {
CURSOR_TYPE,
DEFAULT_VERSION,
@ -529,7 +528,7 @@ export const isTransparent = (color: string) => {
return (
isRGBTransparent ||
isRRGGBBTransparent ||
color === colors.elementBackground[0]
color === COLOR_PALETTE.transparent
);
};

2901
yarn.lock

File diff suppressed because it is too large Load Diff