diff --git a/.npmrc b/.npmrc index cffe8cdef..1b78f1c6f 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 262681895..cdd5ea5a4 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 8fbf228df..2494df108 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 ca329e3e6..6531e29ec 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 (
/locales", + "/src/packages/excalidraw/dist/", + "/src/packages/excalidraw/types", + "/src/packages/excalidraw/example" + ], "transformIgnorePatterns": [ "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)" ], @@ -127,6 +137,7 @@ "test:typecheck": "tsc", "test:update": "yarn test:app --updateSnapshot --watchAll=false", "test": "yarn test:app", + "test:coverage": "react-scripts test --passWithNoTests --coverage --watchAll", "autorelease": "node scripts/autorelease.js", "prerelease": "node scripts/prerelease.js", "release": "node scripts/release.js" diff --git a/public/index.html b/public/index.html index a8633fc4d..f65e481f3 100644 --- a/public/index.html +++ b/public/index.html @@ -150,6 +150,14 @@ <% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %> + + + + <% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %> - <% } %> - - <% } %> @@ -244,5 +227,17 @@

Excalidraw

+ + + diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 440c59191..16c4fad19 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -1,4 +1,4 @@ -import { ColorPicker } from "../components/ColorPicker"; +import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ZoomInIcon, ZoomOutIcon } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; @@ -19,6 +19,7 @@ import { isEraserActive, isHandToolActive, } from "../appState"; +import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", @@ -35,24 +36,21 @@ export const actionChangeViewBackgroundColor = register({ commitToHistory: !!value.viewBackgroundColor, }; }, - PanelComponent: ({ elements, appState, updateData }) => { + PanelComponent: ({ elements, appState, updateData, appProps }) => { // FIXME move me to src/components/mainMenu/DefaultItems.tsx return ( -
- 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/actionClipboard.tsx b/src/actions/actionClipboard.tsx index 661f65f38..18fefafd2 100644 --- a/src/actions/actionClipboard.tsx +++ b/src/actions/actionClipboard.tsx @@ -18,7 +18,7 @@ export const actionCopy = register({ perform: (elements, appState, _, app) => { const selectedElements = getSelectedElements(elements, appState, true); - copyToClipboard(selectedElements, appState, app.files); + copyToClipboard(selectedElements, app.files); return { commitToHistory: false, diff --git a/src/actions/actionElementLock.test.tsx b/src/actions/actionElementLock.test.tsx new file mode 100644 index 000000000..19db5e325 --- /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 c44bd5700..922a5fae3 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/actionExport.tsx b/src/actions/actionExport.tsx index f142eac87..d945ba959 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -26,7 +26,7 @@ export const actionChangeProjectName = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, name: value }, commitToHistory: false }; }, - PanelComponent: ({ appState, updateData, appProps }) => ( + PanelComponent: ({ appState, updateData, appProps, data }) => ( ), }); diff --git a/src/actions/actionFlip.ts b/src/actions/actionFlip.ts index ff6bfe4b7..8edbbc4aa 100644 --- a/src/actions/actionFlip.ts +++ b/src/actions/actionFlip.ts @@ -1,42 +1,17 @@ import { register } from "./register"; import { getSelectedElements } from "../scene"; import { getNonDeletedElements } from "../element"; -import { mutateElement } from "../element/mutateElement"; import { ExcalidrawElement, NonDeleted } from "../element/types"; -import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; -import { AppState } from "../types"; -import { getTransformHandles } from "../element/transformHandles"; -import { updateBoundElements } from "../element/binding"; +import { resizeMultipleElements } from "../element/resizeElements"; +import { AppState, PointerDownState } from "../types"; import { arrayToMap } from "../utils"; +import { CODES, KEYS } from "../keys"; +import { getCommonBoundingBox } from "../element/bounds"; import { - getElementAbsoluteCoords, - getElementPointsCoords, -} from "../element/bounds"; -import { isLinearElement } from "../element/typeChecks"; -import { LinearElementEditor } from "../element/linearElementEditor"; -import { KEYS } from "../keys"; - -const enableActionFlipHorizontal = ( - elements: readonly ExcalidrawElement[], - appState: AppState, -) => { - const eligibleElements = getSelectedElements( - getNonDeletedElements(elements), - appState, - ); - return eligibleElements.length === 1 && eligibleElements[0].type !== "text"; -}; - -const enableActionFlipVertical = ( - elements: readonly ExcalidrawElement[], - appState: AppState, -) => { - const eligibleElements = getSelectedElements( - getNonDeletedElements(elements), - appState, - ); - return eligibleElements.length === 1; -}; + bindOrUnbindSelectedElements, + isBindingEnabled, + unbindLinearElements, +} from "../element/binding"; export const actionFlipHorizontal = register({ name: "flipHorizontal", @@ -48,10 +23,8 @@ 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), }); export const actionFlipVertical = register({ @@ -65,10 +38,8 @@ 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), }); const flipSelectedElements = ( @@ -81,11 +52,6 @@ const flipSelectedElements = ( appState, ); - // remove once we allow for groups of elements to be flipped - if (selectedElements.length > 1) { - return elements; - } - const updatedElements = flipElements( selectedElements, appState, @@ -104,144 +70,20 @@ const flipElements = ( appState: AppState, flipDirection: "horizontal" | "vertical", ): ExcalidrawElement[] => { - elements.forEach((element) => { - flipElement(element, appState); - // If vertical flip, rotate an extra 180 - if (flipDirection === "vertical") { - rotateElement(element, Math.PI); - } - }); + const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements); + + resizeMultipleElements( + { originalElements: arrayToMap(elements) } as PointerDownState, + elements, + "nw", + true, + flipDirection === "horizontal" ? maxX : minX, + flipDirection === "horizontal" ? minY : maxY, + ); + + (isBindingEnabled(appState) + ? bindOrUnbindSelectedElements + : unbindLinearElements)(elements); + return elements; }; - -const flipElement = ( - element: NonDeleted, - appState: AppState, -) => { - const originalX = element.x; - const originalY = element.y; - const width = element.width; - const height = element.height; - const originalAngle = normalizeAngle(element.angle); - - // Rotate back to zero, if necessary - mutateElement(element, { - angle: normalizeAngle(0), - }); - // Flip unrotated by pulling TransformHandle to opposite side - const transformHandles = getTransformHandles(element, appState.zoom); - let usingNWHandle = true; - let nHandle = transformHandles.nw; - if (!nHandle) { - // Use ne handle instead - usingNWHandle = false; - nHandle = transformHandles.ne; - if (!nHandle) { - mutateElement(element, { - angle: originalAngle, - }); - return; - } - } - - let finalOffsetX = 0; - if (isLinearElement(element) && element.points.length < 3) { - finalOffsetX = - element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 - - element.width; - } - - let initialPointsCoords; - if (isLinearElement(element)) { - initialPointsCoords = getElementPointsCoords(element, element.points); - } - const initialElementAbsoluteCoords = getElementAbsoluteCoords(element); - - if (isLinearElement(element) && element.points.length < 3) { - for (let index = 1; index < element.points.length; index++) { - LinearElementEditor.movePoints(element, [ - { - index, - point: [-element.points[index][0], element.points[index][1]], - }, - ]); - } - LinearElementEditor.normalizePoints(element); - } else { - const elWidth = initialPointsCoords - ? initialPointsCoords[2] - initialPointsCoords[0] - : initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0]; - - const startPoint = initialPointsCoords - ? [initialPointsCoords[0], initialPointsCoords[1]] - : [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]]; - - resizeSingleElement( - new Map().set(element.id, element), - false, - element, - usingNWHandle ? "nw" : "ne", - true, - usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth, - startPoint[1], - ); - } - - // Rotate by (360 degrees - original angle) - let angle = normalizeAngle(2 * Math.PI - originalAngle); - if (angle < 0) { - // check, probably unnecessary - angle = normalizeAngle(angle + 2 * Math.PI); - } - mutateElement(element, { - angle, - }); - - // Move back to original spot to appear "flipped in place" - mutateElement(element, { - x: originalX + finalOffsetX, - y: originalY, - width, - height, - }); - - updateBoundElements(element); - - if (initialPointsCoords && isLinearElement(element)) { - // Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin. - // There's still room for improvement since when the line roughness is > 1 - // we still have a small offset of the origin when fliipping the element. - const finalPointsCoords = getElementPointsCoords(element, element.points); - - const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0]; - const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2]; - - const coordsDiff = topLeftCoordsDiff + topRightCoordDiff; - - mutateElement(element, { - x: element.x + coordsDiff * 0.5, - y: element.y, - width, - height, - }); - } -}; - -const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => { - const originalX = element.x; - const originalY = element.y; - let angle = normalizeAngle(element.angle + rotationAngle); - if (angle < 0) { - // check, probably unnecessary - angle = normalizeAngle(2 * Math.PI + angle); - } - mutateElement(element, { - angle, - }); - - // Move back to original spot - mutateElement(element, { - x: originalX, - y: originalY, - }); -}; diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 382e964b9..d319337c3 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, @@ -113,8 +119,8 @@ const getFormValue = function ( elements: readonly ExcalidrawElement[], appState: AppState, getAttribute: (element: ExcalidrawElement) => T, - defaultValue?: T, -): T | null { + defaultValue: T, +): T { const editingElement = appState.editingElement; const nonDeletedElements = getNonDeletedElements(elements); return ( @@ -126,7 +132,7 @@ const getFormValue = function ( getAttribute, ) : defaultValue) ?? - null + defaultValue ); }; @@ -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} /> ), @@ -807,6 +811,7 @@ export const actionChangeTextAlign = register({ ); }, }); + export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", trackEvent: { category: "element" }, @@ -861,16 +866,21 @@ export const actionChangeVerticalAlign = register({ testId: "align-bottom", }, ]} - value={getFormValue(elements, appState, (element) => { - if (isTextElement(element) && element.containerId) { - return element.verticalAlign; - } - const boundTextElement = getBoundTextElement(element); - if (boundTextElement) { - return boundTextElement.verticalAlign; - } - return null; - })} + value={getFormValue( + elements, + appState, + (element) => { + if (isTextElement(element) && element.containerId) { + return element.verticalAlign; + } + const boundTextElement = getBoundTextElement(element); + if (boundTextElement) { + return boundTextElement.verticalAlign; + } + return null; + }, + VERTICAL_ALIGN.MIDDLE, + )} onChange={(value) => updateData(value)} /> diff --git a/src/actions/actionStyles.test.tsx b/src/actions/actionStyles.test.tsx index c73864cc4..238196dfc 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 eea4faf7d..9b53f8173 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/manager.tsx b/src/actions/manager.tsx index 60648e410..e52e91da7 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -118,10 +118,13 @@ export class ActionManager { return true; } - executeAction(action: Action, source: ActionSource = "api") { + executeAction( + action: Action, + source: ActionSource = "api", + value: any = null, + ) { const elements = this.getElementsIncludingDeleted(); const appState = this.getAppState(); - const value = null; trackAction(action, source, appState, elements, this.app, value); diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index be48c6470..b9c24a757 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 b03e1053b..277934adc 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/analytics.ts b/src/analytics.ts index 1e9a429b6..e952bc680 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -20,9 +20,20 @@ export const trackEvent = ( }); } - // MATOMO event tracking _paq must be same as the one in index.html - if (window._paq) { - window._paq.push(["trackEvent", category, action, label, value]); + if (window.sa_event) { + window.sa_event(action, { + category, + label, + value, + }); + } + + if (window.fathom) { + window.fathom.trackEvent(action, { + category, + label, + value, + }); } } catch (error) { console.error("error during analytics", error); diff --git a/src/appState.ts b/src/appState.ts index 6f4db7557..4468eb966 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/assets/lock.svg b/src/assets/lock.svg new file mode 100644 index 000000000..aa9dbf170 --- /dev/null +++ b/src/assets/lock.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/charts.ts b/src/charts.ts index c3b0950d1..b5714686c 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 9e1e6e144..604936e33 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/clipboard.ts b/src/clipboard.ts index 5f7950c53..c0f5844dd 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -2,12 +2,12 @@ import { ExcalidrawElement, NonDeletedExcalidrawElement, } from "./element/types"; -import { AppState, BinaryFiles } from "./types"; +import { BinaryFiles } from "./types"; import { SVG_EXPORT_TAG } from "./scene/export"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { isInitializedImageElement } from "./element/typeChecks"; -import { isPromiseLike } from "./utils"; +import { isPromiseLike, isTestEnv } from "./utils"; type ElementsClipboard = { type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; @@ -55,24 +55,40 @@ const clipboardContainsElements = ( export const copyToClipboard = async ( elements: readonly NonDeletedExcalidrawElement[], - appState: AppState, files: BinaryFiles | null, ) => { + let foundFile = false; + + const _files = elements.reduce((acc, element) => { + if (isInitializedImageElement(element)) { + foundFile = true; + if (files && files[element.fileId]) { + acc[element.fileId] = files[element.fileId]; + } + } + return acc; + }, {} as BinaryFiles); + + if (foundFile && !files) { + console.warn( + "copyToClipboard: attempting to file element(s) without providing associated `files` object.", + ); + } + // select binded text elements when copying const contents: ElementsClipboard = { type: EXPORT_DATA_TYPES.excalidrawClipboard, elements, - files: files - ? elements.reduce((acc, element) => { - if (isInitializedImageElement(element) && files[element.fileId]) { - acc[element.fileId] = files[element.fileId]; - } - return acc; - }, {} as BinaryFiles) - : undefined, + files: files ? _files : undefined, }; const json = JSON.stringify(contents); + + if (isTestEnv()) { + return json; + } + CLIPBOARD = json; + try { PREFER_APP_CLIPBOARD = false; await copyTextToSystemClipboard(json); diff --git a/src/colors.ts b/src/colors.ts index 63cd72839..7da128399 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -1,22 +1,170 @@ import oc from "open-color"; +import { Merge } from "./utility-types"; -const shades = (index: number) => [ - oc.red[index], - oc.pink[index], - oc.grape[index], - oc.violet[index], - oc.indigo[index], - oc.blue[index], - oc.cyan[index], - oc.teal[index], - oc.green[index], - oc.lime[index], - oc.yellow[index], - oc.orange[index], -]; - -export default { - canvasBackground: [oc.white, oc.gray[0], oc.gray[1], ...shades(0)], - elementBackground: ["transparent", oc.gray[4], oc.gray[6], ...shades(6)], - elementStroke: [oc.black, oc.gray[8], oc.gray[7], ...shades(9)], +// FIXME can't put to utils.ts rn because of circular dependency +const pick = , 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; + +export const rgbToHex = (r: number, g: number, b: number) => + `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + +// ----------------------------------------------------------------------------- diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 3bbc0ff1a..875d8447c 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 20def468a..f4c7689bb 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,7 +59,9 @@ import { ELEMENT_TRANSLATE_AMOUNT, ENV, EVENT, + EXPORT_IMAGE_TYPES, GRID_SIZE, + IMAGE_MIME_TYPES, IMAGE_RENDER_TIMEOUT, isAndroid, isBrave, @@ -81,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 { @@ -209,6 +211,8 @@ import { PointerDownState, SceneData, Device, + SidebarName, + SidebarTabName, } from "../types"; import { debounce, @@ -234,6 +238,7 @@ import { getShortcutKey, isTransparent, easeToValuesRAF, + muteFSAbortError, } from "../utils"; import { ContextMenu, @@ -248,6 +253,7 @@ import { generateIdFromFile, getDataURL, getFileFromEvent, + isImageFileHandle, isSupportedImageFile, loadSceneOrLibraryFromBlob, normalizeFile, @@ -287,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 { @@ -297,12 +304,17 @@ import { jotaiStore } from "../jotai"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import BraveMeasureTextError from "./BraveMeasureTextError"; +import { activeEyeDropperAtom } from "./EyeDropper"; + +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"; @@ -339,6 +351,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); @@ -353,8 +367,6 @@ export const useExcalidrawActionManager = () => let didTapTwice: boolean = false; let tappedTwiceTimer = 0; -let cursorX = 0; -let cursorY = 0; let isHoldingSpace: boolean = false; let isPanning: boolean = false; let isDraggingScrollBar: boolean = false; @@ -399,7 +411,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; @@ -412,7 +424,7 @@ class App extends React.Component { hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDown: React.PointerEvent | null = null; lastPointerUp: React.PointerEvent | PointerEvent | null = null; - lastScenePointer: { x: number; y: number } | null = null; + lastViewportPosition = { x: 0, y: 0 }; constructor(props: AppProps) { super(props); @@ -437,7 +449,7 @@ class App extends React.Component { width: window.innerWidth, height: window.innerHeight, showHyperlinkPopup: false, - isSidebarDocked: false, + defaultSidebarDockedPreference: false, }; this.id = nanoid(); @@ -468,7 +480,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); @@ -576,101 +588,93 @@ 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 = () => { @@ -681,6 +685,88 @@ 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 openEyeDropper = ({ type }: { type: "stroke" | "background" }) => { + jotaiStore.set(activeEyeDropperAtom, { + swapPreviewOnAlt: true, + previewType: type === "stroke" ? "strokeColor" : "backgroundColor", + onSelect: (color, event) => { + const shouldUpdateStrokeColor = + (type === "background" && event.altKey) || + (type === "stroke" && !event.altKey); + const selectedElements = getSelectedElements( + this.scene.getElementsIncludingDeleted(), + this.state, + ); + if ( + !selectedElements.length || + this.state.activeTool.type !== "selection" + ) { + if (shouldUpdateStrokeColor) { + this.setState({ + currentItemStrokeColor: color, + }); + } else { + this.setState({ + currentItemBackgroundColor: color, + }); + } + } else { + this.updateScene({ + elements: this.scene.getElementsIncludingDeleted().map((el) => { + if (this.state.selectedElementIds[el.id]) { + return newElementWith(el, { + [shouldUpdateStrokeColor ? "strokeColor" : "backgroundColor"]: + color, + }); + } + return el; + }), + }); + } + }, + keepOpenOnAlt: false, + }); + }; + private syncActionResult = withBatchedUpdates( (actionResult: ActionResult) => { if (this.unmounted || actionResult === false) { @@ -905,6 +991,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 || @@ -950,7 +1037,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(); } @@ -1028,6 +1115,7 @@ class App extends React.Component { this.unmounted = true; this.removeEventListeners(); this.scene.destroy(); + this.library.destroy(); clearTimeout(touchTimeout); touchTimeout = 0; } @@ -1524,7 +1612,10 @@ class App extends React.Component { return; } - const elementUnderCursor = document.elementFromPoint(cursorX, cursorY); + const elementUnderCursor = document.elementFromPoint( + this.lastViewportPosition.x, + this.lastViewportPosition.y, + ); if ( event && (!(elementUnderCursor instanceof HTMLCanvasElement) || @@ -1552,7 +1643,10 @@ class App extends React.Component { // prefer spreadsheet data over image file (MS Office/Libre Office) if (isSupportedImageFile(file) && !data.spreadsheet) { const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( - { clientX: cursorX, clientY: cursorY }, + { + clientX: this.lastViewportPosition.x, + clientY: this.lastViewportPosition.y, + }, this.state, ); @@ -1589,6 +1683,7 @@ class App extends React.Component { elements: data.elements, files: data.files || null, position: "cursor", + retainSeed: isPlainPaste, }); } else if (data.text) { this.addTextFromPaste(data.text, isPlainPaste); @@ -1602,6 +1697,7 @@ class App extends React.Component { elements: readonly ExcalidrawElement[]; files: BinaryFiles | null; position: { clientX: number; clientY: number } | "cursor" | "center"; + retainSeed?: boolean; }) => { const elements = restoreElements(opts.elements, null); const [minX, minY, maxX, maxY] = getCommonBounds(elements); @@ -1613,13 +1709,13 @@ class App extends React.Component { typeof opts.position === "object" ? opts.position.clientX : opts.position === "cursor" - ? cursorX + ? this.lastViewportPosition.x : this.state.width / 2 + this.state.offsetLeft; const clientY = typeof opts.position === "object" ? opts.position.clientY : opts.position === "cursor" - ? cursorY + ? this.lastViewportPosition.y : this.state.height / 2 + this.state.offsetTop; const { x, y } = viewportCoordsToSceneCoords( @@ -1639,6 +1735,9 @@ class App extends React.Component { y: element.y + gridY - minY, }); }), + { + randomizeSeed: !opts.retainSeed, + }, ); const nextElements = [ @@ -1673,7 +1772,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( @@ -1700,7 +1799,10 @@ class App extends React.Component { private addTextFromPaste(text: string, isPlainPaste = false) { const { x, y } = viewportCoordsToSceneCoords( - { clientX: cursorX, clientY: cursorY }, + { + clientX: this.lastViewportPosition.x, + clientY: this.lastViewportPosition.y, + }, this.state, ); @@ -2011,36 +2113,30 @@ 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( (event: MouseEvent) => { - cursorX = event.clientX; - cursorY = event.clientY; + this.lastViewportPosition.x = event.clientX; + this.lastViewportPosition.y = event.clientY; }, ); @@ -2113,6 +2209,7 @@ class App extends React.Component { event.shiftKey && event[KEYS.CTRL_OR_CMD] ) { + event.preventDefault(); this.setState({ openDialog: "imageExport" }); return; } @@ -2282,11 +2379,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(); } } @@ -2297,6 +2394,20 @@ class App extends React.Component { ) { jotaiStore.set(activeConfirmDialogAtom, "clearCanvas"); } + + // eye dropper + // ----------------------------------------------------------------------- + const lowerCased = event.key.toLocaleLowerCase(); + const isPickingStroke = lowerCased === KEYS.S && event.shiftKey; + const isPickingBackground = + event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey); + + if (isPickingStroke || isPickingBackground) { + this.openEyeDropper({ + type: isPickingStroke ? "stroke" : "background", + }); + } + // ----------------------------------------------------------------------- }, ); @@ -2426,8 +2537,8 @@ class App extends React.Component { this.setState((state) => ({ ...getStateForZoom( { - viewportX: cursorX, - viewportY: cursorY, + viewportX: this.lastViewportPosition.x, + viewportY: this.lastViewportPosition.y, nextZoom: getNormalizedZoom(initialScale * event.scale), }, state, @@ -2744,6 +2855,7 @@ class App extends React.Component { containerId: shouldBindToContainer ? container?.id : undefined, groupIds: container?.groupIds ?? [], lineHeight, + angle: container?.angle ?? 0, }); if (!existingTextElement && shouldBindToContainer && container) { @@ -4719,7 +4831,12 @@ class App extends React.Component { pointerDownState.drag.hasOccurred = true; // prevent dragging even if we're no longer holding cmd/ctrl otherwise // it would have weird results (stuff jumping all over the screen) - if (selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl) { + // Checking for editingElement to avoid jump while editing on mobile #6503 + if ( + selectedElements.length > 0 && + !pointerDownState.withCmdOrCtrl && + !this.state.editingElement + ) { const [dragX, dragY] = getGridPoint( pointerCoords.x - pointerDownState.drag.offset.x, pointerCoords.y - pointerDownState.drag.offset.y, @@ -5742,7 +5859,9 @@ class App extends React.Component { const imageFile = await fileOpen({ description: "Image", - extensions: ["jpg", "png", "svg", "gif"], + extensions: Object.keys( + IMAGE_MIME_TYPES, + ) as (keyof typeof IMAGE_MIME_TYPES)[], }); const imageElement = this.createImageElement({ @@ -6334,6 +6453,7 @@ class App extends React.Component { copyText, CONTEXT_MENU_SEPARATOR, actionSelectAll, + actionUnlockAllElements, CONTEXT_MENU_SEPARATOR, actionToggleGridMode, actionToggleZenMode, @@ -6380,7 +6500,7 @@ class App extends React.Component { actionToggleLinearEditor, actionLink, actionDuplicateSelection, - actionToggleLock, + actionToggleElementLock, CONTEXT_MENU_SEPARATOR, actionDeleteSelected, ]; @@ -6414,8 +6534,8 @@ class App extends React.Component { this.translateCanvas((state) => ({ ...getStateForZoom( { - viewportX: cursorX, - viewportY: cursorY, + viewportX: this.lastViewportPosition.x, + viewportY: this.lastViewportPosition.y, nextZoom: getNormalizedZoom(newZoom), }, state, diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 57a7eec26..20dc7b9f2 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 8a4a71e4f..1932d7a29 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 3303c3ebf..bf548d72f 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 000000000..f179d415c --- /dev/null +++ b/src/components/ColorPicker/ColorInput.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getColor } from "./ColorPicker"; +import { useAtom } from "jotai"; +import { activeColorPickerSectionAtom } from "./colorPickerUtils"; +import { eyeDropperIcon } from "../icons"; +import { jotaiScope } from "../../jotai"; +import { KEYS } from "../../keys"; +import { activeEyeDropperAtom } from "../EyeDropper"; +import clsx from "clsx"; +import { t } from "../../i18n"; +import { useDevice } from "../App"; +import { getShortcutKey } from "../../utils"; + +interface ColorInputProps { + color: string; + onChange: (color: string) => void; + label: string; +} + +export const ColorInput = ({ color, onChange, label }: ColorInputProps) => { + const device = useDevice(); + const [innerValue, setInnerValue] = useState(color); + const [activeSection, setActiveColorPickerSection] = useAtom( + activeColorPickerSectionAtom, + ); + + useEffect(() => { + setInnerValue(color); + }, [color]); + + const changeColor = useCallback( + (inputValue: string) => { + const value = inputValue.toLowerCase(); + const color = getColor(value); + + if (color) { + onChange(color); + } + setInnerValue(value); + }, + [onChange], + ); + + const inputRef = useRef(null); + const eyeDropperTriggerRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [activeSection]); + + const [eyeDropperState, setEyeDropperState] = useAtom( + activeEyeDropperAtom, + jotaiScope, + ); + + useEffect(() => { + return () => { + setEyeDropperState(null); + }; + }, [setEyeDropperState]); + + return ( +
+
#
+ { + changeColor(event.target.value); + }} + value={(innerValue || "").replace(/^#/, "")} + onBlur={() => { + setInnerValue(color); + }} + tabIndex={-1} + onFocus={() => setActiveColorPickerSection("hex")} + onKeyDown={(event) => { + if (event.key === KEYS.TAB) { + return; + } else if (event.key === KEYS.ESCAPE) { + eyeDropperTriggerRef.current?.focus(); + } + event.stopPropagation(); + }} + /> + {/* TODO reenable on mobile with a better UX */} + {!device.isMobile && ( + <> +
+
+ setEyeDropperState((s) => + s + ? null + : { + keepOpenOnAlt: false, + onSelect: (color) => onChange(color), + }, + ) + } + title={`${t( + "labels.eyeDropper", + )} — ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `} + > + {eyeDropperIcon} +
+ + )} +
+ ); +}; 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 52ea20a19..815e8e59d 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,18 @@ } } + .color-picker-content { + display: flex; + flex-direction: column; + gap: 0.75rem; + outline: none; + } + .color-picker-content--default { padding: 0.5rem; display: grid; - grid-template-columns: repeat(5, auto); - grid-gap: 0.5rem; + grid-template-columns: repeat(5, 1.875rem); + grid-gap: 0.25rem; border-radius: 4px; &:focus { @@ -178,11 +313,33 @@ } } + .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%; margin: 0; font-size: 0.875rem; + font-family: inherit; background-color: transparent; color: var(--text-primary-color); border: 0; diff --git a/src/components/ColorPicker/ColorPicker.tsx b/src/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 000000000..cec9ef9a9 --- /dev/null +++ b/src/components/ColorPicker/ColorPicker.tsx @@ -0,0 +1,290 @@ +import { isTransparent, isWritableElement } from "../../utils"; +import { ExcalidrawElement } from "../../element/types"; +import { AppState } from "../../types"; +import { TopPicks } from "./TopPicks"; +import { Picker } from "./Picker"; +import * as Popover from "@radix-ui/react-popover"; +import { useAtom } from "jotai"; +import { + activeColorPickerSectionAtom, + ColorPickerType, +} from "./colorPickerUtils"; +import { useDevice, useExcalidrawContainer } from "../App"; +import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors"; +import PickerHeading from "./PickerHeading"; +import { t } from "../../i18n"; +import clsx from "clsx"; +import { jotaiScope } from "../../jotai"; +import { ColorInput } from "./ColorInput"; +import { useRef } from "react"; +import { activeEyeDropperAtom } from "../EyeDropper"; + +import "./ColorPicker.scss"; + +const isValidColor = (color: string) => { + const style = new Option().style; + style.color = color; + return !!style.color; +}; + +export const getColor = (color: string): string | null => { + if (isTransparent(color)) { + return color; + } + + // testing for `#` first fixes a bug on Electron (more specfically, an + // Obsidian popout window), where a hex color without `#` is (incorrectly) + // considered valid + return isValidColor(`#${color}`) + ? `#${color}` + : isValidColor(color) + ? color + : null; +}; + +interface ColorPickerProps { + type: ColorPickerType; + color: string; + onChange: (color: string) => void; + label: string; + elements: readonly ExcalidrawElement[]; + appState: AppState; + palette?: ColorPaletteCustom | null; + topPicks?: ColorTuple; + updateData: (formData?: any) => void; +} + +const ColorPickerPopupContent = ({ + type, + color, + onChange, + label, + elements, + palette = COLOR_PALETTE, + updateData, +}: Pick< + ColorPickerProps, + | "type" + | "color" + | "onChange" + | "label" + | "elements" + | "palette" + | "updateData" +>) => { + const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); + + const [eyeDropperState, setEyeDropperState] = useAtom( + activeEyeDropperAtom, + jotaiScope, + ); + + const { container } = useExcalidrawContainer(); + const { isMobile, isLandscape } = useDevice(); + + const colorInputJSX = ( +
+ {t("colorPicker.hexCode")} + { + onChange(color); + }} + /> +
+ ); + const popoverRef = useRef(null); + + const focusPickerContent = () => { + popoverRef.current + ?.querySelector(".color-picker-content") + ?.focus(); + }; + + return ( + + { + focusPickerContent(); + event.preventDefault(); + }} + onPointerDownOutside={(event) => { + if (eyeDropperState) { + // prevent from closing if we click outside the popover + // while eyedropping (e.g. click when clicking the sidebar; + // the eye-dropper-backdrop is prevented downstream) + event.preventDefault(); + } + }} + onCloseAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + + // return focus to excalidraw container + if (container) { + container.focus(); + } + + updateData({ openPopup: null }); + setActiveColorPickerSection(null); + }} + side={isMobile && !isLandscape ? "bottom" : "right"} + align={isMobile && !isLandscape ? "center" : "start"} + alignOffset={-16} + sideOffset={20} + style={{ + zIndex: 9999, + backgroundColor: "var(--popup-bg-color)", + maxWidth: "208px", + maxHeight: window.innerHeight, + padding: "12px", + borderRadius: "8px", + boxSizing: "border-box", + overflowY: "auto", + boxShadow: + "0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)", + }} + > + {palette ? ( + { + onChange(changedColor); + }} + onEyeDropperToggle={(force) => { + setEyeDropperState((state) => { + if (force) { + state = state || { + keepOpenOnAlt: true, + onSelect: onChange, + }; + state.keepOpenOnAlt = true; + return state; + } + + return force === false || state + ? null + : { + keepOpenOnAlt: false, + onSelect: onChange, + }; + }); + }} + onEscape={(event) => { + if (eyeDropperState) { + setEyeDropperState(null); + } else if (isWritableElement(event.target)) { + focusPickerContent(); + } else { + updateData({ openPopup: null }); + } + }} + label={label} + type={type} + elements={elements} + updateData={updateData} + > + {colorInputJSX} + + ) : ( + colorInputJSX + )} + + + + ); +}; + +const ColorPickerTrigger = ({ + label, + color, + type, +}: { + color: string; + 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 000000000..b028dcc76 --- /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; + 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 000000000..145060d19 --- /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 000000000..a2e17526c --- /dev/null +++ b/src/components/ColorPicker/Picker.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useState } from "react"; +import { t } from "../../i18n"; + +import { ExcalidrawElement } from "../../element/types"; +import { ShadeList } from "./ShadeList"; + +import PickerColorList from "./PickerColorList"; +import { useAtom } from "jotai"; +import { CustomColorList } from "./CustomColorList"; +import { colorPickerKeyNavHandler } from "./keyboardNavHandlers"; +import PickerHeading from "./PickerHeading"; +import { + ColorPickerType, + activeColorPickerSectionAtom, + getColorNameAndShadeFromColor, + getMostUsedCustomColors, + isCustomColor, +} from "./colorPickerUtils"; +import { + ColorPaletteCustom, + DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, + DEFAULT_ELEMENT_STROKE_COLOR_INDEX, +} from "../../colors"; +import { KEYS } from "../../keys"; +import { EVENT } from "../../constants"; + +interface PickerProps { + color: string; + onChange: (color: string) => void; + label: string; + type: ColorPickerType; + elements: readonly ExcalidrawElement[]; + palette: ColorPaletteCustom; + updateData: (formData?: any) => void; + children?: React.ReactNode; + onEyeDropperToggle: (force?: boolean) => void; + onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; +} + +export const Picker = ({ + color, + onChange, + label, + type, + elements, + palette, + updateData, + children, + onEyeDropperToggle, + onEscape, +}: PickerProps) => { + const [customColors] = React.useState(() => { + if (type === "canvasBackground") { + return []; + } + return getMostUsedCustomColors(elements, type, palette); + }); + + const [activeColorPickerSection, setActiveColorPickerSection] = useAtom( + activeColorPickerSectionAtom, + ); + + const colorObj = getColorNameAndShadeFromColor({ + color, + palette, + }); + + useEffect(() => { + if (!activeColorPickerSection) { + const isCustom = isCustomColor({ color, palette }); + const isCustomButNotInList = isCustom && !customColors.includes(color); + + setActiveColorPickerSection( + isCustomButNotInList + ? "hex" + : isCustom + ? "custom" + : colorObj?.shade != null + ? "shades" + : "baseColors", + ); + } + }, [ + activeColorPickerSection, + color, + palette, + setActiveColorPickerSection, + colorObj, + customColors, + ]); + + const [activeShade, setActiveShade] = useState( + colorObj?.shade ?? + (type === "elementBackground" + ? DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX + : DEFAULT_ELEMENT_STROKE_COLOR_INDEX), + ); + + useEffect(() => { + if (colorObj?.shade != null) { + setActiveShade(colorObj.shade); + } + + const keyup = (event: KeyboardEvent) => { + if (event.key === KEYS.ALT) { + onEyeDropperToggle(false); + } + }; + document.addEventListener(EVENT.KEYUP, keyup, { capture: true }); + return () => { + document.removeEventListener(EVENT.KEYUP, keyup, { capture: true }); + }; + }, [colorObj, onEyeDropperToggle]); + + const pickerRef = React.useRef(null); + + return ( +
+
{ + const handled = colorPickerKeyNavHandler({ + event, + activeColorPickerSection, + palette, + color, + onChange, + onEyeDropperToggle, + customColors, + setActiveColorPickerSection, + updateData, + activeShade, + onEscape, + }); + + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + }} + className="color-picker-content" + // to allow focusing by clicking but not by tabbing + tabIndex={-1} + > + {!!customColors.length && ( +
+ + {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 000000000..2491afba8 --- /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, + getColorNameAndShadeFromColor, +} from "./colorPickerUtils"; +import HotkeyLabel from "./HotkeyLabel"; +import { ColorPaletteCustom } from "../../colors"; +import { t } from "../../i18n"; + +interface PickerColorListProps { + palette: ColorPaletteCustom; + color: string; + onChange: (color: string) => void; + label: string; + activeShade: number; +} + +const PickerColorList = ({ + palette, + color, + onChange, + label, + activeShade, +}: PickerColorListProps) => { + const colorObj = getColorNameAndShadeFromColor({ + color: color || "transparent", + palette, + }); + const [activeColorPickerSection, setActiveColorPickerSection] = useAtom( + activeColorPickerSectionAtom, + ); + + const btnRef = useRef(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 000000000..043731366 --- /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 000000000..81ddaab24 --- /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, + getColorNameAndShadeFromColor, +} from "./colorPickerUtils"; +import HotkeyLabel from "./HotkeyLabel"; +import { t } from "../../i18n"; +import { ColorPaletteCustom } from "../../colors"; + +interface ShadeListProps { + hex: string; + onChange: (color: string) => void; + palette: ColorPaletteCustom; +} + +export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => { + const colorObj = getColorNameAndShadeFromColor({ + color: hex || "transparent", + palette, + }); + + const [activeColorPickerSection, setActiveColorPickerSection] = useAtom( + activeColorPickerSectionAtom, + ); + + const btnRef = useRef(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 000000000..ae420c3fd --- /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; + 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 000000000..37e5c88a6 --- /dev/null +++ b/src/components/ColorPicker/colorPickerUtils.ts @@ -0,0 +1,136 @@ +import { ExcalidrawElement } from "../../element/types"; +import { atom } from "jotai"; +import { + ColorPickerColor, + ColorPaletteCustom, + MAX_CUSTOM_COLORS_USED_IN_CANVAS, +} from "../../colors"; + +export const getColorNameAndShadeFromColor = ({ + palette, + color, +}: { + palette: ColorPaletteCustom; + color: string; +}): { + colorName: ColorPickerColor; + shade: number | null; +} | null => { + for (const [colorName, colorVal] of Object.entries(palette)) { + if (Array.isArray(colorVal)) { + const shade = colorVal.indexOf(color); + if (shade > -1) { + return { colorName: colorName as ColorPickerColor, shade }; + } + } else if (colorVal === color) { + return { colorName: colorName as ColorPickerColor, shade: null }; + } + } + return null; +}; + +export const colorPickerHotkeyBindings = [ + ["q", "w", "e", "r", "t"], + ["a", "s", "d", "f", "g"], + ["z", "x", "c", "v", "b"], +].flat(); + +export const isCustomColor = ({ + color, + palette, +}: { + color: string; + palette: ColorPaletteCustom; +}) => { + const paletteValues = Object.values(palette).flat(); + return !paletteValues.includes(color); +}; + +export const getMostUsedCustomColors = ( + elements: readonly ExcalidrawElement[], + type: "elementBackground" | "elementStroke", + palette: ColorPaletteCustom, +) => { + const elementColorTypeMap = { + elementBackground: "backgroundColor", + elementStroke: "strokeColor", + }; + + const colors = elements.filter((element) => { + if (element.isDeleted) { + return false; + } + + const color = + element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"]; + + return isCustomColor({ color, palette }); + }); + + const colorCountMap = new Map(); + 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 000000000..95ee7beeb --- /dev/null +++ b/src/components/ColorPicker/keyboardNavHandlers.ts @@ -0,0 +1,287 @@ +import { KEYS } from "../../keys"; +import { + ColorPickerColor, + ColorPalette, + ColorPaletteCustom, + COLORS_PER_ROW, + COLOR_PALETTE, +} from "../../colors"; +import { ValueOf } from "../../utility-types"; +import { + ActiveColorPickerSectionAtomType, + colorPickerHotkeyBindings, + getColorNameAndShadeFromColor, +} from "./colorPickerUtils"; + +const arrowHandler = ( + eventKey: string, + currentIndex: number | null, + length: number, +) => { + const rows = Math.ceil(length / COLORS_PER_ROW); + + currentIndex = currentIndex ?? -1; + + switch (eventKey) { + case "ArrowLeft": { + const prevIndex = currentIndex - 1; + return prevIndex < 0 ? length - 1 : prevIndex; + } + case "ArrowRight": { + return (currentIndex + 1) % length; + } + case "ArrowDown": { + const nextIndex = currentIndex + COLORS_PER_ROW; + return nextIndex >= length ? currentIndex % COLORS_PER_ROW : nextIndex; + } + case "ArrowUp": { + const prevIndex = currentIndex - COLORS_PER_ROW; + const newIndex = + prevIndex < 0 ? COLORS_PER_ROW * rows + prevIndex : prevIndex; + return newIndex >= length ? undefined : newIndex; + } + } +}; + +interface HotkeyHandlerProps { + e: React.KeyboardEvent; + colorObj: { colorName: ColorPickerColor; shade: number | null } | null; + onChange: (color: string) => void; + palette: ColorPaletteCustom; + customColors: string[]; + setActiveColorPickerSection: ( + update: React.SetStateAction, + ) => void; + activeShade: number; +} + +/** + * @returns true if the event was handled + */ +const hotkeyHandler = ({ + e, + colorObj, + onChange, + palette, + customColors, + setActiveColorPickerSection, + activeShade, +}: HotkeyHandlerProps): boolean => { + if (colorObj?.shade != null) { + // shift + numpad is extremely messed up on windows apparently + if ( + ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].includes(e.code) && + e.shiftKey + ) { + const newShade = Number(e.code.slice(-1)) - 1; + onChange(palette[colorObj.colorName][newShade]); + setActiveColorPickerSection("shades"); + return true; + } + } + + if (["1", "2", "3", "4", "5"].includes(e.key)) { + const c = customColors[Number(e.key) - 1]; + if (c) { + onChange(customColors[Number(e.key) - 1]); + setActiveColorPickerSection("custom"); + return true; + } + } + + if (colorPickerHotkeyBindings.includes(e.key)) { + const index = colorPickerHotkeyBindings.indexOf(e.key); + const paletteKey = Object.keys(palette)[index] as keyof ColorPalette; + const paletteValue = palette[paletteKey]; + const r = Array.isArray(paletteValue) + ? paletteValue[activeShade] + : paletteValue; + onChange(r); + setActiveColorPickerSection("baseColors"); + return true; + } + return false; +}; + +interface ColorPickerKeyNavHandlerProps { + event: React.KeyboardEvent; + activeColorPickerSection: ActiveColorPickerSectionAtomType; + palette: ColorPaletteCustom; + color: string; + onChange: (color: string) => void; + customColors: string[]; + setActiveColorPickerSection: ( + update: React.SetStateAction, + ) => void; + updateData: (formData?: any) => void; + activeShade: number; + onEyeDropperToggle: (force?: boolean) => void; + onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; +} + +/** + * @returns true if the event was handled + */ +export const colorPickerKeyNavHandler = ({ + event, + activeColorPickerSection, + palette, + color, + onChange, + customColors, + setActiveColorPickerSection, + updateData, + activeShade, + onEyeDropperToggle, + onEscape, +}: ColorPickerKeyNavHandlerProps): boolean => { + if (event[KEYS.CTRL_OR_CMD]) { + return false; + } + + if (event.key === KEYS.ESCAPE) { + onEscape(event); + return true; + } + + // checkt using `key` to ignore combos with Alt modifier + if (event.key === KEYS.ALT) { + onEyeDropperToggle(true); + return true; + } + + if (event.key === KEYS.I) { + onEyeDropperToggle(); + return true; + } + + const colorObj = getColorNameAndShadeFromColor({ color, palette }); + + if (event.key === KEYS.TAB) { + const sectionsMap: Record< + NonNullable, + boolean + > = { + custom: !!customColors.length, + baseColors: true, + shades: colorObj?.shade != null, + hex: true, + }; + + const sections = Object.entries(sectionsMap).reduce((acc, [key, value]) => { + if (value) { + acc.push(key as ActiveColorPickerSectionAtomType); + } + return acc; + }, [] as ActiveColorPickerSectionAtomType[]); + + const activeSectionIndex = sections.indexOf(activeColorPickerSection); + const indexOffset = event.shiftKey ? -1 : 1; + const nextSectionIndex = + activeSectionIndex + indexOffset > sections.length - 1 + ? 0 + : activeSectionIndex + indexOffset < 0 + ? sections.length - 1 + : activeSectionIndex + indexOffset; + + const nextSection = sections[nextSectionIndex]; + + if (nextSection) { + setActiveColorPickerSection(nextSection); + } + + if (nextSection === "custom") { + onChange(customColors[0]); + } else if (nextSection === "baseColors") { + const baseColorName = ( + Object.entries(palette) as [string, ValueOf][] + ).find(([name, shades]) => { + if (Array.isArray(shades)) { + return shades.includes(color); + } else if (shades === color) { + return name; + } + return null; + }); + + if (!baseColorName) { + onChange(COLOR_PALETTE.black); + } + } + + event.preventDefault(); + event.stopPropagation(); + + return true; + } + + if ( + hotkeyHandler({ + e: event, + colorObj, + onChange, + palette, + customColors, + setActiveColorPickerSection, + activeShade, + }) + ) { + return true; + } + + if (activeColorPickerSection === "shades") { + if (colorObj) { + const { shade } = colorObj; + const newShade = arrowHandler(event.key, shade, COLORS_PER_ROW); + + if (newShade !== undefined) { + onChange(palette[colorObj.colorName][newShade]); + return true; + } + } + } + + if (activeColorPickerSection === "baseColors") { + if (colorObj) { + const { colorName } = colorObj; + const colorNames = Object.keys(palette) as (keyof ColorPalette)[]; + const indexOfColorName = colorNames.indexOf(colorName); + + const newColorIndex = arrowHandler( + event.key, + indexOfColorName, + colorNames.length, + ); + + if (newColorIndex !== undefined) { + const newColorName = colorNames[newColorIndex]; + const newColorNameValue = palette[newColorName]; + + onChange( + Array.isArray(newColorNameValue) + ? newColorNameValue[activeShade] + : newColorNameValue, + ); + return true; + } + } + } + + if (activeColorPickerSection === "custom") { + const indexOfColor = customColors.indexOf(color); + + const newColorIndex = arrowHandler( + event.key, + indexOfColor, + customColors.length, + ); + + if (newColorIndex !== undefined) { + const newColor = customColors[newColorIndex]; + onChange(newColor); + return true; + } + } + + return false; +}; diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index aebb42de7..9061fefa0 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,11 +26,12 @@ const ConfirmDialog = (props: Props) => { } = props; const setAppState = useExcalidrawSetAppState(); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); + const { container } = useExcalidrawContainer(); return ( @@ -42,6 +43,7 @@ const ConfirmDialog = (props: Props) => { setAppState({ openMenu: null }); setIsLibraryMenuOpen(false); onCancel(); + container?.focus(); }} /> { setAppState({ openMenu: null }); setIsLibraryMenuOpen(false); onConfirm(); + container?.focus(); }} actionType="danger" /> diff --git a/src/components/ContextMenu.scss b/src/components/ContextMenu.scss index 579763119..81ced3880 100644 --- a/src/components/ContextMenu.scss +++ b/src/components/ContextMenu.scss @@ -30,6 +30,7 @@ background-color: transparent; border: none; white-space: nowrap; + font-family: inherit; display: grid; grid-template-columns: 1fr 0.2fr; diff --git a/src/components/DefaultSidebar.test.tsx b/src/components/DefaultSidebar.test.tsx new file mode 100644 index 000000000..64cfe5ba6 --- /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 000000000..48a78faac --- /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.scss b/src/components/Dialog.scss index 604b3c64e..405f9235a 100644 --- a/src/components/Dialog.scss +++ b/src/components/Dialog.scss @@ -14,4 +14,33 @@ padding: 0 0 0.75rem; margin-bottom: 1.5rem; } + + .Dialog__close { + color: var(--color-gray-40); + margin: 0; + position: absolute; + top: 0.75rem; + right: 0.5rem; + border: 0; + background-color: transparent; + line-height: 0; + cursor: pointer; + + &:hover { + color: var(--color-gray-60); + } + &:active { + color: var(--color-gray-40); + } + + @include isMobile { + top: 1.25rem; + right: 1.25rem; + } + + svg { + width: 1.5rem; + height: 1.5rem; + } + } } diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 24a078dd5..161d18056 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -12,20 +12,18 @@ import "./Dialog.scss"; import { back, CloseIcon } from "./icons"; import { Island } from "./Island"; import { Modal } from "./Modal"; -import { AppState } from "../types"; import { queryFocusableElements } from "../utils"; import { useSetAtom } from "jotai"; -import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; +import { isLibraryMenuOpenAtom } from "./LibraryMenu"; import { jotaiScope } from "../jotai"; export interface DialogProps { children: React.ReactNode; className?: string; - small?: boolean; + size?: "small" | "regular" | "wide"; onCloseRequest(): void; - title: React.ReactNode; + title: React.ReactNode | false; autofocus?: boolean; - theme?: AppState["theme"]; closeOnClickOutside?: boolean; } @@ -33,6 +31,7 @@ export const Dialog = (props: DialogProps) => { const [islandNode, setIslandNode] = useCallbackRefState(); const [lastActiveElement] = useState(document.activeElement); const { id } = useExcalidrawContainer(); + const device = useDevice(); useEffect(() => { if (!islandNode) { @@ -86,23 +85,26 @@ export const Dialog = (props: DialogProps) => { -

- {props.title} - -

+ {props.title && ( +

+ {props.title} +

+ )} +
{props.children}
diff --git a/src/components/ErrorDialog.tsx b/src/components/ErrorDialog.tsx index 56c303c15..74d265f73 100644 --- a/src/components/ErrorDialog.tsx +++ b/src/components/ErrorDialog.tsx @@ -28,7 +28,7 @@ export const ErrorDialog = ({ <> {modalIsShown && ( diff --git a/src/components/EyeDropper.scss b/src/components/EyeDropper.scss new file mode 100644 index 000000000..3273e4d77 --- /dev/null +++ b/src/components/EyeDropper.scss @@ -0,0 +1,48 @@ +.excalidraw { + .excalidraw-eye-dropper-container, + .excalidraw-eye-dropper-backdrop { + position: absolute; + width: 100%; + height: 100%; + z-index: 2; + touch-action: none; + } + + .excalidraw-eye-dropper-container { + pointer-events: none; + } + + .excalidraw-eye-dropper-backdrop { + pointer-events: all; + } + + .excalidraw-eye-dropper-preview { + pointer-events: none; + width: 3rem; + height: 3rem; + position: fixed; + z-index: 999999; + border-radius: 1rem; + border: 1px solid var(--default-border-color); + filter: var(--theme-filter); + } + + .excalidraw-eye-dropper-trigger { + width: 1.25rem; + height: 1.25rem; + cursor: pointer; + padding: 4px; + margin-right: -4px; + margin-left: -2px; + border-radius: 0.5rem; + color: var(--icon-fill-color); + + &:hover { + background: var(--button-hover-bg); + } + &.selected { + color: var(--color-primary); + background: var(--color-primary-light); + } + } +} diff --git a/src/components/EyeDropper.tsx b/src/components/EyeDropper.tsx new file mode 100644 index 000000000..794ad6026 --- /dev/null +++ b/src/components/EyeDropper.tsx @@ -0,0 +1,217 @@ +import { atom } from "jotai"; +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { COLOR_PALETTE, rgbToHex } from "../colors"; +import { EVENT } from "../constants"; +import { useUIAppState } from "../context/ui-appState"; +import { mutateElement } from "../element/mutateElement"; +import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer"; +import { useOutsideClick } from "../hooks/useOutsideClick"; +import { KEYS } from "../keys"; +import { invalidateShapeForElement } from "../renderer/renderElement"; +import { getSelectedElements } from "../scene"; +import Scene from "../scene/Scene"; +import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App"; + +import "./EyeDropper.scss"; + +type EyeDropperProperties = { + keepOpenOnAlt: boolean; + swapPreviewOnAlt?: boolean; + onSelect?: (color: string, event: PointerEvent) => void; + previewType?: "strokeColor" | "backgroundColor"; +}; + +export const activeEyeDropperAtom = atom(null); + +export const EyeDropper: React.FC<{ + onCancel: () => void; + onSelect: Required["onSelect"]; + swapPreviewOnAlt?: EyeDropperProperties["swapPreviewOnAlt"]; + previewType?: EyeDropperProperties["previewType"]; +}> = ({ + onCancel, + onSelect, + swapPreviewOnAlt, + previewType = "backgroundColor", +}) => { + const eyeDropperContainer = useCreatePortalContainer({ + className: "excalidraw-eye-dropper-backdrop", + parentSelector: ".excalidraw-eye-dropper-container", + }); + const appState = useUIAppState(); + const elements = useExcalidrawElements(); + const app = useApp(); + + const selectedElements = getSelectedElements(elements, appState); + + const metaStuffRef = useRef({ selectedElements, app }); + metaStuffRef.current.selectedElements = selectedElements; + metaStuffRef.current.app = app; + + const { container: excalidrawContainer } = useExcalidrawContainer(); + + useEffect(() => { + const colorPreviewDiv = ref.current; + + if (!colorPreviewDiv || !app.canvas || !eyeDropperContainer) { + return; + } + + let currentColor = COLOR_PALETTE.black; + let isHoldingPointerDown = false; + + const ctx = app.canvas.getContext("2d")!; + + const mouseMoveListener = ({ + clientX, + clientY, + altKey, + }: { + clientX: number; + clientY: number; + altKey: boolean; + }) => { + // FIXME swap offset when the preview gets outside viewport + colorPreviewDiv.style.top = `${clientY + 20}px`; + colorPreviewDiv.style.left = `${clientX + 20}px`; + + const pixel = ctx.getImageData( + clientX * window.devicePixelRatio - appState.offsetLeft, + clientY * window.devicePixelRatio - appState.offsetTop, + 1, + 1, + ).data; + + currentColor = rgbToHex(pixel[0], pixel[1], pixel[2]); + + if (isHoldingPointerDown) { + for (const element of metaStuffRef.current.selectedElements) { + mutateElement( + element, + { + [altKey && swapPreviewOnAlt + ? previewType === "strokeColor" + ? "backgroundColor" + : "strokeColor" + : previewType]: currentColor, + }, + false, + ); + invalidateShapeForElement(element); + } + Scene.getScene( + metaStuffRef.current.selectedElements[0], + )?.informMutation(); + } + + colorPreviewDiv.style.background = currentColor; + }; + + const pointerDownListener = (event: PointerEvent) => { + isHoldingPointerDown = true; + // NOTE we can't event.preventDefault() as that would stop + // pointermove events + event.stopImmediatePropagation(); + }; + + const pointerUpListener = (event: PointerEvent) => { + isHoldingPointerDown = false; + + // since we're not preventing default on pointerdown, the focus would + // goes back to `body` so we want to refocus the editor container instead + excalidrawContainer?.focus(); + + event.stopImmediatePropagation(); + event.preventDefault(); + + onSelect(currentColor, event); + }; + + const keyDownListener = (event: KeyboardEvent) => { + if (event.key === KEYS.ESCAPE) { + event.preventDefault(); + event.stopImmediatePropagation(); + onCancel(); + } + }; + + // ------------------------------------------------------------------------- + + eyeDropperContainer.tabIndex = -1; + // focus container so we can listen on keydown events + eyeDropperContainer.focus(); + + // init color preview else it would show only after the first mouse move + mouseMoveListener({ + clientX: metaStuffRef.current.app.lastViewportPosition.x, + clientY: metaStuffRef.current.app.lastViewportPosition.y, + altKey: false, + }); + + eyeDropperContainer.addEventListener(EVENT.KEYDOWN, keyDownListener); + eyeDropperContainer.addEventListener( + EVENT.POINTER_DOWN, + pointerDownListener, + ); + eyeDropperContainer.addEventListener(EVENT.POINTER_UP, pointerUpListener); + window.addEventListener("pointermove", mouseMoveListener, { + passive: true, + }); + window.addEventListener(EVENT.BLUR, onCancel); + + return () => { + isHoldingPointerDown = false; + eyeDropperContainer.removeEventListener(EVENT.KEYDOWN, keyDownListener); + eyeDropperContainer.removeEventListener( + EVENT.POINTER_DOWN, + pointerDownListener, + ); + eyeDropperContainer.removeEventListener( + EVENT.POINTER_UP, + pointerUpListener, + ); + window.removeEventListener("pointermove", mouseMoveListener); + window.removeEventListener(EVENT.BLUR, onCancel); + }; + }, [ + app.canvas, + eyeDropperContainer, + onCancel, + onSelect, + swapPreviewOnAlt, + previewType, + excalidrawContainer, + appState.offsetLeft, + appState.offsetTop, + ]); + + const ref = useRef(null); + + useOutsideClick( + ref, + () => { + onCancel(); + }, + (event) => { + if ( + event.target.closest( + ".excalidraw-eye-dropper-trigger, .excalidraw-eye-dropper-backdrop", + ) + ) { + return true; + } + // consider all other clicks as outside + return false; + }, + ); + + if (!eyeDropperContainer) { + return null; + } + + return createPortal( +
, + eyeDropperContainer, + ); +}; diff --git a/src/components/FilledButton.scss b/src/components/FilledButton.scss new file mode 100644 index 000000000..d742e22e7 --- /dev/null +++ b/src/components/FilledButton.scss @@ -0,0 +1,95 @@ +@import "../css/variables.module"; + +.excalidraw { + .ExcButton { + &--color-primary { + color: var(--input-bg-color); + + --accent-color: var(--color-primary); + --accent-color-hover: var(--color-primary-darker); + --accent-color-active: var(--color-primary-darkest); + } + + &--color-danger { + color: var(--input-bg-color); + + --accent-color: var(--color-danger); + --accent-color-hover: #d65550; + --accent-color-active: #d1413c; + } + + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + flex-wrap: nowrap; + + border-radius: 0.5rem; + + font-family: "Assistant"; + + user-select: none; + + transition: all 150ms ease-out; + + &--size-large { + font-weight: 400; + font-size: 0.875rem; + height: 3rem; + padding: 0.5rem 1.5rem; + gap: 0.75rem; + + letter-spacing: 0.4px; + } + + &--size-medium { + font-weight: 600; + font-size: 0.75rem; + height: 2.5rem; + padding: 0.5rem 1rem; + gap: 0.5rem; + + letter-spacing: normal; + } + + &--variant-filled { + background: var(--accent-color); + border: 1px solid transparent; + + &:hover { + background: var(--accent-color-hover); + } + + &:active { + background: var(--accent-color-active); + } + } + + &--variant-outlined, + &--variant-icon { + border: 1px solid var(--accent-color); + color: var(--accent-color); + background: transparent; + + &:hover { + border: 1px solid var(--accent-color-hover); + color: var(--accent-color-hover); + } + + &:active { + border: 1px solid var(--accent-color-active); + color: var(--accent-color-active); + } + } + + &--variant-icon { + padding: 0.5rem 0.75rem; + width: 3rem; + } + + &__icon { + width: 1.25rem; + height: 1.25rem; + } + } +} diff --git a/src/components/FilledButton.tsx b/src/components/FilledButton.tsx new file mode 100644 index 000000000..0db724216 --- /dev/null +++ b/src/components/FilledButton.tsx @@ -0,0 +1,61 @@ +import React, { forwardRef } from "react"; +import clsx from "clsx"; + +import "./FilledButton.scss"; + +export type ButtonVariant = "filled" | "outlined" | "icon"; +export type ButtonColor = "primary" | "danger"; +export type ButtonSize = "medium" | "large"; + +export type FilledButtonProps = { + label: string; + + children?: React.ReactNode; + onClick?: () => void; + + variant?: ButtonVariant; + color?: ButtonColor; + size?: ButtonSize; + className?: string; + + startIcon?: React.ReactNode; +}; + +export const FilledButton = forwardRef( + ( + { + children, + startIcon, + onClick, + label, + variant = "filled", + color = "primary", + size = "medium", + className, + }, + ref, + ) => { + return ( + + ); + }, +); diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index bd2f38417..da43330fd 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -164,6 +164,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { label={t("toolBar.eraser")} shortcuts={[KEYS.E, KEYS["0"]]} /> + h3 { + display: none; + + @include isMobile { + display: block; + } + } + + @include isMobile { + flex-direction: column; + height: calc(100vh - 5rem); + } + + &__preview { + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + height: 360px; + width: 55%; + + margin-right: 1.5rem; + + @include isMobile { + max-width: unset; + margin-right: unset; + + width: 100%; + height: unset; + flex-grow: 1; + } + + &__filename { + & > input { + margin-top: 1rem; + } + } + + &__canvas { + box-sizing: border-box; + width: 100%; + height: 100%; + display: flex; + flex-grow: 1; + justify-content: center; + align-items: center; + + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") + left center; + + border: 1px solid var(--ImageExportModal-preview-border); + border-radius: 12px; + + overflow: hidden; + padding: 1rem; + + & > canvas { + max-width: calc(100% - 2rem); + max-height: calc(100% - 2rem); + + filter: none !important; + + @include isMobile { + max-height: 100%; + } + } + + @include isMobile { + margin-top: 24px; + max-width: unset; + } + } + } + + &__settings { + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 18px; + + @include isMobile { + margin-left: unset; + margin-top: 1rem; + flex-direction: row; + gap: 6px 34px; + + align-content: flex-start; + } + + &__setting { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + @include isMobile { + flex-direction: column; + align-items: start; + justify-content: unset; + height: 52px; + } + + &__label { + display: flex; + flex-direction: row; + align-items: center; + + font-family: "Assistant"; + font-weight: 600; + font-size: 1rem; + line-height: 150%; + + & svg { + width: 20px; + height: 20px; + margin-left: 10px; + } + } + + &__content { + display: flex; + height: 100%; + align-items: center; + } + } + + &__buttons { + flex-grow: 1; + flex-wrap: wrap; + display: flex; + flex-direction: row; + gap: 11px; + + align-items: flex-end; + align-content: flex-end; + + @include isMobile { + padding-top: 32px; + flex-basis: 100%; + justify-content: center; + } + } + } + } +} diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index 0e4eff365..042d5a3fb 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -1,21 +1,40 @@ import React, { useEffect, useRef, useState } from "react"; + +import type { ActionManager } from "../actions/manager"; +import type { AppClassProperties, BinaryFiles, UIAppState } from "../types"; + +import { + actionExportWithDarkMode, + actionChangeExportBackground, + actionChangeExportEmbedScene, + actionChangeExportScale, + actionChangeProjectName, +} from "../actions/actionExport"; import { probablySupportsClipboardBlob } from "../clipboard"; +import { + DEFAULT_EXPORT_PADDING, + EXPORT_IMAGE_TYPES, + isFirefox, + EXPORT_SCALES, +} from "../constants"; + import { canvasToBlob } from "../data/blob"; +import { nativeFileSystemSupported } from "../data/filesystem"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; import { getSelectedElements, isSomeElementSelected } from "../scene"; -import { AppState, BinaryFiles } 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 { nativeFileSystemSupported } from "../data/filesystem"; -import { ActionManager } from "../actions/manager"; import { exportToCanvas } from "../packages/utils"; +import { copyIcon, downloadIcon, helpIcon } from "./icons"; +import { Dialog } from "./Dialog"; +import { RadioGroup } from "./RadioGroup"; +import { Switch } from "./Switch"; +import { Tooltip } from "./Tooltip"; + +import "./ImageExportDialog.scss"; +import { useAppProps } from "./App"; +import { FilledButton } from "./FilledButton"; + const supportsContextFilters = "filter" in document.createElement("canvas").getContext("2d")!; @@ -31,57 +50,36 @@ export const ErrorCanvasPreview = () => { ); }; -export type ExportCB = ( - elements: readonly NonDeletedExcalidrawElement[], - scale?: number, -) => void; - -const ExportButton: React.FC<{ - color: keyof OpenColor; - onClick: () => void; - title: string; - shade?: number; - children?: React.ReactNode; -}> = ({ children, title, onClick, color, shade = 6 }) => { - return ( - - ); +type ImageExportModalProps = { + appState: UIAppState; + elements: readonly NonDeletedExcalidrawElement[]; + files: BinaryFiles; + actionManager: ActionManager; + onExportImage: AppClassProperties["onExportImage"]; }; const ImageExportModal = ({ - elements, appState, + elements, files, - exportPadding = DEFAULT_EXPORT_PADDING, actionManager, - onExportToPng, - onExportToSvg, - onExportToClipboard, -}: { - appState: AppState; - elements: readonly NonDeletedExcalidrawElement[]; - files: BinaryFiles; - exportPadding?: number; - actionManager: ActionManager; - onExportToPng: ExportCB; - onExportToSvg: ExportCB; - onExportToClipboard: ExportCB; - onCloseRequest: () => void; -}) => { + onExportImage, +}: ImageExportModalProps) => { + const appProps = useAppProps(); + const [projectName, setProjectName] = useState(appState.name); + const someElementIsSelected = isSomeElementSelected(elements, appState); + const [exportSelected, setExportSelected] = useState(someElementIsSelected); + const [exportWithBackground, setExportWithBackground] = useState( + appState.exportBackground, + ); + const [exportDarkMode, setExportDarkMode] = useState( + appState.exportWithDarkMode, + ); + const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene); + const [exportScale, setExportScale] = useState(appState.exportScale); + const previewRef = useRef(null); const [renderError, setRenderError] = useState(null); @@ -89,16 +87,13 @@ const ImageExportModal = ({ ? getSelectedElements(elements, appState, true) : elements; - useEffect(() => { - setExportSelected(someElementIsSelected); - }, [someElementIsSelected]); - useEffect(() => { const previewNode = previewRef.current; if (!previewNode) { return; } const maxWidth = previewNode.offsetWidth; + const maxHeight = previewNode.offsetHeight; if (!maxWidth) { return; } @@ -106,8 +101,8 @@ const ImageExportModal = ({ elements: exportedElements, appState, files, - exportPadding, - maxWidthOrHeight: maxWidth, + exportPadding: DEFAULT_EXPORT_PADDING, + maxWidthOrHeight: Math.max(maxWidth, maxHeight), }) .then((canvas) => { setRenderError(null); @@ -121,86 +116,193 @@ const ImageExportModal = ({ console.error(error); setRenderError(error); }); - }, [appState, files, exportedElements, exportPadding]); + }, [appState, files, exportedElements]); return ( -
-
- {renderError && } -
- {supportsContextFilters && - actionManager.renderAction("exportWithDarkMode")} -
-
- {actionManager.renderAction("changeExportBackground")} - {someElementIsSelected && ( - setExportSelected(checked)} - > - {t("labels.onlySelected")} - +
+

{t("imageExportDialog.header")}

+
+
+ {renderError && } +
+
+ {!nativeFileSystemSupported && ( + { + setProjectName(event.target.value); + actionManager.executeAction( + actionChangeProjectName, + "ui", + event.target.value, + ); + }} + /> )} - {actionManager.renderAction("changeExportEmbedScene")}
-
- - {actionManager.renderAction("changeExportScale")} - -

- {t("buttons.scale")} -

-
-
- {!nativeFileSystemSupported && - actionManager.renderAction("changeProjectName")} -
- - onExportToPng(exportedElements)} - > - PNG - - onExportToSvg(exportedElements)} - > - SVG - - {/* firefox supports clipboard API under a flag, - so let's throw and tell people what they can do */} - {(probablySupportsClipboardBlob || isFirefox) && ( - onExportToClipboard(exportedElements)} - color="gray" - shade={7} +
+

{t("imageExportDialog.header")}

+ {someElementIsSelected && ( + - {clipboard} - + { + setExportSelected(checked); + }} + /> + )} - + + { + setExportWithBackground(checked); + actionManager.executeAction( + actionChangeExportBackground, + "ui", + checked, + ); + }} + /> + + {supportsContextFilters && ( + + { + setExportDarkMode(checked); + actionManager.executeAction( + actionExportWithDarkMode, + "ui", + checked, + ); + }} + /> + + )} + + { + setEmbedScene(checked); + actionManager.executeAction( + actionChangeExportEmbedScene, + "ui", + checked, + ); + }} + /> + + + { + setExportScale(scale); + actionManager.executeAction(actionChangeExportScale, "ui", scale); + }} + choices={EXPORT_SCALES.map((scale) => ({ + value: scale, + label: `${scale}\u00d7`, + }))} + /> + + +
+ + onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements) + } + startIcon={downloadIcon} + > + {t("imageExportDialog.button.exportToPng")} + + + onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements) + } + startIcon={downloadIcon} + > + {t("imageExportDialog.button.exportToSvg")} + + {(probablySupportsClipboardBlob || isFirefox) && ( + + onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements) + } + startIcon={copyIcon} + > + {t("imageExportDialog.button.copyPngToClipboard")} + + )} +
+
+
+ ); +}; + +type ExportSettingProps = { + label: string; + children: React.ReactNode; + tooltip?: string; + name?: string; +}; + +const ExportSetting = ({ + label, + children, + tooltip, + name, +}: ExportSettingProps) => { + return ( +
+ +
+ {children} +
); }; @@ -208,45 +310,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 8f89ebcb7..f40ebe453 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 7103d3ade..bf64bf5cb 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,32 @@ 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, useAtom, 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 { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; + +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 +62,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,23 +109,23 @@ const LayerUI = ({ onLockToggle, onHandToolToggle, onPenModeToggle, - onInsertElements, showExitZenModeBtn, renderTopRightUI, renderCustomStats, - renderCustomSidebar, - libraryReturnUrl, UIOptions, - focusContainer, - library, - id, onImageAction, + onExportImage, renderWelcomeScreen, children, }: LayerUIProps) => { const device = useDevice(); const tunnels = useInitializeTunnels(); + const [eyeDropperState, setEyeDropperState] = useAtom( + activeEyeDropperAtom, + jotaiScope, + ); + const renderJSONExportDialog = () => { if (!UIOptions.canvasActions.export) { return null; @@ -149,46 +149,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 +165,8 @@ const LayerUI = ({
{/* wrapping to Fragment stops React from occasionally complaining about identical Keys */} - - {renderWelcomeScreen && } + + {renderWelcomeScreen && }
); @@ -250,7 +218,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 +305,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 +329,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 && } @@ -368,6 +356,21 @@ const LayerUI = ({ {appState.errorMessage} )} + {eyeDropperState && !device.isMobile && ( + { + setEyeDropperState(null); + }} + onSelect={(color, event) => { + setEyeDropperState((state) => { + return state?.keepOpenOnAlt && event.altKey ? state : null; + }); + eyeDropperState?.onSelect?.(color, event); + }} + /> + )} {appState.openDialog === "help" && ( { @@ -382,7 +385,6 @@ const LayerUI = ({ setAppState({ pasteDialog: { shown: false, data: null }, @@ -390,7 +392,7 @@ const LayerUI = ({ } /> )} - {device.isMobile && ( + {device.isMobile && !eyeDropperState && ( )} - {!device.isMobile && ( <>
- {renderWelcomeScreen && } + {renderWelcomeScreen && } {renderFixedSideContainer()}