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:
parent
6977c32631
commit
5b7596582f
@ -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",
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -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),
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
|
@ -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");
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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],
|
||||
};
|
||||
};
|
||||
|
||||
|
183
src/colors.ts
183
src/colors.ts
@ -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;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
75
src/components/ColorPicker/ColorInput.tsx
Normal file
75
src/components/ColorPicker/ColorInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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%;
|
235
src/components/ColorPicker/ColorPicker.tsx
Normal file
235
src/components/ColorPicker/ColorPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
63
src/components/ColorPicker/CustomColorList.tsx
Normal file
63
src/components/ColorPicker/CustomColorList.tsx
Normal 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>
|
||||
);
|
||||
};
|
29
src/components/ColorPicker/HotkeyLabel.tsx
Normal file
29
src/components/ColorPicker/HotkeyLabel.tsx
Normal 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;
|
156
src/components/ColorPicker/Picker.tsx
Normal file
156
src/components/ColorPicker/Picker.tsx
Normal 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>
|
||||
);
|
||||
};
|
86
src/components/ColorPicker/PickerColorList.tsx
Normal file
86
src/components/ColorPicker/PickerColorList.tsx
Normal 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;
|
7
src/components/ColorPicker/PickerHeading.tsx
Normal file
7
src/components/ColorPicker/PickerHeading.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const PickerHeading = ({ children }: { children: ReactNode }) => (
|
||||
<div className="color-picker__heading">{children}</div>
|
||||
);
|
||||
|
||||
export default PickerHeading;
|
105
src/components/ColorPicker/ShadeList.tsx
Normal file
105
src/components/ColorPicker/ShadeList.tsx
Normal 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>
|
||||
);
|
||||
};
|
64
src/components/ColorPicker/TopPicks.tsx
Normal file
64
src/components/ColorPicker/TopPicks.tsx
Normal 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>
|
||||
);
|
||||
};
|
139
src/components/ColorPicker/colorPickerUtils.ts
Normal file
139
src/components/ColorPicker/colorPickerUtils.ts
Normal 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";
|
249
src/components/ColorPicker/keyboardNavHandlers.ts
Normal file
249
src/components/ColorPicker/keyboardNavHandlers.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
@ -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,
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -48,7 +48,7 @@ const MenuContent = ({
|
||||
<Island
|
||||
className="dropdown-menu-container"
|
||||
padding={2}
|
||||
style={{ zIndex: 1 }}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
{children}
|
||||
</Island>
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -1521,7 +1521,7 @@ describe("textWysiwyg", () => {
|
||||
roundness: {
|
||||
type: 3,
|
||||
},
|
||||
strokeColor: "#000000",
|
||||
strokeColor: "#1e1e1e",
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
type: "rectangle",
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
14
src/i18n.ts
14
src/i18n.ts
@ -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) {
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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
@ -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",
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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",
|
||||
|
@ -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
@ -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",
|
||||
|
@ -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");
|
||||
|
@ -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": "",
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -20,7 +20,7 @@ Object {
|
||||
"currentItemRoughness": 1,
|
||||
"currentItemRoundness": "round",
|
||||
"currentItemStartArrowhead": null,
|
||||
"currentItemStrokeColor": "#000000",
|
||||
"currentItemStrokeColor": "#1e1e1e",
|
||||
"currentItemStrokeStyle": "solid",
|
||||
"currentItemStrokeWidth": 1,
|
||||
"currentItemTextAlign": "left",
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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;
|
||||
}>;
|
||||
|
@ -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
|
||||
);
|
||||
};
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user