diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4174011b7e..0e33c3f431 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -330,6 +330,7 @@ import { getContainerElement, getDefaultLineHeight, getLineHeightInPx, + getMinTextElementWidth, isMeasureTextSupported, isValidTextContainer, measureText, @@ -1696,6 +1697,7 @@ class App extends React.Component { canvas={this.interactiveCanvas} elementsMap={elementsMap} visibleElements={visibleElements} + allElementsMap={allElementsMap} selectedElements={selectedElements} sceneNonce={sceneNonce} selectionNonce={ @@ -4718,6 +4720,7 @@ class App extends React.Component { sceneY, insertAtParentCenter = true, container, + autoEdit = true, }: { /** X position to insert text at */ sceneX: number; @@ -4726,6 +4729,7 @@ class App extends React.Component { /** whether to attempt to insert at element center if applicable */ insertAtParentCenter?: boolean; container?: ExcalidrawTextContainer | null; + autoEdit?: boolean; }) => { let shouldBindToContainer = false; @@ -4858,13 +4862,16 @@ class App extends React.Component { } } - this.setState({ - editingElement: element, - }); - - this.handleTextWysiwyg(element, { - isExistingElement: !!existingTextElement, - }); + if (autoEdit || existingTextElement || container) { + this.handleTextWysiwyg(element, { + isExistingElement: !!existingTextElement, + }); + } else { + this.setState({ + draggingElement: element, + multiElement: null, + }); + } }; private handleCanvasDoubleClick = ( @@ -5899,7 +5906,6 @@ class App extends React.Component { if (this.state.activeTool.type === "text") { this.handleTextOnPointerDown(event, pointerDownState); - return; } else if ( this.state.activeTool.type === "arrow" || this.state.activeTool.type === "line" @@ -6020,6 +6026,7 @@ class App extends React.Component { ); const clicklength = event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0); + if (this.device.editor.isMobile && clicklength < 300) { const hitElement = this.getElementAtPosition( scenePointer.x, @@ -6693,6 +6700,7 @@ class App extends React.Component { sceneY, insertAtParentCenter: !event.altKey, container, + autoEdit: false, }); resetCursor(this.interactiveCanvas); @@ -8043,6 +8051,28 @@ class App extends React.Component { return; } + if (isTextElement(draggingElement)) { + const minWidth = getMinTextElementWidth( + getFontString({ + fontSize: draggingElement.fontSize, + fontFamily: draggingElement.fontFamily, + }), + draggingElement.lineHeight, + ); + + if (draggingElement.width < minWidth) { + mutateElement(draggingElement, { + autoResize: true, + }); + } + + this.resetCursor(); + + this.handleTextWysiwyg(draggingElement, { + isExistingElement: true, + }); + } + if ( activeTool.type !== "selection" && draggingElement && @@ -9410,6 +9440,7 @@ class App extends React.Component { distance(pointerDownState.origin.y, pointerCoords.y), shouldMaintainAspectRatio(event), shouldResizeFromCenter(event), + this.state.zoom.value, ); } else { let [gridX, gridY] = getGridPoint( @@ -9467,6 +9498,7 @@ class App extends React.Component { ? !shouldMaintainAspectRatio(event) : shouldMaintainAspectRatio(event), shouldResizeFromCenter(event), + this.state.zoom.value, aspectRatio, this.state.originSnapOffset, ); diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index ceed879b73..2c14a6ab3a 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -9,7 +9,10 @@ import type { RenderableElementsMap, RenderInteractiveSceneCallback, } from "../../scene/types"; -import type { NonDeletedExcalidrawElement } from "../../element/types"; +import type { + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, +} from "../../element/types"; import { isRenderThrottlingEnabled } from "../../reactUtils"; import { renderInteractiveScene } from "../../renderer/interactiveScene"; @@ -19,6 +22,7 @@ type InteractiveCanvasProps = { elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; + allElementsMap: NonDeletedSceneElementsMap; sceneNonce: number | undefined; selectionNonce: number | undefined; scale: number; @@ -122,6 +126,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { elementsMap: props.elementsMap, visibleElements: props.visibleElements, selectedElements: props.selectedElements, + allElementsMap: props.allElementsMap, scale: window.devicePixelRatio, appState: props.appState, renderConfig: { @@ -197,6 +202,7 @@ const getRelevantAppStateProps = ( activeEmbeddable: appState.activeEmbeddable, snapLines: appState.snapLines, zenModeEnabled: appState.zenModeEnabled, + editingElement: appState.editingElement, }); const areEqual = ( diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 2af6ffa122..7bffd6698a 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -25,6 +25,11 @@ export const supportsResizeObserver = export const APP_NAME = "Excalidraw"; +// distance when creating text before it's considered `autoResize: false` +// we're using higher threshold so that clicks that end up being drags +// don't unintentionally create text elements that are wrapped to a few chars +// (happens a lot with fast clicks with the text tool) +export const TEXT_AUTOWRAP_THRESHOLD = 36; // px export const DRAGGING_THRESHOLD = 10; // px export const LINE_CONFIRM_THRESHOLD = 8; // px export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index b109528631..2c951148e4 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -4,11 +4,17 @@ import { getCommonBounds } from "./bounds"; import { mutateElement } from "./mutateElement"; import { getPerfectElementSize } from "./sizeHelpers"; import type { NonDeletedExcalidrawElement } from "./types"; -import type { AppState, PointerDownState } from "../types"; -import { getBoundTextElement } from "./textElement"; +import type { AppState, NormalizedZoomValue, PointerDownState } from "../types"; +import { getBoundTextElement, getMinTextElementWidth } from "./textElement"; import { getGridPoint } from "../math"; import type Scene from "../scene/Scene"; -import { isArrowElement, isFrameLikeElement } from "./typeChecks"; +import { + isArrowElement, + isFrameLikeElement, + isTextElement, +} from "./typeChecks"; +import { getFontString } from "../utils"; +import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; export const dragSelectedElements = ( pointerDownState: PointerDownState, @@ -140,6 +146,7 @@ export const dragNewElement = ( height: number, shouldMaintainAspectRatio: boolean, shouldResizeFromCenter: boolean, + zoom: NormalizedZoomValue, /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is true */ widthAspectRatio?: number | null, @@ -185,12 +192,41 @@ export const dragNewElement = ( newY = originY - height / 2; } + let textAutoResize = null; + + // NOTE this should apply only to creating text elements, not existing + // (once we rewrite appState.draggingElement to actually mean dragging + // elements) + if (isTextElement(draggingElement)) { + height = draggingElement.height; + const minWidth = getMinTextElementWidth( + getFontString({ + fontSize: draggingElement.fontSize, + fontFamily: draggingElement.fontFamily, + }), + draggingElement.lineHeight, + ); + width = Math.max(width, minWidth); + + if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) { + textAutoResize = { + autoResize: false, + }; + } + + newY = originY; + if (shouldResizeFromCenter) { + newX = originX - width / 2; + } + } + if (width !== 0 && height !== 0) { mutateElement(draggingElement, { x: newX + (originOffset?.x ?? 0), y: newY + (originOffset?.y ?? 0), width, height, + ...textAutoResize, }); } }; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index debac0840e..2ff71186b6 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -1,8 +1,4 @@ -import { - BOUND_TEXT_PADDING, - MIN_FONT_SIZE, - SHIFT_LOCKING_ANGLE, -} from "../constants"; +import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants"; import { rescalePoints } from "../points"; import { rotate, centerPoint, rotatePoint } from "../math"; @@ -51,7 +47,7 @@ import { getApproxMinLineHeight, wrapText, measureText, - getMinCharWidth, + getMinTextElementWidth, } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; @@ -352,8 +348,13 @@ const resizeSingleTextElement = ( const boundsCurrentWidth = esx2 - esx1; const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0]; - const minWidth = - getMinCharWidth(getFontString(element)) + BOUND_TEXT_PADDING * 2; + const minWidth = getMinTextElementWidth( + getFontString({ + fontSize: element.fontSize, + fontFamily: element.fontFamily, + }), + element.lineHeight, + ); let scaleX = atStartBoundsWidth / boundsCurrentWidth; diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index aabcba3bfa..db4230e241 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -938,3 +938,10 @@ export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { } return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY]; }; + +export const getMinTextElementWidth = ( + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2; +}; diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx index 204e1a6905..2b0266eeb9 100644 --- a/packages/excalidraw/element/textWysiwyg.test.tsx +++ b/packages/excalidraw/element/textWysiwyg.test.tsx @@ -576,7 +576,7 @@ describe("textWysiwyg", () => { it("text should never go beyond max width", async () => { UI.clickTool("text"); - mouse.clickAt(750, 300); + mouse.click(0, 0); textarea = await getTextEditor(textEditorSelector, true); updateTextEditor( diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 22d756bf42..d6b27e72db 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -47,13 +47,18 @@ import { getNormalizedCanvasDimensions, } from "./helpers"; import oc from "open-color"; -import { isFrameLikeElement, isLinearElement } from "../element/typeChecks"; +import { + isFrameLikeElement, + isLinearElement, + isTextElement, +} from "../element/typeChecks"; import type { ElementsMap, ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFrameLikeElement, ExcalidrawLinearElement, + ExcalidrawTextElement, GroupId, NonDeleted, } from "../element/types"; @@ -303,7 +308,6 @@ const renderSelectionBorder = ( cy: number; activeEmbeddable: boolean; }, - padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2, ) => { const { angle, @@ -320,6 +324,8 @@ const renderSelectionBorder = ( const elementWidth = elementX2 - elementX1; const elementHeight = elementY2 - elementY1; + const padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2; + const linePadding = padding / appState.zoom.value; const lineWidth = 8 / appState.zoom.value; const spaceWidth = 4 / appState.zoom.value; @@ -570,11 +576,34 @@ const renderTransformHandles = ( }); }; +const renderTextBox = ( + text: NonDeleted, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + selectionColor: InteractiveCanvasRenderConfig["selectionColor"], +) => { + context.save(); + const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / 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); + context.translate(cx + appState.scrollX, cy + appState.scrollY); + context.rotate(text.angle); + context.lineWidth = 1 / appState.zoom.value; + context.strokeStyle = selectionColor; + context.strokeRect(shiftX, shiftY, width, height); + context.restore(); +}; + const _renderInteractiveScene = ({ canvas, elementsMap, visibleElements, selectedElements, + allElementsMap, scale, appState, renderConfig, @@ -626,12 +655,31 @@ const _renderInteractiveScene = ({ // Paint selection element if (appState.selectionElement) { try { - renderSelectionElement(appState.selectionElement, context, appState); + renderSelectionElement( + appState.selectionElement, + context, + appState, + renderConfig.selectionColor, + ); } catch (error: any) { console.error(error); } } + if (appState.editingElement && isTextElement(appState.editingElement)) { + const textElement = allElementsMap.get(appState.editingElement.id) as + | ExcalidrawTextElement + | undefined; + if (textElement && !textElement.autoResize) { + renderTextBox( + textElement, + context, + appState, + renderConfig.selectionColor, + ); + } + } + if (appState.isBindingEnabled) { appState.suggestedBindings .filter((binding) => binding != null) @@ -810,7 +858,12 @@ const _renderInteractiveScene = ({ "mouse", // when we render we don't know which pointer type so use mouse, getOmitSidesForDevice(device), ); - if (!appState.viewModeEnabled && showBoundingBox) { + if ( + !appState.viewModeEnabled && + showBoundingBox && + // do not show transform handles when text is being edited + !isTextElement(appState.editingElement) + ) { renderTransformHandles( context, renderConfig, diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 8914b83a9c..a002491425 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -24,6 +24,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import type { StaticCanvasRenderConfig, RenderableElementsMap, + InteractiveCanvasRenderConfig, } from "../scene/types"; import { distance, getFontString, isRTL } from "../utils"; import { getCornerRadius, isRightAngle } from "../math"; @@ -618,6 +619,7 @@ export const renderSelectionElement = ( element: NonDeletedExcalidrawElement, context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, + selectionColor: InteractiveCanvasRenderConfig["selectionColor"], ) => { context.save(); context.translate(element.x + appState.scrollX, element.y + appState.scrollY); @@ -631,7 +633,7 @@ export const renderSelectionElement = ( context.fillRect(offset, offset, element.width, element.height); context.lineWidth = 1 / appState.zoom.value; - context.strokeStyle = " rgb(105, 101, 219)"; + context.strokeStyle = selectionColor; context.strokeRect(offset, offset, element.width, element.height); context.restore(); diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 532e8b89cb..fb3cc20fcb 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -55,7 +55,7 @@ export type InteractiveCanvasRenderConfig = { remotePointerUserStates: Map; remotePointerUsernames: Map; remotePointerButton: Map; - selectionColor?: string; + selectionColor: string; // extra options passed to the renderer // --------------------------------------------------------------------------- renderScrollbars?: boolean; @@ -83,6 +83,7 @@ export type InteractiveSceneRenderConfig = { elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; + allElementsMap: NonDeletedSceneElementsMap; scale: number; appState: InteractiveCanvasAppState; renderConfig: InteractiveCanvasRenderConfig; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 2052ccdc9d..aa79a3ad8a 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -1375,6 +1375,7 @@ export const isActiveToolNonLinearSnappable = ( activeToolType === TOOL_TYPE.diamond || activeToolType === TOOL_TYPE.frame || activeToolType === TOOL_TYPE.magicframe || - activeToolType === TOOL_TYPE.image + activeToolType === TOOL_TYPE.image || + activeToolType === TOOL_TYPE.text ); }; diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 547c527553..692f0b1e45 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -1051,11 +1051,11 @@ describe("Test Linear Elements", () => { arrayToMap(h.elements), ), ).toMatchInlineSnapshot(` - { - "x": 75, - "y": 60, - } - `); + { + "x": 75, + "y": 60, + } + `); expect(textElement.text).toMatchInlineSnapshot(` "Online whiteboard collaboration made diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index a9cc47d974..52355f9e0d 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -197,6 +197,7 @@ export type InteractiveCanvasAppState = Readonly< // SnapLines snapLines: AppState["snapLines"]; zenModeEnabled: AppState["zenModeEnabled"]; + editingElement: AppState["editingElement"]; } >;