diff --git a/.npmrc b/.npmrc index cffe8cdef1..1b78f1c6f2 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ save-exact=true +legacy-peer-deps=true diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx index 2626818955..cdd5ea5a49 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx @@ -16,7 +16,6 @@ function App() { className="custom-footer" onClick={() => alert("This is dummy footer")} > - {" "} custom footer diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/main-menu.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/main-menu.mdx index 8fbf228df1..2494df108f 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/main-menu.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/main-menu.mdx @@ -14,8 +14,7 @@ function App() { Item1 window.alert("Item2")}> - {" "} - Item 2{" "} + Item 2 @@ -93,7 +92,6 @@ function App() { style={{ height: "2rem" }} onClick={() => window.alert("custom menu item")} > - {" "} custom item diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx index ca329e3e62..6531e29ecf 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx @@ -3,7 +3,7 @@ ## renderTopRightUI
-  (isMobile: boolean, appState:{" "}
+  (isMobile: boolean, appState:
   
     AppState
   
@@ -29,8 +29,7 @@ function App() {
               }}
               onClick={() => window.alert("This is dummy top right UI")}
             >
-              {" "}
-              Click me{" "}
+              Click me
             
           );
         }}
@@ -55,8 +54,7 @@ function App() {
        (
           

- {" "} - Dummy stats will be shown here{" "} + Dummy stats will be shown here

)} /> @@ -105,8 +103,7 @@ function App() { return (
{ + PanelComponent: ({ elements, appState, updateData, appProps }) => { // FIXME move me to src/components/mainMenu/DefaultItems.tsx return ( -
- updateData({ viewBackgroundColor: color })} - isActive={appState.openPopup === "canvasColorPicker"} - setActive={(active) => - updateData({ openPopup: active ? "canvasColorPicker" : null }) - } - data-testid="canvas-background-picker" - elements={elements} - appState={appState} - /> -
+ updateData({ viewBackgroundColor: color })} + data-testid="canvas-background-picker" + elements={elements} + appState={appState} + updateData={updateData} + /> ); }, }); diff --git a/src/actions/actionElementLock.test.tsx b/src/actions/actionElementLock.test.tsx new file mode 100644 index 0000000000..19db5e3259 --- /dev/null +++ b/src/actions/actionElementLock.test.tsx @@ -0,0 +1,68 @@ +import { Excalidraw } from "../packages/excalidraw/index"; +import { queryByTestId, fireEvent } from "@testing-library/react"; +import { render } from "../tests/test-utils"; +import { Pointer, UI } from "../tests/helpers/ui"; +import { API } from "../tests/helpers/api"; + +const { h } = window; +const mouse = new Pointer("mouse"); + +describe("element locking", () => { + it("should not show unlockAllElements action in contextMenu if no elements locked", async () => { + await render(); + + mouse.rightClickAt(0, 0); + + const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements"); + expect(item).toBe(null); + }); + + it("should unlock all elements and select them when using unlockAllElements action in contextMenu", async () => { + await render( + , + ); + + mouse.rightClickAt(0, 0); + + expect(Object.keys(h.state.selectedElementIds).length).toBe(0); + expect(h.elements.map((el) => el.locked)).toEqual([true, true, false]); + + const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements"); + expect(item).not.toBe(null); + + fireEvent.click(item!.querySelector("button")!); + + expect(h.elements.map((el) => el.locked)).toEqual([false, false, false]); + // should select the unlocked elements + expect(h.state.selectedElementIds).toEqual({ + [h.elements[0].id]: true, + [h.elements[1].id]: true, + }); + }); +}); diff --git a/src/actions/actionToggleLock.ts b/src/actions/actionElementLock.ts similarity index 53% rename from src/actions/actionToggleLock.ts rename to src/actions/actionElementLock.ts index c44bd57008..922a5fae38 100644 --- a/src/actions/actionToggleLock.ts +++ b/src/actions/actionElementLock.ts @@ -5,8 +5,11 @@ import { getSelectedElements } from "../scene"; import { arrayToMap } from "../utils"; import { register } from "./register"; -export const actionToggleLock = register({ - name: "toggleLock", +const shouldLock = (elements: readonly ExcalidrawElement[]) => + elements.every((el) => !el.locked); + +export const actionToggleElementLock = register({ + name: "toggleElementLock", trackEvent: { category: "element" }, perform: (elements, appState) => { const selectedElements = getSelectedElements(elements, appState, true); @@ -15,20 +18,21 @@ export const actionToggleLock = register({ return false; } - const operation = getOperation(selectedElements); + const nextLockState = shouldLock(selectedElements); const selectedElementsMap = arrayToMap(selectedElements); - const lock = operation === "lock"; return { elements: elements.map((element) => { if (!selectedElementsMap.has(element.id)) { return element; } - return newElementWith(element, { locked: lock }); + return newElementWith(element, { locked: nextLockState }); }), appState: { ...appState, - selectedLinearElement: lock ? null : appState.selectedLinearElement, + selectedLinearElement: nextLockState + ? null + : appState.selectedLinearElement, }, commitToHistory: true, }; @@ -41,7 +45,7 @@ export const actionToggleLock = register({ : "labels.elementLock.lock"; } - return getOperation(selected) === "lock" + return shouldLock(selected) ? "labels.elementLock.lockAll" : "labels.elementLock.unlockAll"; }, @@ -55,6 +59,31 @@ export const actionToggleLock = register({ }, }); -const getOperation = ( - elements: readonly ExcalidrawElement[], -): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock"); +export const actionUnlockAllElements = register({ + name: "unlockAllElements", + trackEvent: { category: "canvas" }, + viewMode: false, + predicate: (elements) => { + return elements.some((element) => element.locked); + }, + perform: (elements, appState) => { + const lockedElements = elements.filter((el) => el.locked); + + return { + elements: elements.map((element) => { + if (element.locked) { + return newElementWith(element, { locked: false }); + } + return element; + }), + appState: { + ...appState, + selectedElementIds: Object.fromEntries( + lockedElements.map((el) => [el.id, true]), + ), + }, + commitToHistory: true, + }; + }, + contextItemLabel: "labels.elementLock.unlockAll", +}); diff --git a/src/actions/actionFlip.ts b/src/actions/actionFlip.ts index a4efbc88b5..b662308bd3 100644 --- a/src/actions/actionFlip.ts +++ b/src/actions/actionFlip.ts @@ -4,9 +4,8 @@ import { getNonDeletedElements } from "../element"; import { ExcalidrawElement, NonDeleted } from "../element/types"; import { resizeMultipleElements } from "../element/resizeElements"; import { AppState, PointerDownState } from "../types"; -import { updateBoundElements } from "../element/binding"; import { arrayToMap } from "../utils"; -import { KEYS } from "../keys"; +import { CODES, KEYS } from "../keys"; import { getCommonBoundingBox } from "../element/bounds"; const enableActionFlipHorizontal = ( @@ -33,7 +32,7 @@ export const actionFlipHorizontal = register({ commitToHistory: true, }; }, - keyTest: (event) => event.shiftKey && event.code === "KeyH", + keyTest: (event) => event.shiftKey && event.code === CODES.H, contextItemLabel: "labels.flipHorizontal", predicate: (elements, appState) => enableActionFlipHorizontal(elements, appState), @@ -50,7 +49,7 @@ export const actionFlipVertical = register({ }; }, keyTest: (event) => - event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD], + event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD], contextItemLabel: "labels.flipVertical", predicate: (elements, appState) => enableActionFlipVertical(elements, appState), diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 382e964b9c..6e921d6ea8 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -1,7 +1,13 @@ import { AppState } from "../../src/types"; +import { + DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, + DEFAULT_ELEMENT_BACKGROUND_PICKS, + DEFAULT_ELEMENT_STROKE_COLOR_PALETTE, + DEFAULT_ELEMENT_STROKE_PICKS, +} from "../colors"; import { trackEvent } from "../analytics"; import { ButtonIconSelect } from "../components/ButtonIconSelect"; -import { ColorPicker } from "../components/ColorPicker"; +import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { IconPicker } from "../components/IconPicker"; // TODO barnabasmolnar/editor-redesign // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon, @@ -226,10 +232,12 @@ export const actionChangeStrokeColor = register({ commitToHistory: !!value.currentItemStrokeColor, }; }, - PanelComponent: ({ elements, appState, updateData }) => ( + PanelComponent: ({ elements, appState, updateData, appProps }) => ( <> updateData({ currentItemStrokeColor: color })} - isActive={appState.openPopup === "strokeColorPicker"} - setActive={(active) => - updateData({ openPopup: active ? "strokeColorPicker" : null }) - } elements={elements} appState={appState} + updateData={updateData} /> ), @@ -269,10 +274,12 @@ export const actionChangeBackgroundColor = register({ commitToHistory: !!value.currentItemBackgroundColor, }; }, - PanelComponent: ({ elements, appState, updateData }) => ( + PanelComponent: ({ elements, appState, updateData, appProps }) => ( <> updateData({ currentItemBackgroundColor: color })} - isActive={appState.openPopup === "backgroundColorPicker"} - setActive={(active) => - updateData({ openPopup: active ? "backgroundColorPicker" : null }) - } elements={elements} appState={appState} + updateData={updateData} /> ), diff --git a/src/actions/actionStyles.test.tsx b/src/actions/actionStyles.test.tsx index c73864cc48..238196dfc9 100644 --- a/src/actions/actionStyles.test.tsx +++ b/src/actions/actionStyles.test.tsx @@ -1,9 +1,14 @@ import ExcalidrawApp from "../excalidraw-app"; -import { t } from "../i18n"; import { CODES } from "../keys"; import { API } from "../tests/helpers/api"; import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; -import { fireEvent, render, screen } from "../tests/test-utils"; +import { + act, + fireEvent, + render, + screen, + togglePopover, +} from "../tests/test-utils"; import { copiedStyles } from "./actionStyles"; const { h } = window; @@ -14,7 +19,14 @@ describe("actionStyles", () => { beforeEach(async () => { await render(); }); - it("should copy & paste styles via keyboard", () => { + + afterEach(async () => { + // https://github.com/floating-ui/floating-ui/issues/1908#issuecomment-1301553793 + // affects node v16+ + await act(async () => {}); + }); + + it("should copy & paste styles via keyboard", async () => { UI.clickTool("rectangle"); mouse.down(10, 10); mouse.up(20, 20); @@ -24,10 +36,10 @@ describe("actionStyles", () => { mouse.up(20, 20); // Change some styles of second rectangle - UI.clickLabeledElement("Stroke"); - UI.clickLabeledElement(t("colors.c92a2a")); - UI.clickLabeledElement("Background"); - UI.clickLabeledElement(t("colors.e64980")); + togglePopover("Stroke"); + UI.clickOnTestId("color-red"); + togglePopover("Background"); + UI.clickOnTestId("color-blue"); // Fill style fireEvent.click(screen.getByTitle("Cross-hatch")); // Stroke width @@ -60,8 +72,8 @@ describe("actionStyles", () => { const firstRect = API.getSelectedElement(); expect(firstRect.id).toBe(h.elements[0].id); - expect(firstRect.strokeColor).toBe("#c92a2a"); - expect(firstRect.backgroundColor).toBe("#e64980"); + expect(firstRect.strokeColor).toBe("#e03131"); + expect(firstRect.backgroundColor).toBe("#a5d8ff"); expect(firstRect.fillStyle).toBe("cross-hatch"); expect(firstRect.strokeWidth).toBe(2); // Bold: 2 expect(firstRect.strokeStyle).toBe("dotted"); diff --git a/src/actions/index.ts b/src/actions/index.ts index eea4faf7d5..9b53f81731 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -84,5 +84,5 @@ export { actionToggleZenMode } from "./actionToggleZenMode"; export { actionToggleStats } from "./actionToggleStats"; export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionLink } from "../element/Hyperlink"; -export { actionToggleLock } from "./actionToggleLock"; +export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index be48c64707..b9c24a7572 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -34,7 +34,7 @@ export type ShortcutName = | "flipHorizontal" | "flipVertical" | "hyperlink" - | "toggleLock" + | "toggleElementLock" > | "saveScene" | "imageExport"; @@ -80,7 +80,7 @@ const shortcutMap: Record = { flipVertical: [getShortcutKey("Shift+V")], viewMode: [getShortcutKey("Alt+R")], hyperlink: [getShortcutKey("CtrlOrCmd+K")], - toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")], + toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")], }; export const getShortcutFromShortcutName = (name: ShortcutName) => { diff --git a/src/actions/types.ts b/src/actions/types.ts index b03e1053b2..277934adc9 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -111,7 +111,8 @@ export type ActionName = | "unbindText" | "hyperlink" | "bindText" - | "toggleLock" + | "unlockAllElements" + | "toggleElementLock" | "toggleLinearEditor" | "toggleEraserTool" | "toggleHandTool" diff --git a/src/appState.ts b/src/appState.ts index 6f4db75572..4468eb9666 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -1,4 +1,4 @@ -import oc from "open-color"; +import { COLOR_PALETTE } from "./colors"; import { DEFAULT_ELEMENT_PROPS, DEFAULT_FONT_FAMILY, @@ -58,7 +58,7 @@ export const getDefaultAppState = (): Omit< fileHandle: null, gridSize: null, isBindingEnabled: true, - isSidebarDocked: false, + defaultSidebarDockedPreference: false, isLoading: false, isResizing: false, isRotating: false, @@ -84,7 +84,7 @@ export const getDefaultAppState = (): Omit< startBoundElement: null, suggestedBindings: [], toast: null, - viewBackgroundColor: oc.white, + viewBackgroundColor: COLOR_PALETTE.white, zenModeEnabled: false, zoom: { value: 1 as NormalizedZoomValue, @@ -150,7 +150,11 @@ const APP_STATE_STORAGE_CONF = (< gridSize: { browser: true, export: true, server: true }, height: { browser: false, export: false, server: false }, isBindingEnabled: { browser: false, export: false, server: false }, - isSidebarDocked: { browser: true, export: false, server: false }, + defaultSidebarDockedPreference: { + browser: true, + export: false, + server: false, + }, isLoading: { browser: false, export: false, server: false }, isResizing: { browser: false, export: false, server: false }, isRotating: { browser: false, export: false, server: false }, diff --git a/src/charts.ts b/src/charts.ts index c3b0950d1f..b5714686c2 100644 --- a/src/charts.ts +++ b/src/charts.ts @@ -1,5 +1,14 @@ -import colors from "./colors"; -import { DEFAULT_FONT_SIZE, ENV } from "./constants"; +import { + COLOR_PALETTE, + DEFAULT_CHART_COLOR_INDEX, + getAllColorsSpecificShade, +} from "./colors"; +import { + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + ENV, + VERTICAL_ALIGN, +} from "./constants"; import { newElement, newLinearElement, newTextElement } from "./element"; import { NonDeletedExcalidrawElement } from "./element/types"; import { randomId } from "./random"; @@ -153,15 +162,22 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => { return result; }; -const bgColors = colors.elementBackground.slice( - 2, - colors.elementBackground.length, -); +const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); // Put all the common properties here so when the whole chart is selected // the properties dialog shows the correct selected values const commonProps = { - strokeColor: colors.elementStroke[0], + fillStyle: "hachure", + fontFamily: DEFAULT_FONT_FAMILY, + fontSize: DEFAULT_FONT_SIZE, + opacity: 100, + roughness: 1, + strokeColor: COLOR_PALETTE.black, + roundness: null, + strokeStyle: "solid", + strokeWidth: 1, + verticalAlign: VERTICAL_ALIGN.MIDDLE, + locked: false, } as const; const getChartDimentions = (spreadsheet: Spreadsheet) => { @@ -322,7 +338,7 @@ const chartBaseElements = ( y: y - chartHeight, width: chartWidth, height: chartHeight, - strokeColor: colors.elementStroke[0], + strokeColor: COLOR_PALETTE.black, fillStyle: "solid", opacity: 6, }) diff --git a/src/clients.ts b/src/clients.ts index 9e1e6e144a..604936e334 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -1,6 +1,17 @@ -import colors from "./colors"; +import { + DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, + DEFAULT_ELEMENT_STROKE_COLOR_INDEX, + getAllColorsSpecificShade, +} from "./colors"; import { AppState } from "./types"; +const BG_COLORS = getAllColorsSpecificShade( + DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, +); +const STROKE_COLORS = getAllColorsSpecificShade( + DEFAULT_ELEMENT_STROKE_COLOR_INDEX, +); + export const getClientColors = (clientId: string, appState: AppState) => { if (appState?.collaborators) { const currentUser = appState.collaborators.get(clientId); @@ -11,18 +22,19 @@ export const getClientColors = (clientId: string, appState: AppState) => { // Naive way of getting an integer out of the clientId const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0); - // Skip transparent & gray colors - const backgrounds = colors.elementBackground.slice(3); - const strokes = colors.elementStroke.slice(3); return { - background: backgrounds[sum % backgrounds.length], - stroke: strokes[sum % strokes.length], + background: BG_COLORS[sum % BG_COLORS.length], + stroke: STROKE_COLORS[sum % STROKE_COLORS.length], }; }; -export const getClientInitials = (userName?: string | null) => { - if (!userName?.trim()) { - return "?"; - } - return userName.trim()[0].toUpperCase(); +/** + * returns first char, capitalized + */ +export const getNameInitial = (name?: string | null) => { + // first char can be a surrogate pair, hence using codePointAt + const firstCodePoint = name?.trim()?.codePointAt(0); + return ( + firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?" + ).toUpperCase(); }; diff --git a/src/colors.ts b/src/colors.ts index 63cd728395..198ec12e23 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -1,22 +1,167 @@ import oc from "open-color"; +import { Merge } from "./utility-types"; -const shades = (index: number) => [ - oc.red[index], - oc.pink[index], - oc.grape[index], - oc.violet[index], - oc.indigo[index], - oc.blue[index], - oc.cyan[index], - oc.teal[index], - oc.green[index], - oc.lime[index], - oc.yellow[index], - oc.orange[index], -]; - -export default { - canvasBackground: [oc.white, oc.gray[0], oc.gray[1], ...shades(0)], - elementBackground: ["transparent", oc.gray[4], oc.gray[6], ...shades(6)], - elementStroke: [oc.black, oc.gray[8], oc.gray[7], ...shades(9)], +// FIXME can't put to utils.ts rn because of circular dependency +const pick = , K extends readonly (keyof R)[]>( + source: R, + keys: K, +) => { + return keys.reduce((acc, key: K[number]) => { + if (key in source) { + acc[key] = source[key]; + } + return acc; + }, {} as Pick) as Pick; }; + +export type ColorPickerColor = + | Exclude + | "transparent" + | "bronze"; +export type ColorTuple = readonly [string, string, string, string, string]; +export type ColorPalette = Merge< + Record, + { 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, +) => { + 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; + +// ----------------------------------------------------------------------------- diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 3bbc0ff1a6..875d8447ca 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -14,7 +14,7 @@ import { hasText, } from "../scene"; import { SHAPES } from "../shapes"; -import { AppState, Zoom } from "../types"; +import { UIAppState, Zoom } from "../types"; import { capitalizeString, isTransparent, @@ -28,19 +28,20 @@ import { trackEvent } from "../analytics"; import { hasBoundTextElement } from "../element/typeChecks"; import clsx from "clsx"; import { actionToggleZenMode } from "../actions"; -import "./Actions.scss"; import { Tooltip } from "./Tooltip"; import { shouldAllowVerticalAlign, suppportsHorizontalAlign, } from "../element/textElement"; +import "./Actions.scss"; + export const SelectedShapeActions = ({ appState, elements, renderAction, }: { - appState: AppState; + appState: UIAppState; elements: readonly ExcalidrawElement[]; renderAction: ActionManager["renderAction"]; }) => { @@ -215,10 +216,10 @@ export const ShapesSwitcher = ({ appState, }: { canvas: HTMLCanvasElement | null; - activeTool: AppState["activeTool"]; - setAppState: React.Component["setState"]; + activeTool: UIAppState["activeTool"]; + setAppState: React.Component["setState"]; onImageAction: (data: { pointerType: PointerType | null }) => void; - appState: AppState; + appState: UIAppState; }) => ( <> {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { diff --git a/src/components/App.tsx b/src/components/App.tsx index d22a0507cb..e6f9616985 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -33,7 +33,7 @@ import { actionBindText, actionUngroup, actionLink, - actionToggleLock, + actionToggleElementLock, actionToggleLinearEditor, } from "../actions"; import { createRedoAction, createUndoAction } from "../actions/actionHistory"; @@ -59,6 +59,7 @@ import { ELEMENT_TRANSLATE_AMOUNT, ENV, EVENT, + EXPORT_IMAGE_TYPES, GRID_SIZE, IMAGE_MIME_TYPES, IMAGE_RENDER_TIMEOUT, @@ -82,7 +83,7 @@ import { VERTICAL_ALIGN, ZOOM_STEP, } from "../constants"; -import { loadFromBlob } from "../data"; +import { exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { restore, restoreElements } from "../data/restore"; import { @@ -210,6 +211,8 @@ import { PointerDownState, SceneData, Device, + SidebarName, + SidebarTabName, } from "../types"; import { debounce, @@ -235,6 +238,7 @@ import { getShortcutKey, isTransparent, easeToValuesRAF, + muteFSAbortError, } from "../utils"; import { ContextMenu, @@ -249,6 +253,7 @@ import { generateIdFromFile, getDataURL, getFileFromEvent, + isImageFileHandle, isSupportedImageFile, loadSceneOrLibraryFromBlob, normalizeFile, @@ -288,6 +293,7 @@ import { isLocalLink, } from "../element/Hyperlink"; import { shouldShowBoundingBox } from "../element/transformHandles"; +import { actionUnlockAllElements } from "../actions/actionElementLock"; import { Fonts } from "../scene/Fonts"; import { actionPaste } from "../actions/actionClipboard"; import { @@ -299,11 +305,15 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import BraveMeasureTextError from "./BraveMeasureTextError"; +const AppContext = React.createContext(null!); +const AppPropsContext = React.createContext(null!); + const deviceContextInitialValue = { isSmScreen: false, isMobile: false, isTouchScreen: false, canDeviceFitSidebar: false, + isLandscape: false, }; const DeviceContext = React.createContext(deviceContextInitialValue); DeviceContext.displayName = "DeviceContext"; @@ -340,6 +350,8 @@ const ExcalidrawActionManagerContext = React.createContext( ); ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; +export const useApp = () => useContext(AppContext); +export const useAppProps = () => useContext(AppPropsContext); export const useDevice = () => useContext(DeviceContext); export const useExcalidrawContainer = () => useContext(ExcalidrawContainerContext); @@ -400,7 +412,7 @@ class App extends React.Component { private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; - private id: string; + public id: string; private history: History; private excalidrawContainerValue: { container: HTMLDivElement | null; @@ -438,7 +450,7 @@ class App extends React.Component { width: window.innerWidth, height: window.innerHeight, showHyperlinkPopup: false, - isSidebarDocked: false, + defaultSidebarDockedPreference: false, }; this.id = nanoid(); @@ -469,7 +481,7 @@ class App extends React.Component { setActiveTool: this.setActiveTool, setCursor: this.setCursor, resetCursor: this.resetCursor, - toggleMenu: this.toggleMenu, + toggleSidebar: this.toggleSidebar, } as const; if (typeof excalidrawRef === "function") { excalidrawRef(api); @@ -577,101 +589,92 @@ class App extends React.Component { this.props.handleKeyboardGlobally ? undefined : this.onKeyDown } > - - - - - - - - this.addElementsFromPasteOrLibrary({ - elements, - position: "center", - files: null, - }) - } - langCode={getLanguage().code} - renderTopRightUI={renderTopRightUI} - renderCustomStats={renderCustomStats} - renderCustomSidebar={this.props.renderSidebar} - showExitZenModeBtn={ - typeof this.props?.zenModeEnabled === "undefined" && - this.state.zenModeEnabled - } - libraryReturnUrl={this.props.libraryReturnUrl} - UIOptions={this.props.UIOptions} - focusContainer={this.focusContainer} - library={this.library} - id={this.id} - onImageAction={this.onImageAction} - renderWelcomeScreen={ - !this.state.isLoading && - this.state.showWelcomeScreen && - this.state.activeTool.type === "selection" && - !this.scene.getElementsIncludingDeleted().length - } + + + + + + + - {this.props.children} - -
-
- {selectedElement.length === 1 && - !this.state.contextMenu && - this.state.showHyperlinkPopup && ( - + - )} - {this.state.toast !== null && ( - this.setToast(null)} - duration={this.state.toast.duration} - closable={this.state.toast.closable} - /> - )} - {this.state.contextMenu && ( - - )} -
{this.renderCanvas()}
- - {" "} - - - - + actionManager={this.actionManager} + elements={this.scene.getNonDeletedElements()} + onLockToggle={this.toggleLock} + onPenModeToggle={this.togglePenMode} + onHandToolToggle={this.onHandToolToggle} + langCode={getLanguage().code} + renderTopRightUI={renderTopRightUI} + renderCustomStats={renderCustomStats} + showExitZenModeBtn={ + typeof this.props?.zenModeEnabled === "undefined" && + this.state.zenModeEnabled + } + UIOptions={this.props.UIOptions} + onImageAction={this.onImageAction} + onExportImage={this.onExportImage} + renderWelcomeScreen={ + !this.state.isLoading && + this.state.showWelcomeScreen && + this.state.activeTool.type === "selection" && + !this.scene.getElementsIncludingDeleted().length + } + > + {this.props.children} +
+
+
+ {selectedElement.length === 1 && + !this.state.contextMenu && + this.state.showHyperlinkPopup && ( + + )} + {this.state.toast !== null && ( + this.setToast(null)} + duration={this.state.toast.duration} + closable={this.state.toast.closable} + /> + )} + {this.state.contextMenu && ( + + )} +
{this.renderCanvas()}
+ + + + + + + +
); } public focusContainer: AppClassProperties["focusContainer"] = () => { - if (this.props.autoFocus) { - this.excalidrawContainerRef.current?.focus(); - } + this.excalidrawContainerRef.current?.focus(); }; public getSceneElementsIncludingDeleted = () => { @@ -682,6 +685,45 @@ class App extends React.Component { return this.scene.getNonDeletedElements(); }; + public onInsertElements = (elements: readonly ExcalidrawElement[]) => { + this.addElementsFromPasteOrLibrary({ + elements, + position: "center", + files: null, + }); + }; + + public onExportImage = async ( + type: keyof typeof EXPORT_IMAGE_TYPES, + elements: readonly NonDeletedExcalidrawElement[], + ) => { + trackEvent("export", type, "ui"); + const fileHandle = await exportCanvas( + type, + elements, + this.state, + this.files, + { + exportBackground: this.state.exportBackground, + name: this.state.name, + viewBackgroundColor: this.state.viewBackgroundColor, + }, + ) + .catch(muteFSAbortError) + .catch((error) => { + console.error(error); + this.setState({ errorMessage: error.message }); + }); + + if ( + this.state.exportEmbedScene && + fileHandle && + isImageFileHandle(fileHandle) + ) { + this.setState({ fileHandle }); + } + }; + private syncActionResult = withBatchedUpdates( (actionResult: ActionResult) => { if (this.unmounted || actionResult === false) { @@ -906,6 +948,7 @@ class App extends React.Component { ? this.props.UIOptions.dockedSidebarBreakpoint : MQ_RIGHT_SIDEBAR_MIN_WIDTH; this.device = updateObject(this.device, { + isLandscape: width > height, isSmScreen: width < MQ_SM_MAX_WIDTH, isMobile: width < MQ_MAX_WIDTH_PORTRAIT || @@ -951,7 +994,7 @@ class App extends React.Component { this.scene.addCallback(this.onSceneUpdated); this.addEventListeners(); - if (this.excalidrawContainerRef.current) { + if (this.props.autoFocus && this.excalidrawContainerRef.current) { this.focusContainer(); } @@ -1679,7 +1722,7 @@ class App extends React.Component { openSidebar: this.state.openSidebar && this.device.canDeviceFitSidebar && - this.state.isSidebarDocked + this.state.defaultSidebarDockedPreference ? this.state.openSidebar : null, selectedElementIds: newElements.reduce( @@ -2017,30 +2060,24 @@ class App extends React.Component { /** * @returns whether the menu was toggled on or off */ - public toggleMenu = ( - type: "library" | "customSidebar", - force?: boolean, - ): boolean => { - if (type === "customSidebar" && !this.props.renderSidebar) { - console.warn( - `attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`, - ); - return false; + public toggleSidebar = ({ + name, + tab, + force, + }: { + name: SidebarName; + tab?: SidebarTabName; + force?: boolean; + }): boolean => { + let nextName; + if (force === undefined) { + nextName = this.state.openSidebar?.name === name ? null : name; + } else { + nextName = force ? name : null; } + this.setState({ openSidebar: nextName ? { name: nextName, tab } : null }); - if (type === "library" || type === "customSidebar") { - let nextValue; - if (force === undefined) { - nextValue = this.state.openSidebar === type ? null : type; - } else { - nextValue = force ? type : null; - } - this.setState({ openSidebar: nextValue }); - - return !!nextValue; - } - - return false; + return !!nextName; }; private updateCurrentCursorPosition = withBatchedUpdates( @@ -2288,11 +2325,11 @@ class App extends React.Component { (hasBackground(this.state.activeTool.type) || selectedElements.some((element) => hasBackground(element.type))) ) { - this.setState({ openPopup: "backgroundColorPicker" }); + this.setState({ openPopup: "elementBackground" }); event.stopPropagation(); } if (event.key === KEYS.S) { - this.setState({ openPopup: "strokeColorPicker" }); + this.setState({ openPopup: "elementStroke" }); event.stopPropagation(); } } @@ -6348,6 +6385,7 @@ class App extends React.Component { copyText, CONTEXT_MENU_SEPARATOR, actionSelectAll, + actionUnlockAllElements, CONTEXT_MENU_SEPARATOR, actionToggleGridMode, actionToggleZenMode, @@ -6394,7 +6432,7 @@ class App extends React.Component { actionToggleLinearEditor, actionLink, actionDuplicateSelection, - actionToggleLock, + actionToggleElementLock, CONTEXT_MENU_SEPARATOR, actionDeleteSelected, ]; diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 57a7eec261..20dc7b9f2b 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -1,7 +1,7 @@ import "./Avatar.scss"; import React, { useState } from "react"; -import { getClientInitials } from "../clients"; +import { getNameInitial } from "../clients"; type AvatarProps = { onClick: (e: React.MouseEvent) => void; @@ -12,7 +12,7 @@ type AvatarProps = { }; export const Avatar = ({ color, onClick, name, src }: AvatarProps) => { - const shortName = getClientInitials(name); + const shortName = getNameInitial(name); const [error, setError] = useState(false); const loadImg = !error && src; const style = loadImg ? undefined : { background: color }; diff --git a/src/components/BraveMeasureTextError.tsx b/src/components/BraveMeasureTextError.tsx index 8a4a71e4fd..1932d7a294 100644 --- a/src/components/BraveMeasureTextError.tsx +++ b/src/components/BraveMeasureTextError.tsx @@ -1,39 +1,40 @@ -import { t } from "../i18n"; +import Trans from "./Trans"; + const BraveMeasureTextError = () => { return (

- {t("errors.brave_measure_text_error.start")}   - - {t("errors.brave_measure_text_error.aggressive_block_fingerprint")} - {" "} - {t("errors.brave_measure_text_error.setting_enabled")}. -
-
- {t("errors.brave_measure_text_error.break")}{" "} - - {t("errors.brave_measure_text_error.text_elements")} - {" "} - {t("errors.brave_measure_text_error.in_your_drawings")}. + {el}} + />

- {t("errors.brave_measure_text_error.strongly_recommend")}{" "} - - {" "} - {t("errors.brave_measure_text_error.steps")} - {" "} - {t("errors.brave_measure_text_error.how")}. + {el}} + />

- {t("errors.brave_measure_text_error.disable_setting")}{" "} - - {t("errors.brave_measure_text_error.issue")} - {" "} - {t("errors.brave_measure_text_error.write")}{" "} - - {t("errors.brave_measure_text_error.discord")} - - . + ( + + {el} + + )} + /> +

+

+ ( + + {el} + + )} + discordLink={(el) => {el}.} + />

); diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 3303c3ebf8..bf548d72fb 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,8 +1,12 @@ +import clsx from "clsx"; +import { composeEventHandlers } from "../utils"; import "./Button.scss"; interface ButtonProps extends React.HTMLAttributes { type?: "button" | "submit" | "reset"; onSelect: () => any; + /** whether button is in active state */ + selected?: boolean; children: React.ReactNode; className?: string; } @@ -15,18 +19,18 @@ interface ButtonProps extends React.HTMLAttributes { export const Button = ({ type = "button", onSelect, + selected, children, className = "", ...rest }: ButtonProps) => { return ( - ); - }); - }; - - return ( -
-
-
-
{ - if (el) { - gallery.current = el; - } - }} - // to allow focusing by clicking but not by tabbing - tabIndex={-1} - > -
- {renderColors(colors)} -
- {!!customColors.length && ( -
- - {t("labels.canvasColors")} - -
- {renderColors(customColors, true)} -
-
- )} - - {showInput && ( - { - onChange(color); - }} - ref={colorInput} - /> - )} -
-
- ); -}; - -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 ( - - ); - }, -); - -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(null); - const coords = pickerButton.current?.getBoundingClientRect(); - - return ( -
-
-
-
- { - onChange(color); - }} - /> -
- - {isActive ? ( -
- - event.target !== pickerButton.current && setActive(false) - } - > - { - onChange(changedColor); - }} - onClose={() => { - setActive(false); - pickerButton.current?.focus(); - }} - label={label} - showInput={false} - type={type} - elements={elements} - /> - -
- ) : null} -
-
- ); -}; diff --git a/src/components/ColorPicker/ColorInput.tsx b/src/components/ColorPicker/ColorInput.tsx new file mode 100644 index 0000000000..bb9a85510f --- /dev/null +++ b/src/components/ColorPicker/ColorInput.tsx @@ -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(null); + const divRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [activeSection]); + + return ( + + ); +}; diff --git a/src/components/ColorPicker.scss b/src/components/ColorPicker/ColorPicker.scss similarity index 63% rename from src/components/ColorPicker.scss rename to src/components/ColorPicker/ColorPicker.scss index b816b25536..bc7146b6b5 100644 --- a/src/components/ColorPicker.scss +++ b/src/components/ColorPicker/ColorPicker.scss @@ -1,6 +1,134 @@ -@import "../css/variables.module"; +@import "../../css/variables.module"; .excalidraw { + .focus-visible-none { + &:focus-visible { + outline: none !important; + } + } + + .color-picker__heading { + padding: 0 0.5rem; + font-size: 0.75rem; + text-align: left; + } + + .color-picker-container { + display: grid; + grid-template-columns: 1fr 20px 1.625rem; + padding: 0.25rem 0px; + align-items: center; + + @include isMobile { + max-width: 175px; + } + } + + .color-picker__top-picks { + display: flex; + justify-content: space-between; + } + + .color-picker__button { + --radius: 0.25rem; + + padding: 0; + margin: 0; + width: 1.35rem; + height: 1.35rem; + border: 1px solid var(--color-gray-30); + border-radius: var(--radius); + filter: var(--theme-filter); + background-color: var(--swatch-color); + background-position: left center; + position: relative; + font-family: inherit; + box-sizing: border-box; + + &:hover { + &::after { + content: ""; + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + box-shadow: 0 0 0 1px var(--color-gray-30); + border-radius: calc(var(--radius) + 1px); + filter: var(--theme-filter); + } + } + + &.active { + .color-picker__button-outline { + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + box-shadow: 0 0 0 1px var(--color-primary-darkest); + z-index: 1; // due hover state so this has preference + border-radius: calc(var(--radius) + 1px); + filter: var(--theme-filter); + } + } + + &:focus-visible { + outline: none; + + &::after { + content: ""; + position: absolute; + top: -4px; + right: -4px; + bottom: -4px; + left: -4px; + border: 3px solid var(--focus-highlight-color); + border-radius: calc(var(--radius) + 1px); + } + + &.active { + .color-picker__button-outline { + display: none; + } + } + } + + &--large { + --radius: 0.5rem; + width: 1.875rem; + height: 1.875rem; + } + + &.is-transparent { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg=="); + } + + &--no-focus-visible { + border: 0; + &::after { + display: none; + } + &:focus-visible { + outline: none !important; + } + } + + &.active-color { + border-radius: calc(var(--radius) + 1px); + width: 1.625rem; + height: 1.625rem; + } + } + + .color-picker__button__hotkey-label { + position: absolute; + right: 4px; + bottom: 4px; + filter: none; + font-size: 11px; + } + .color-picker { background: var(--popup-bg-color); border: 0 solid transparentize($oc-white, 0.75); @@ -72,11 +200,17 @@ } } + .color-picker-content { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + .color-picker-content--default { padding: 0.5rem; display: grid; - grid-template-columns: repeat(5, auto); - grid-gap: 0.5rem; + grid-template-columns: repeat(5, 1.875rem); + grid-gap: 0.25rem; border-radius: 4px; &:focus { @@ -178,6 +312,27 @@ } } + .color-picker__input-label { + display: grid; + grid-template-columns: auto 1fr auto auto; + gap: 8px; + align-items: center; + border: 1px solid var(--default-border-color); + border-radius: 8px; + padding: 0 12px; + margin: 8px; + box-sizing: border-box; + + &:focus-within { + box-shadow: 0 0 0 1px var(--color-primary-darkest); + border-radius: var(--border-radius-lg); + } + } + + .color-picker__input-hash { + padding: 0 0.25rem; + } + .color-picker-input { box-sizing: border-box; width: 100%; diff --git a/src/components/ColorPicker/ColorPicker.tsx b/src/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 0000000000..25ac7c1a85 --- /dev/null +++ b/src/components/ColorPicker/ColorPicker.tsx @@ -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 = ( +
+ {t("colorPicker.hexCode")} + { + onChange(color); + }} + /> +
+ ); + + return ( + + { + // 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 ? ( + { + onChange(changedColor); + }} + label={label} + type={type} + elements={elements} + updateData={updateData} + > + {colorInputJSX} + + ) : ( + colorInputJSX + )} + + + + ); +}; + +const ColorPickerTrigger = ({ + label, + color, + type, +}: { + color: string | null; + label: string; + type: ColorPickerType; +}) => { + return ( + +
+ + ); +}; + +export const ColorPicker = ({ + type, + color, + onChange, + label, + elements, + palette = COLOR_PALETTE, + topPicks, + updateData, + appState, +}: ColorPickerProps) => { + return ( +
+
+ +
+ { + updateData({ openPopup: open ? type : null }); + }} + > + {/* serves as an active color indicator as well */} + + {/* popup content */} + {appState.openPopup === type && ( + + )} + +
+
+ ); +}; diff --git a/src/components/ColorPicker/CustomColorList.tsx b/src/components/ColorPicker/CustomColorList.tsx new file mode 100644 index 0000000000..f606482b85 --- /dev/null +++ b/src/components/ColorPicker/CustomColorList.tsx @@ -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(null); + + useEffect(() => { + if (btnRef.current) { + btnRef.current.focus(); + } + }, [color, activeColorPickerSection]); + + return ( +
+ {colors.map((c, i) => { + return ( + + ); + })} +
+ ); +}; diff --git a/src/components/ColorPicker/HotkeyLabel.tsx b/src/components/ColorPicker/HotkeyLabel.tsx new file mode 100644 index 0000000000..145060d195 --- /dev/null +++ b/src/components/ColorPicker/HotkeyLabel.tsx @@ -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 ( +
+ {isShade && "⇧"} + {keyLabel} +
+ ); +}; + +export default HotkeyLabel; diff --git a/src/components/ColorPicker/Picker.tsx b/src/components/ColorPicker/Picker.tsx new file mode 100644 index 0000000000..be6410e13f --- /dev/null +++ b/src/components/ColorPicker/Picker.tsx @@ -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 ( +
+
{ + 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 && ( +
+ + {t("colorPicker.mostUsedCustomColors")} + + +
+ )} + +
+ {t("colorPicker.colors")} + +
+ +
+ {t("colorPicker.shades")} + +
+ {children} +
+
+ ); +}; diff --git a/src/components/ColorPicker/PickerColorList.tsx b/src/components/ColorPicker/PickerColorList.tsx new file mode 100644 index 0000000000..bd2d2bfcf8 --- /dev/null +++ b/src/components/ColorPicker/PickerColorList.tsx @@ -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(null); + + useEffect(() => { + if (btnRef.current && activeColorPickerSection === "baseColors") { + btnRef.current.focus(); + } + }, [colorObj?.colorName, activeColorPickerSection]); + + return ( +
+ {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 ( + + ); + })} +
+ ); +}; + +export default PickerColorList; diff --git a/src/components/ColorPicker/PickerHeading.tsx b/src/components/ColorPicker/PickerHeading.tsx new file mode 100644 index 0000000000..0437313669 --- /dev/null +++ b/src/components/ColorPicker/PickerHeading.tsx @@ -0,0 +1,7 @@ +import { ReactNode } from "react"; + +const PickerHeading = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +export default PickerHeading; diff --git a/src/components/ColorPicker/ShadeList.tsx b/src/components/ColorPicker/ShadeList.tsx new file mode 100644 index 0000000000..96c353e3df --- /dev/null +++ b/src/components/ColorPicker/ShadeList.tsx @@ -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(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 ( +
+ {shades.map((color, i) => ( + + ))} +
+ ); + } + } + + return ( +
+
+ ); +}; diff --git a/src/components/ColorPicker/TopPicks.tsx b/src/components/ColorPicker/TopPicks.tsx new file mode 100644 index 0000000000..3345632ebf --- /dev/null +++ b/src/components/ColorPicker/TopPicks.tsx @@ -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 ( +
+ {colors.map((color: string) => ( + + ))} +
+ ); +}; diff --git a/src/components/ColorPicker/colorPickerUtils.ts b/src/components/ColorPicker/colorPickerUtils.ts new file mode 100644 index 0000000000..4c4075b2a9 --- /dev/null +++ b/src/components/ColorPicker/colorPickerUtils.ts @@ -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(); + 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(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"; diff --git a/src/components/ColorPicker/keyboardNavHandlers.ts b/src/components/ColorPicker/keyboardNavHandlers.ts new file mode 100644 index 0000000000..4ed539ceed --- /dev/null +++ b/src/components/ColorPicker/keyboardNavHandlers.ts @@ -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, + ) => 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, + ) => 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, + 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][] + ).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); + } + } +}; diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index aebb42de7a..a0f257e365 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -4,8 +4,8 @@ import { Dialog, DialogProps } from "./Dialog"; import "./ConfirmDialog.scss"; import DialogActionButton from "./DialogActionButton"; import { useSetAtom } from "jotai"; -import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; -import { useExcalidrawSetAppState } from "./App"; +import { isLibraryMenuOpenAtom } from "./LibraryMenu"; +import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App"; import { jotaiScope } from "../jotai"; interface Props extends Omit { @@ -26,6 +26,7 @@ const ConfirmDialog = (props: Props) => { } = props; const setAppState = useExcalidrawSetAppState(); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); + const { container } = useExcalidrawContainer(); return ( { setAppState({ openMenu: null }); setIsLibraryMenuOpen(false); onCancel(); + container?.focus(); }} /> { setAppState({ openMenu: null }); setIsLibraryMenuOpen(false); onConfirm(); + container?.focus(); }} actionType="danger" /> diff --git a/src/components/DefaultSidebar.test.tsx b/src/components/DefaultSidebar.test.tsx new file mode 100644 index 0000000000..64cfe5ba69 --- /dev/null +++ b/src/components/DefaultSidebar.test.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import { DEFAULT_SIDEBAR } from "../constants"; +import { DefaultSidebar } from "../packages/excalidraw/index"; +import { + fireEvent, + waitFor, + withExcalidrawDimensions, +} from "../tests/test-utils"; +import { + assertExcalidrawWithSidebar, + assertSidebarDockButton, +} from "./Sidebar/Sidebar.test"; + +const { h } = window; + +describe("DefaultSidebar", () => { + it("when `docked={undefined}` & `onDock={undefined}`, should allow docking", async () => { + await assertExcalidrawWithSidebar( + , + DEFAULT_SIDEBAR.name, + async () => { + expect(h.state.defaultSidebarDockedPreference).toBe(false); + + const { dockButton } = await assertSidebarDockButton(true); + + fireEvent.click(dockButton); + await waitFor(() => { + expect(h.state.defaultSidebarDockedPreference).toBe(true); + expect(dockButton).toHaveClass("selected"); + }); + + fireEvent.click(dockButton); + await waitFor(() => { + expect(h.state.defaultSidebarDockedPreference).toBe(false); + expect(dockButton).not.toHaveClass("selected"); + }); + }, + ); + }); + + it("when `docked={undefined}` & `onDock`, should allow docking", async () => { + await assertExcalidrawWithSidebar( + {}} />, + DEFAULT_SIDEBAR.name, + async () => { + expect(h.state.defaultSidebarDockedPreference).toBe(false); + + const { dockButton } = await assertSidebarDockButton(true); + + fireEvent.click(dockButton); + await waitFor(() => { + expect(h.state.defaultSidebarDockedPreference).toBe(true); + expect(dockButton).toHaveClass("selected"); + }); + + fireEvent.click(dockButton); + await waitFor(() => { + expect(h.state.defaultSidebarDockedPreference).toBe(false); + expect(dockButton).not.toHaveClass("selected"); + }); + }, + ); + }); + + it("when `docked={true}` & `onDock`, should allow docking", async () => { + await assertExcalidrawWithSidebar( + {}} />, + DEFAULT_SIDEBAR.name, + async () => { + expect(h.state.defaultSidebarDockedPreference).toBe(false); + + const { dockButton } = await assertSidebarDockButton(true); + + fireEvent.click(dockButton); + await waitFor(() => { + expect(h.state.defaultSidebarDockedPreference).toBe(true); + expect(dockButton).toHaveClass("selected"); + }); + + fireEvent.click(dockButton); + await waitFor(() => { + expect(h.state.defaultSidebarDockedPreference).toBe(false); + expect(dockButton).not.toHaveClass("selected"); + }); + }, + ); + }); + + it("when `onDock={false}`, should disable docking", async () => { + await assertExcalidrawWithSidebar( + , + DEFAULT_SIDEBAR.name, + async () => { + await withExcalidrawDimensions( + { width: 1920, height: 1080 }, + async () => { + expect(h.state.defaultSidebarDockedPreference).toBe(false); + + await assertSidebarDockButton(false); + }, + ); + }, + ); + }); + + it("when `docked={true}` & `onDock={false}`, should force-dock sidebar", async () => { + await assertExcalidrawWithSidebar( + , + DEFAULT_SIDEBAR.name, + async () => { + expect(h.state.defaultSidebarDockedPreference).toBe(false); + + const { sidebar } = await assertSidebarDockButton(false); + expect(sidebar).toHaveClass("sidebar--docked"); + }, + ); + }); + + it("when `docked={true}` & `onDock={undefined}`, should force-dock sidebar", async () => { + await assertExcalidrawWithSidebar( + , + DEFAULT_SIDEBAR.name, + async () => { + expect(h.state.defaultSidebarDockedPreference).toBe(false); + + const { sidebar } = await assertSidebarDockButton(false); + expect(sidebar).toHaveClass("sidebar--docked"); + }, + ); + }); + + it("when `docked={false}` & `onDock={undefined}`, should force-undock sidebar", async () => { + await assertExcalidrawWithSidebar( + , + DEFAULT_SIDEBAR.name, + async () => { + expect(h.state.defaultSidebarDockedPreference).toBe(false); + + const { sidebar } = await assertSidebarDockButton(false); + expect(sidebar).not.toHaveClass("sidebar--docked"); + }, + ); + }); +}); diff --git a/src/components/DefaultSidebar.tsx b/src/components/DefaultSidebar.tsx new file mode 100644 index 0000000000..48a78faac8 --- /dev/null +++ b/src/components/DefaultSidebar.tsx @@ -0,0 +1,118 @@ +import clsx from "clsx"; +import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants"; +import { useTunnels } from "../context/tunnels"; +import { useUIAppState } from "../context/ui-appState"; +import { t } from "../i18n"; +import { MarkOptional, Merge } from "../utility-types"; +import { composeEventHandlers } from "../utils"; +import { useExcalidrawSetAppState } from "./App"; +import { withInternalFallback } from "./hoc/withInternalFallback"; +import { LibraryMenu } from "./LibraryMenu"; +import { SidebarProps, SidebarTriggerProps } from "./Sidebar/common"; +import { Sidebar } from "./Sidebar/Sidebar"; + +const DefaultSidebarTrigger = withInternalFallback( + "DefaultSidebarTrigger", + ( + props: Omit & + React.HTMLAttributes, + ) => { + const { DefaultSidebarTriggerTunnel } = useTunnels(); + return ( + + + + ); + }, +); +DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger"; + +const DefaultTabTriggers = ({ + children, + ...rest +}: { children: React.ReactNode } & React.HTMLAttributes) => { + const { DefaultSidebarTabTriggersTunnel } = useTunnels(); + return ( + + {children} + + ); +}; +DefaultTabTriggers.displayName = "DefaultTabTriggers"; + +export const DefaultSidebar = Object.assign( + withInternalFallback( + "DefaultSidebar", + ({ + children, + className, + onDock, + docked, + ...rest + }: Merge< + MarkOptional, "children">, + { + /** pass `false` to disable docking */ + onDock?: SidebarProps["onDock"] | false; + } + >) => { + const appState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + + const { DefaultSidebarTabTriggersTunnel } = useTunnels(); + + return ( + { + setAppState({ defaultSidebarDockedPreference: docked }); + }) + } + > + + + {rest.__fallback && ( +
+ {t("toolBar.library")} +
+ )} + +
+ + + + {children} +
+
+ ); + }, + ), + { + Trigger: DefaultSidebarTrigger, + TabTriggers: DefaultTabTriggers, + }, +); diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 24a078dd5f..363bb849f7 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -15,7 +15,7 @@ import { Modal } from "./Modal"; import { AppState } from "../types"; import { queryFocusableElements } from "../utils"; import { useSetAtom } from "jotai"; -import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; +import { isLibraryMenuOpenAtom } from "./LibraryMenu"; import { jotaiScope } from "../jotai"; export interface DialogProps { diff --git a/src/components/HintViewer.tsx b/src/components/HintViewer.tsx index 6615c91e7a..a1b6308f4a 100644 --- a/src/components/HintViewer.tsx +++ b/src/components/HintViewer.tsx @@ -1,9 +1,7 @@ import { t } from "../i18n"; import { NonDeletedExcalidrawElement } from "../element/types"; import { getSelectedElements } from "../scene"; - -import "./HintViewer.scss"; -import { AppState, Device } from "../types"; +import { Device, UIAppState } from "../types"; import { isImageElement, isLinearElement, @@ -13,8 +11,10 @@ import { import { getShortcutKey } from "../utils"; import { isEraserActive } from "../appState"; +import "./HintViewer.scss"; + interface HintViewerProps { - appState: AppState; + appState: UIAppState; elements: readonly NonDeletedExcalidrawElement[]; isMobile: boolean; device: Device; @@ -29,7 +29,7 @@ const getHints = ({ const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const multiMode = appState.multiElement !== null; - if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) { + if (appState.openSidebar && !device.canDeviceFitSidebar) { return null; } diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index 0e4eff3657..38467d6eb1 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -4,18 +4,23 @@ import { canvasToBlob } from "../data/blob"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; import { getSelectedElements, isSomeElementSelected } from "../scene"; -import { AppState, BinaryFiles } from "../types"; +import { AppClassProperties, BinaryFiles, UIAppState } from "../types"; import { Dialog } from "./Dialog"; import { clipboard } from "./icons"; import Stack from "./Stack"; -import "./ExportDialog.scss"; import OpenColor from "open-color"; import { CheckboxItem } from "./CheckboxItem"; -import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants"; +import { + DEFAULT_EXPORT_PADDING, + EXPORT_IMAGE_TYPES, + isFirefox, +} from "../constants"; import { nativeFileSystemSupported } from "../data/filesystem"; import { ActionManager } from "../actions/manager"; import { exportToCanvas } from "../packages/utils"; +import "./ExportDialog.scss"; + const supportsContextFilters = "filter" in document.createElement("canvas").getContext("2d")!; @@ -64,21 +69,14 @@ const ImageExportModal = ({ elements, appState, files, - exportPadding = DEFAULT_EXPORT_PADDING, actionManager, - onExportToPng, - onExportToSvg, - onExportToClipboard, + onExportImage, }: { - appState: AppState; + appState: UIAppState; elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles; - exportPadding?: number; actionManager: ActionManager; - onExportToPng: ExportCB; - onExportToSvg: ExportCB; - onExportToClipboard: ExportCB; - onCloseRequest: () => void; + onExportImage: AppClassProperties["onExportImage"]; }) => { const someElementIsSelected = isSomeElementSelected(elements, appState); const [exportSelected, setExportSelected] = useState(someElementIsSelected); @@ -89,10 +87,6 @@ const ImageExportModal = ({ ? getSelectedElements(elements, appState, true) : elements; - useEffect(() => { - setExportSelected(someElementIsSelected); - }, [someElementIsSelected]); - useEffect(() => { const previewNode = previewRef.current; if (!previewNode) { @@ -106,7 +100,7 @@ const ImageExportModal = ({ elements: exportedElements, appState, files, - exportPadding, + exportPadding: DEFAULT_EXPORT_PADDING, maxWidthOrHeight: maxWidth, }) .then((canvas) => { @@ -121,7 +115,7 @@ const ImageExportModal = ({ console.error(error); setRenderError(error); }); - }, [appState, files, exportedElements, exportPadding]); + }, [appState, files, exportedElements]); return (
@@ -176,7 +170,9 @@ const ImageExportModal = ({ color="indigo" title={t("buttons.exportToPng")} aria-label={t("buttons.exportToPng")} - onClick={() => onExportToPng(exportedElements)} + onClick={() => + onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements) + } > PNG @@ -184,7 +180,9 @@ const ImageExportModal = ({ color="red" title={t("buttons.exportToSvg")} aria-label={t("buttons.exportToSvg")} - onClick={() => onExportToSvg(exportedElements)} + onClick={() => + onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements) + } > SVG @@ -193,7 +191,9 @@ const ImageExportModal = ({ {(probablySupportsClipboardBlob || isFirefox) && ( onExportToClipboard(exportedElements)} + onClick={() => + onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements) + } color="gray" shade={7} > @@ -208,45 +208,31 @@ const ImageExportModal = ({ export const ImageExportDialog = ({ elements, appState, - setAppState, files, - exportPadding = DEFAULT_EXPORT_PADDING, actionManager, - onExportToPng, - onExportToSvg, - onExportToClipboard, + onExportImage, + onCloseRequest, }: { - appState: AppState; - setAppState: React.Component["setState"]; + appState: UIAppState; elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles; - exportPadding?: number; actionManager: ActionManager; - onExportToPng: ExportCB; - onExportToSvg: ExportCB; - onExportToClipboard: ExportCB; + onExportImage: AppClassProperties["onExportImage"]; + onCloseRequest: () => void; }) => { - const handleClose = React.useCallback(() => { - setAppState({ openDialog: null }); - }, [setAppState]); + if (appState.openDialog !== "imageExport") { + return null; + } return ( - <> - {appState.openDialog === "imageExport" && ( - - - - )} - + + + ); }; diff --git a/src/components/JSONExportDialog.tsx b/src/components/JSONExportDialog.tsx index 8f89ebcb73..f40ebe4532 100644 --- a/src/components/JSONExportDialog.tsx +++ b/src/components/JSONExportDialog.tsx @@ -2,7 +2,7 @@ import React from "react"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; -import { AppState, ExportOpts, BinaryFiles } from "../types"; +import { ExportOpts, BinaryFiles, UIAppState } from "../types"; import { Dialog } from "./Dialog"; import { exportToFileIcon, LinkIcon } from "./icons"; import { ToolButton } from "./ToolButton"; @@ -28,7 +28,7 @@ const JSONExportModal = ({ exportOpts, canvas, }: { - appState: AppState; + appState: UIAppState; files: BinaryFiles; elements: readonly NonDeletedExcalidrawElement[]; actionManager: ActionManager; @@ -96,12 +96,12 @@ export const JSONExportDialog = ({ setAppState, }: { elements: readonly NonDeletedExcalidrawElement[]; - appState: AppState; + appState: UIAppState; files: BinaryFiles; actionManager: ActionManager; exportOpts: ExportOpts; canvas: HTMLCanvasElement | null; - setAppState: React.Component["setState"]; + setAppState: React.Component["setState"]; }) => { const handleClose = React.useCallback(() => { setAppState({ openDialog: null }); diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 7103d3adec..f05f5df116 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -1,18 +1,23 @@ import clsx from "clsx"; import React from "react"; import { ActionManager } from "../actions/manager"; -import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants"; -import { exportCanvas } from "../data"; +import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants"; import { isTextElement, showSelectedShapeActions } from "../element"; import { NonDeletedExcalidrawElement } from "../element/types"; import { Language, t } from "../i18n"; import { calculateScrollCenter } from "../scene"; -import { ExportType } from "../scene/types"; -import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; -import { isShallowEqual, muteFSAbortError } from "../utils"; +import { + AppProps, + AppState, + ExcalidrawProps, + BinaryFiles, + UIAppState, + AppClassProperties, +} from "../types"; +import { capitalizeString, isShallowEqual } from "../utils"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { ErrorDialog } from "./ErrorDialog"; -import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; +import { ImageExportDialog } from "./ImageExportDialog"; import { FixedSideContainer } from "./FixedSideContainer"; import { HintViewer } from "./HintViewer"; import { Island } from "./Island"; @@ -24,32 +29,31 @@ import { Section } from "./Section"; import { HelpDialog } from "./HelpDialog"; import Stack from "./Stack"; import { UserList } from "./UserList"; -import Library from "../data/library"; import { JSONExportDialog } from "./JSONExportDialog"; -import { LibraryButton } from "./LibraryButton"; -import { isImageFileHandle } from "../data/blob"; -import { LibraryMenu } from "./LibraryMenu"; - -import "./LayerUI.scss"; -import "./Toolbar.scss"; import { PenModeButton } from "./PenModeButton"; import { trackEvent } from "../analytics"; import { useDevice } from "../components/App"; import { Stats } from "./Stats"; import { actionToggleStats } from "../actions/actionToggleStats"; import Footer from "./footer/Footer"; -import { hostSidebarCountersAtom } from "./Sidebar/Sidebar"; +import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { jotaiScope } from "../jotai"; -import { Provider, useAtom } from "jotai"; +import { Provider, useAtomValue } from "jotai"; import MainMenu from "./main-menu/MainMenu"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { HandButton } from "./HandButton"; import { isHandToolActive } from "../appState"; -import { TunnelsContext, useInitializeTunnels } from "./context/tunnels"; +import { TunnelsContext, useInitializeTunnels } from "../context/tunnels"; +import { LibraryIcon } from "./icons"; +import { UIAppStateContext } from "../context/ui-appState"; +import { DefaultSidebar } from "./DefaultSidebar"; + +import "./LayerUI.scss"; +import "./Toolbar.scss"; interface LayerUIProps { actionManager: ActionManager; - appState: AppState; + appState: UIAppState; files: BinaryFiles; canvas: HTMLCanvasElement | null; setAppState: React.Component["setState"]; @@ -57,18 +61,13 @@ interface LayerUIProps { onLockToggle: () => void; onHandToolToggle: () => void; onPenModeToggle: () => void; - onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; showExitZenModeBtn: boolean; langCode: Language["code"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; - renderCustomSidebar?: ExcalidrawProps["renderSidebar"]; - libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; UIOptions: AppProps["UIOptions"]; - focusContainer: () => void; - library: Library; - id: string; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; + onExportImage: AppClassProperties["onExportImage"]; renderWelcomeScreen: boolean; children?: React.ReactNode; } @@ -109,17 +108,12 @@ const LayerUI = ({ onLockToggle, onHandToolToggle, onPenModeToggle, - onInsertElements, showExitZenModeBtn, renderTopRightUI, renderCustomStats, - renderCustomSidebar, - libraryReturnUrl, UIOptions, - focusContainer, - library, - id, onImageAction, + onExportImage, renderWelcomeScreen, children, }: LayerUIProps) => { @@ -149,46 +143,14 @@ const LayerUI = ({ return null; } - const createExporter = - (type: ExportType): ExportCB => - async (exportedElements) => { - trackEvent("export", type, "ui"); - const fileHandle = await exportCanvas( - type, - exportedElements, - appState, - files, - { - exportBackground: appState.exportBackground, - name: appState.name, - viewBackgroundColor: appState.viewBackgroundColor, - }, - ) - .catch(muteFSAbortError) - .catch((error) => { - console.error(error); - setAppState({ errorMessage: error.message }); - }); - - if ( - appState.exportEmbedScene && - fileHandle && - isImageFileHandle(fileHandle) - ) { - setAppState({ fileHandle }); - } - }; - return ( setAppState({ openDialog: null })} /> ); }; @@ -197,8 +159,8 @@ const LayerUI = ({
{/* wrapping to Fragment stops React from occasionally complaining about identical Keys */} - - {renderWelcomeScreen && } + + {renderWelcomeScreen && }
); @@ -250,7 +212,7 @@ const LayerUI = ({ {(heading: React.ReactNode) => (
{renderWelcomeScreen && ( - + )} {renderTopRightUI?.(device.isMobile, appState)} - {!appState.viewModeEnabled && ( - - )} + {!appState.viewModeEnabled && + // hide button when sidebar docked + (!isSidebarDocked || + appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && ( + + )}
@@ -334,21 +299,21 @@ const LayerUI = ({ }; const renderSidebars = () => { - return appState.openSidebar === "customSidebar" ? ( - renderCustomSidebar?.() || null - ) : appState.openSidebar === "library" ? ( - { + trackEvent( + "sidebar", + `toggleDock (${docked ? "dock" : "undock"})`, + `(${device.isMobile ? "mobile" : "desktop"})`, + ); + }} /> - ) : null; + ); }; - const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope); + const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope); const layerUIJSX = ( <> @@ -358,8 +323,25 @@ const LayerUI = ({ {children} {/* render component fallbacks. Can be rendered anywhere as they'll be tunneled away. We only render tunneled components that actually - have defaults when host do not render anything. */} + have defaults when host do not render anything. */} + { + if (open) { + trackEvent( + "sidebar", + `${DEFAULT_SIDEBAR.name} (open)`, + `button (${device.isMobile ? "mobile" : "desktop"})`, + ); + } + }} + tab={DEFAULT_SIDEBAR.defaultTab} + > + {t("toolBar.library")} + {/* ------------------------------------------------------------------ */} {appState.isLoading && } @@ -382,7 +364,6 @@ const LayerUI = ({ setAppState({ pasteDialog: { shown: false, data: null }, @@ -410,7 +391,6 @@ const LayerUI = ({ renderWelcomeScreen={renderWelcomeScreen} /> )} - {!device.isMobile && ( <>
- {renderWelcomeScreen && } + {renderWelcomeScreen && } {renderFixedSideContainer()}