diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index bb94166dbd..ea7fdb1b77 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -11,6 +11,7 @@ import { isBoundToContainer, isFrameLikeElement, isLinearElement, + isTextElement, } from "./typeChecks"; import { elementOverlapsWithFrame, @@ -25,6 +26,7 @@ import type { ElementsMap, ElementsMapOrArray, ExcalidrawElement, + NonDeleted, NonDeletedExcalidrawElement, } from "./types"; @@ -288,3 +290,19 @@ export const getSelectionStateForElements = ( ), }; }; + +/** + * Returns editing or single-selected text element, if any. + */ +export const getActiveTextElement = ( + selectedElements: readonly NonDeleted[], + appState: Pick, +) => { + const activeTextElement = + appState.editingTextElement || + (selectedElements.length === 1 && + isTextElement(selectedElements[0]) && + selectedElements[0]); + + return activeTextElement || null; +}; diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index bca2237940..a046db6ea8 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -191,7 +191,7 @@ export const getFormValue = function ( elements: readonly ExcalidrawElement[], app: AppClassProperties, getAttribute: (element: ExcalidrawElement) => T, - isRelevantElement: true | ((element: ExcalidrawElement) => boolean), + elementPredicate: true | ((element: ExcalidrawElement) => boolean), defaultValue: T | ((isSomeElementSelected: boolean) => T), ): T { const editingTextElement = app.state.editingTextElement; @@ -209,9 +209,9 @@ export const getFormValue = function ( if (hasSelection) { const selectedElements = app.scene.getSelectedElements(app.state); const targetElements = - isRelevantElement === true + elementPredicate === true ? selectedElements - : selectedElements.filter((el) => isRelevantElement(el)); + : selectedElements.filter((el) => elementPredicate(el)); ret = reduceToCommonValue(targetElements, getAttribute) ?? @@ -730,9 +730,28 @@ export const actionChangeOpacity = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ app, updateData }) => ( - - ), + PanelComponent: ({ elements, appState, app, updateData }) => { + const opacity = getFormValue( + elements, + app, + (element) => element.opacity, + true, + (hasSelection) => (hasSelection ? null : appState.currentItemOpacity), + ); + + return ( + + ); + }, }); export const actionChangeFontSize = register( diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts index d4338e7797..dcc0f16955 100644 --- a/packages/excalidraw/actions/actionTextAutoResize.ts +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -1,24 +1,24 @@ import { getFontString } from "@excalidraw/common"; -import { newElementWith } from "@excalidraw/element"; +import { isExcalidrawElement, newElementWith } from "@excalidraw/element"; import { measureText } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; +import type { ExcalidrawElement } from "@excalidraw/element/types"; + import { getSelectedElements } from "../scene"; import { register } from "./register"; -import type { AppClassProperties } from "../types"; - export const actionTextAutoResize = register({ name: "autoResize", label: "labels.autoResize", icon: null, trackEvent: { category: "element" }, - predicate: (elements, appState, _: unknown, app: AppClassProperties) => { + predicate: (elements, appState, _: unknown) => { const selectedElements = getSelectedElements(elements, appState); return ( selectedElements.length === 1 && @@ -26,13 +26,18 @@ export const actionTextAutoResize = register({ !selectedElements[0].autoResize ); }, - perform: (elements, appState, _, app) => { + perform: (elements, appState, targetElement) => { const selectedElements = getSelectedElements(elements, appState); + const targetTextElement = + isExcalidrawElement(targetElement) && isTextElement(targetElement) + ? targetElement + : (selectedElements[0] as ExcalidrawElement | undefined); + return { appState, elements: elements.map((element) => { - if (element.id === selectedElements[0].id && isTextElement(element)) { + if (element.id === targetTextElement?.id && isTextElement(element)) { const metrics = measureText( element.originalText, getFontString(element), diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 905c17048f..480dae2f7b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -257,6 +257,7 @@ import { handleFocusPointPointerUp, maybeHandleArrowPointlikeDrag, getUncroppedWidthAndHeight, + getActiveTextElement, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -416,6 +417,7 @@ import { import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; +import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle"; import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; @@ -692,6 +694,7 @@ class App extends React.Component { lastPointerMoveEvent: PointerEvent | null = null; /** current frame pointer cords */ lastPointerMoveCoords: { x: number; y: number } | null = null; + private lastCompletedCanvasClicks: { x: number; y: number }[] = []; /** previous frame pointer coords */ previousPointerMoveCoords: { x: number; y: number } | null = null; lastViewportPosition = { x: 0, y: 0 }; @@ -1253,6 +1256,26 @@ class App extends React.Component { ) as NullableGridSize; }; + private getTextCreationGridPoint = (x: number, y: number) => { + const effectiveGridSize = this.getEffectiveGridSize(); + + if (effectiveGridSize === null) { + return null; + } + + const getTextCreationGridCoordinate = (coordinate: number) => { + const topLeftGridPoint = + Math.floor(coordinate / effectiveGridSize) * effectiveGridSize; + + return topLeftGridPoint; + }; + + return { + x: getTextCreationGridCoordinate(x), + y: getTextCreationGridCoordinate(y), + }; + }; + private getHTMLIFrameElement( element: ExcalidrawIframeLikeElement, ): HTMLIFrameElement | undefined { @@ -2341,6 +2364,7 @@ class App extends React.Component { } handleCanvasRef={this.handleInteractiveCanvasRef} onContextMenu={this.handleCanvasContextMenu} + onClick={this.handleCanvasClick} onPointerMove={this.handleCanvasPointerMove} onPointerUp={this.handleCanvasPointerUp} onPointerCancel={this.removePointer} @@ -3594,10 +3618,14 @@ class App extends React.Component { this.lassoTrail.endPath(); this.deselectElements(); - // @ts-ignore this.handleCanvasDoubleClick({ clientX: touch.clientX, clientY: touch.clientY, + type: "touch", + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, }); } didTapTwice = false; @@ -5846,6 +5874,58 @@ class App extends React.Component { return null; } + private isHittingTextAutoResizeHandle = ( + selectedElements: NonDeleted[], + point: Readonly<{ x: number; y: number }>, + ): boolean => { + const activeTextElement = getActiveTextElement( + selectedElements, + this.state, + ); + + if ( + activeTextElement && + !activeTextElement.isDeleted && + !activeTextElement.autoResize && + isPointHittingTextAutoResizeHandle( + point, + activeTextElement, + this.state.zoom.value, + this.editorInterface.formFactor, + ) + ) { + return true; + } + + return false; + }; + + private handleTextAutoResizeHandlePointerDown = ( + selectedElements: NonDeleted[], + point: Readonly<{ x: number; y: number }>, + ) => { + const activeTextElement = getActiveTextElement( + selectedElements, + this.state, + ); + if ( + !activeTextElement || + !this.isHittingTextAutoResizeHandle(selectedElements, point) + ) { + return false; + } + + this.actionManager.executeAction( + actionTextAutoResize, + "ui", + // we need to pass down the element since it may already be deselected + // due to the pointerdown + activeTextElement, + ); + this.resetCursor(); + return true; + }; + // NOTE: Hot path for hit testing, so avoid unnecessary computations private getElementAtPosition( x: number, @@ -6138,11 +6218,32 @@ class App extends React.Component { y: sceneY, }); + const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY); + + const newTextElementPosition = parentCenterPosition + ? { + x: parentCenterPosition.elementCenterX, + y: parentCenterPosition.elementCenterY, + } + : !existingTextElement + ? { + x: textCreationGridPoint?.x ?? sceneX, + y: + textCreationGridPoint === null + ? // Free text starts from a point cursor, so center the first line box on it. + sceneY - getLineHeightInPx(fontSize, lineHeight) / 2 + : textCreationGridPoint.y, + } + : { + x: sceneX, + y: sceneY, + }; + const element = existingTextElement || newTextElement({ - x: parentCenterPosition ? parentCenterPosition.elementCenterX : sceneX, - y: parentCenterPosition ? parentCenterPosition.elementCenterY : sceneY, + x: newTextElementPosition.x, + y: newTextElementPosition.y, strokeColor: this.state.currentItemStrokeColor, backgroundColor: this.state.currentItemBackgroundColor, fillStyle: this.state.currentItemFillStyle, @@ -6220,10 +6321,46 @@ class App extends React.Component { } }; + private shouldHandleBrowserCanvasDoubleClick = (type: string) => { + // TODO remove this once we consolidate double-click logic and handle + // ourselves for all event types together + if (type === "touch") { + return true; + } + if (this.lastCompletedCanvasClicks.length === 0) { + return true; + } + + if (this.lastCompletedCanvasClicks.length < 2) { + return false; + } + + const [firstClick, secondClick] = this.lastCompletedCanvasClicks; + + return ( + pointDistance( + pointFrom(firstClick.x, firstClick.y), + pointFrom(secondClick.x, secondClick.y), + ) <= DOUBLE_TAP_POSITION_THRESHOLD + ); + }; + private handleCanvasDoubleClick = ( - event: React.MouseEvent, + event: Pick< + React.MouseEvent, + | "type" + | "clientX" + | "clientY" + | "altKey" + | "ctrlKey" + | "metaKey" + | "shiftKey" + >, ) => { - if (this.state.editingTextElement) { + if ( + this.state.editingTextElement || + !this.shouldHandleBrowserCanvasDoubleClick(event.type) + ) { return; } // case: double-clicking with arrow/line tool selected would both create @@ -6413,6 +6550,21 @@ class App extends React.Component { } }; + private handleCanvasClick = (event: React.MouseEvent) => { + if (event.button !== POINTER_BUTTON.MAIN) { + this.lastCompletedCanvasClicks = []; + return; + } + + this.lastCompletedCanvasClicks = [ + ...this.lastCompletedCanvasClicks.slice(-1), + { + x: event.clientX, + y: event.clientY, + }, + ]; + }; + private getElementLinkAtPosition = ( scenePointer: Readonly<{ x: number; y: number }>, hitElementMightBeLocked: NonDeletedExcalidrawElement | null, @@ -6949,6 +7101,12 @@ class App extends React.Component { const elements = this.scene.getNonDeletedElements(); const selectedElements = this.scene.getSelectedElements(this.state); + + if (this.isHittingTextAutoResizeHandle(selectedElements, scenePointer)) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + return; + } + if ( selectedElements.length === 1 && !isOverScrollBar && @@ -7093,7 +7251,9 @@ class App extends React.Component { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } else if ( // if using cmd/ctrl, we're not dragging - !event[KEYS.CTRL_OR_CMD] + !event[KEYS.CTRL_OR_CMD] && + // editing text -> don't show move cursor when hovering over its bbox + hitElement?.id !== this.state.editingTextElement?.id ) { if ( (hitElement || @@ -7314,6 +7474,8 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + const selectedElements = this.scene.getSelectedElements(this.state); + // If Ctrl is not held, ensure isBindingEnabled reflects the user preference. if (!event.ctrlKey) { const preferenceEnabled = this.state.bindingPreference === "enabled"; @@ -7537,6 +7699,15 @@ class App extends React.Component { selectedElementsAreBeingDragged: false, }); + if ( + this.handleTextAutoResizeHandlePointerDown( + selectedElements, + pointerDownState.origin, + ) + ) { + return; + } + if (this.handleDraggingScrollBar(event, pointerDownState)) { return; } diff --git a/packages/excalidraw/components/Range.tsx b/packages/excalidraw/components/Range.tsx index 501ad7d188..7a4bf397e7 100644 --- a/packages/excalidraw/components/Range.tsx +++ b/packages/excalidraw/components/Range.tsx @@ -1,74 +1,78 @@ import React, { useEffect } from "react"; -import { t } from "../i18n"; - import "./Range.scss"; -import type { AppClassProperties } from "../types"; - export type RangeProps = { - updateData: (value: number) => void; - app: AppClassProperties; + label: React.ReactNode; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + minLabel?: React.ReactNode; + hasCommonValue?: boolean; testId?: string; }; -export const Range = ({ updateData, app, testId }: RangeProps) => { +export const Range = ({ + label, + value, + onChange, + min = 0, + max = 100, + step = 10, + minLabel = min, + hasCommonValue = true, + testId, +}: RangeProps) => { const rangeRef = React.useRef(null); const valueRef = React.useRef(null); - const selectedElements = app.scene.getSelectedElements(app.state); - let hasCommonOpacity = true; - const firstElement = selectedElements.at(0); - const leastCommonOpacity = selectedElements.reduce((acc, element) => { - if (acc != null && acc !== element.opacity) { - hasCommonOpacity = false; - } - if (acc == null || acc > element.opacity) { - return element.opacity; - } - return acc; - }, firstElement?.opacity ?? null); - - const value = leastCommonOpacity ?? app.state.currentItemOpacity; useEffect(() => { if (rangeRef.current && valueRef.current) { const rangeElement = rangeRef.current; const valueElement = valueRef.current; const inputWidth = rangeElement.offsetWidth; - const thumbWidth = 15; // 15 is the width of the thumb + const thumbWidth = + parseFloat( + getComputedStyle(rangeElement).getPropertyValue( + "--slider-thumb-size", + ), + ) || 16; + const progress = ((value - min) / (max - min || 1)) * 100; const position = - (value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; + (progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; valueElement.style.left = `${position}px`; - rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`; + rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`; } - }, [value]); + }, [max, min, value]); return ( ); diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index a30088f30a..2aabddfbab 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -54,6 +54,7 @@ type InteractiveCanvasProps = { DOMAttributes["onContextMenu"], undefined >; + onClick: Exclude["onClick"], undefined>; onPointerMove: Exclude< DOMAttributes["onPointerMove"], undefined @@ -213,6 +214,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { height={props.appState.height * props.scale} ref={props.handleCanvasRef} onContextMenu={props.onContextMenu} + onClick={props.onClick} onPointerMove={props.onPointerMove} onPointerUp={props.onPointerUp} onPointerCancel={props.onPointerCancel} diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 56c308713e..7d31bf9a32 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -41,6 +41,7 @@ import { maxBindingDistance_simple, isTextElement, LinearElementEditor, + getActiveTextElement, } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element"; @@ -58,6 +59,8 @@ import { isFocusPointVisible, } from "@excalidraw/element"; +import type { EditorInterface } from "@excalidraw/common"; + import type { TransformHandles, TransformHandleType, @@ -86,6 +89,10 @@ import { } from "../scene/scrollbars"; import { getClientColor, renderRemoteCursors } from "../clients"; +import { + getTextAutoResizeHandle, + getTextBoxPadding, +} from "../textAutoResizeHandle"; import { bootstrapCanvas, @@ -1489,21 +1496,58 @@ const renderTextBox = ( selectionColor: InteractiveCanvasRenderConfig["selectionColor"], ) => { context.save(); - const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; + const padding = getTextBoxPadding(appState.zoom.value); const width = text.width + padding * 2; const height = text.height + padding * 2; - const cx = text.x + width / 2; - const cy = text.y + height / 2; - const shiftX = -(width / 2 + padding); - const shiftY = -(height / 2 + padding); + const cx = text.x + text.width / 2; + const cy = text.y + text.height / 2; + const shiftX = -(text.width / 2 + padding); + const shiftY = -(text.height / 2 + padding); context.translate(cx + appState.scrollX, cy + appState.scrollY); context.rotate(text.angle); context.lineWidth = 1 / appState.zoom.value; context.strokeStyle = selectionColor; + context.globalAlpha = 0.5; + context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]); context.strokeRect(shiftX, shiftY, width, height); context.restore(); }; +const renderResetAutoResizeHandle = ( + text: NonDeleted, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + selectionColor: InteractiveCanvasRenderConfig["selectionColor"], + formFactor: EditorInterface["formFactor"], +) => { + const autoResizeHandle = getTextAutoResizeHandle( + text, + appState.zoom.value, + formFactor, + ); + + if (!autoResizeHandle) { + return; + } + + context.save(); + context.globalAlpha = 0.5; + context.lineWidth = 1.5 / appState.zoom.value; + context.lineCap = "round"; + context.strokeStyle = selectionColor; + context.beginPath(); + context.moveTo( + autoResizeHandle.start[0] + appState.scrollX, + autoResizeHandle.start[1] + appState.scrollY, + ); + context.lineTo( + autoResizeHandle.end[0] + appState.scrollX, + autoResizeHandle.end[1] + appState.scrollY, + ); + context.stroke(); + context.restore(); +}; + const _renderInteractiveScene = ({ app, canvas, @@ -1584,10 +1628,19 @@ const _renderInteractiveScene = ({ } } - if ( - appState.editingTextElement && - isTextElement(appState.editingTextElement) - ) { + const activeTextElement = getActiveTextElement(selectedElements, appState); + + if (activeTextElement && !activeTextElement.autoResize) { + renderResetAutoResizeHandle( + activeTextElement, + context, + appState, + renderConfig.selectionColor, + editorInterface.formFactor, + ); + } + + if (appState.editingTextElement) { const textElement = allElementsMap.get(appState.editingTextElement.id) as | ExcalidrawTextElement | undefined; diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c81459c1c4..050a20878b 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -224,7 +224,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 29, + "version": 28, "width": "94.00000", "x": 0, "y": 0, @@ -350,7 +350,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 28, + "version": 27, "width": "88.00000", }, "inserted": { @@ -381,7 +381,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 25, + "version": 24, "width": "88.00000", }, }, @@ -437,7 +437,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], ], "startBinding": null, - "version": 29, + "version": 28, "width": "94.00000", "x": 0, "y": 0, @@ -462,7 +462,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 28, + "version": 27, "width": "88.00000", "x": 6, "y": "7.20923", @@ -1360,9 +1360,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 8, + "version": 7, "width": 88, - "x": 6, + "x": "6.00000", "y": "2.00947", } `; @@ -1537,12 +1537,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 8, + "version": 7, }, "inserted": { "endBinding": null, "startBinding": null, - "version": 7, + "version": 6, }, }, }, @@ -1722,7 +1722,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "updated": 1, "version": 8, "width": 88, - "x": 6, + "x": "6.00000", "y": "38.80379", } `; @@ -1867,7 +1867,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "version": 8, "width": 88, - "x": 6, + "x": "6.00000", "y": "38.80379", }, "inserted": { @@ -2416,7 +2416,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 11, "width": 488, "x": 6, "y": "-5.39000", @@ -2581,7 +2581,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 12, + "version": 11, "width": 488, "x": 6, "y": "-5.39000", @@ -16254,7 +16254,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", @@ -16307,7 +16307,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 10, + "version": 11, }, "inserted": { "endBinding": { @@ -16327,7 +16327,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 8, + "version": 9, }, }, }, @@ -16669,14 +16669,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 7, + "version": 8, "width": "88.00000", "x": 6, "y": "0.00880", }, "inserted": { "isDeleted": true, - "version": 6, + "version": 7, }, }, }, @@ -17002,7 +17002,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", @@ -17307,14 +17307,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 8, + "version": 9, }, }, }, @@ -17648,7 +17648,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", @@ -17953,14 +17953,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 8, + "version": 9, }, }, }, @@ -18292,7 +18292,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, + "version": 11, "width": "88.00000", "x": 6, "y": "0.01000", @@ -18361,7 +18361,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 10, + "version": 11, }, "inserted": { "endBinding": { @@ -18373,7 +18373,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "mode": "orbit", }, "startBinding": null, - "version": 8, + "version": 9, }, }, "id2": { @@ -18683,14 +18683,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 7, + "version": 8, "width": "88.00000", "x": 6, "y": "0.00880", }, "inserted": { "isDeleted": true, - "version": 6, + "version": 7, }, }, }, @@ -19044,7 +19044,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, + "version": 12, "width": "88.00000", "x": 6, "y": "0.01000", @@ -19124,12 +19124,12 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 11, + "version": 12, }, "inserted": { "endBinding": null, "startBinding": null, - "version": 9, + "version": 10, }, }, }, @@ -19431,14 +19431,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 7, + "version": 8, "width": "88.00000", "x": 6, "y": "0.00880", }, "inserted": { "isDeleted": true, - "version": 6, + "version": 7, }, }, }, diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 80a7e4c561..079af75969 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -4,6 +4,7 @@ import { elementCenterPoint, getCommonBounds, getElementPointsCoords, + getLineHeightInPx, } from "@excalidraw/element"; import { cropElement } from "@excalidraw/element"; import { @@ -20,7 +21,7 @@ import { isTextElement, isFrameLikeElement, } from "@excalidraw/element"; -import { KEYS, arrayToMap } from "@excalidraw/common"; +import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -516,8 +517,17 @@ export class UI { UI.clickTool(type); if (type === "text") { + const clickY = h.state.gridModeEnabled + ? y + : y + + getLineHeightInPx( + h.state.currentItemFontSize, + getLineHeight(h.state.currentItemFontFamily), + ) / + 2; + mouse.reset(); - mouse.click(x, y); + mouse.click(x, clickY); } else if ((type === "line" || type === "arrow") && points.length > 2) { points.forEach((point) => { mouse.reset(); diff --git a/packages/excalidraw/textAutoResizeHandle.ts b/packages/excalidraw/textAutoResizeHandle.ts new file mode 100644 index 0000000000..abf1e76ddc --- /dev/null +++ b/packages/excalidraw/textAutoResizeHandle.ts @@ -0,0 +1,88 @@ +import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "@excalidraw/common"; +import { + pointFrom, + pointRotateRads, + type GlobalPoint, + type Radians, +} from "@excalidraw/math"; + +import type { EditorInterface } from "@excalidraw/common"; + +import type { ExcalidrawTextElement } from "@excalidraw/element/types"; + +const TEXT_AUTO_RESIZE_HANDLE_GAP = 12; +const TEXT_AUTO_RESIZE_HANDLE_LENGTH = 16; +const TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH = 10; +const TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT = + TEXT_AUTO_RESIZE_HANDLE_LENGTH + 2; +const MAX_HANDLE_HEIGHT_RATIO = 0.8; + +export const getTextBoxPadding = (zoomValue: number) => + (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / zoomValue; + +export const getTextAutoResizeHandle = ( + textElement: ExcalidrawTextElement, + zoomValue: number, + formFactor: EditorInterface["formFactor"], +) => { + if ( + formFactor !== "desktop" || + TEXT_AUTO_RESIZE_HANDLE_LENGTH > + textElement.height * zoomValue * MAX_HANDLE_HEIGHT_RATIO + ) { + return null; + } + + const padding = getTextBoxPadding(zoomValue); + const gap = TEXT_AUTO_RESIZE_HANDLE_GAP / zoomValue; + const length = TEXT_AUTO_RESIZE_HANDLE_LENGTH / zoomValue; + const center = pointFrom( + textElement.x + textElement.width / 2, + textElement.y + textElement.height / 2, + ); + const handleCenter = pointRotateRads( + pointFrom(center[0] + textElement.width / 2 + padding + gap, center[1]), + center, + textElement.angle, + ); + + return { + center: handleCenter, + start: pointRotateRads( + pointFrom(handleCenter[0], handleCenter[1] - length / 2), + handleCenter, + textElement.angle, + ) as GlobalPoint, + end: pointRotateRads( + pointFrom(handleCenter[0], handleCenter[1] + length / 2), + handleCenter, + textElement.angle, + ) as GlobalPoint, + hitboxWidth: TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH / zoomValue, + hitboxHeight: TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT / zoomValue, + }; +}; + +export const isPointHittingTextAutoResizeHandle = ( + point: Readonly<{ x: number; y: number }>, + textElement: ExcalidrawTextElement, + zoomValue: number, + formFactor: EditorInterface["formFactor"], +) => { + const handle = getTextAutoResizeHandle(textElement, zoomValue, formFactor); + + if (!handle) { + return false; + } + + const unrotatedPoint = pointRotateRads( + pointFrom(point.x, point.y), + handle.center, + -textElement.angle as Radians, + ); + + return ( + Math.abs(unrotatedPoint[0] - handle.center[0]) <= handle.hitboxWidth / 2 && + Math.abs(unrotatedPoint[1] - handle.center[1]) <= handle.hitboxHeight / 2 + ); +}; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 63397ea488..cae4e7b93f 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -32,6 +32,7 @@ import type { OrderedExcalidrawElement, ExcalidrawNonSelectionElement, BindMode, + ExcalidrawTextElement, } from "@excalidraw/element/types"; import type { @@ -327,7 +328,7 @@ export interface AppState { /** * set when a new text is created or when an existing text is being edited */ - editingTextElement: NonDeletedExcalidrawElement | null; + editingTextElement: ExcalidrawTextElement | null; activeTool: { /** * indicates a previous tool we should revert back to if we deselect the @@ -876,9 +877,8 @@ export type PointerDownState = Readonly<{ // by default same as PointerDownState.origin. On alt-duplication, reset // to current pointer position at time of duplication. origin: { x: number; y: number }; - // Whether to block drag after lasso selection - // this is meant to be used to block dragging after lasso selection on PCs - // until the next pointer down + // explicit flag for specific scenarios such as: + // - after lasso selection until the next pointer down blockDragging: boolean; }; // We need to have these in the state so that we can unsubscribe them diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index 352b1a1529..2a0e5b73c7 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -1,7 +1,10 @@ import { queryByText } from "@testing-library/react"; import { pointFrom } from "@excalidraw/math"; -import { getOriginalContainerHeightFromCache } from "@excalidraw/element"; +import { + getLineHeightInPx, + getOriginalContainerHeightFromCache, +} from "@excalidraw/element"; import { CODES, @@ -210,6 +213,42 @@ describe("textWysiwyg", () => { expect(h.elements.length).toBe(1); }); + it("should vertically center newly created text on the cursor when clicked with text tool", async () => { + API.setAppState({ + currentItemFontFamily: FONT_FAMILY.Cascadia, + currentItemFontSize: 40, + }); + UI.clickTool("text"); + + mouse.clickAt(120, 80); + + const editor = await getTextEditor(); + const text = h.elements[0] as ExcalidrawTextElement; + const lineHeightPx = getLineHeightInPx(text.fontSize, text.lineHeight); + + expect(editor).not.toBe(null); + expect(text.y + lineHeightPx / 2).toBe(80); + }); + + it("should snap newly created text top-left to the current grid cell when clicked with text tool in grid mode", async () => { + API.setAppState({ + currentItemFontFamily: FONT_FAMILY.Cascadia, + currentItemFontSize: 40, + gridModeEnabled: true, + gridSize: 24, + }); + UI.clickTool("text"); + + mouse.clickAt(113, 86); + + const editor = await getTextEditor(); + const text = h.elements[0] as ExcalidrawTextElement; + + expect(editor).not.toBe(null); + expect(text.x).toBe(96); + expect(text.y).toBe(72); + }); + it("should edit text under cursor when double-clicked with selection tool", async () => { const text = API.createElement({ type: "text", @@ -1572,7 +1611,7 @@ describe("textWysiwyg", () => { version: 2, width: 610, x: 15, - y: 25, + y: 12.5, }), ); expect(h.elements[2] as ExcalidrawTextElement).toEqual( diff --git a/packages/math/src/point.ts b/packages/math/src/point.ts index ed25a713d8..13121d681b 100644 --- a/packages/math/src/point.ts +++ b/packages/math/src/point.ts @@ -123,10 +123,15 @@ export function pointsEqual( * @returns The rotated point */ export function pointRotateRads( - [x, y]: Point, - [cx, cy]: Point, + point: Point, + center: Point, angle: Radians, ): Point { + if (!angle) { + return point; + } + const [x, y] = point; + const [cx, cy] = center; return pointFrom( (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,