diff --git a/package.json b/package.json index 246c785a..91a4400b 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "i18next-browser-languagedetector": "6.1.4", "idb-keyval": "6.0.3", "image-blob-reduce": "3.0.1", - "jotai": "1.6.4", + "jotai": "1.13.1", "lodash.throttle": "4.1.1", "nanoid": "3.3.3", "open-color": "1.9.1", diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 6e921d6e..d319337c 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -119,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 ( @@ -132,7 +132,7 @@ const getFormValue = function ( getAttribute, ) : defaultValue) ?? - null + defaultValue ); }; @@ -811,6 +811,7 @@ export const actionChangeTextAlign = register({ ); }, }); + export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", trackEvent: { category: "element" }, @@ -865,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/colors.ts b/src/colors.ts index 198ec12e..7da12839 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -164,4 +164,7 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => 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/App.tsx b/src/components/App.tsx index 0b730fc4..f4c7689b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -304,6 +304,7 @@ 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!); @@ -366,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; @@ -425,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); @@ -634,6 +633,7 @@ class App extends React.Component {
+
{selectedElement.length === 1 && !this.state.contextMenu && this.state.showHyperlinkPopup && ( @@ -724,6 +724,49 @@ class App extends React.Component { } }; + 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) { @@ -1569,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) || @@ -1597,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, ); @@ -1660,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( @@ -1750,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, ); @@ -2083,8 +2135,8 @@ class App extends React.Component { private updateCurrentCursorPosition = withBatchedUpdates( (event: MouseEvent) => { - cursorX = event.clientX; - cursorY = event.clientY; + this.lastViewportPosition.x = event.clientX; + this.lastViewportPosition.y = event.clientY; }, ); @@ -2342,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", + }); + } + // ----------------------------------------------------------------------- }, ); @@ -2471,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, @@ -6468,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/ColorPicker/ColorInput.tsx b/src/components/ColorPicker/ColorInput.tsx index bb9a8551..f179d415 100644 --- a/src/components/ColorPicker/ColorInput.tsx +++ b/src/components/ColorPicker/ColorInput.tsx @@ -2,15 +2,23 @@ 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 | null; + 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, @@ -34,7 +42,7 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => { ); const inputRef = useRef(null); - const divRef = useRef(null); + const eyeDropperTriggerRef = useRef(null); useEffect(() => { if (inputRef.current) { @@ -42,8 +50,19 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => { } }, [activeSection]); + const [eyeDropperState, setEyeDropperState] = useAtom( + activeEyeDropperAtom, + jotaiScope, + ); + + useEffect(() => { + return () => { + setEyeDropperState(null); + }; + }, [setEyeDropperState]); + return ( -