diff --git a/package.json b/package.json index 246c785a52..91a4400b6e 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/actionExport.tsx b/src/actions/actionExport.tsx index f142eac87d..d945ba9592 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 0dadc23b10..8edbbc4aa5 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 { - getElementAbsoluteCoords, - getElementPointsCoords, -} from "../element/bounds"; -import { isLinearElement } from "../element/typeChecks"; -import { LinearElementEditor } from "../element/linearElementEditor"; import { CODES, 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; -}; +import { getCommonBoundingBox } from "../element/bounds"; +import { + bindOrUnbindSelectedElements, + isBindingEnabled, + unbindLinearElements, +} from "../element/binding"; export const actionFlipHorizontal = register({ name: "flipHorizontal", @@ -50,8 +25,6 @@ export const actionFlipHorizontal = register({ }, keyTest: (event) => event.shiftKey && event.code === CODES.H, contextItemLabel: "labels.flipHorizontal", - predicate: (elements, appState) => - enableActionFlipHorizontal(elements, appState), }); export const actionFlipVertical = register({ @@ -67,8 +40,6 @@ export const actionFlipVertical = register({ keyTest: (event) => 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 6e921d6ea8..d319337c3f 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/actions/manager.tsx b/src/actions/manager.tsx index 60648e4108..e52e91da73 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/assets/lock.svg b/src/assets/lock.svg new file mode 100644 index 0000000000..aa9dbf1701 --- /dev/null +++ b/src/assets/lock.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/colors.ts b/src/colors.ts index 198ec12e23..7da128399c 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 96f470d38f..23fd0212ab 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -305,6 +305,7 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import BraveMeasureTextError from "./BraveMeasureTextError"; import { convertToExcalidrawElements } from "../data/transform"; +import { activeEyeDropperAtom } from "./EyeDropper"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -367,8 +368,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; @@ -426,7 +425,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); @@ -635,6 +634,7 @@ class App extends React.Component {
+
{selectedElement.length === 1 && !this.state.contextMenu && this.state.showHyperlinkPopup && ( @@ -725,6 +725,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) { @@ -1073,6 +1116,7 @@ class App extends React.Component { this.unmounted = true; this.removeEventListeners(); this.scene.destroy(); + this.library.destroy(); clearTimeout(touchTimeout); touchTimeout = 0; } @@ -1569,7 +1613,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) || @@ -1596,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, ); @@ -1659,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( @@ -1749,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, ); @@ -2084,8 +2137,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; }, ); @@ -2158,6 +2211,7 @@ class App extends React.Component { event.shiftKey && event[KEYS.CTRL_OR_CMD] ) { + event.preventDefault(); this.setState({ openDialog: "imageExport" }); return; } @@ -2342,6 +2396,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 +2539,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 +6536,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 bb9a85510f..f179d415c5 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 ( -