From 760fd7b3a685e61e73bf0e34f3983ae0dd341b6a Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 5 Dec 2022 21:03:13 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Support=20labels=20for=20arrow=20?= =?UTF-8?q?=F0=9F=94=A5=20(#5723)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support arrow with text * render arrow -> clear rect-> render text * move bound text when linear elements move * fix centering cursor when linear element rotated * fix y coord when new line added and container has 3 points * update text position when 2nd point moved * support adding label on top of 2nd point when 3 points are present * change linear element editor shortcut to cmd+enter and fix tests * scale bound text points when resizing via bounding box * ohh yeah rotation works :) * fix coords when updating text properties * calculate new position after rotation always from original position * rotate the bound text by same angle as parent * don't rotate text and make sure dimensions and coords are always calculated from original point * hardcoding the text width for now * Move the linear element when bound text hit * Rotation working yaay * consider text element angle when editing * refactor * update x2 coords if needed when text updated * simplify * consider bound text to be part of bounding box when hit * show bounding box correctly when multiple element selected * fix typo * support rotating multiple elements * support multiple element resizing * shift bound text to mid point when odd points * Always render linear element handles inside editor after element rendered so point is visible for bound text * Delete bound text when point attached to it deleted * move bound to mid segement mid point when points are even * shift bound text when points nearby deleted and handle segment deletion * Resize working :) * more resize fixes * don't update cache-its breaking delete points, look for better soln * update mid point cache for bound elements when updated * introduce wrapping when resizing * wrap when resize for 2 pointer linear elements * support adding text for linear elements with more than 3 points * export to svg working :) * clip from nearest enclosing element with non transparent color if present when exporting and fill with correct color in canvas * fix snap * use visible elements * Make export to svg work with Mask :) * remove id * mask canvas linear element area where label is added * decide the position of bound text during render * fix coords when editing * fix multiple resize * update cache when bound text version changes * fix masking when rotated * render text in correct position in preview * remove unnecessary code * fix masking when rotating linear element * fix masking with zoom * fix mask in preview for export * fix offsets in export view * fix coords on svg export * fix mask when element rotated in svg * enable double-click to enter text * fix hint * Position cursor correctly and text dimensiosn when height of element is negative * don't allow 2 pointer linear element with bound text width to go beyond min width * code cleanup * fix freedraw * Add padding * don't show vertical align action for linear element containers * Add specs for getBoundTextElementPosition * more specs * move some utils to linearElementEditor.ts * remove only :p * check absoulte coods in test * Add test to hide vertical align for linear eleemnt with bound text * improve export preview * support labels only for arrows * spec * fix large texts * fix tests * fix zooming * enter line editor with cmd+double click * Allow points to move beyond min width/height for 2 pointer arrow with bound text * fix hint for line editing * attempt to fix arrow getting deselected * fix hint and shortcut * Add padding of 5px when creating bound text and add spec * Wrap bound text when arrow binding containers moved * Add spec * remove * set boundTextElementVersion to null if not present * dont use cache when version mismatch * Add a padding of 5px vertically when creating text * Add box sizing content box * Set bound elements when text element created to fix the padding * fix zooming in editor * fix zoom in export * remove globalCompositeOperation and use clearRect instead of fillRect --- src/actions/actionProperties.tsx | 3 + src/components/Actions.tsx | 9 +- src/components/App.tsx | 111 +++-- src/components/HelpDialog.tsx | 5 +- src/element/binding.ts | 5 + src/element/bounds.ts | 142 ++++--- src/element/collision.ts | 28 +- src/element/linearElementEditor.ts | 229 +++++++++- src/element/newElement.ts | 50 ++- src/element/resizeElements.ts | 77 ++-- src/element/resizeTest.ts | 2 +- src/element/textElement.ts | 348 +++++++++++----- src/element/textWysiwyg.test.tsx | 40 +- src/element/textWysiwyg.tsx | 52 ++- src/element/transformHandles.ts | 8 +- src/element/typeChecks.ts | 9 +- src/element/types.ts | 8 +- src/locales/en.json | 2 +- src/points.ts | 1 - src/renderer/renderElement.ts | 271 ++++++++++-- src/renderer/renderScene.ts | 52 ++- src/tests/helpers/api.ts | 3 + src/tests/linearElementEditor.test.tsx | 552 +++++++++++++++++++++++-- src/tests/utils.ts | 19 + src/utils.ts | 5 +- 25 files changed, 1668 insertions(+), 363 deletions(-) diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index e75e8695..ae73d6a9 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -816,16 +816,19 @@ export const actionChangeVerticalAlign = register({ value: VERTICAL_ALIGN.TOP, text: t("labels.alignTop"), icon: , + testId: "align-top", }, { value: VERTICAL_ALIGN.MIDDLE, text: t("labels.centerVertically"), icon: , + testId: "align-middle", }, { value: VERTICAL_ALIGN.BOTTOM, text: t("labels.alignBottom"), icon: , + testId: "align-bottom", }, ]} value={getFormValue(elements, appState, (element) => { diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 75233d18..5ee6e340 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -25,11 +25,12 @@ import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; import { hasStrokeColor } from "../scene/comparisons"; import { trackEvent } from "../analytics"; -import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; +import { hasBoundTextElement } from "../element/typeChecks"; import clsx from "clsx"; import { actionToggleZenMode } from "../actions"; import "./Actions.scss"; import { Tooltip } from "./Tooltip"; +import { shouldAllowVerticalAlign } from "../element/textElement"; export const SelectedShapeActions = ({ appState, @@ -125,10 +126,8 @@ export const SelectedShapeActions = ({ )} - {targetElements.some( - (element) => - hasBoundTextElement(element) || isBoundToContainer(element), - ) && renderAction("changeVerticalAlign")} + {shouldAllowVerticalAlign(targetElements) && + renderAction("changeVerticalAlign")} {(canHaveArrowheads(appState.activeTool.type) || targetElements.some((element) => canHaveArrowheads(element.type))) && ( <>{renderAction("changeArrowhead")} diff --git a/src/components/App.tsx b/src/components/App.tsx index f865079a..ae943128 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -126,6 +126,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement"; import { deepCopyElement, newFreeDrawElement } from "../element/newElement"; import { hasBoundTextElement, + isArrowElement, isBindingElement, isBindingElementType, isBoundToContainer, @@ -254,6 +255,7 @@ import { getApproxMinLineHeight, getApproxMinLineWidth, getBoundTextElement, + getContainerCenter, getContainerDims, getTextBindableContainerAtPosition, isValidTextContainer, @@ -2049,23 +2051,23 @@ class App extends React.Component { this.scene.getNonDeletedElements(), this.state, ); - if (selectedElements.length === 1) { const selectedElement = selectedElements[0]; - - if (isLinearElement(selectedElement)) { - if ( - !this.state.editingLinearElement || - this.state.editingLinearElement.elementId !== - selectedElements[0].id - ) { - this.history.resumeRecording(); - this.setState({ - editingLinearElement: new LinearElementEditor( - selectedElement, - this.scene, - ), - }); + if (event[KEYS.CTRL_OR_CMD]) { + if (isLinearElement(selectedElement)) { + if ( + !this.state.editingLinearElement || + this.state.editingLinearElement.elementId !== + selectedElements[0].id + ) { + this.history.resumeRecording(); + this.setState({ + editingLinearElement: new LinearElementEditor( + selectedElement, + this.scene, + ), + }); + } } } else if ( isTextElement(selectedElement) || @@ -2075,9 +2077,12 @@ class App extends React.Component { if (!isTextElement(selectedElement)) { container = selectedElement as ExcalidrawTextContainer; } + const midPoint = getContainerCenter(selectedElement, this.state); + const sceneX = midPoint.x; + const sceneY = midPoint.y; this.startTextEditing({ - sceneX: selectedElement.x + selectedElement.width / 2, - sceneY: selectedElement.y + selectedElement.height / 2, + sceneX, + sceneY, container, }); event.preventDefault(); @@ -2521,7 +2526,12 @@ class App extends React.Component { existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); } - if (!existingTextElement && shouldBindToContainer && container) { + if ( + !existingTextElement && + shouldBindToContainer && + container && + !isArrowElement(container) + ) { const fontString = { fontSize: this.state.currentItemFontSize, fontFamily: this.state.currentItemFontFamily, @@ -2574,6 +2584,14 @@ class App extends React.Component { locked: false, }); + if (!existingTextElement && shouldBindToContainer && container) { + mutateElement(container, { + boundElements: (container.boundElements || []).concat({ + type: "text", + id: element.id, + }), + }); + } this.setState({ editingElement: element }); if (!existingTextElement) { @@ -2625,8 +2643,9 @@ class App extends React.Component { if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { if ( - !this.state.editingLinearElement || - this.state.editingLinearElement.elementId !== selectedElements[0].id + event[KEYS.CTRL_OR_CMD] && + (!this.state.editingLinearElement || + this.state.editingLinearElement.elementId !== selectedElements[0].id) ) { this.history.resumeRecording(); this.setState({ @@ -2635,8 +2654,13 @@ class App extends React.Component { this.scene, ), }); + return; + } else if ( + this.state.editingLinearElement && + this.state.editingLinearElement.elementId === selectedElements[0].id + ) { + return; } - return; } resetCursor(this.canvas); @@ -2680,9 +2704,11 @@ class App extends React.Component { sceneY, ); if (container) { - if (hasBoundTextElement(container)) { - sceneX = container.x + container.width / 2; - sceneY = container.y + container.height / 2; + if (isArrowElement(container) || hasBoundTextElement(container)) { + const midPoint = getContainerCenter(container, this.state); + + sceneX = midPoint.x; + sceneY = midPoint.y; } } this.startTextEditing({ @@ -2783,6 +2809,7 @@ class App extends React.Component { event: React.PointerEvent, ) => { this.savePointer(event.clientX, event.clientY, this.state.cursorButton); + if (gesture.pointers.has(event.pointerId)) { gesture.pointers.set(event.pointerId, { x: event.clientX, @@ -3091,15 +3118,18 @@ class App extends React.Component { ); } else if ( // if using cmd/ctrl, we're not dragging - !event[KEYS.CTRL_OR_CMD] && - (hitElement || - this.isHittingCommonBoundingBoxOfSelectedElements( - scenePointer, - selectedElements, - )) && - !hitElement?.locked + !event[KEYS.CTRL_OR_CMD] ) { - setCursor(this.canvas, CURSOR_TYPE.MOVE); + if ( + (hitElement || + this.isHittingCommonBoundingBoxOfSelectedElements( + scenePointer, + selectedElements, + )) && + !hitElement?.locked + ) { + setCursor(this.canvas, CURSOR_TYPE.MOVE); + } } else { setCursor(this.canvas, CURSOR_TYPE.AUTO); } @@ -3209,6 +3239,8 @@ class App extends React.Component { linearElementEditor.elementId, ); + const boundTextElement = getBoundTextElement(element); + if (!element) { return; } @@ -3249,6 +3281,11 @@ class App extends React.Component { ) ) { setCursor(this.canvas, CURSOR_TYPE.MOVE); + } else if ( + boundTextElement && + hitTest(boundTextElement, this.state, scenePointerX, scenePointerY) + ) { + setCursor(this.canvas, CURSOR_TYPE.MOVE); } if ( @@ -6305,8 +6342,14 @@ class App extends React.Component { container?: ExcalidrawTextContainer | null, ) { if (container) { - const elementCenterX = container.x + container.width / 2; - const elementCenterY = container.y + container.height / 2; + let elementCenterX = container.x + container.width / 2; + let elementCenterY = container.y + container.height / 2; + + const elementCenter = getContainerCenter(container, appState); + if (elementCenter) { + elementCenterX = elementCenter.x; + elementCenterY = elementCenter.y; + } const distanceToCenter = Math.hypot( x - elementCenterX, y - elementCenterY, diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 9a23fad0..5e20cf44 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -157,7 +157,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { /> @@ -361,6 +362,10 @@ export const updateBoundElements = ( endBinding, changedElement as ExcalidrawBindableElement, ); + const boundText = getBoundTextElement(element); + if (boundText) { + handleBindTextResize(element, false); + } }); }; diff --git a/src/element/bounds.ts b/src/element/bounds.ts index 793f4c50..9de28795 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -4,6 +4,7 @@ import { Arrowhead, ExcalidrawFreeDrawElement, NonDeleted, + ExcalidrawTextElementWithContainer, } from "./types"; import { distance2d, rotate } from "../math"; import rough from "roughjs/bin/rough"; @@ -13,8 +14,15 @@ import { getShapeForElement, generateRoughOptions, } from "../renderer/renderElement"; -import { isFreeDrawElement, isLinearElement } from "./typeChecks"; +import { + isArrowElement, + isFreeDrawElement, + isLinearElement, + isTextElement, +} from "./typeChecks"; import { rescalePoints } from "../points"; +import { getBoundTextElement, getContainerElement } from "./textElement"; +import { LinearElementEditor } from "./linearElementEditor"; // x and y position of top left corner, x and y position of bottom right corner export type Bounds = readonly [number, number, number, number]; @@ -24,17 +32,39 @@ type MaybeQuadraticSolution = [number | null, number | null] | false; // This set of functions retrieves the absolute position of the 4 points. export const getElementAbsoluteCoords = ( element: ExcalidrawElement, -): Bounds => { + includeBoundText: boolean = false, +): [number, number, number, number, number, number] => { if (isFreeDrawElement(element)) { return getFreeDrawElementAbsoluteCoords(element); } else if (isLinearElement(element)) { - return getLinearElementAbsoluteCoords(element); + return LinearElementEditor.getElementAbsoluteCoords( + element, + includeBoundText, + ); + } else if (isTextElement(element)) { + const container = getContainerElement(element); + if (isArrowElement(container)) { + const coords = LinearElementEditor.getBoundTextElementPosition( + container, + element as ExcalidrawTextElementWithContainer, + ); + return [ + coords.x, + coords.y, + coords.x + element.width, + coords.y + element.height, + coords.x + element.width / 2, + coords.y + element.height / 2, + ]; + } } return [ element.x, element.y, element.x + element.width, element.y + element.height, + element.x + element.width / 2, + element.y + element.height / 2, ]; }; @@ -159,7 +189,7 @@ const getCubicBezierCurveBound = ( return [minX, minY, maxX, maxY]; }; -const getMinMaxXYFromCurvePathOps = ( +export const getMinMaxXYFromCurvePathOps = ( ops: Op[], transformXY?: (x: number, y: number) => [number, number], ): [number, number, number, number] => { @@ -230,59 +260,13 @@ const getBoundsFromPoints = ( const getFreeDrawElementAbsoluteCoords = ( element: ExcalidrawFreeDrawElement, -): [number, number, number, number] => { +): [number, number, number, number, number, number] => { const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points); - - return [ - minX + element.x, - minY + element.y, - maxX + element.x, - maxY + element.y, - ]; -}; - -const getLinearElementAbsoluteCoords = ( - element: ExcalidrawLinearElement, -): [number, number, number, number] => { - let coords: [number, number, number, number]; - - if (element.points.length < 2 || !getShapeForElement(element)) { - // XXX this is just a poor estimate and not very useful - const { minX, minY, maxX, maxY } = element.points.reduce( - (limits, [x, y]) => { - limits.minY = Math.min(limits.minY, y); - limits.minX = Math.min(limits.minX, x); - - limits.maxX = Math.max(limits.maxX, x); - limits.maxY = Math.max(limits.maxY, y); - - return limits; - }, - { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, - ); - coords = [ - minX + element.x, - minY + element.y, - maxX + element.x, - maxY + element.y, - ]; - } else { - const shape = getShapeForElement(element)!; - - // first element is always the curve - const ops = getCurvePathOps(shape[0]); - - const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); - - coords = [ - minX + element.x, - minY + element.y, - maxX + element.x, - maxY + element.y, - ]; - } - - return coords; + const x1 = minX + element.x; + const y1 = minY + element.y; + const x2 = maxX + element.x; + const y2 = maxY + element.y; + return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2]; }; export const getArrowheadPoints = ( @@ -420,7 +404,23 @@ const getLinearElementRotatedBounds = ( cy, element.angle, ); - return [x, y, x, y]; + + let coords: [number, number, number, number] = [x, y, x, y]; + const boundTextElement = getBoundTextElement(element); + if (boundTextElement) { + const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( + element, + [x, y, x, y], + boundTextElement, + ); + coords = [ + coordsWithBoundText[0], + coordsWithBoundText[1], + coordsWithBoundText[2], + coordsWithBoundText[3], + ]; + } + return coords; } // first element is always the curve @@ -429,8 +429,28 @@ const getLinearElementRotatedBounds = ( const ops = getCurvePathOps(shape); const transformXY = (x: number, y: number) => rotate(element.x + x, element.y + y, cx, cy, element.angle); - - return getMinMaxXYFromCurvePathOps(ops, transformXY); + const res = getMinMaxXYFromCurvePathOps(ops, transformXY); + let coords: [number, number, number, number] = [ + res[0], + res[1], + res[2], + res[3], + ]; + const boundTextElement = getBoundTextElement(element); + if (boundTextElement) { + const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( + element, + coords, + boundTextElement, + ); + coords = [ + coordsWithBoundText[0], + coordsWithBoundText[1], + coordsWithBoundText[2], + coordsWithBoundText[3], + ]; + } + return coords; }; // We could cache this stuff @@ -439,9 +459,7 @@ export const getElementBounds = ( ): [number, number, number, number] => { let bounds: [number, number, number, number]; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - const cx = (x1 + x2) / 2; - const cy = (y1 + y2) / 2; + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); if (isFreeDrawElement(element)) { const [minX, minY, maxX, maxY] = getBoundsFromPoints( element.points.map(([x, y]) => diff --git a/src/element/collision.ts b/src/element/collision.ts index e47e5e55..e300f821 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -36,6 +36,7 @@ import { hasBoundTextElement, isImageElement } from "./typeChecks"; import { isTextElement } from "."; import { isTransparent } from "../utils"; import { shouldShowBoundingBox } from "./transformHandles"; +import { getBoundTextElement } from "./textElement"; const isElementDraggableFromInside = ( element: NonDeletedExcalidrawElement, @@ -72,6 +73,13 @@ export const hitTest = ( return isPointHittingElementBoundingBox(element, point, threshold); } + const boundTextElement = getBoundTextElement(element); + if (boundTextElement) { + const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y); + if (isHittingBoundTextElement) { + return true; + } + } return isHittingElementNotConsideringBoundingBox(element, appState, point); }; @@ -83,6 +91,13 @@ export const isHittingElementBoundingBoxWithoutHittingElement = ( ): boolean => { const threshold = 10 / appState.zoom.value; + // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element + // eg for linear elements text can be outside the element bounding box + const boundTextElement = getBoundTextElement(element); + if (boundTextElement && hitTest(boundTextElement, appState, x, y)) { + return false; + } + return ( !isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) && isPointHittingElementBoundingBox(element, [x, y], threshold) @@ -95,7 +110,6 @@ export const isHittingElementNotConsideringBoundingBox = ( point: Point, ): boolean => { const threshold = 10 / appState.zoom.value; - const check = isTextElement(element) ? isStrictlyInside : isElementDraggableFromInside(element) @@ -382,6 +396,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => { if (!getShapeForElement(element)) { return false; } + const [point, pointAbs, hwidth, hheight] = pointRelativeToElement( args.element, args.point, @@ -434,8 +449,9 @@ const pointRelativeToElement = ( pointTuple: Point, ): [GA.Point, GA.Point, number, number] => { const point = GAPoint.from(pointTuple); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const elementCoords = getElementAbsoluteCoords(element); - const center = coordsCenter(elementCoords); + const center = coordsCenter([x1, y1, x2, y2]); // GA has angle orientation opposite to `rotate` const rotate = GATransform.rotation(center, element.angle); const pointRotated = GATransform.apply(rotate, point); @@ -466,8 +482,8 @@ export const pointInAbsoluteCoords = ( const relativizationToElementCenter = ( element: ExcalidrawElement, ): GA.Transform => { - const elementCoords = getElementAbsoluteCoords(element); - const center = coordsCenter(elementCoords); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const center = coordsCenter([x1, y1, x2, y2]); // GA has angle orientation opposite to `rotate` const rotate = GATransform.rotation(center, element.angle); const translate = GA.reverse( @@ -524,8 +540,8 @@ export const determineFocusPoint = ( adjecentPoint: Point, ): Point => { if (focus === 0) { - const elementCoords = getElementAbsoluteCoords(element); - const center = coordsCenter(elementCoords); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const center = coordsCenter([x1, y1, x2, y2]); return GAPoint.toTuple(center); } const relateToCenter = relativizationToElementCenter(element); diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts index f0d0a215..4e53b034 100644 --- a/src/element/linearElementEditor.ts +++ b/src/element/linearElementEditor.ts @@ -4,6 +4,7 @@ import { ExcalidrawElement, PointBinding, ExcalidrawBindableElement, + ExcalidrawTextElementWithContainer, } from "./types"; import { distance2d, @@ -19,7 +20,11 @@ import { arePointsEqual, } from "../math"; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; -import { getElementPointsCoords } from "./bounds"; +import { + getCurvePathOps, + getElementPointsCoords, + getMinMaxXYFromCurvePathOps, +} from "./bounds"; import { Point, AppState, PointerCoords } from "../types"; import { mutateElement } from "./mutateElement"; import History from "../history"; @@ -33,6 +38,8 @@ import { import { tupleToCoors } from "../utils"; import { isBindingElement } from "./typeChecks"; import { shouldRotateWithDiscreteAngle } from "../keys"; +import { getBoundTextElement, handleBindTextResize } from "./textElement"; +import { getShapeForElement } from "../renderer/renderElement"; import { DRAGGING_THRESHOLD } from "../constants"; const editorMidPointsCache: { @@ -40,7 +47,6 @@ const editorMidPointsCache: { points: (Point | null)[]; zoom: number | null; } = { version: null, points: [], zoom: null }; - export class LinearElementEditor { public readonly elementId: ExcalidrawElement["id"] & { _brand: "excalidrawLinearElementId"; @@ -257,6 +263,11 @@ export class LinearElementEditor { }; }), ); + + const boundTextElement = getBoundTextElement(element); + if (boundTextElement) { + handleBindTextResize(element, false); + } } // suggest bindings for first and last point if selected @@ -388,8 +399,14 @@ export class LinearElementEditor { element: NonDeleted, appState: AppState, ): typeof editorMidPointsCache["points"] => { - // Since its not needed outside editor unless 2 pointer lines - if (!appState.editingLinearElement && element.points.length > 2) { + const boundText = getBoundTextElement(element); + + // Since its not needed outside editor unless 2 pointer lines or bound text + if ( + !appState.editingLinearElement && + element.points.length > 2 && + !boundText + ) { return []; } if ( @@ -661,7 +678,6 @@ export class LinearElementEditor { scenePointer.x, scenePointer.y, ); - // if we clicked on a point, set the element as hitElement otherwise // it would get deselected if the point is outside the hitbox area if (clickedPointIndex >= 0 || segmentMidpoint) { @@ -1055,7 +1071,6 @@ export class LinearElementEditor { const offsetY = 0; const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; - LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); } @@ -1223,7 +1238,6 @@ export class LinearElementEditor { const dX = prevCenterX - nextCenterX; const dY = prevCenterY - nextCenterY; const rotated = rotate(offsetX, offsetY, dX, dY, element.angle); - mutateElement(element, { ...otherUpdates, points: nextPoints, @@ -1258,6 +1272,207 @@ export class LinearElementEditor { return rotatePoint([width, height], [0, 0], -element.angle); } + + static getBoundTextElementPosition = ( + element: ExcalidrawLinearElement, + boundTextElement: ExcalidrawTextElementWithContainer, + ): { x: number; y: number } => { + const points = LinearElementEditor.getPointsGlobalCoordinates(element); + if (points.length < 2) { + mutateElement(boundTextElement, { isDeleted: true }); + } + let x = 0; + let y = 0; + if (element.points.length % 2 === 1) { + const index = Math.floor(element.points.length / 2); + const midPoint = LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[index], + ); + x = midPoint[0] - boundTextElement.width / 2; + y = midPoint[1] - boundTextElement.height / 2; + } else { + const index = element.points.length / 2 - 1; + + let midSegmentMidpoint = editorMidPointsCache.points[index]; + if (element.points.length === 2) { + midSegmentMidpoint = centerPoint(points[0], points[1]); + } + if ( + !midSegmentMidpoint || + editorMidPointsCache.version !== element.version + ) { + midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( + element, + points[index], + points[index + 1], + index + 1, + ); + } + x = midSegmentMidpoint[0] - boundTextElement.width / 2; + y = midSegmentMidpoint[1] - boundTextElement.height / 2; + } + return { x, y }; + }; + + static getMinMaxXYWithBoundText = ( + element: ExcalidrawLinearElement, + elementBounds: [number, number, number, number], + boundTextElement: ExcalidrawTextElementWithContainer, + ): [number, number, number, number, number, number] => { + let [x1, y1, x2, y2] = elementBounds; + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const { x: boundTextX1, y: boundTextY1 } = + LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + ); + const boundTextX2 = boundTextX1 + boundTextElement.width; + const boundTextY2 = boundTextY1 + boundTextElement.height; + + const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle); + const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle); + + const counterRotateBoundTextTopLeft = rotatePoint( + [boundTextX1, boundTextY1], + + [cx, cy], + + -element.angle, + ); + const counterRotateBoundTextTopRight = rotatePoint( + [boundTextX2, boundTextY1], + + [cx, cy], + + -element.angle, + ); + const counterRotateBoundTextBottomLeft = rotatePoint( + [boundTextX1, boundTextY2], + + [cx, cy], + + -element.angle, + ); + const counterRotateBoundTextBottomRight = rotatePoint( + [boundTextX2, boundTextY2], + + [cx, cy], + + -element.angle, + ); + + if ( + topLeftRotatedPoint[0] < topRightRotatedPoint[0] && + topLeftRotatedPoint[1] >= topRightRotatedPoint[1] + ) { + x1 = Math.min(x1, counterRotateBoundTextBottomLeft[0]); + x2 = Math.max( + x2, + Math.max( + counterRotateBoundTextTopRight[0], + counterRotateBoundTextBottomRight[0], + ), + ); + y1 = Math.min(y1, counterRotateBoundTextTopLeft[1]); + + y2 = Math.max(y2, counterRotateBoundTextBottomRight[1]); + } else if ( + topLeftRotatedPoint[0] >= topRightRotatedPoint[0] && + topLeftRotatedPoint[1] > topRightRotatedPoint[1] + ) { + x1 = Math.min(x1, counterRotateBoundTextBottomRight[0]); + x2 = Math.max( + x2, + Math.max( + counterRotateBoundTextTopLeft[0], + counterRotateBoundTextTopRight[0], + ), + ); + y1 = Math.min(y1, counterRotateBoundTextBottomLeft[1]); + + y2 = Math.max(y2, counterRotateBoundTextTopRight[1]); + } else if (topLeftRotatedPoint[0] >= topRightRotatedPoint[0]) { + x1 = Math.min(x1, counterRotateBoundTextTopRight[0]); + x2 = Math.max(x2, counterRotateBoundTextBottomLeft[0]); + y1 = Math.min(y1, counterRotateBoundTextBottomRight[1]); + + y2 = Math.max(y2, counterRotateBoundTextTopLeft[1]); + } else if (topLeftRotatedPoint[1] <= topRightRotatedPoint[1]) { + x1 = Math.min( + x1, + Math.min( + counterRotateBoundTextTopRight[0], + counterRotateBoundTextTopLeft[0], + ), + ); + + x2 = Math.max(x2, counterRotateBoundTextBottomRight[0]); + y1 = Math.min(y1, counterRotateBoundTextTopRight[1]); + y2 = Math.max(y2, counterRotateBoundTextBottomLeft[1]); + } + + return [x1, y1, x2, y2, cx, cy]; + }; + + static getElementAbsoluteCoords = ( + element: ExcalidrawLinearElement, + includeBoundText: boolean = false, + ): [number, number, number, number, number, number] => { + let coords: [number, number, number, number, number, number]; + let x1; + let y1; + let x2; + let y2; + if (element.points.length < 2 || !getShapeForElement(element)) { + // XXX this is just a poor estimate and not very useful + const { minX, minY, maxX, maxY } = element.points.reduce( + (limits, [x, y]) => { + limits.minY = Math.min(limits.minY, y); + limits.minX = Math.min(limits.minX, x); + + limits.maxX = Math.max(limits.maxX, x); + limits.maxY = Math.max(limits.maxY, y); + + return limits; + }, + { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, + ); + x1 = minX + element.x; + y1 = minY + element.y; + x2 = maxX + element.x; + y2 = maxY + element.y; + } else { + const shape = getShapeForElement(element)!; + + // first element is always the curve + const ops = getCurvePathOps(shape[0]); + + const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); + x1 = minX + element.x; + y1 = minY + element.y; + x2 = maxX + element.x; + y2 = maxY + element.y; + } + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + coords = [x1, y1, x2, y2, cx, cy]; + + if (!includeBoundText) { + return coords; + } + const boundTextElement = getBoundTextElement(element); + if (boundTextElement) { + coords = LinearElementEditor.getMinMaxXYWithBoundText( + element, + [x1, y1, x2, y2], + boundTextElement, + ); + } + + return coords; + }; } const normalizeSelectedPoints = ( diff --git a/src/element/newElement.ts b/src/element/newElement.ts index f35a0d0f..2c041800 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -11,7 +11,7 @@ import { Arrowhead, ExcalidrawFreeDrawElement, FontFamilyValues, - ExcalidrawRectangleElement, + ExcalidrawTextContainer, } from "../element/types"; import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils"; import { randomInteger, randomId } from "../random"; @@ -22,6 +22,8 @@ import { getElementAbsoluteCoords } from "."; import { adjustXYWithRotation } from "../math"; import { getResizedElementAbsoluteCoords } from "./bounds"; import { + getBoundTextElement, + getBoundTextElementOffset, getContainerDims, getContainerElement, measureText, @@ -29,6 +31,7 @@ import { wrapText, } from "./textElement"; import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; +import { isArrowElement } from "./typeChecks"; type ElementConstructorOpts = MarkOptional< Omit, @@ -131,7 +134,7 @@ export const newTextElement = ( fontFamily: FontFamilyValues; textAlign: TextAlign; verticalAlign: VerticalAlign; - containerId?: ExcalidrawRectangleElement["id"]; + containerId?: ExcalidrawTextContainer["id"]; } & ElementConstructorOpts, ): NonDeleted => { const text = normalizeText(opts.text); @@ -231,16 +234,21 @@ const getAdjustedDimensions = ( // make sure container dimensions are set properly when // text editor overflows beyond viewport dimensions if (container) { + const boundTextElementPadding = getBoundTextElementOffset(element); + const containerDims = getContainerDims(container); let height = containerDims.height; let width = containerDims.width; - if (nextHeight > height - BOUND_TEXT_PADDING * 2) { - height = nextHeight + BOUND_TEXT_PADDING * 2; + if (nextHeight > height - boundTextElementPadding * 2) { + height = nextHeight + boundTextElementPadding * 2; } - if (nextWidth > width - BOUND_TEXT_PADDING * 2) { - width = nextWidth + BOUND_TEXT_PADDING * 2; + if (nextWidth > width - boundTextElementPadding * 2) { + width = nextWidth + boundTextElementPadding * 2; } - if (height !== containerDims.height || width !== containerDims.width) { + if ( + !isArrowElement(container) && + (height !== containerDims.height || width !== containerDims.width) + ) { mutateElement(container, { height, width }); } } @@ -270,11 +278,35 @@ export const refreshTextDimensions = ( }; export const getMaxContainerWidth = (container: ExcalidrawElement) => { - return getContainerDims(container).width - BOUND_TEXT_PADDING * 2; + const width = getContainerDims(container).width; + if (isArrowElement(container)) { + const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2; + if (containerWidth <= 0) { + const boundText = getBoundTextElement(container); + if (boundText) { + return boundText.width; + } + return BOUND_TEXT_PADDING * 8 * 2; + } + return containerWidth; + } + return width - BOUND_TEXT_PADDING * 2; }; export const getMaxContainerHeight = (container: ExcalidrawElement) => { - return getContainerDims(container).height - BOUND_TEXT_PADDING * 2; + const height = getContainerDims(container).height; + if (isArrowElement(container)) { + const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2; + if (containerHeight <= 0) { + const boundText = getBoundTextElement(container); + if (boundText) { + return boundText.height; + } + return BOUND_TEXT_PADDING * 8 * 2; + } + return height; + } + return height - BOUND_TEXT_PADDING * 2; }; export const updateTextElement = ( diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 8ab61106..46e1c0d1 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -1,4 +1,4 @@ -import { BOUND_TEXT_PADDING, SHIFT_LOCKING_ANGLE } from "../constants"; +import { SHIFT_LOCKING_ANGLE } from "../constants"; import { rescalePoints } from "../points"; import { @@ -12,6 +12,8 @@ import { ExcalidrawTextElement, NonDeletedExcalidrawElement, NonDeleted, + ExcalidrawElement, + ExcalidrawTextElementWithContainer, } from "./types"; import { getElementAbsoluteCoords, @@ -20,6 +22,7 @@ import { getCommonBoundingBox, } from "./bounds"; import { + isArrowElement, isBoundToContainer, isFreeDrawElement, isLinearElement, @@ -40,6 +43,7 @@ import { getApproxMinLineWidth, getBoundTextElement, getBoundTextElementId, + getBoundTextElementOffset, getContainerElement, handleBindTextResize, measureText, @@ -75,6 +79,7 @@ export const transformElements = ( pointerX, pointerY, shouldRotateWithDiscreteAngle, + pointerDownState.originalElements, ); updateBoundElements(element); } else if ( @@ -142,6 +147,7 @@ const rotateSingleElement = ( pointerX: number, pointerY: number, shouldRotateWithDiscreteAngle: boolean, + originalElements: Map>, ) => { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const cx = (x1 + x2) / 2; @@ -152,11 +158,17 @@ const rotateSingleElement = ( angle -= angle % SHIFT_LOCKING_ANGLE; } angle = normalizeAngle(angle); - mutateElement(element, { angle }); const boundTextElementId = getBoundTextElementId(element); + + mutateElement(element, { angle }); if (boundTextElementId) { - const textElement = Scene.getScene(element)!.getElement(boundTextElementId); - mutateElement(textElement!, { angle }); + const textElement = Scene.getScene(element)!.getElement( + boundTextElementId, + ) as ExcalidrawTextElementWithContainer; + + if (!isArrowElement(element)) { + mutateElement(textElement, { angle }); + } } }; @@ -412,10 +424,12 @@ export const resizeSingleElement = ( }; } if (shouldMaintainAspectRatio) { + const boundTextElementPadding = + getBoundTextElementOffset(boundTextElement); const nextFont = measureFontSizeFromWH( boundTextElement, - eleNewWidth - BOUND_TEXT_PADDING * 2, - eleNewHeight - BOUND_TEXT_PADDING * 2, + eleNewWidth - boundTextElementPadding * 2, + eleNewHeight - boundTextElementPadding * 2, ); if (nextFont === null) { return; @@ -504,24 +518,36 @@ export const resizeSingleElement = ( newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); // Readjust points for linear elements - const rescaledPoints = rescalePointsInElement( - stateAtResizeStart, - eleNewWidth, - eleNewHeight, - true, - ); + let rescaledElementPointsY; + let rescaledPoints; + + if (isLinearElement(element) || isFreeDrawElement(element)) { + rescaledElementPointsY = rescalePoints( + 1, + eleNewHeight, + (stateAtResizeStart as ExcalidrawLinearElement).points, + true, + ); + + rescaledPoints = rescalePoints( + 0, + eleNewWidth, + rescaledElementPointsY, + true, + ); + } + // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner // So we need to readjust (x,y) to be where the first point should be const newOrigin = [...newTopLeft]; newOrigin[0] += stateAtResizeStart.x - newBoundsX1; newOrigin[1] += stateAtResizeStart.y - newBoundsY1; - const resizedElement = { width: Math.abs(eleNewWidth), height: Math.abs(eleNewHeight), x: newOrigin[0], y: newOrigin[1], - ...rescaledPoints, + points: rescaledPoints, }; if ("scale" in element && "scale" in stateAtResizeStart) { @@ -545,6 +571,7 @@ export const resizeSingleElement = ( updateBoundElements(element, { newSize: { width: resizedElement.width, height: resizedElement.height }, }); + mutateElement(element, resizedElement); if (boundTextElement && boundTextFont) { mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize }); @@ -667,7 +694,7 @@ const resizeMultipleElements = ( const boundTextElement = getBoundTextElement(element.latest); if (boundTextElement || isTextElement(element.orig)) { - const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0; + const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2; const textMeasurements = measureFontSizeFromWH( boundTextElement ?? (element.orig as ExcalidrawTextElement), width - optionalPadding, @@ -697,6 +724,7 @@ const resizeMultipleElements = ( if (boundTextElement && boundTextUpdates) { mutateElement(boundTextElement, boundTextUpdates); + handleBindTextResize(element.latest, transformHandleType); } }); @@ -717,7 +745,7 @@ const rotateMultipleElements = ( centerAngle += SHIFT_LOCKING_ANGLE / 2; centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; } - elements.forEach((element, index) => { + elements.forEach((element) => { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; @@ -737,13 +765,16 @@ const rotateMultipleElements = ( }); const boundTextElementId = getBoundTextElementId(element); if (boundTextElementId) { - const textElement = - Scene.getScene(element)!.getElement(boundTextElementId)!; - mutateElement(textElement, { - x: textElement.x + (rotatedCX - cx), - y: textElement.y + (rotatedCY - cy), - angle: normalizeAngle(centerAngle + origAngle), - }); + const textElement = Scene.getScene(element)!.getElement( + boundTextElementId, + ) as ExcalidrawTextElementWithContainer; + if (!isArrowElement(element)) { + mutateElement(textElement, { + x: textElement.x + (rotatedCX - cx), + y: textElement.y + (rotatedCY - cy), + angle: normalizeAngle(centerAngle + origAngle), + }); + } } }); }; diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index dbdab5a4..a3447208 100644 --- a/src/element/resizeTest.ts +++ b/src/element/resizeTest.ts @@ -94,7 +94,7 @@ export const getTransformHandleTypeFromCoords = ( pointerType: PointerType, ): MaybeTransformHandleType => { const transformHandles = getTransformHandlesFromCoords( - [x1, y1, x2, y2], + [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], 0, zoom, pointerType, diff --git a/src/element/textElement.ts b/src/element/textElement.ts index 142dda0d..4f39d361 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -13,11 +13,17 @@ import { MaybeTransformHandleType } from "./transformHandles"; import Scene from "../scene/Scene"; import { isTextElement } from "."; import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement"; +import { + isBoundToContainer, + isImageElement, + isArrowElement, +} from "./typeChecks"; +import { LinearElementEditor } from "./linearElementEditor"; +import { AppState } from "../types"; import { isTextBindableContainer } from "./typeChecks"; import { getElementAbsoluteCoords } from "../element"; -import { AppState } from "../types"; import { getSelectedElements } from "../scene"; -import { isImageElement } from "./typeChecks"; +import { isHittingElementNotConsideringBoundingBox } from "./collision"; export const normalizeText = (text: string) => { return ( @@ -52,36 +58,47 @@ export const redrawTextBoundingBox = ( let coordX = textElement.x; // Resize container and vertically center align the text if (container) { - const containerDims = getContainerDims(container); - let nextHeight = containerDims.height; - if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) { - coordY = container.y + BOUND_TEXT_PADDING; - } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { - coordY = - container.y + - containerDims.height - - metrics.height - - BOUND_TEXT_PADDING; - } else { - coordY = container.y + containerDims.height / 2 - metrics.height / 2; - if (metrics.height > getMaxContainerHeight(container)) { - nextHeight = metrics.height + BOUND_TEXT_PADDING * 2; - coordY = container.y + nextHeight / 2 - metrics.height / 2; + if (!isArrowElement(container)) { + const containerDims = getContainerDims(container); + let nextHeight = containerDims.height; + const boundTextElementPadding = getBoundTextElementOffset(textElement); + if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) { + coordY = container.y + boundTextElementPadding; + } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { + coordY = + container.y + + containerDims.height - + metrics.height - + boundTextElementPadding; + } else { + coordY = container.y + containerDims.height / 2 - metrics.height / 2; + if (metrics.height > getMaxContainerHeight(container)) { + nextHeight = metrics.height + boundTextElementPadding * 2; + coordY = container.y + nextHeight / 2 - metrics.height / 2; + } + } + if (textElement.textAlign === TEXT_ALIGN.LEFT) { + coordX = container.x + boundTextElementPadding; + } else if (textElement.textAlign === TEXT_ALIGN.RIGHT) { + coordX = + container.x + + containerDims.width - + metrics.width - + boundTextElementPadding; + } else { + coordX = container.x + containerDims.width / 2 - metrics.width / 2; } - } - if (textElement.textAlign === TEXT_ALIGN.LEFT) { - coordX = container.x + BOUND_TEXT_PADDING; - } else if (textElement.textAlign === TEXT_ALIGN.RIGHT) { - coordX = - container.x + containerDims.width - metrics.width - BOUND_TEXT_PADDING; + mutateElement(container, { height: nextHeight }); } else { - coordX = container.x + container.width / 2 - metrics.width / 2; + const centerX = textElement.x + textElement.width / 2; + const centerY = textElement.y + textElement.height / 2; + const diffWidth = metrics.width - textElement.width; + const diffHeight = metrics.height - textElement.height; + coordY = centerY - (textElement.height + diffHeight) / 2; + coordX = centerX - (textElement.width + diffWidth) / 2; } - - mutateElement(container, { height: nextHeight }); } - mutateElement(textElement, { width: metrics.width, height: metrics.height, @@ -129,84 +146,113 @@ export const bindTextToShapeAfterDuplication = ( }; export const handleBindTextResize = ( - element: NonDeletedExcalidrawElement, + container: NonDeletedExcalidrawElement, transformHandleType: MaybeTransformHandleType, ) => { - const boundTextElementId = getBoundTextElementId(element); - if (boundTextElementId) { - const textElement = Scene.getScene(element)!.getElement( + const boundTextElementId = getBoundTextElementId(container); + if (!boundTextElementId) { + return; + } + let textElement = Scene.getScene(container)!.getElement( + boundTextElementId, + ) as ExcalidrawTextElement; + if (textElement && textElement.text) { + if (!container) { + return; + } + + textElement = Scene.getScene(container)!.getElement( boundTextElementId, ) as ExcalidrawTextElement; - if (textElement && textElement.text) { - if (!element) { - return; - } - let text = textElement.text; - let nextHeight = textElement.height; - let nextWidth = textElement.width; - let containerHeight = element.height; - let nextBaseLine = textElement.baseline; - if (transformHandleType !== "n" && transformHandleType !== "s") { - if (text) { - text = wrapText( - textElement.originalText, - getFontString(textElement), - getMaxContainerWidth(element), - ); - } - - const dimensions = measureText( - text, + let text = textElement.text; + let nextHeight = textElement.height; + let nextWidth = textElement.width; + const containerDims = getContainerDims(container); + const maxWidth = getMaxContainerWidth(container); + const maxHeight = getMaxContainerHeight(container); + let containerHeight = containerDims.height; + let nextBaseLine = textElement.baseline; + if (transformHandleType !== "n" && transformHandleType !== "s") { + if (text) { + text = wrapText( + textElement.originalText, getFontString(textElement), - element.width, + maxWidth, ); - nextHeight = dimensions.height; - nextWidth = dimensions.width; - nextBaseLine = dimensions.baseline; } - // increase height in case text element height exceeds - if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) { - containerHeight = nextHeight + BOUND_TEXT_PADDING * 2; - const diff = containerHeight - element.height; - // fix the y coord when resizing from ne/nw/n - const updatedY = - transformHandleType === "ne" || - transformHandleType === "nw" || - transformHandleType === "n" - ? element.y - diff - : element.y; - mutateElement(element, { - height: containerHeight, - y: updatedY, - }); - } - - let updatedY; - if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) { - updatedY = element.y + BOUND_TEXT_PADDING; - } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { - updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING; - } else { - updatedY = element.y + element.height / 2 - nextHeight / 2; - } - const updatedX = - textElement.textAlign === TEXT_ALIGN.LEFT - ? element.x + BOUND_TEXT_PADDING - : textElement.textAlign === TEXT_ALIGN.RIGHT - ? element.x + element.width - nextWidth - BOUND_TEXT_PADDING - : element.x + element.width / 2 - nextWidth / 2; - mutateElement(textElement, { + const dimensions = measureText( text, - width: nextWidth, - height: nextHeight, - x: updatedX, + getFontString(textElement), + maxWidth, + ); + nextHeight = dimensions.height; + nextWidth = dimensions.width; + nextBaseLine = dimensions.baseline; + } + // increase height in case text element height exceeds + if (nextHeight > maxHeight) { + containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2; + const diff = containerHeight - containerDims.height; + // fix the y coord when resizing from ne/nw/n + const updatedY = + !isArrowElement(container) && + (transformHandleType === "ne" || + transformHandleType === "nw" || + transformHandleType === "n") + ? container.y - diff + : container.y; + mutateElement(container, { + height: containerHeight, y: updatedY, - baseline: nextBaseLine, }); } + + mutateElement(textElement, { + text, + width: nextWidth, + height: nextHeight, + + baseline: nextBaseLine, + }); + if (!isArrowElement(container)) { + updateBoundTextPosition( + container, + textElement as ExcalidrawTextElementWithContainer, + ); + } } }; +const updateBoundTextPosition = ( + container: ExcalidrawElement, + boundTextElement: ExcalidrawTextElementWithContainer, +) => { + const containerDims = getContainerDims(container); + const boundTextElementPadding = getBoundTextElementOffset(boundTextElement); + let y; + if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) { + y = container.y + boundTextElementPadding; + } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { + y = + container.y + + containerDims.height - + boundTextElement.height - + boundTextElementPadding; + } else { + y = container.y + containerDims.height / 2 - boundTextElement.height / 2; + } + const x = + boundTextElement.textAlign === TEXT_ALIGN.LEFT + ? container.x + boundTextElementPadding + : boundTextElement.textAlign === TEXT_ALIGN.RIGHT + ? container.x + + containerDims.width - + boundTextElement.width - + boundTextElementPadding + : container.x + containerDims.width / 2 - boundTextElement.width / 2; + + mutateElement(boundTextElement, { x, y }); +}; // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js export const measureText = ( text: string, @@ -411,6 +457,7 @@ export const charWidth = (() => { })(); export const getApproxMinLineWidth = (font: FontString) => { const maxCharWidth = getMaxCharWidth(font); + if (maxCharWidth === 0) { return ( measureText(DUMMY_TEXT.split("").join("\n"), font).width + @@ -491,7 +538,9 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => { export const getContainerElement = ( element: - | (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null }) + | (ExcalidrawElement & { + containerId: ExcalidrawElement["id"] | null; + }) | null, ) => { if (!element) { @@ -504,9 +553,106 @@ export const getContainerElement = ( }; export const getContainerDims = (element: ExcalidrawElement) => { + const MIN_WIDTH = 300; + if (isArrowElement(element)) { + const width = Math.max(element.width, MIN_WIDTH); + const height = element.height; + return { width, height }; + } return { width: element.width, height: element.height }; }; +export const getContainerCenter = ( + container: ExcalidrawElement, + appState: AppState, +) => { + if (!isArrowElement(container)) { + return { + x: container.x + container.width / 2, + y: container.y + container.height / 2, + }; + } + const points = LinearElementEditor.getPointsGlobalCoordinates(container); + if (points.length % 2 === 1) { + const index = Math.floor(container.points.length / 2); + const midPoint = LinearElementEditor.getPointGlobalCoordinates( + container, + container.points[index], + ); + return { x: midPoint[0], y: midPoint[1] }; + } + const index = container.points.length / 2 - 1; + let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints( + container, + appState, + )[index]; + if (!midSegmentMidpoint) { + midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( + container, + points[index], + points[index + 1], + index + 1, + ); + } + return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; +}; + +export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { + const container = getContainerElement(textElement); + if (!container || isArrowElement(container)) { + return textElement.angle; + } + return container.angle; +}; + +export const getBoundTextElementOffset = ( + boundTextElement: ExcalidrawTextElement | null, +) => { + const container = getContainerElement(boundTextElement); + if (!container) { + return 0; + } + if (isArrowElement(container)) { + return BOUND_TEXT_PADDING * 8; + } + return BOUND_TEXT_PADDING; +}; + +export const getBoundTextElementPosition = ( + container: ExcalidrawElement, + boundTextElement: ExcalidrawTextElementWithContainer, +) => { + if (isArrowElement(container)) { + return LinearElementEditor.getBoundTextElementPosition( + container, + boundTextElement, + ); + } +}; + +export const shouldAllowVerticalAlign = ( + selectedElements: NonDeletedExcalidrawElement[], +) => { + return selectedElements.some((element) => { + const hasBoundContainer = isBoundToContainer(element); + if (hasBoundContainer) { + const container = getContainerElement(element); + if (isTextElement(element) && isArrowElement(container)) { + return false; + } + return true; + } + const boundTextElement = getBoundTextElement(element); + if (boundTextElement) { + if (isArrowElement(element)) { + return false; + } + return true; + } + return false; + }); +}; + export const getTextBindableContainerAtPosition = ( elements: readonly ExcalidrawElement[], appState: AppState, @@ -515,7 +661,9 @@ export const getTextBindableContainerAtPosition = ( ): ExcalidrawTextContainer | null => { const selectedElements = getSelectedElements(elements, appState); if (selectedElements.length === 1) { - return selectedElements[0] as ExcalidrawTextContainer; + return isTextBindableContainer(selectedElements[0], false) + ? selectedElements[0] + : null; } let hitElement = null; // We need to to hit testing from front (end of the array) to back (beginning of the array) @@ -524,7 +672,16 @@ export const getTextBindableContainerAtPosition = ( continue; } const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]); - if (x1 < x && x < x2 && y1 < y && y < y2) { + if ( + isArrowElement(elements[index]) && + isHittingElementNotConsideringBoundingBox(elements[index], appState, [ + x, + y, + ]) + ) { + hitElement = elements[index]; + break; + } else if (x1 < x && x < x2 && y1 < y && y < y2) { hitElement = elements[index]; break; } @@ -538,6 +695,7 @@ export const isValidTextContainer = (element: ExcalidrawElement) => { element.type === "rectangle" || element.type === "ellipse" || element.type === "diamond" || - isImageElement(element) + isImageElement(element) || + isArrowElement(element) ); }; diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index 72aeb3fd..a3929cf3 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -513,6 +513,9 @@ describe("textWysiwyg", () => { const text = h.elements[1] as ExcalidrawTextElementWithContainer; expect(text.type).toBe("text"); expect(text.containerId).toBe(rectangle.id); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); mouse.down(); const editor = document.querySelector( ".excalidraw-textEditorContainer > textarea", @@ -586,20 +589,19 @@ describe("textWysiwyg", () => { }); it("shouldn't bind to non-text-bindable containers", async () => { - const line = API.createElement({ - type: "line", + const freedraw = API.createElement({ + type: "freedraw", width: 100, height: 0, - points: [ - [0, 0], - [100, 0], - ], }); - h.elements = [line]; + h.elements = [freedraw]; UI.clickTool("text"); - mouse.clickAt(line.x + line.width / 2, line.y + line.height / 2); + mouse.clickAt( + freedraw.x + freedraw.width / 2, + freedraw.y + freedraw.height / 2, + ); const editor = document.querySelector( ".excalidraw-textEditorContainer > textarea", @@ -613,20 +615,22 @@ describe("textWysiwyg", () => { fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); editor.dispatchEvent(new Event("input")); - expect(line.boundElements).toBe(null); + expect(freedraw.boundElements).toBe(null); expect(h.elements[1].type).toBe("text"); expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null); }); - it("shouldn't create text element when pressing 'Enter' key on non text bindable container", async () => { - h.elements = []; - const freeDraw = UI.createElement("freedraw", { - width: 100, - height: 50, + ["freedraw", "line"].forEach((type: any) => { + it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => { + h.elements = []; + const elemnet = UI.createElement(type, { + width: 100, + height: 50, + }); + API.setSelectedElements([elemnet]); + Keyboard.keyPress(KEYS.ENTER); + expect(h.elements.length).toBe(1); }); - API.setSelectedElements([freeDraw]); - Keyboard.keyPress(KEYS.ENTER); - expect(h.elements.length).toBe(1); }); it("should'nt bind text to container when not double clicked on center", async () => { @@ -1206,7 +1210,7 @@ describe("textWysiwyg", () => { fireEvent.change(editor, { target: { value: " " } }); editor.blur(); - expect(rectangle.boundElements).toBeNull(); + expect(rectangle.boundElements).toStrictEqual([]); expect(h.elements[1].isDeleted).toBe(true); }); }); diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index da43d278..ffa75d31 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -6,11 +6,16 @@ import { isTestEnv, } from "../utils"; import Scene from "../scene/Scene"; -import { isBoundToContainer, isTextElement } from "./typeChecks"; -import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; +import { + isArrowElement, + isBoundToContainer, + isTextElement, +} from "./typeChecks"; +import { CLASSES, VERTICAL_ALIGN } from "../constants"; import { ExcalidrawElement, ExcalidrawLinearElement, + ExcalidrawTextElementWithContainer, ExcalidrawTextElement, } from "./types"; import { AppState } from "../types"; @@ -18,8 +23,10 @@ import { mutateElement } from "./mutateElement"; import { getApproxLineHeight, getBoundTextElementId, + getBoundTextElementOffset, getContainerDims, getContainerElement, + getTextElementAngle, measureText, normalizeText, wrapText, @@ -30,7 +37,8 @@ import { } from "../actions/actionProperties"; import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import App from "../components/App"; -import { getMaxContainerWidth } from "./newElement"; +import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement"; +import { LinearElementEditor } from "./linearElementEditor"; import { parseClipboard } from "../clipboard"; const getTransform = ( @@ -108,7 +116,7 @@ export const textWysiwyg = ({ getFontString(updatedTextElement), ); if (updatedTextElement && isTextElement(updatedTextElement)) { - const coordX = updatedTextElement.x; + let coordX = updatedTextElement.x; let coordY = updatedTextElement.y; const container = getContainerElement(updatedTextElement); let maxWidth = updatedTextElement.width; @@ -119,6 +127,15 @@ export const textWysiwyg = ({ // what is going to be used for unbounded text let height = updatedTextElement.height; if (container && updatedTextElement.containerId) { + if (isArrowElement(container)) { + const boundTextCoords = + LinearElementEditor.getBoundTextElementPosition( + container, + updatedTextElement as ExcalidrawTextElementWithContainer, + ); + coordX = boundTextCoords.x; + coordY = boundTextCoords.y; + } const propertiesUpdated = textPropertiesUpdated( updatedTextElement, editable, @@ -138,16 +155,19 @@ export const textWysiwyg = ({ if (!originalContainerHeight) { originalContainerHeight = containerDims.height; } - maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2; - maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2; + maxWidth = getMaxContainerWidth(container); + maxHeight = getMaxContainerHeight(container); + // autogrow container height if text exceeds - if (height > maxHeight) { + + if (!isArrowElement(container) && height > maxHeight) { const diff = Math.min(height - maxHeight, approxLineHeight); mutateElement(container, { height: containerDims.height + diff }); return; } else if ( // autoshrink container height until original container height // is reached when text is removed + !isArrowElement(container) && containerDims.height > originalContainerHeight && height < maxHeight ) { @@ -159,11 +179,16 @@ export const textWysiwyg = ({ else { // vertically center align the text if (verticalAlign === VERTICAL_ALIGN.MIDDLE) { - coordY = container.y + containerDims.height / 2 - height / 2; + if (!isArrowElement(container)) { + coordY = container.y + containerDims.height / 2 - height / 2; + } } if (verticalAlign === VERTICAL_ALIGN.BOTTOM) { coordY = - container.y + containerDims.height - height - BOUND_TEXT_PADDING; + container.y + + containerDims.height - + height - + getBoundTextElementOffset(updatedTextElement); } } } @@ -197,7 +222,7 @@ export const textWysiwyg = ({ // Make sure text editor height doesn't go beyond viewport const editorMaxHeight = (appState.height - viewportY) / appState.zoom.value; - const angle = container ? container.angle : updatedTextElement.angle; + Object.assign(editable.style, { font: getFontString(updatedTextElement), // must be defined *after* font ¯\_(ツ)_/¯ @@ -209,7 +234,7 @@ export const textWysiwyg = ({ transform: getTransform( width, height, - angle, + getTextElementAngle(updatedTextElement), appState, maxWidth, editorMaxHeight, @@ -246,6 +271,8 @@ export const textWysiwyg = ({ whiteSpace = "pre-wrap"; wordBreak = "break-word"; } + const isContainerArrow = isArrowElement(getContainerElement(element)); + const background = isContainerArrow ? "#fff" : "transparent"; Object.assign(editable.style, { position: "absolute", display: "inline-block", @@ -256,7 +283,7 @@ export const textWysiwyg = ({ border: 0, outline: 0, resize: "none", - background: "transparent", + background, overflow: "hidden", // must be specified because in dark mode canvas creates a stacking context zIndex: "var(--zIndex-wysiwyg)", @@ -264,6 +291,7 @@ export const textWysiwyg = ({ // prevent line wrapping (`whitespace: nowrap` doesn't work on FF) whiteSpace, overflowWrap: "break-word", + boxSizing: "content-box", }); updateWysiwygStyle(); diff --git a/src/element/transformHandles.ts b/src/element/transformHandles.ts index 210a2eab..823ba5c7 100644 --- a/src/element/transformHandles.ts +++ b/src/element/transformHandles.ts @@ -4,7 +4,7 @@ import { PointerType, } from "./types"; -import { getElementAbsoluteCoords, Bounds } from "./bounds"; +import { getElementAbsoluteCoords } from "./bounds"; import { rotate } from "../math"; import { AppState, Zoom } from "../types"; import { isTextElement } from "."; @@ -81,7 +81,7 @@ const generateTransformHandle = ( }; export const getTransformHandlesFromCoords = ( - [x1, y1, x2, y2]: Bounds, + [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number], angle: number, zoom: Zoom, pointerType: PointerType, @@ -97,8 +97,6 @@ export const getTransformHandlesFromCoords = ( const width = x2 - x1; const height = y2 - y1; - const cx = (x1 + x2) / 2; - const cy = (y1 + y2) / 2; const dashedLineMargin = margin / zoom.value; const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value); @@ -256,7 +254,7 @@ export const getTransformHandles = ( ? DEFAULT_SPACING + 8 : DEFAULT_SPACING; return getTransformHandlesFromCoords( - getElementAbsoluteCoords(element), + getElementAbsoluteCoords(element, true), element.angle, zoom, pointerType, diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts index 053b31cc..57c75159 100644 --- a/src/element/typeChecks.ts +++ b/src/element/typeChecks.ts @@ -60,6 +60,12 @@ export const isLinearElement = ( return element != null && isLinearElementType(element.type); }; +export const isArrowElement = ( + element?: ExcalidrawElement | null, +): element is ExcalidrawLinearElement => { + return element != null && element.type === "arrow"; +}; + export const isLinearElementType = ( elementType: AppState["activeTool"]["type"], ): boolean => { @@ -110,7 +116,8 @@ export const isTextBindableContainer = ( (element.type === "rectangle" || element.type === "diamond" || element.type === "ellipse" || - element.type === "image") + element.type === "image" || + isArrowElement(element)) ); }; diff --git a/src/element/types.ts b/src/element/types.ts index 8f810965..de60a1b5 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -141,7 +141,8 @@ export type ExcalidrawTextContainer = | ExcalidrawRectangleElement | ExcalidrawDiamondElement | ExcalidrawEllipseElement - | ExcalidrawImageElement; + | ExcalidrawImageElement + | ExcalidrawArrowEleement; export type ExcalidrawTextElementWithContainer = { containerId: ExcalidrawTextContainer["id"]; @@ -166,6 +167,11 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase & endArrowhead: Arrowhead | null; }>; +export type ExcalidrawArrowEleement = ExcalidrawLinearElement & + Readonly<{ + type: "arrow"; + }>; + export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & Readonly<{ type: "freedraw"; diff --git a/src/locales/en.json b/src/locales/en.json index 9fedaa3f..54ac904d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -237,7 +237,7 @@ "resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center", "resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center", "rotate": "You can constrain angles by holding SHIFT while rotating", - "lineEditor_info": "Double-click or press Enter to edit points", + "lineEditor_info": "Hold CtrlOrCmd and Double-click or press CtrlOrCmd + Enter to edit points", "lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move", "lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points", "placeImage": "Click to place the image, or click and drag to set its size manually", diff --git a/src/points.ts b/src/points.ts index 641a332d..84aea927 100644 --- a/src/points.ts +++ b/src/points.ts @@ -51,6 +51,5 @@ export const rescalePoints = ( return currentDimension === dimension ? value + translation : value; }) as [number, number], ); - return nextPoints; }; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index a951f66d..60b6f793 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -6,12 +6,14 @@ import { NonDeletedExcalidrawElement, ExcalidrawFreeDrawElement, ExcalidrawImageElement, + ExcalidrawTextElementWithContainer, } from "../element/types"; import { isTextElement, isLinearElement, isFreeDrawElement, isInitializedImageElement, + isArrowElement, } from "../element/typeChecks"; import { getDiamondPoints, @@ -37,7 +39,13 @@ import { VERTICAL_ALIGN, } from "../constants"; import { getStroke, StrokeOptions } from "perfect-freehand"; -import { getApproxLineHeight } from "../element/textElement"; +import { + getApproxLineHeight, + getBoundTextElement, + getBoundTextElementOffset, + getContainerElement, +} from "../element/textElement"; +import { LinearElementEditor } from "../element/linearElementEditor"; // using a stronger invert (100% vs our regular 93%) and saturate // as a temp hack to make images in dark theme look closer to original @@ -80,6 +88,7 @@ export interface ExcalidrawElementWithCanvas { canvasZoom: Zoom["value"]; canvasOffsetX: number; canvasOffsetY: number; + boundTextElementVersion: number | null; } const generateElementCanvas = ( @@ -148,6 +157,7 @@ const generateElementCanvas = ( canvasZoom: zoom.value, canvasOffsetX, canvasOffsetY, + boundTextElementVersion: getBoundTextElement(element)?.version || null, }; }; @@ -272,7 +282,7 @@ const drawElementOnCanvas = ( : element.height / lines.length; let verticalOffset = element.height - element.baseline; if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) { - verticalOffset = BOUND_TEXT_PADDING; + verticalOffset = getBoundTextElementOffset(element); } const horizontalOffset = @@ -656,11 +666,13 @@ const generateElementWithCanvas = ( prevElementWithCanvas && prevElementWithCanvas.canvasZoom !== zoom.value && !renderConfig?.shouldCacheIgnoreZoom; + const boundTextElementVersion = getBoundTextElement(element)?.version || null; if ( !prevElementWithCanvas || shouldRegenerateBecauseZoom || - prevElementWithCanvas.theme !== renderConfig.theme + prevElementWithCanvas.theme !== renderConfig.theme || + prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ) { const elementWithCanvas = generateElementCanvas( element, @@ -683,6 +695,7 @@ const drawElementFromCanvas = ( ) => { const element = elementWithCanvas.element; const padding = getCanvasPadding(element); + const zoom = elementWithCanvas.canvasZoom; let [x1, y1, x2, y2] = getElementAbsoluteCoords(element); // Free draw elements will otherwise "shuffle" as the min x and y change @@ -712,18 +725,93 @@ const drawElementFromCanvas = ( (1 / window.devicePixelRatio) * scaleXFactor, (1 / window.devicePixelRatio) * scaleYFactor, ); - context.translate(cx * scaleXFactor, cy * scaleYFactor); - context.rotate(element.angle * scaleXFactor * scaleYFactor); + const boundTextElement = getBoundTextElement(element); - context.drawImage( - elementWithCanvas.canvas!, - (-(x2 - x1) / 2) * window.devicePixelRatio - - (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom, - (-(y2 - y1) / 2) * window.devicePixelRatio - - (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom, - elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom, - elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom, - ); + if (isArrowElement(element) && boundTextElement) { + const tempCanvas = document.createElement("canvas"); + const tempCanvasContext = tempCanvas.getContext("2d")!; + + // Take max dimensions of arrow canvas so that when canvas is rotated + // the arrow doesn't get clipped + const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); + tempCanvas.width = + maxDim * window.devicePixelRatio * zoom + + padding * elementWithCanvas.canvasZoom * 10; + tempCanvas.height = + maxDim * window.devicePixelRatio * zoom + + padding * elementWithCanvas.canvasZoom * 10; + const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2; + const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2; + + tempCanvasContext.translate(tempCanvas.width / 2, tempCanvas.height / 2); + tempCanvasContext.rotate(element.angle); + + tempCanvasContext.drawImage( + elementWithCanvas.canvas!, + -elementWithCanvas.canvas.width / 2, + -elementWithCanvas.canvas.height / 2, + elementWithCanvas.canvas.width, + elementWithCanvas.canvas.height, + ); + + const [, , , , boundTextCx, boundTextCy] = + getElementAbsoluteCoords(boundTextElement); + + tempCanvasContext.rotate(-element.angle); + + // Shift the canvas to the center of the bound text element + const shiftX = + tempCanvas.width / 2 - + (boundTextCx - x1) * window.devicePixelRatio * zoom - + offsetX - + padding * zoom; + + const shiftY = + tempCanvas.height / 2 - + (boundTextCy - y1) * window.devicePixelRatio * zoom - + offsetY - + padding * zoom; + tempCanvasContext.translate(-shiftX, -shiftY); + + // Clear the bound text area + tempCanvasContext.clearRect( + -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + zoom, + -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + zoom, + (boundTextElement.width + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + zoom, + (boundTextElement.height + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + zoom, + ); + + context.translate(cx * scaleXFactor, cy * scaleYFactor); + context.drawImage( + tempCanvas, + (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding, + (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding, + tempCanvas.width / zoom, + tempCanvas.height / zoom, + ); + } else { + context.translate(cx * scaleXFactor, cy * scaleYFactor); + + context.rotate(element.angle * scaleXFactor * scaleYFactor); + + context.drawImage( + elementWithCanvas.canvas!, + (-(x2 - x1) / 2) * window.devicePixelRatio - + (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom, + (-(y2 - y1) / 2) * window.devicePixelRatio - + (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom, + elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom, + elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom, + ); + } context.restore(); // Clear the nested element we appended to the DOM @@ -734,6 +822,7 @@ export const renderElement = ( rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: RenderConfig, + appState: AppState, ) => { const generator = rc.generator; switch (element.type) { @@ -796,21 +885,94 @@ export const renderElement = ( const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const cx = (x1 + x2) / 2 + renderConfig.scrollX; const cy = (y1 + y2) / 2 + renderConfig.scrollY; - const shiftX = (x2 - x1) / 2 - (element.x - x1); - const shiftY = (y2 - y1) / 2 - (element.y - y1); + let shiftX = (x2 - x1) / 2 - (element.x - x1); + let shiftY = (y2 - y1) / 2 - (element.y - y1); + if (isTextElement(element)) { + const container = getContainerElement(element); + if (isArrowElement(container)) { + const boundTextCoords = + LinearElementEditor.getBoundTextElementPosition( + container, + element as ExcalidrawTextElementWithContainer, + ); + shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1); + shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1); + } + } context.save(); context.translate(cx, cy); - context.rotate(element.angle); if (element.type === "image") { context.scale(element.scale[0], element.scale[1]); } - context.translate(-shiftX, -shiftY); if (shouldResetImageFilter(element, renderConfig)) { context.filter = "none"; } + const boundTextElement = getBoundTextElement(element); + + if (isArrowElement(element) && boundTextElement) { + const tempCanvas = document.createElement("canvas"); + + const tempCanvasContext = tempCanvas.getContext("2d")!; + + // Take max dimensions of arrow canvas so that when canvas is rotated + // the arrow doesn't get clipped + const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); + const padding = getCanvasPadding(element); + tempCanvas.width = + maxDim * appState.exportScale + padding * 10 * appState.exportScale; + tempCanvas.height = + maxDim * appState.exportScale + padding * 10 * appState.exportScale; + + tempCanvasContext.translate( + tempCanvas.width / 2, + tempCanvas.height / 2, + ); + tempCanvasContext.scale(appState.exportScale, appState.exportScale); + + // Shift the canvas to left most point of the arrow + shiftX = element.width / 2 - (element.x - x1); + shiftY = element.height / 2 - (element.y - y1); + + tempCanvasContext.rotate(element.angle); + const tempRc = rough.canvas(tempCanvas); + + tempCanvasContext.translate(-shiftX, -shiftY); + + drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig); + + tempCanvasContext.translate(shiftX, shiftY); + + tempCanvasContext.rotate(-element.angle); + + // Shift the canvas to center of bound text + const [, , , , boundTextCx, boundTextCy] = + getElementAbsoluteCoords(boundTextElement); + const boundTextShiftX = (x1 + x2) / 2 - boundTextCx; + const boundTextShiftY = (y1 + y2) / 2 - boundTextCy; + tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY); + + // Clear the bound text area + tempCanvasContext.clearRect( + -boundTextElement.width / 2, + -boundTextElement.height / 2, + boundTextElement.width, + boundTextElement.height, + ); + context.scale(1 / appState.exportScale, 1 / appState.exportScale); + context.drawImage( + tempCanvas, + -tempCanvas.width / 2, + -tempCanvas.height / 2, + tempCanvas.width, + tempCanvas.height, + ); + } else { + context.rotate(element.angle); + context.translate(-shiftX, -shiftY); + drawElementOnCanvas(element, rc, context, renderConfig); + } - drawElementOnCanvas(element, rc, context, renderConfig); context.restore(); // not exporting → optimized rendering (cache & render from element // canvases) @@ -851,13 +1013,28 @@ export const renderElementToSvg = ( rsvg: RoughSVG, svgRoot: SVGElement, files: BinaryFiles, - offsetX?: number, - offsetY?: number, + offsetX: number, + offsetY: number, exportWithDarkMode?: boolean, ) => { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - const cx = (x2 - x1) / 2 - (element.x - x1); - const cy = (y2 - y1) / 2 - (element.y - y1); + let cx = (x2 - x1) / 2 - (element.x - x1); + let cy = (y2 - y1) / 2 - (element.y - y1); + if (isTextElement(element)) { + const container = getContainerElement(element); + if (isArrowElement(container)) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(container); + + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + container, + element as ExcalidrawTextElementWithContainer, + ); + cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); + cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); + offsetX = offsetX + boundTextCoords.x - element.x; + offsetY = offsetY + boundTextCoords.y - element.y; + } + } const degree = (180 * element.angle) / Math.PI; const generator = rsvg.generator; @@ -904,8 +1081,54 @@ export const renderElementToSvg = ( } case "line": case "arrow": { + const boundText = getBoundTextElement(element); + const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); + if (boundText) { + maskPath.setAttribute("id", `mask-${element.id}`); + const maskRectVisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + offsetX = offsetX || 0; + offsetY = offsetY || 0; + maskRectVisible.setAttribute("x", "0"); + maskRectVisible.setAttribute("y", "0"); + maskRectVisible.setAttribute("fill", "#fff"); + maskRectVisible.setAttribute( + "width", + `${element.width + 100 + offsetX}`, + ); + maskRectVisible.setAttribute( + "height", + `${element.height + 100 + offsetY}`, + ); + + maskPath.appendChild(maskRectVisible); + const maskRectInvisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + element, + boundText, + ); + + const maskX = offsetX + boundTextCoords.x - element.x; + const maskY = offsetY + boundTextCoords.y - element.y; + + maskRectInvisible.setAttribute("x", maskX.toString()); + maskRectInvisible.setAttribute("y", maskY.toString()); + maskRectInvisible.setAttribute("fill", "#000"); + maskRectInvisible.setAttribute("width", `${boundText.width}`); + maskRectInvisible.setAttribute("height", `${boundText.height}`); + maskRectInvisible.setAttribute("opacity", "1"); + maskPath.appendChild(maskRectInvisible); + } generateElementShape(element, generator); const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (boundText) { + group.setAttribute("mask", `url(#mask-${element.id})`); + } const opacity = element.opacity / 100; group.setAttribute("stroke-linecap", "round"); @@ -935,6 +1158,7 @@ export const renderElementToSvg = ( group.appendChild(node); }); root.appendChild(group); + root.append(maskPath); break; } case "freedraw": { @@ -1033,6 +1257,7 @@ export const renderElementToSvg = ( node.setAttribute("stroke-opacity", `${opacity}`); node.setAttribute("fill-opacity", `${opacity}`); } + node.setAttribute( "transform", `translate(${offsetX || 0} ${ diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 7c089ed5..c8b64b47 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -348,7 +348,6 @@ export const _renderScene = ({ context.setTransform(1, 0, 0, 1, 0, 0); context.save(); context.scale(scale, scale); - // When doing calculations based on canvas width we should used normalized one const normalizedCanvasWidth = canvas.width / scale; const normalizedCanvasHeight = canvas.height / scale; @@ -410,7 +409,7 @@ export const _renderScene = ({ undefined; visibleElements.forEach((element) => { try { - renderElement(element, rc, context, renderConfig); + renderElement(element, rc, context, renderConfig, appState); // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to // ShapeCache returns empty hence making sure that we get the // correct element from visible elements @@ -440,7 +439,13 @@ export const _renderScene = ({ // Paint selection element if (appState.selectionElement) { try { - renderElement(appState.selectionElement, rc, context, renderConfig); + renderElement( + appState.selectionElement, + rc, + context, + renderConfig, + appState, + ); } catch (error: any) { console.error(error); } @@ -453,6 +458,22 @@ export const _renderScene = ({ renderBindingHighlight(context, renderConfig, suggestedBinding!); }); } + const locallySelectedElements = getSelectedElements(elements, appState); + + // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to + // ShapeCache returns empty hence making sure that we get the + // correct element from visible elements + if ( + locallySelectedElements.length === 1 && + appState.editingLinearElement?.elementId === locallySelectedElements[0].id + ) { + renderLinearPointHandles( + context, + appState, + renderConfig, + locallySelectedElements[0] as NonDeleted, + ); + } if ( appState.selectedLinearElement && @@ -466,7 +487,6 @@ export const _renderScene = ({ !appState.multiElement && !appState.editingLinearElement ) { - const locallySelectedElements = getSelectedElements(elements, appState); const showBoundingBox = shouldShowBoundingBox( locallySelectedElements, appState, @@ -515,8 +535,8 @@ export const _renderScene = ({ } if (selectionColors.length) { - const [elementX1, elementY1, elementX2, elementY2] = - getElementAbsoluteCoords(element); + const [elementX1, elementY1, elementX2, elementY2, cx, cy] = + getElementAbsoluteCoords(element, true); acc.push({ angle: element.angle, elementX1, @@ -525,10 +545,12 @@ export const _renderScene = ({ elementY2, selectionColors, dashed: !!renderConfig.remoteSelectedElementIds[element.id], + cx, + cy, }); } return acc; - }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[]; dashed?: boolean }[]); + }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[]; dashed?: boolean; cx: number; cy: number }[]); const addSelectionForGroupId = (groupId: GroupId) => { const groupElements = getElementsInGroup(elements, groupId); @@ -540,8 +562,10 @@ export const _renderScene = ({ elementX2, elementY1, elementY2, - selectionColors: [selectionColor], + selectionColors: [oc.black], dashed: true, + cx: elementX1 + (elementX2 - elementX1) / 2, + cy: elementY1 + (elementY2 - elementY1) / 2, }); }; @@ -600,7 +624,7 @@ export const _renderScene = ({ context.lineWidth = lineWidth; context.setLineDash(initialLineDash); const transformHandles = getTransformHandlesFromCoords( - [x1, y1, x2, y2], + [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], 0, renderConfig.zoom, "mouse", @@ -861,6 +885,8 @@ const renderSelectionBorder = ( elementY2: number; selectionColors: string[]; dashed?: boolean; + cx: number; + cy: number; }, padding = DEFAULT_SPACING * 2, ) => { @@ -871,6 +897,8 @@ const renderSelectionBorder = ( elementX2, elementY2, selectionColors, + cx, + cy, dashed, } = elementProperties; const elementWidth = elementX2 - elementX1; @@ -900,8 +928,8 @@ const renderSelectionBorder = ( elementY1 - linePadding, elementWidth + linePadding * 2, elementHeight + linePadding * 2, - elementX1 + elementWidth / 2, - elementY1 + elementHeight / 2, + cx, + cy, angle, ); } @@ -1117,7 +1145,7 @@ export const renderSceneToSvg = ( return; } // render elements - elements.forEach((element) => { + elements.forEach((element, index) => { if (!element.isDeleted) { try { renderElementToSvg( diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index 14d9a8e2..3e0de153 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -109,6 +109,9 @@ export class API { fileId?: T extends "image" ? string : never; scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; status?: T extends "image" ? ExcalidrawImageElement["status"] : never; + endBinding?: T extends "arrow" + ? ExcalidrawLinearElement["endBinding"] + : never; }): T extends "arrow" | "line" ? ExcalidrawLinearElement : T extends "freedraw" diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index e46311e4..9e7fa567 100644 --- a/src/tests/linearElementEditor.test.tsx +++ b/src/tests/linearElementEditor.test.tsx @@ -1,20 +1,30 @@ import ReactDOM from "react-dom"; -import { ExcalidrawLinearElement } from "../element/types"; +import { + ExcalidrawElement, + ExcalidrawLinearElement, + ExcalidrawTextElementWithContainer, + FontString, +} from "../element/types"; import ExcalidrawApp from "../excalidraw-app"; import { centerPoint } from "../math"; import { reseed } from "../random"; import * as Renderer from "../renderer/renderScene"; -import { Keyboard, Pointer } from "./helpers/ui"; +import { Keyboard, Pointer, UI } from "./helpers/ui"; import { screen, render, fireEvent, GlobalTestState } from "./test-utils"; import { API } from "../tests/helpers/api"; import { Point } from "../types"; import { KEYS } from "../keys"; import { LinearElementEditor } from "../element/linearElementEditor"; -import { queryByText } from "@testing-library/react"; +import { queryByTestId, queryByText } from "@testing-library/react"; +import { resize, rotate } from "./utils"; +import { getBoundTextElementPosition, wrapText } from "../element/textElement"; +import { getMaxContainerWidth } from "../element/newElement"; +import * as textElementUtils from "../element/textElement"; const renderScene = jest.spyOn(Renderer, "renderScene"); const { h } = window; +const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; describe("Test Linear Elements", () => { let container: HTMLElement; @@ -44,23 +54,23 @@ describe("Test Linear Elements", () => { strokeSharpness: ExcalidrawLinearElement["strokeSharpness"] = "sharp", roughness: ExcalidrawLinearElement["roughness"] = 0, ) => { - h.elements = [ - API.createElement({ - x: p1[0], - y: p1[1], - width: p2[0] - p1[0], - height: 0, - type, - roughness, - points: [ - [0, 0], - [p2[0] - p1[0], p2[1] - p1[1]], - ], - strokeSharpness, - }), - ]; + const line = API.createElement({ + x: p1[0], + y: p1[1], + width: p2[0] - p1[0], + height: 0, + type, + roughness, + points: [ + [0, 0], + [p2[0] - p1[0], p2[1] - p1[1]], + ], + strokeSharpness, + }); + h.elements = [line]; mouse.clickAt(p1[0], p1[1]); + return line; }; const createThreePointerLinearElement = ( @@ -70,23 +80,23 @@ describe("Test Linear Elements", () => { ) => { //dragging line from midpoint const p3 = [midpoint[0] + delta - p1[0], midpoint[1] + delta - p1[1]]; - h.elements = [ - API.createElement({ - x: p1[0], - y: p1[1], - width: p3[0] - p1[0], - height: 0, - type, - roughness, - points: [ - [0, 0], - [p3[0], p3[1]], - [p2[0] - p1[0], p2[1] - p1[1]], - ], - strokeSharpness, - }), - ]; + const line = API.createElement({ + x: p1[0], + y: p1[1], + width: p3[0] - p1[0], + height: 0, + type, + roughness, + points: [ + [0, 0], + [p3[0], p3[1]], + [p2[0] - p1[0], p2[1] - p1[1]], + ], + strokeSharpness, + }); + h.elements = [line]; mouse.clickAt(p1[0], p1[1]); + return line; }; const enterLineEditingMode = ( @@ -98,7 +108,9 @@ describe("Test Linear Elements", () => { } else { mouse.clickAt(p1[0], p1[1]); } - Keyboard.keyPress(KEYS.ENTER); + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ENTER); + }); expect(h.state.editingLinearElement?.elementId).toEqual(line.id); }; @@ -216,6 +228,16 @@ describe("Test Linear Elements", () => { expect(h.state.editingLinearElement?.elementId).toBeUndefined(); }); + it("should enter line editor when using double clicked with ctrl key", () => { + createTwoPointerLinearElement("line"); + expect(h.state.editingLinearElement?.elementId).toBeUndefined(); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + mouse.doubleClick(); + }); + expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + }); + describe("Inside editor", () => { it("should not drag line and add midpoint when dragged irrespective of threshold", () => { createTwoPointerLinearElement("line"); @@ -358,8 +380,8 @@ describe("Test Linear Elements", () => { let line: ExcalidrawLinearElement; beforeEach(() => { - createThreePointerLinearElement("line"); - line = h.elements[0] as ExcalidrawLinearElement; + line = createThreePointerLinearElement("line"); + expect(line.points.length).toEqual(3); enterLineEditingMode(line); @@ -478,7 +500,7 @@ describe("Test Linear Elements", () => { // delete 3rd point deletePoint(points[2]); expect(line.points.length).toEqual(3); - expect(renderScene).toHaveBeenCalledTimes(21); + expect(renderScene).toHaveBeenCalledTimes(22); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, @@ -503,8 +525,7 @@ describe("Test Linear Elements", () => { let line: ExcalidrawLinearElement; beforeEach(() => { - createThreePointerLinearElement("line", "round"); - line = h.elements[0] as ExcalidrawLinearElement; + line = createThreePointerLinearElement("line", "round"); expect(line.points.length).toEqual(3); enterLineEditingMode(line); @@ -667,7 +688,6 @@ describe("Test Linear Elements", () => { fillStyle: "solid", }), ]; - const origPoints = line.points.map((point) => [...point]); const dragEndPositionOffset = [100, 100] as const; API.setSelectedElements([line]); enterLineEditingMode(line, true); @@ -682,11 +702,457 @@ describe("Test Linear Elements", () => { 0, ], Array [ - ${origPoints[1][0] - dragEndPositionOffset[0]}, - ${origPoints[1][1] - dragEndPositionOffset[1]}, + -60, + -100, ], ] `); }); }); + + describe("Test bound text element", () => { + const DEFAULT_TEXT = "Online whiteboard collaboration made easy"; + + const createBoundTextElement = ( + text: string, + container: ExcalidrawLinearElement, + ) => { + const textElement = API.createElement({ + type: "text", + x: 0, + y: 0, + text: wrapText(text, font, getMaxContainerWidth(container)), + containerId: container.id, + width: 30, + height: 20, + }) as ExcalidrawTextElementWithContainer; + + container = { + ...container, + boundElements: (container.boundElements || []).concat({ + type: "text", + id: textElement.id, + }), + }; + const elements: ExcalidrawElement[] = []; + h.elements.forEach((element) => { + if (element.id === container.id) { + elements.push(container); + } else { + elements.push(element); + } + }); + const updatedTextElement = { ...textElement, originalText: text }; + h.elements = [...elements, updatedTextElement]; + return { textElement: updatedTextElement, container }; + }; + + describe("Test getBoundTextElementPosition", () => { + it("should return correct position for 2 pointer arrow", () => { + createTwoPointerLinearElement("arrow"); + const arrow = h.elements[0] as ExcalidrawLinearElement; + const { textElement, container } = createBoundTextElement( + DEFAULT_TEXT, + arrow, + ); + const position = LinearElementEditor.getBoundTextElementPosition( + container, + textElement, + ); + expect(position).toMatchInlineSnapshot(` + Object { + "x": 25, + "y": 10, + } + `); + }); + + it("should return correct position for arrow with odd points", () => { + createThreePointerLinearElement("arrow", "round"); + const arrow = h.elements[0] as ExcalidrawLinearElement; + const { textElement, container } = createBoundTextElement( + DEFAULT_TEXT, + arrow, + ); + + const position = LinearElementEditor.getBoundTextElementPosition( + container, + textElement, + ); + expect(position).toMatchInlineSnapshot(` + Object { + "x": 75, + "y": 60, + } + `); + }); + + it("should return correct position for arrow with even points", () => { + createThreePointerLinearElement("arrow", "round"); + const arrow = h.elements[0] as ExcalidrawLinearElement; + const { textElement, container } = createBoundTextElement( + DEFAULT_TEXT, + arrow, + ); + enterLineEditingMode(container); + // This is the expected midpoint for line with round edge + // hence hardcoding it so if later some bug is introduced + // this will fail and we can fix it + const firstSegmentMidpoint: Point = [ + 55.9697848965255, 47.442326230998205, + ]; + // drag line from first segment midpoint + drag(firstSegmentMidpoint, [ + firstSegmentMidpoint[0] + delta, + firstSegmentMidpoint[1] + delta, + ]); + + const position = LinearElementEditor.getBoundTextElementPosition( + container, + textElement, + ); + expect(position).toMatchInlineSnapshot(` + Object { + "x": 85.82201843191861, + "y": 75.63461309860818, + } + `); + }); + }); + + it("should bind text to arrow when double clicked", async () => { + createTwoPointerLinearElement("arrow"); + const arrow = h.elements[0] as ExcalidrawLinearElement; + + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(arrow.id); + mouse.doubleClickAt(arrow.x, arrow.y); + expect(h.elements.length).toBe(2); + + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(arrow.id); + mouse.down(); + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + fireEvent.change(editor, { + target: { value: DEFAULT_TEXT }, + }); + + await new Promise((r) => setTimeout(r, 0)); + editor.blur(); + expect(arrow.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) + .toMatchInlineSnapshot(` + "Online whiteboard + collaboration made + easy" + `); + }); + + it("should bind text to arrow when clicked on arrow and enter pressed", async () => { + const arrow = createTwoPointerLinearElement("arrow"); + + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(arrow.id); + + Keyboard.keyPress(KEYS.ENTER); + + expect(h.elements.length).toBe(2); + + const textElement = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(textElement.type).toBe("text"); + expect(textElement.containerId).toBe(arrow.id); + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + await new Promise((r) => setTimeout(r, 0)); + + fireEvent.change(editor, { + target: { value: DEFAULT_TEXT }, + }); + editor.blur(); + expect(arrow.boundElements).toStrictEqual([ + { id: textElement.id, type: "text" }, + ]); + expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) + .toMatchInlineSnapshot(` + "Online whiteboard + collaboration made + easy" + `); + }); + + it("should not bind text to line when double clicked", async () => { + const line = createTwoPointerLinearElement("line"); + + expect(h.elements.length).toBe(1); + mouse.doubleClickAt(line.x, line.y); + + expect(h.elements.length).toBe(2); + + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBeNull(); + expect(line.boundElements).toBeNull(); + }); + + it("should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated", () => { + createThreePointerLinearElement("arrow", "round"); + + const arrow = h.elements[0] as ExcalidrawLinearElement; + + const { textElement, container } = createBoundTextElement( + DEFAULT_TEXT, + arrow, + ); + + expect(container.angle).toBe(0); + expect(textElement.angle).toBe(0); + expect(getBoundTextElementPosition(arrow, textElement)) + .toMatchInlineSnapshot(` + Object { + "x": 75, + "y": 60, + } + `); + expect(textElement.text).toMatchInlineSnapshot(` + "Online whiteboard + collaboration made + easy" + `); + expect(LinearElementEditor.getElementAbsoluteCoords(container, true)) + .toMatchInlineSnapshot(` + Array [ + 20, + 20, + 105, + 80, + 55.45893770831013, + 45, + ] + `); + + rotate(container, -35, 55); + expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`); + expect(textElement.angle).toBe(0); + expect(getBoundTextElementPosition(container, textElement)) + .toMatchInlineSnapshot(` + Object { + "x": 21.73926141863671, + "y": 73.31003398390868, + } + `); + expect(textElement.text).toMatchInlineSnapshot(` + "Online whiteboard + collaboration made + easy" + `); + expect(LinearElementEditor.getElementAbsoluteCoords(container, true)) + .toMatchInlineSnapshot(` + Array [ + 20, + 20, + 102.41961302274555, + 86.49012635273976, + 55.45893770831013, + 45, + ] + `); + }); + + it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => { + createThreePointerLinearElement("arrow", "round"); + + const arrow = h.elements[0] as ExcalidrawLinearElement; + + const { textElement, container } = createBoundTextElement( + DEFAULT_TEXT, + arrow, + ); + expect(container.width).toBe(70); + expect(container.height).toBe(50); + expect(getBoundTextElementPosition(container, textElement)) + .toMatchInlineSnapshot(` + Object { + "x": 75, + "y": 60, + } + `); + expect(textElement.text).toMatchInlineSnapshot(` + "Online whiteboard + collaboration made + easy" + `); + expect(LinearElementEditor.getElementAbsoluteCoords(container, true)) + .toMatchInlineSnapshot(` + Array [ + 20, + 20, + 105, + 80, + 55.45893770831013, + 45, + ] + `); + + resize(container, "ne", [300, 200]); + + expect({ width: container.width, height: container.height }) + .toMatchInlineSnapshot(` + Object { + "height": 10, + "width": 367, + } + `); + + expect(getBoundTextElementPosition(container, textElement)) + .toMatchInlineSnapshot(` + Object { + "x": 386.5, + "y": 70, + } + `); + expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) + .toMatchInlineSnapshot(` + "Online whiteboard + collaboration made easy" + `); + expect(LinearElementEditor.getElementAbsoluteCoords(container, true)) + .toMatchInlineSnapshot(` + Array [ + 20, + 60, + 391.8122896842806, + 70, + 205.9061448421403, + 65, + ] + `); + }); + + it("should resize and position the bound text correctly when 2 pointer linear element resized", () => { + createTwoPointerLinearElement("arrow"); + + const arrow = h.elements[0] as ExcalidrawLinearElement; + const { textElement, container } = createBoundTextElement( + DEFAULT_TEXT, + arrow, + ); + expect(container.width).toBe(40); + expect(getBoundTextElementPosition(container, textElement)) + .toMatchInlineSnapshot(` + Object { + "x": 25, + "y": 10, + } + `); + expect(textElement.text).toMatchInlineSnapshot(` + "Online whiteboard + collaboration made + easy" + `); + const points = LinearElementEditor.getPointsGlobalCoordinates(container); + + // Drag from last point + drag(points[1], [points[1][0] + 300, points[1][1]]); + + expect({ width: container.width, height: container.height }) + .toMatchInlineSnapshot(` + Object { + "height": 0, + "width": 340, + } + `); + + expect(getBoundTextElementPosition(container, textElement)) + .toMatchInlineSnapshot(` + Object { + "x": 189.5, + "y": 20, + } + `); + expect(textElement.text).toMatchInlineSnapshot(` + "Online whiteboard + collaboration made easy" + `); + }); + + it("should not render vertical align tool when element selected", () => { + createTwoPointerLinearElement("arrow"); + const arrow = h.elements[0] as ExcalidrawLinearElement; + + createBoundTextElement(DEFAULT_TEXT, arrow); + API.setSelectedElements([arrow]); + + expect(queryByTestId(container, "align-top")).toBeNull(); + expect(queryByTestId(container, "align-middle")).toBeNull(); + expect(queryByTestId(container, "align-bottom")).toBeNull(); + }); + + it("should wrap the bound text when arrow bound container moves", async () => { + const rect = UI.createElement("rectangle", { + x: 400, + width: 200, + height: 500, + }); + const arrow = UI.createElement("arrow", { + x: 210, + y: 250, + width: 400, + height: 1, + }); + + mouse.select(arrow); + Keyboard.keyPress(KEYS.ENTER); + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + await new Promise((r) => setTimeout(r, 0)); + fireEvent.change(editor, { target: { value: DEFAULT_TEXT } }); + editor.blur(); + + const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; + + expect(arrow.endBinding?.elementId).toBe(rect.id); + expect(arrow.width).toBe(400); + expect(rect.x).toBe(400); + expect(rect.y).toBe(0); + expect( + wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)), + ).toMatchInlineSnapshot(` + "Online whiteboard collaboration + made easy" + `); + const handleBindTextResizeSpy = jest.spyOn( + textElementUtils, + "handleBindTextResize", + ); + + mouse.select(rect); + mouse.downAt(rect.x, rect.y); + mouse.moveTo(200, 0); + mouse.upAt(200, 0); + + expect(arrow.width).toBe(170); + expect(rect.x).toBe(200); + expect(rect.y).toBe(0); + expect(handleBindTextResizeSpy).toHaveBeenCalledWith( + h.elements[1], + false, + ); + expect( + wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)), + ).toMatchInlineSnapshot(` + "Online whiteboard + collaboration made + easy" + `); + }); + }); }); diff --git a/src/tests/utils.ts b/src/tests/utils.ts index 4e533d56..2c91c3fc 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -27,3 +27,22 @@ export const resize = ( mouse.up(); }); }; + +export const rotate = ( + element: ExcalidrawElement, + deltaX: number, + deltaY: number, + keyboardModifiers: KeyboardModifiers = {}, +) => { + mouse.select(element); + const handle = getTransformHandles(element, h.state.zoom, "mouse").rotation!; + const clientX = handle[0] + handle[2] / 2; + const clientY = handle[1] + handle[3] / 2; + + Keyboard.withModifierKeys(keyboardModifiers, () => { + mouse.reset(); + mouse.down(clientX, clientY); + mouse.move(clientX + deltaX, clientY + deltaY); + mouse.up(); + }); +}; diff --git a/src/utils.ts b/src/utils.ts index 9a92560d..aef6a7d5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -327,13 +327,12 @@ export const getShortcutKey = (shortcut: string): string => { .replace(/\bAlt\b/i, "Alt") .replace(/\bShift\b/i, "Shift") .replace(/\b(Enter|Return)\b/i, "Enter"); - if (isDarwin) { return shortcut - .replace(/\bCtrlOrCmd\b/i, "Cmd") + .replace(/\bCtrlOrCmd\b/gi, "Cmd") .replace(/\bAlt\b/i, "Option"); } - return shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl"); + return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl"); }; export const viewportCoordsToSceneCoords = (