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": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-popover": "1.0.3",
|
||||||
"@radix-ui/react-tabs": "1.0.2",
|
"@radix-ui/react-tabs": "1.0.2",
|
||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "6.2.5",
|
||||||
"@sentry/integrations": "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 { ZoomInIcon, ZoomOutIcon } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||||
@ -19,6 +19,7 @@ import {
|
|||||||
isEraserActive,
|
isEraserActive,
|
||||||
isHandToolActive,
|
isHandToolActive,
|
||||||
} from "../appState";
|
} from "../appState";
|
||||||
|
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
@ -35,24 +36,21 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
commitToHistory: !!value.viewBackgroundColor,
|
commitToHistory: !!value.viewBackgroundColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative" }}>
|
<ColorPicker
|
||||||
<ColorPicker
|
palette={null}
|
||||||
label={t("labels.canvasBackground")}
|
topPicks={DEFAULT_CANVAS_BACKGROUND_PICKS}
|
||||||
type="canvasBackground"
|
label={t("labels.canvasBackground")}
|
||||||
color={appState.viewBackgroundColor}
|
type="canvasBackground"
|
||||||
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
color={appState.viewBackgroundColor}
|
||||||
isActive={appState.openPopup === "canvasColorPicker"}
|
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
||||||
setActive={(active) =>
|
data-testid="canvas-background-picker"
|
||||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
elements={elements}
|
||||||
}
|
appState={appState}
|
||||||
data-testid="canvas-background-picker"
|
updateData={updateData}
|
||||||
elements={elements}
|
/>
|
||||||
appState={appState}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -14,7 +14,7 @@ import {
|
|||||||
} from "../element/bounds";
|
} from "../element/bounds";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
|
|
||||||
const enableActionFlipHorizontal = (
|
const enableActionFlipHorizontal = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@ -48,7 +48,7 @@ export const actionFlipHorizontal = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.shiftKey && event.code === "KeyH",
|
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||||
contextItemLabel: "labels.flipHorizontal",
|
contextItemLabel: "labels.flipHorizontal",
|
||||||
predicate: (elements, appState) =>
|
predicate: (elements, appState) =>
|
||||||
enableActionFlipHorizontal(elements, appState),
|
enableActionFlipHorizontal(elements, appState),
|
||||||
@ -65,7 +65,7 @@ export const actionFlipVertical = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
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",
|
contextItemLabel: "labels.flipVertical",
|
||||||
predicate: (elements, appState) =>
|
predicate: (elements, appState) =>
|
||||||
enableActionFlipVertical(elements, appState),
|
enableActionFlipVertical(elements, appState),
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
import { AppState } from "../../src/types";
|
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 { trackEvent } from "../analytics";
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||||
import { IconPicker } from "../components/IconPicker";
|
import { IconPicker } from "../components/IconPicker";
|
||||||
// TODO barnabasmolnar/editor-redesign
|
// TODO barnabasmolnar/editor-redesign
|
||||||
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
||||||
@ -226,10 +232,12 @@ export const actionChangeStrokeColor = register({
|
|||||||
commitToHistory: !!value.currentItemStrokeColor,
|
commitToHistory: !!value.currentItemStrokeColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||||
<>
|
<>
|
||||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||||
|
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||||
type="elementStroke"
|
type="elementStroke"
|
||||||
label={t("labels.stroke")}
|
label={t("labels.stroke")}
|
||||||
color={getFormValue(
|
color={getFormValue(
|
||||||
@ -239,12 +247,9 @@ export const actionChangeStrokeColor = register({
|
|||||||
appState.currentItemStrokeColor,
|
appState.currentItemStrokeColor,
|
||||||
)}
|
)}
|
||||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||||
isActive={appState.openPopup === "strokeColorPicker"}
|
|
||||||
setActive={(active) =>
|
|
||||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
|
||||||
}
|
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
updateData={updateData}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@ -269,10 +274,12 @@ export const actionChangeBackgroundColor = register({
|
|||||||
commitToHistory: !!value.currentItemBackgroundColor,
|
commitToHistory: !!value.currentItemBackgroundColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||||
<>
|
<>
|
||||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||||
|
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||||
type="elementBackground"
|
type="elementBackground"
|
||||||
label={t("labels.background")}
|
label={t("labels.background")}
|
||||||
color={getFormValue(
|
color={getFormValue(
|
||||||
@ -282,12 +289,9 @@ export const actionChangeBackgroundColor = register({
|
|||||||
appState.currentItemBackgroundColor,
|
appState.currentItemBackgroundColor,
|
||||||
)}
|
)}
|
||||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||||
isActive={appState.openPopup === "backgroundColorPicker"}
|
|
||||||
setActive={(active) =>
|
|
||||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
|
||||||
}
|
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
updateData={updateData}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import ExcalidrawApp from "../excalidraw-app";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { CODES } from "../keys";
|
import { CODES } from "../keys";
|
||||||
import { API } from "../tests/helpers/api";
|
import { API } from "../tests/helpers/api";
|
||||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
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";
|
import { copiedStyles } from "./actionStyles";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
@ -14,7 +19,14 @@ describe("actionStyles", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<ExcalidrawApp />);
|
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");
|
UI.clickTool("rectangle");
|
||||||
mouse.down(10, 10);
|
mouse.down(10, 10);
|
||||||
mouse.up(20, 20);
|
mouse.up(20, 20);
|
||||||
@ -24,10 +36,10 @@ describe("actionStyles", () => {
|
|||||||
mouse.up(20, 20);
|
mouse.up(20, 20);
|
||||||
|
|
||||||
// Change some styles of second rectangle
|
// Change some styles of second rectangle
|
||||||
UI.clickLabeledElement("Stroke");
|
togglePopover("Stroke");
|
||||||
UI.clickLabeledElement(t("colors.c92a2a"));
|
UI.clickOnTestId("color-red");
|
||||||
UI.clickLabeledElement("Background");
|
togglePopover("Background");
|
||||||
UI.clickLabeledElement(t("colors.e64980"));
|
UI.clickOnTestId("color-blue");
|
||||||
// Fill style
|
// Fill style
|
||||||
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
||||||
// Stroke width
|
// Stroke width
|
||||||
@ -60,8 +72,8 @@ describe("actionStyles", () => {
|
|||||||
|
|
||||||
const firstRect = API.getSelectedElement();
|
const firstRect = API.getSelectedElement();
|
||||||
expect(firstRect.id).toBe(h.elements[0].id);
|
expect(firstRect.id).toBe(h.elements[0].id);
|
||||||
expect(firstRect.strokeColor).toBe("#c92a2a");
|
expect(firstRect.strokeColor).toBe("#e03131");
|
||||||
expect(firstRect.backgroundColor).toBe("#e64980");
|
expect(firstRect.backgroundColor).toBe("#a5d8ff");
|
||||||
expect(firstRect.fillStyle).toBe("cross-hatch");
|
expect(firstRect.fillStyle).toBe("cross-hatch");
|
||||||
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
||||||
expect(firstRect.strokeStyle).toBe("dotted");
|
expect(firstRect.strokeStyle).toBe("dotted");
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import oc from "open-color";
|
import { COLOR_PALETTE } from "./colors";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_PROPS,
|
DEFAULT_ELEMENT_PROPS,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
@ -84,7 +84,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
toast: null,
|
toast: null,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: COLOR_PALETTE.white,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: {
|
zoom: {
|
||||||
value: 1 as NormalizedZoomValue,
|
value: 1 as NormalizedZoomValue,
|
||||||
|
@ -1,5 +1,14 @@
|
|||||||
import colors from "./colors";
|
import {
|
||||||
import { DEFAULT_FONT_SIZE, ENV } from "./constants";
|
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 { newElement, newLinearElement, newTextElement } from "./element";
|
||||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||||
import { randomId } from "./random";
|
import { randomId } from "./random";
|
||||||
@ -153,15 +162,22 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const bgColors = colors.elementBackground.slice(
|
const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
|
||||||
2,
|
|
||||||
colors.elementBackground.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Put all the common properties here so when the whole chart is selected
|
// Put all the common properties here so when the whole chart is selected
|
||||||
// the properties dialog shows the correct selected values
|
// the properties dialog shows the correct selected values
|
||||||
const commonProps = {
|
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;
|
} as const;
|
||||||
|
|
||||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||||
@ -322,7 +338,7 @@ const chartBaseElements = (
|
|||||||
y: y - chartHeight,
|
y: y - chartHeight,
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
height: chartHeight,
|
height: chartHeight,
|
||||||
strokeColor: colors.elementStroke[0],
|
strokeColor: COLOR_PALETTE.black,
|
||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
opacity: 6,
|
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";
|
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) => {
|
export const getClientColors = (clientId: string, appState: AppState) => {
|
||||||
if (appState?.collaborators) {
|
if (appState?.collaborators) {
|
||||||
const currentUser = appState.collaborators.get(clientId);
|
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
|
// Naive way of getting an integer out of the clientId
|
||||||
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
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 {
|
return {
|
||||||
background: backgrounds[sum % backgrounds.length],
|
background: BG_COLORS[sum % BG_COLORS.length],
|
||||||
stroke: strokes[sum % strokes.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 oc from "open-color";
|
||||||
|
import { Merge } from "./utility-types";
|
||||||
|
|
||||||
const shades = (index: number) => [
|
// FIXME can't put to utils.ts rn because of circular dependency
|
||||||
oc.red[index],
|
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||||
oc.pink[index],
|
source: R,
|
||||||
oc.grape[index],
|
keys: K,
|
||||||
oc.violet[index],
|
) => {
|
||||||
oc.indigo[index],
|
return keys.reduce((acc, key: K[number]) => {
|
||||||
oc.blue[index],
|
if (key in source) {
|
||||||
oc.cyan[index],
|
acc[key] = source[key];
|
||||||
oc.teal[index],
|
}
|
||||||
oc.green[index],
|
return acc;
|
||||||
oc.lime[index],
|
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
|
||||||
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)],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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,
|
isMobile: false,
|
||||||
isTouchScreen: false,
|
isTouchScreen: false,
|
||||||
canDeviceFitSidebar: false,
|
canDeviceFitSidebar: false,
|
||||||
|
isLandscape: false,
|
||||||
};
|
};
|
||||||
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
|
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
|
||||||
DeviceContext.displayName = "DeviceContext";
|
DeviceContext.displayName = "DeviceContext";
|
||||||
@ -947,6 +948,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
? this.props.UIOptions.dockedSidebarBreakpoint
|
? this.props.UIOptions.dockedSidebarBreakpoint
|
||||||
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
|
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
|
||||||
this.device = updateObject(this.device, {
|
this.device = updateObject(this.device, {
|
||||||
|
isLandscape: width > height,
|
||||||
isSmScreen: width < MQ_SM_MAX_WIDTH,
|
isSmScreen: width < MQ_SM_MAX_WIDTH,
|
||||||
isMobile:
|
isMobile:
|
||||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
width < MQ_MAX_WIDTH_PORTRAIT ||
|
||||||
@ -2323,11 +2325,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
(hasBackground(this.state.activeTool.type) ||
|
(hasBackground(this.state.activeTool.type) ||
|
||||||
selectedElements.some((element) => hasBackground(element.type)))
|
selectedElements.some((element) => hasBackground(element.type)))
|
||||||
) {
|
) {
|
||||||
this.setState({ openPopup: "backgroundColorPicker" });
|
this.setState({ openPopup: "elementBackground" });
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
if (event.key === KEYS.S) {
|
if (event.key === KEYS.S) {
|
||||||
this.setState({ openPopup: "strokeColorPicker" });
|
this.setState({ openPopup: "elementStroke" });
|
||||||
event.stopPropagation();
|
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 {
|
.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 {
|
.color-picker {
|
||||||
background: var(--popup-bg-color);
|
background: var(--popup-bg-color);
|
||||||
border: 0 solid transparentize($oc-white, 0.75);
|
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 {
|
.color-picker-content--default {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, auto);
|
grid-template-columns: repeat(5, 1.875rem);
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.25rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
&:focus {
|
&: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 {
|
.color-picker-input {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
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 clsx from "clsx";
|
||||||
import oc from "open-color";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useDevice } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import { exportToSvg } from "../packages/utils";
|
import { exportToSvg } from "../packages/utils";
|
||||||
@ -7,6 +6,7 @@ import { LibraryItem } from "../types";
|
|||||||
import "./LibraryUnit.scss";
|
import "./LibraryUnit.scss";
|
||||||
import { CheckboxItem } from "./CheckboxItem";
|
import { CheckboxItem } from "./CheckboxItem";
|
||||||
import { PlusIcon } from "./icons";
|
import { PlusIcon } from "./icons";
|
||||||
|
import { COLOR_PALETTE } from "../colors";
|
||||||
|
|
||||||
export const LibraryUnit = ({
|
export const LibraryUnit = ({
|
||||||
id,
|
id,
|
||||||
@ -40,7 +40,7 @@ export const LibraryUnit = ({
|
|||||||
elements,
|
elements,
|
||||||
appState: {
|
appState: {
|
||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: COLOR_PALETTE.white,
|
||||||
},
|
},
|
||||||
files: null,
|
files: null,
|
||||||
});
|
});
|
||||||
|
@ -5,6 +5,7 @@ import { focusNearestParent } from "../utils";
|
|||||||
|
|
||||||
import "./ProjectName.scss";
|
import "./ProjectName.scss";
|
||||||
import { useExcalidrawContainer } from "./App";
|
import { useExcalidrawContainer } from "./App";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
@ -26,7 +27,7 @@ export const ProjectName = (props: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === KEYS.ENTER) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.nativeEvent.isComposing || event.keyCode === 229) {
|
if (event.nativeEvent.isComposing || event.keyCode === 229) {
|
||||||
return;
|
return;
|
||||||
|
@ -48,7 +48,7 @@ const MenuContent = ({
|
|||||||
<Island
|
<Island
|
||||||
className="dropdown-menu-container"
|
className="dropdown-menu-container"
|
||||||
padding={2}
|
padding={2}
|
||||||
style={{ zIndex: 1 }}
|
style={{ zIndex: 2 }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Island>
|
</Island>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import cssVariables from "./css/variables.module.scss";
|
import cssVariables from "./css/variables.module.scss";
|
||||||
import { AppProps } from "./types";
|
import { AppProps } from "./types";
|
||||||
import { ExcalidrawElement, FontFamilyValues } from "./element/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 isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||||
export const isWindows = /^Win/.test(navigator.platform);
|
export const isWindows = /^Win/.test(navigator.platform);
|
||||||
@ -272,8 +272,8 @@ export const DEFAULT_ELEMENT_PROPS: {
|
|||||||
opacity: ExcalidrawElement["opacity"];
|
opacity: ExcalidrawElement["opacity"];
|
||||||
locked: ExcalidrawElement["locked"];
|
locked: ExcalidrawElement["locked"];
|
||||||
} = {
|
} = {
|
||||||
strokeColor: oc.black,
|
strokeColor: COLOR_PALETTE.black,
|
||||||
backgroundColor: "transparent",
|
backgroundColor: COLOR_PALETTE.transparent,
|
||||||
fillStyle: "hachure",
|
fillStyle: "hachure",
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
|
@ -34,13 +34,13 @@ import { LinearElementEditor } from "../element/linearElementEditor";
|
|||||||
import { bumpVersion } from "../element/mutateElement";
|
import { bumpVersion } from "../element/mutateElement";
|
||||||
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
|
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import oc from "open-color";
|
|
||||||
import { MarkOptional, Mutable } from "../utility-types";
|
import { MarkOptional, Mutable } from "../utility-types";
|
||||||
import {
|
import {
|
||||||
detectLineHeight,
|
detectLineHeight,
|
||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
measureBaseline,
|
measureBaseline,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
|
import { COLOR_PALETTE } from "../colors";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@ -119,8 +119,8 @@ const restoreElementWithProperties = <
|
|||||||
angle: element.angle || 0,
|
angle: element.angle || 0,
|
||||||
x: extra.x ?? element.x ?? 0,
|
x: extra.x ?? element.x ?? 0,
|
||||||
y: extra.y ?? element.y ?? 0,
|
y: extra.y ?? element.y ?? 0,
|
||||||
strokeColor: element.strokeColor || oc.black,
|
strokeColor: element.strokeColor || COLOR_PALETTE.black,
|
||||||
backgroundColor: element.backgroundColor || "transparent",
|
backgroundColor: element.backgroundColor || COLOR_PALETTE.transparent,
|
||||||
width: element.width || 0,
|
width: element.width || 0,
|
||||||
height: element.height || 0,
|
height: element.height || 0,
|
||||||
seed: element.seed ?? 1,
|
seed: element.seed ?? 1,
|
||||||
|
@ -1521,7 +1521,7 @@ describe("textWysiwyg", () => {
|
|||||||
roundness: {
|
roundness: {
|
||||||
type: 3,
|
type: 3,
|
||||||
},
|
},
|
||||||
strokeColor: "#000000",
|
strokeColor: "#1e1e1e",
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
|
@ -636,20 +636,46 @@ export const textWysiwyg = ({
|
|||||||
// in that same tick.
|
// in that same tick.
|
||||||
const target = event?.target;
|
const target = event?.target;
|
||||||
|
|
||||||
const isTargetColorPicker =
|
const isTargetPickerTrigger =
|
||||||
target instanceof HTMLInputElement &&
|
target instanceof HTMLElement &&
|
||||||
target.closest(".color-picker-input") &&
|
target.classList.contains("active-color");
|
||||||
isWritableElement(target);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
editable.onblur = handleSubmit;
|
editable.onblur = handleSubmit;
|
||||||
if (target && isTargetColorPicker) {
|
|
||||||
target.onblur = () => {
|
if (isTargetPickerTrigger) {
|
||||||
editable.focus();
|
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
|
// case: clicking on the same property → no change → no update → no focus
|
||||||
if (!isTargetColorPicker) {
|
if (!isTargetPickerTrigger) {
|
||||||
editable.focus();
|
editable.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -657,16 +683,16 @@ export const textWysiwyg = ({
|
|||||||
|
|
||||||
// prevent blur when changing properties from the menu
|
// prevent blur when changing properties from the menu
|
||||||
const onPointerDown = (event: MouseEvent) => {
|
const onPointerDown = (event: MouseEvent) => {
|
||||||
const isTargetColorPicker =
|
const isTargetPickerTrigger =
|
||||||
event.target instanceof HTMLInputElement &&
|
event.target instanceof HTMLElement &&
|
||||||
event.target.closest(".color-picker-input") &&
|
event.target.classList.contains("active-color");
|
||||||
isWritableElement(event.target);
|
|
||||||
if (
|
if (
|
||||||
((event.target instanceof HTMLElement ||
|
((event.target instanceof HTMLElement ||
|
||||||
event.target instanceof SVGElement) &&
|
event.target instanceof SVGElement) &&
|
||||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
||||||
!isWritableElement(event.target)) ||
|
!isWritableElement(event.target)) ||
|
||||||
isTargetColorPicker
|
isTargetPickerTrigger
|
||||||
) {
|
) {
|
||||||
editable.onblur = null;
|
editable.onblur = null;
|
||||||
window.addEventListener("pointerup", bindBlurEvent);
|
window.addEventListener("pointerup", bindBlurEvent);
|
||||||
@ -680,7 +706,7 @@ export const textWysiwyg = ({
|
|||||||
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
|
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
|
||||||
updateWysiwygStyle();
|
updateWysiwygStyle();
|
||||||
const isColorPickerActive = !!document.activeElement?.closest(
|
const isColorPickerActive = !!document.activeElement?.closest(
|
||||||
".color-picker-input",
|
".color-picker-content",
|
||||||
);
|
);
|
||||||
if (!isColorPickerActive) {
|
if (!isColorPickerActive) {
|
||||||
editable.focus();
|
editable.focus();
|
||||||
|
@ -17,6 +17,7 @@ import { trackEvent } from "../../analytics";
|
|||||||
import { getFrame } from "../../utils";
|
import { getFrame } from "../../utils";
|
||||||
import DialogActionButton from "../../components/DialogActionButton";
|
import DialogActionButton from "../../components/DialogActionButton";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
|
import { KEYS } from "../../keys";
|
||||||
|
|
||||||
const getShareIcon = () => {
|
const getShareIcon = () => {
|
||||||
const navigator = window.navigator as any;
|
const navigator = window.navigator as any;
|
||||||
@ -148,7 +149,9 @@ const RoomDialog = ({
|
|||||||
value={username.trim() || ""}
|
value={username.trim() || ""}
|
||||||
className="RoomDialog-username TextInput"
|
className="RoomDialog-username TextInput"
|
||||||
onChange={(event) => onUsernameChange(event.target.value)}
|
onChange={(event) => onUsernameChange(event.target.value)}
|
||||||
onKeyPress={(event) => event.key === "Enter" && handleClose()}
|
onKeyPress={(event) =>
|
||||||
|
event.key === KEYS.ENTER && handleClose()
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
|
14
src/i18n.ts
14
src/i18n.ts
@ -124,7 +124,8 @@ const findPartsForData = (data: any, parts: string[]) => {
|
|||||||
|
|
||||||
export const t = (
|
export const t = (
|
||||||
path: string,
|
path: string,
|
||||||
replacement?: { [key: string]: string | number },
|
replacement?: { [key: string]: string | number } | null,
|
||||||
|
fallback?: string,
|
||||||
) => {
|
) => {
|
||||||
if (currentLang.code.startsWith(TEST_LANG_CODE)) {
|
if (currentLang.code.startsWith(TEST_LANG_CODE)) {
|
||||||
const name = replacement
|
const name = replacement
|
||||||
@ -136,9 +137,16 @@ export const t = (
|
|||||||
const parts = path.split(".");
|
const parts = path.split(".");
|
||||||
let translation =
|
let translation =
|
||||||
findPartsForData(currentLangData, parts) ||
|
findPartsForData(currentLangData, parts) ||
|
||||||
findPartsForData(fallbackLangData, parts);
|
findPartsForData(fallbackLangData, parts) ||
|
||||||
|
fallback;
|
||||||
if (translation === undefined) {
|
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) {
|
if (replacement) {
|
||||||
|
@ -394,51 +394,21 @@
|
|||||||
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor"
|
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor"
|
||||||
},
|
},
|
||||||
"colors": {
|
"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",
|
"transparent": "Transparent",
|
||||||
"ced4da": "Gray 4",
|
"black": "Black",
|
||||||
"868e96": "Gray 6",
|
"white": "White",
|
||||||
"fa5252": "Red 6",
|
"red": "Red",
|
||||||
"e64980": "Pink 6",
|
"pink": "Pink",
|
||||||
"be4bdb": "Grape 6",
|
"grape": "Grape",
|
||||||
"7950f2": "Violet 6",
|
"violet": "Violet",
|
||||||
"4c6ef5": "Indigo 6",
|
"gray": "Gray",
|
||||||
"228be6": "Blue 6",
|
"blue": "Blue",
|
||||||
"15aabf": "Cyan 6",
|
"cyan": "Cyan",
|
||||||
"12b886": "Teal 6",
|
"teal": "Teal",
|
||||||
"40c057": "Green 6",
|
"green": "Green",
|
||||||
"82c91e": "Lime 6",
|
"yellow": "Yellow",
|
||||||
"fab005": "Yellow 6",
|
"orange": "Orange",
|
||||||
"fd7e14": "Orange 6",
|
"bronze": "Bronze"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"welcomeScreen": {
|
"welcomeScreen": {
|
||||||
"app": {
|
"app": {
|
||||||
@ -452,5 +422,12 @@
|
|||||||
"toolbarHint": "Pick a tool & Start drawing!",
|
"toolbarHint": "Pick a tool & Start drawing!",
|
||||||
"helpHint": "Shortcuts & help"
|
"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 { ImportedLibraryData } from "../../../data/types";
|
||||||
import CustomFooter from "./CustomFooter";
|
import CustomFooter from "./CustomFooter";
|
||||||
import MobileFooter from "./MobileFooter";
|
import MobileFooter from "./MobileFooter";
|
||||||
|
import { KEYS } from "../../../keys";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -55,9 +56,9 @@ type PointerDownState = {
|
|||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// This is so that we use the bundled excalidraw.development.js file instead
|
// This is so that we use the bundled excalidraw.development.js file instead
|
||||||
// of the actual source code
|
// of the actual source code
|
||||||
|
|
||||||
const {
|
const {
|
||||||
exportToCanvas,
|
exportToCanvas,
|
||||||
exportToSvg,
|
exportToSvg,
|
||||||
@ -484,7 +485,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
}}
|
}}
|
||||||
onBlur={saveComment}
|
onBlur={saveComment}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (!event.shiftKey && event.key === "Enter") {
|
if (!event.shiftKey && event.key === KEYS.ENTER) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
saveComment();
|
saveComment();
|
||||||
}
|
}
|
||||||
@ -521,9 +522,11 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
</MainMenu>
|
</MainMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App" ref={appRef}>
|
<div className="App" ref={appRef}>
|
||||||
<h1>{appTitle}</h1>
|
<h1>{appTitle}</h1>
|
||||||
|
{/* TODO fix type */}
|
||||||
<ExampleSidebar>
|
<ExampleSidebar>
|
||||||
<div className="button-wrapper">
|
<div className="button-wrapper">
|
||||||
<button onClick={loadSceneOrLibrary}>Load Scene or Library</button>
|
<button onClick={loadSceneOrLibrary}>Load Scene or Library</button>
|
||||||
|
@ -63,7 +63,7 @@
|
|||||||
"sass-loader": "13.0.2",
|
"sass-loader": "13.0.2",
|
||||||
"terser-webpack-plugin": "5.3.3",
|
"terser-webpack-plugin": "5.3.3",
|
||||||
"ts-loader": "9.3.1",
|
"ts-loader": "9.3.1",
|
||||||
"typescript": "4.7.4",
|
"typescript": "4.9.4",
|
||||||
"webpack": "5.76.0",
|
"webpack": "5.76.0",
|
||||||
"webpack-bundle-analyzer": "4.5.0",
|
"webpack-bundle-analyzer": "4.5.0",
|
||||||
"webpack-cli": "4.10.0",
|
"webpack-cli": "4.10.0",
|
||||||
|
@ -3678,10 +3678,10 @@ type-is@~1.6.18:
|
|||||||
media-typer "0.3.0"
|
media-typer "0.3.0"
|
||||||
mime-types "~2.1.24"
|
mime-types "~2.1.24"
|
||||||
|
|
||||||
typescript@4.7.4:
|
typescript@4.9.4:
|
||||||
version "4.7.4"
|
version "4.9.4"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78"
|
||||||
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
|
integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==
|
||||||
|
|
||||||
unicode-canonical-property-names-ecmascript@^2.0.0:
|
unicode-canonical-property-names-ecmascript@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
|
@ -29,6 +29,7 @@ describe("Test MobileMenu", () => {
|
|||||||
expect(h.app.device).toMatchInlineSnapshot(`
|
expect(h.app.device).toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
"canDeviceFitSidebar": false,
|
"canDeviceFitSidebar": false,
|
||||||
|
"isLandscape": true,
|
||||||
"isMobile": true,
|
"isMobile": true,
|
||||||
"isSmScreen": false,
|
"isSmScreen": false,
|
||||||
"isTouchScreen": false,
|
"isTouchScreen": false,
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -35,7 +35,7 @@ Object {
|
|||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
@ -68,7 +68,7 @@ Object {
|
|||||||
"type": 2,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
@ -101,7 +101,7 @@ Object {
|
|||||||
"type": 2,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
@ -147,7 +147,7 @@ Object {
|
|||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
@ -180,7 +180,7 @@ Object {
|
|||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
|
@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
|
|||||||
class="excalidraw-wysiwyg"
|
class="excalidraw-wysiwyg"
|
||||||
data-type="wysiwyg"
|
data-type="wysiwyg"
|
||||||
dir="auto"
|
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"
|
tabindex="0"
|
||||||
wrap="off"
|
wrap="off"
|
||||||
/>
|
/>
|
||||||
|
@ -18,7 +18,7 @@ Object {
|
|||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 401146281,
|
"seed": 401146281,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
@ -49,7 +49,7 @@ Object {
|
|||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
@ -80,7 +80,7 @@ Object {
|
|||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
@ -116,7 +116,7 @@ Object {
|
|||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
@ -152,7 +152,7 @@ Object {
|
|||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 449462985,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
@ -206,7 +206,7 @@ Object {
|
|||||||
"focus": -0.6000000000000001,
|
"focus": -0.6000000000000001,
|
||||||
"gap": 10,
|
"gap": 10,
|
||||||
},
|
},
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
|
@ -40,7 +40,7 @@ Object {
|
|||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
@ -93,7 +93,7 @@ Object {
|
|||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -33,7 +33,7 @@ Object {
|
|||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
@ -79,7 +79,7 @@ Object {
|
|||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
@ -110,7 +110,7 @@ Object {
|
|||||||
"type": 2,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "diamond",
|
"type": "diamond",
|
||||||
@ -141,7 +141,7 @@ Object {
|
|||||||
"type": 2,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "ellipse",
|
"type": "ellipse",
|
||||||
@ -172,7 +172,7 @@ Object {
|
|||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 337897,
|
"seed": 337897,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
queryByText,
|
queryByText,
|
||||||
queryAllByText,
|
queryAllByText,
|
||||||
waitFor,
|
waitFor,
|
||||||
|
togglePopover,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
import ExcalidrawApp from "../excalidraw-app";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import * as Renderer from "../renderer/renderScene";
|
import * as Renderer from "../renderer/renderScene";
|
||||||
@ -19,7 +20,6 @@ import { ShortcutName } from "../actions/shortcuts";
|
|||||||
import { copiedStyles } from "../actions/actionStyles";
|
import { copiedStyles } from "../actions/actionStyles";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { setDateTimeForTests } from "../utils";
|
import { setDateTimeForTests } from "../utils";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { LibraryItem } from "../types";
|
import { LibraryItem } from "../types";
|
||||||
|
|
||||||
const checkpoint = (name: string) => {
|
const checkpoint = (name: string) => {
|
||||||
@ -303,10 +303,10 @@ describe("contextMenu element", () => {
|
|||||||
mouse.up(20, 20);
|
mouse.up(20, 20);
|
||||||
|
|
||||||
// Change some styles of second rectangle
|
// Change some styles of second rectangle
|
||||||
UI.clickLabeledElement("Stroke");
|
togglePopover("Stroke");
|
||||||
UI.clickLabeledElement(t("colors.c92a2a"));
|
UI.clickOnTestId("color-red");
|
||||||
UI.clickLabeledElement("Background");
|
togglePopover("Background");
|
||||||
UI.clickLabeledElement(t("colors.e64980"));
|
UI.clickOnTestId("color-blue");
|
||||||
// Fill style
|
// Fill style
|
||||||
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
||||||
// Stroke width
|
// Stroke width
|
||||||
@ -320,13 +320,20 @@ describe("contextMenu element", () => {
|
|||||||
target: { value: "60" },
|
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();
|
mouse.reset();
|
||||||
|
|
||||||
// Copy styles of second rectangle
|
// Copy styles of second rectangle
|
||||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
clientX: 40,
|
clientX: 40,
|
||||||
clientY: 40,
|
clientY: 40,
|
||||||
});
|
});
|
||||||
|
|
||||||
let contextMenu = UI.queryContextMenu();
|
let contextMenu = UI.queryContextMenu();
|
||||||
fireEvent.click(queryByText(contextMenu!, "Copy styles")!);
|
fireEvent.click(queryByText(contextMenu!, "Copy styles")!);
|
||||||
const secondRect = JSON.parse(copiedStyles)[0];
|
const secondRect = JSON.parse(copiedStyles)[0];
|
||||||
@ -344,8 +351,8 @@ describe("contextMenu element", () => {
|
|||||||
|
|
||||||
const firstRect = API.getSelectedElement();
|
const firstRect = API.getSelectedElement();
|
||||||
expect(firstRect.id).toBe(h.elements[0].id);
|
expect(firstRect.id).toBe(h.elements[0].id);
|
||||||
expect(firstRect.strokeColor).toBe("#c92a2a");
|
expect(firstRect.strokeColor).toBe("#e03131");
|
||||||
expect(firstRect.backgroundColor).toBe("#e64980");
|
expect(firstRect.backgroundColor).toBe("#a5d8ff");
|
||||||
expect(firstRect.fillStyle).toBe("cross-hatch");
|
expect(firstRect.fillStyle).toBe("cross-hatch");
|
||||||
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
||||||
expect(firstRect.strokeStyle).toBe("dotted");
|
expect(firstRect.strokeStyle).toBe("dotted");
|
||||||
|
@ -33,7 +33,7 @@ Object {
|
|||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
@ -173,7 +173,7 @@ Object {
|
|||||||
},
|
},
|
||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
"simulatePressure": true,
|
"simulatePressure": true,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "freedraw",
|
"type": "freedraw",
|
||||||
@ -219,7 +219,7 @@ Object {
|
|||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
@ -265,7 +265,7 @@ Object {
|
|||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"type": "line",
|
"type": "line",
|
||||||
@ -302,7 +302,7 @@ Object {
|
|||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"text": "text",
|
"text": "text",
|
||||||
@ -342,7 +342,7 @@ Object {
|
|||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
"strokeColor": "#000000",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 1,
|
"strokeWidth": 1,
|
||||||
"text": "",
|
"text": "",
|
||||||
|
@ -237,6 +237,15 @@ export class UI {
|
|||||||
fireEvent.click(element);
|
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
|
* 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
|
* 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
|
<div
|
||||||
class="Island dropdown-menu-container"
|
class="Island dropdown-menu-container"
|
||||||
style="--padding: 2; z-index: 1;"
|
style="--padding: 2; z-index: 2;"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="dropdown-menu-item dropdown-menu-item-base"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
@ -115,7 +115,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should render menu
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="Island dropdown-menu-container"
|
class="Island dropdown-menu-container"
|
||||||
style="--padding: 2; z-index: 1;"
|
style="--padding: 2; z-index: 2;"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-label="Open"
|
aria-label="Open"
|
||||||
@ -523,38 +523,84 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should render menu
|
|||||||
<div
|
<div
|
||||||
style="padding: 0px 0.625rem;"
|
style="padding: 0px 0.625rem;"
|
||||||
>
|
>
|
||||||
<div
|
<div>
|
||||||
style="position: relative;"
|
<div
|
||||||
>
|
aria-modal="true"
|
||||||
<div>
|
class="color-picker-container"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="color-picker-control-container"
|
class="color-picker__top-picks"
|
||||||
>
|
>
|
||||||
<div
|
<button
|
||||||
class="color-picker-label-swatch-container"
|
class="color-picker__button active"
|
||||||
>
|
style="--swatch-color: #ffffff;"
|
||||||
<button
|
title="#ffffff"
|
||||||
aria-label="Canvas background"
|
type="button"
|
||||||
class="color-picker-label-swatch"
|
|
||||||
style="--swatch-color: #ffffff;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
class="color-input-container"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="color-picker-hash"
|
class="color-picker__button-outline"
|
||||||
>
|
|
||||||
#
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
aria-label="Canvas background"
|
|
||||||
class="color-picker-input"
|
|
||||||
spellcheck="false"
|
|
||||||
value="ffffff"
|
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -20,7 +20,7 @@ Object {
|
|||||||
"currentItemRoughness": 1,
|
"currentItemRoughness": 1,
|
||||||
"currentItemRoundness": "round",
|
"currentItemRoundness": "round",
|
||||||
"currentItemStartArrowhead": null,
|
"currentItemStartArrowhead": null,
|
||||||
"currentItemStrokeColor": "#000000",
|
"currentItemStrokeColor": "#1e1e1e",
|
||||||
"currentItemStrokeStyle": "solid",
|
"currentItemStrokeStyle": "solid",
|
||||||
"currentItemStrokeWidth": 1,
|
"currentItemStrokeWidth": 1,
|
||||||
"currentItemTextAlign": "left",
|
"currentItemTextAlign": "left",
|
||||||
|
@ -12,11 +12,11 @@ import {
|
|||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
|
togglePopover,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
import { defaultLang } from "../i18n";
|
import { defaultLang } from "../i18n";
|
||||||
import { FONT_FAMILY } from "../constants";
|
import { FONT_FAMILY } from "../constants";
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@ -42,7 +42,6 @@ const checkpoint = (name: string) => {
|
|||||||
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
@ -159,13 +158,14 @@ describe("regression tests", () => {
|
|||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
mouse.down(10, 10);
|
mouse.down(10, 10);
|
||||||
mouse.up(10, 10);
|
mouse.up(10, 10);
|
||||||
|
togglePopover("Background");
|
||||||
|
UI.clickOnTestId("color-yellow");
|
||||||
|
UI.clickOnTestId("color-red");
|
||||||
|
|
||||||
UI.clickLabeledElement("Background");
|
togglePopover("Stroke");
|
||||||
UI.clickLabeledElement(t("colors.fa5252"));
|
UI.clickOnTestId("color-blue");
|
||||||
UI.clickLabeledElement("Stroke");
|
expect(API.getSelectedElement().backgroundColor).toBe("#ffc9c9");
|
||||||
UI.clickLabeledElement(t("colors.5f3dc4"));
|
expect(API.getSelectedElement().strokeColor).toBe("#1971c2");
|
||||||
expect(API.getSelectedElement().backgroundColor).toBe("#fa5252");
|
|
||||||
expect(API.getSelectedElement().strokeColor).toBe("#5f3dc4");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("click on an element and drag it", () => {
|
it("click on an element and drag it", () => {
|
||||||
@ -988,8 +988,8 @@ describe("regression tests", () => {
|
|||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
// change background color since default is transparent
|
// change background color since default is transparent
|
||||||
// and transparent elements can't be selected by clicking inside of them
|
// and transparent elements can't be selected by clicking inside of them
|
||||||
UI.clickLabeledElement("Background");
|
togglePopover("Background");
|
||||||
UI.clickLabeledElement(t("colors.fa5252"));
|
UI.clickOnTestId("color-red");
|
||||||
mouse.down();
|
mouse.down();
|
||||||
mouse.up(1000, 1000);
|
mouse.up(1000, 1000);
|
||||||
|
|
||||||
@ -1088,15 +1088,14 @@ describe("regression tests", () => {
|
|||||||
assertSelectedElements(rect3);
|
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");
|
UI.clickTool("rectangle");
|
||||||
expect(screen.queryByText(/fill/i)).not.toBeNull();
|
expect(screen.queryByText(/fill/i)).not.toBeNull();
|
||||||
mouse.down();
|
mouse.down();
|
||||||
mouse.up(10, 10);
|
mouse.up(10, 10);
|
||||||
expect(screen.queryByText(/fill/i)).toBeNull();
|
expect(screen.queryByText(/fill/i)).toBeNull();
|
||||||
|
togglePopover("Background");
|
||||||
UI.clickLabeledElement("Background");
|
UI.clickOnTestId("color-red");
|
||||||
UI.clickLabeledElement(t("colors.fa5252"));
|
|
||||||
// select rectangle
|
// select rectangle
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
mouse.click();
|
mouse.click();
|
||||||
|
@ -16,6 +16,7 @@ import { STORAGE_KEYS } from "../excalidraw-app/app_constants";
|
|||||||
import { SceneData } from "../types";
|
import { SceneData } from "../types";
|
||||||
import { getSelectedElements } from "../scene/selection";
|
import { getSelectedElements } from "../scene/selection";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { UI } from "./helpers/ui";
|
||||||
|
|
||||||
const customQueries = {
|
const customQueries = {
|
||||||
...queries,
|
...queries,
|
||||||
@ -186,11 +187,6 @@ export const assertSelectedElements = (
|
|||||||
expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
|
expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleMenu = (container: HTMLElement) => {
|
|
||||||
// open menu
|
|
||||||
fireEvent.click(container.querySelector(".dropdown-menu-button")!);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createPasteEvent = (
|
export const createPasteEvent = (
|
||||||
text:
|
text:
|
||||||
| string
|
| 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;
|
isRotating: boolean;
|
||||||
zoom: Zoom;
|
zoom: Zoom;
|
||||||
openMenu: "canvas" | "shape" | null;
|
openMenu: "canvas" | "shape" | null;
|
||||||
openPopup:
|
openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
|
||||||
| "canvasColorPicker"
|
|
||||||
| "backgroundColorPicker"
|
|
||||||
| "strokeColorPicker"
|
|
||||||
| null;
|
|
||||||
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
|
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
|
||||||
openDialog: "imageExport" | "help" | "jsonExport" | null;
|
openDialog: "imageExport" | "help" | "jsonExport" | null;
|
||||||
/**
|
/**
|
||||||
@ -542,4 +538,5 @@ export type Device = Readonly<{
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
isTouchScreen: boolean;
|
isTouchScreen: boolean;
|
||||||
canDeviceFitSidebar: boolean;
|
canDeviceFitSidebar: boolean;
|
||||||
|
isLandscape: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
|
import { COLOR_PALETTE } from "./colors";
|
||||||
import colors from "./colors";
|
|
||||||
import {
|
import {
|
||||||
CURSOR_TYPE,
|
CURSOR_TYPE,
|
||||||
DEFAULT_VERSION,
|
DEFAULT_VERSION,
|
||||||
@ -529,7 +528,7 @@ export const isTransparent = (color: string) => {
|
|||||||
return (
|
return (
|
||||||
isRGBTransparent ||
|
isRGBTransparent ||
|
||||||
isRRGGBBTransparent ||
|
isRRGGBBTransparent ||
|
||||||
color === colors.elementBackground[0]
|
color === COLOR_PALETTE.transparent
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user