diff --git a/README.md b/README.md index c5f7f5cd4..31ee567de 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The Excalidraw editor (npm package) supports: ## Excalidraw.com -The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/maielo/new-readme/src/excalidraw-app) is part of this repository as well, and the app features: +The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features: - 📡 PWA support (works offline). - 🤼 Real-time collaboration. diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index 8186a1617..afdb85c44 100644 --- a/src/actions/actionBoundText.tsx +++ b/src/actions/actionBoundText.tsx @@ -37,10 +37,9 @@ export const actionUnbindText = register({ selectedElements.forEach((element) => { const boundTextElement = getBoundTextElement(element); if (boundTextElement) { - const { width, height, baseline } = measureTextElement( - boundTextElement, - { text: boundTextElement.originalText }, - ); + const { width, height } = measureTextElement(boundTextElement, { + text: boundTextElement.originalText, + }); const originalContainerHeight = getOriginalContainerHeightFromCache( element.id, ); @@ -50,7 +49,6 @@ export const actionUnbindText = register({ containerId: null, width, height, - baseline, text: boundTextElement.originalText, }); mutateElement(element, { diff --git a/src/components/App.tsx b/src/components/App.tsx index 96ba9bf0a..c416e80b0 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -109,6 +109,7 @@ import { textWysiwyg, transformElements, updateTextElement, + redrawTextBoundingBox, } from "../element"; import { bindOrUnbindLinearElement, @@ -276,7 +277,6 @@ import { getContainerElement, getTextBindableContainerAtPosition, isValidTextContainer, - redrawTextBoundingBox, } from "../element/textElement"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { @@ -1688,6 +1688,7 @@ class App extends React.Component { oldIdToDuplicatedId.set(element.id, newElement.id); return newElement; }); + bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId); const nextElements = [ ...this.scene.getElementsIncludingDeleted(), @@ -1700,6 +1701,14 @@ class App extends React.Component { } this.scene.replaceAllElements(nextElements); + + newElements.forEach((newElement) => { + if (isTextElement(newElement) && isBoundToContainer(newElement)) { + const container = getContainerElement(newElement); + redrawTextBoundingBox(newElement, container); + } + }); + this.history.resumeRecording(); this.setState( @@ -2728,14 +2737,6 @@ class App extends React.Component { element, ]); } - - // case: creating new text not centered to parent element → offset Y - // so that the text is centered to cursor position - if (!parentCenterPosition) { - mutateElement(element, { - y: element.y - element.baseline / 2, - }); - } } this.setState({ diff --git a/src/components/main-menu/DefaultItems.tsx b/src/components/main-menu/DefaultItems.tsx index 6e89b598c..b3cc23b90 100644 --- a/src/components/main-menu/DefaultItems.tsx +++ b/src/components/main-menu/DefaultItems.tsx @@ -1,5 +1,5 @@ import { getShortcutFromShortcutName } from "../../actions/shortcuts"; -import { t } from "../../i18n"; +import { useI18n } from "../../i18n"; import { useExcalidrawAppState, useExcalidrawSetAppState, @@ -33,9 +33,7 @@ import { useSetAtom } from "jotai"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; export const LoadScene = () => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); if (!actionManager.isActionEnabled(actionLoadScene)) { @@ -57,9 +55,7 @@ export const LoadScene = () => { LoadScene.displayName = "LoadScene"; export const SaveToActiveFile = () => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); if (!actionManager.isActionEnabled(actionSaveToActiveFile)) { @@ -80,9 +76,7 @@ SaveToActiveFile.displayName = "SaveToActiveFile"; export const SaveAsImage = () => { const setAppState = useExcalidrawSetAppState(); - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); return ( { SaveAsImage.displayName = "SaveAsImage"; export const Help = () => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); @@ -119,9 +111,8 @@ export const Help = () => { Help.displayName = "Help"; export const ClearCanvas = () => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); + const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom); const actionManager = useExcalidrawActionManager(); @@ -143,6 +134,7 @@ export const ClearCanvas = () => { ClearCanvas.displayName = "ClearCanvas"; export const ToggleTheme = () => { + const { t } = useI18n(); const appState = useExcalidrawAppState(); const actionManager = useExcalidrawActionManager(); @@ -175,6 +167,7 @@ export const ToggleTheme = () => { ToggleTheme.displayName = "ToggleTheme"; export const ChangeCanvasBackground = () => { + const { t } = useI18n(); const appState = useExcalidrawAppState(); const actionManager = useExcalidrawActionManager(); @@ -195,9 +188,7 @@ export const ChangeCanvasBackground = () => { ChangeCanvasBackground.displayName = "ChangeCanvasBackground"; export const Export = () => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); const setAppState = useExcalidrawSetAppState(); return ( void; isCollaborating: boolean; }) => { - // FIXME Hack until we tie "t" to lang state - // eslint-disable-next-line - const appState = useExcalidrawAppState(); + const { t } = useI18n(); return ( any; }) => { - // FIXME when we tie t() to lang state - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const appState = useExcalidrawAppState(); - + const { t } = useI18n(); return ( {t("labels.liveCollaboration")} diff --git a/src/constants.ts b/src/constants.ts index c3952b92c..aa25667a2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,6 +9,9 @@ export const isFirefox = "netscape" in window && navigator.userAgent.indexOf("rv:") > 1 && navigator.userAgent.indexOf("Gecko") > 1; +export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1; +export const isSafari = + !isChrome && navigator.userAgent.indexOf("Safari") !== -1; export const APP_NAME = "Excalidraw"; diff --git a/src/css/styles.scss b/src/css/styles.scss index 28a42d069..c663e55be 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -530,6 +530,7 @@ // (doesn't work in Firefox) ::-webkit-scrollbar { width: 3px; + height: 3px; } ::-webkit-scrollbar-thumb { @@ -567,8 +568,8 @@ } .App-toolbar--mobile { - overflow-x: hidden; - max-width: 100vw; + overflow-x: auto; + max-width: 90vw; .ToolIcon__keybinding { display: none; diff --git a/src/data/restore.ts b/src/data/restore.ts index 63fb567c8..b9dd5fcfd 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -176,7 +176,6 @@ const restoreElement = ( fontSize, fontFamily, text: element.text ?? "", - baseline: element.baseline, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, containerId: element.containerId ?? null, diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 1a2ac9549..cd672bc1e 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -22,15 +22,15 @@ import { getElementAbsoluteCoords } from "."; import { adjustXYWithRotation } from "../math"; import { getResizedElementAbsoluteCoords } from "./bounds"; import { - getBoundTextElement, getBoundTextElementOffset, getContainerDims, getContainerElement, measureTextElement, normalizeText, wrapTextElement, + getMaxContainerWidth, } from "./textElement"; -import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; +import { VERTICAL_ALIGN } from "../constants"; import { isArrowElement } from "./typeChecks"; import { getSubtypeMethods, isValidSubtype } from "../subtypes"; @@ -189,7 +189,6 @@ export const newTextElement = ( y: opts.y - offsets.y, width: metrics.width, height: metrics.height, - baseline: metrics.baseline, containerId: opts.containerId || null, originalText: text, }, @@ -206,18 +205,12 @@ const getAdjustedDimensions = ( y: number; width: number; height: number; - baseline: number; } => { - let maxWidth = null; const container = getContainerElement(element); - if (container) { - maxWidth = getMaxContainerWidth(container); - } - const { - width: nextWidth, - height: nextHeight, - baseline: nextBaseline, - } = measureTextElement(element, { text: nextText }, maxWidth); + + const { width: nextWidth, height: nextHeight } = measureTextElement(element, { + text: nextText, + }); const { textAlign, verticalAlign } = element; let x: number; let y: number; @@ -226,11 +219,9 @@ const getAdjustedDimensions = ( verticalAlign === VERTICAL_ALIGN.MIDDLE && !element.containerId ) { - const prevMetrics = measureTextElement( - element, - { fontSize: element.fontSize }, - maxWidth, - ); + const prevMetrics = measureTextElement(element, { + fontSize: element.fontSize, + }); const offsets = getTextElementPositionOffsets(element, { width: nextWidth - prevMetrics.width, height: nextHeight - prevMetrics.height, @@ -294,7 +285,6 @@ const getAdjustedDimensions = ( height: nextHeight, x: Number.isFinite(x) ? x : element.x, y: Number.isFinite(y) ? y : element.y, - baseline: nextBaseline, }; }; @@ -312,38 +302,6 @@ export const refreshTextDimensions = ( return { text, ...dimensions }; }; -export const getMaxContainerWidth = (container: ExcalidrawElement) => { - 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) => { - 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 = ( textElement: ExcalidrawTextElement, { diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 4f672342a..0c0337ed7 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -43,12 +43,10 @@ import { getApproxMinLineWidth, getBoundTextElement, getBoundTextElementId, - getBoundTextElementOffset, getContainerElement, handleBindTextResize, - measureTextElement, + getMaxContainerWidth, } from "./textElement"; -import { getMaxContainerWidth } from "./newElement"; export const normalizeAngle = (angle: number): number => { if (angle >= 2 * Math.PI) { @@ -192,11 +190,10 @@ const rescalePointsInElement = ( const MIN_FONT_SIZE = 1; -const measureFontSizeFromWH = ( +const measureFontSizeFromWidth = ( element: NonDeleted, nextWidth: number, - nextHeight: number, -): { size: number; baseline: number } | null => { +): number | null => { // We only use width to scale font on resize let width = element.width; @@ -211,15 +208,8 @@ const measureFontSizeFromWH = ( if (nextFontSize < MIN_FONT_SIZE) { return null; } - const metrics = measureTextElement( - element, - { fontSize: nextFontSize }, - element.containerId ? width : null, - ); - return { - size: nextFontSize, - baseline: metrics.baseline + (nextHeight - metrics.height), - }; + + return nextFontSize; }; const getSidesForTransformHandle = ( @@ -290,8 +280,8 @@ const resizeSingleTextElement = ( if (scale > 0) { const nextWidth = element.width * scale; const nextHeight = element.height * scale; - const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight); - if (nextFont === null) { + const nextFontSize = measureFontSizeFromWidth(element, nextWidth); + if (nextFontSize === null) { return; } const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( @@ -315,10 +305,9 @@ const resizeSingleTextElement = ( deltaY2, ); mutateElement(element, { - fontSize: nextFont.size, + fontSize: nextFontSize, width: nextWidth, height: nextHeight, - baseline: nextFont.baseline, x: nextElementX, y: nextElementY, }); @@ -371,7 +360,7 @@ export const resizeSingleElement = ( let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleY = atStartBoundsHeight / boundsCurrentHeight; - let boundTextFont: { fontSize?: number; baseline?: number } = {}; + let boundTextFont: { fontSize?: number } = {}; const boundTextElement = getBoundTextElement(element); if (transformHandleDirection.includes("e")) { @@ -423,23 +412,24 @@ export const resizeSingleElement = ( if (stateOfBoundTextElementAtResize) { boundTextFont = { fontSize: stateOfBoundTextElementAtResize.fontSize, - baseline: stateOfBoundTextElementAtResize.baseline, }; } if (shouldMaintainAspectRatio) { - const boundTextElementPadding = - getBoundTextElementOffset(boundTextElement); - const nextFont = measureFontSizeFromWH( + const updatedElement = { + ...element, + width: eleNewWidth, + height: eleNewHeight, + }; + + const nextFontSize = measureFontSizeFromWidth( boundTextElement, - eleNewWidth - boundTextElementPadding * 2, - eleNewHeight - boundTextElementPadding * 2, + getMaxContainerWidth(updatedElement), ); - if (nextFont === null) { + if (nextFontSize === null) { return; } boundTextFont = { - fontSize: nextFont.size, - baseline: nextFont.baseline, + fontSize: nextFontSize, }; } else { const minWidth = getApproxMinLineWidth(getFontString(boundTextElement)); @@ -683,7 +673,6 @@ const resizeMultipleElements = ( y: number; points?: Point[]; fontSize?: number; - baseline?: number; } = { width, height, @@ -692,31 +681,32 @@ const resizeMultipleElements = ( ...rescaledPoints, }; - let boundTextUpdates: { fontSize: number; baseline: number } | null = null; + let boundTextUpdates: { fontSize: number } | null = null; const boundTextElement = getBoundTextElement(element.latest); if (boundTextElement || isTextElement(element.orig)) { - const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2; - const textMeasurements = measureFontSizeFromWH( + const updatedElement = { + ...element.latest, + width, + height, + }; + const fontSize = measureFontSizeFromWidth( boundTextElement ?? (element.orig as ExcalidrawTextElement), - width - optionalPadding, - height - optionalPadding, + getMaxContainerWidth(updatedElement), ); - if (!textMeasurements) { + if (!fontSize) { return; } if (isTextElement(element.orig)) { - update.fontSize = textMeasurements.size; - update.baseline = textMeasurements.baseline; + update.fontSize = fontSize; } if (boundTextElement) { boundTextUpdates = { - fontSize: textMeasurements.size, - baseline: textMeasurements.baseline, + fontSize, }; } } diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts index e1b9ff6f0..87219b298 100644 --- a/src/element/textElement.test.ts +++ b/src/element/textElement.test.ts @@ -1,5 +1,12 @@ import { BOUND_TEXT_PADDING } from "../constants"; -import { measureText, wrapText } from "./textElement"; +import { API } from "../tests/helpers/api"; +import { + computeContainerHeightForBoundText, + getContainerCoords, + getMaxContainerWidth, + getMaxContainerHeight, + wrapText, +} from "./textElement"; import { FontString } from "./types"; describe("Test wrapText", () => { @@ -65,6 +72,13 @@ up`, width: 250, res: "Hello whats up", }, + { + desc: "should push the word if its equal to max width", + width: 60, + res: `Hello +whats +up`, + }, ].forEach((data) => { it(`should ${data.desc}`, () => { const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); @@ -72,6 +86,7 @@ up`, }); }); }); + describe("When text contain new lines", () => { const text = `Hello whats up`; @@ -162,35 +177,115 @@ break it now`, }); describe("Test measureText", () => { - const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; - const text = "Hello World"; + describe("Test getContainerCoords", () => { + const params = { width: 200, height: 100, x: 10, y: 20 }; - it("should add correct attributes when maxWidth is passed", () => { - const maxWidth = 200 - BOUND_TEXT_PADDING * 2; - const res = measureText(text, font, maxWidth); + it("should compute coords correctly when ellipse", () => { + const element = API.createElement({ + type: "ellipse", + ...params, + }); + expect(getContainerCoords(element)).toEqual({ + x: 44.2893218813452455, + y: 39.64466094067262, + }); + }); - expect(res.container).toMatchInlineSnapshot(` -
- -
- `); + it("should compute coords correctly when rectangle", () => { + const element = API.createElement({ + type: "rectangle", + ...params, + }); + expect(getContainerCoords(element)).toEqual({ + x: 15, + y: 25, + }); + }); + + it("should compute coords correctly when diamond", () => { + const element = API.createElement({ + type: "diamond", + ...params, + }); + expect(getContainerCoords(element)).toEqual({ + x: 65, + y: 50, + }); + }); }); - it("should add correct attributes when maxWidth is not passed", () => { - const res = measureText(text, font); + describe("Test computeContainerHeightForBoundText", () => { + const params = { + width: 178, + height: 194, + }; - expect(res.container).toMatchInlineSnapshot(` -
- -
- `); + it("should compute container height correctly for rectangle", () => { + const element = API.createElement({ + type: "rectangle", + ...params, + }); + expect(computeContainerHeightForBoundText(element, 150)).toEqual(160); + }); + + it("should compute container height correctly for ellipse", () => { + const element = API.createElement({ + type: "ellipse", + ...params, + }); + expect(computeContainerHeightForBoundText(element, 150)).toEqual(226); + }); + + it("should compute container height correctly for diamond", () => { + const element = API.createElement({ + type: "diamond", + ...params, + }); + expect(computeContainerHeightForBoundText(element, 150)).toEqual(320); + }); + }); + + describe("Test getMaxContainerWidth", () => { + const params = { + width: 178, + height: 194, + }; + + it("should return max width when container is rectangle", () => { + const container = API.createElement({ type: "rectangle", ...params }); + expect(getMaxContainerWidth(container)).toBe(168); + }); + + it("should return max width when container is ellipse", () => { + const container = API.createElement({ type: "ellipse", ...params }); + expect(getMaxContainerWidth(container)).toBe(116); + }); + + it("should return max width when container is diamond", () => { + const container = API.createElement({ type: "diamond", ...params }); + expect(getMaxContainerWidth(container)).toBe(79); + }); + }); + + describe("Test getMaxContainerHeight", () => { + const params = { + width: 178, + height: 194, + }; + + it("should return max height when container is rectangle", () => { + const container = API.createElement({ type: "rectangle", ...params }); + expect(getMaxContainerHeight(container)).toBe(184); + }); + + it("should return max height when container is ellipse", () => { + const container = API.createElement({ type: "ellipse", ...params }); + expect(getMaxContainerHeight(container)).toBe(127); + }); + + it("should return max height when container is diamond", () => { + const container = API.createElement({ type: "diamond", ...params }); + expect(getMaxContainerHeight(container)).toBe(87); + }); }); }); diff --git a/src/element/textElement.ts b/src/element/textElement.ts index b7c47dfab..a7808cf7b 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -13,7 +13,6 @@ import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants"; import { MaybeTransformHandleType } from "./transformHandles"; import Scene from "../scene/Scene"; import { isTextElement } from "."; -import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement"; import { isBoundToContainer, isImageElement, @@ -30,16 +29,16 @@ import { updateOriginalContainerCache, } from "./textWysiwyg"; -export const measureTextElement = function (element, next, maxWidth) { +export const measureTextElement = function (element, next) { const map = getSubtypeMethods(element.subtype); if (map?.measureText) { - return map.measureText(element, next, maxWidth); + return map.measureText(element, next); } const fontSize = next?.fontSize ?? element.fontSize; const font = getFontString({ fontSize, fontFamily: element.fontFamily }); const text = next?.text ?? element.text; - return measureText(text, font, maxWidth); + return measureText(text, font); } as SubtypeMethods["measureText"]; export const wrapTextElement = function (element, containerWidth, next) { @@ -69,78 +68,69 @@ export const redrawTextBoundingBox = ( container: ExcalidrawElement | null, ) => { let maxWidth = undefined; - let text = textElement.text; + + const boundTextUpdates = { + x: textElement.x, + y: textElement.y, + text: textElement.text, + width: textElement.width, + height: textElement.height, + }; + + boundTextUpdates.text = textElement.text; + if (container) { maxWidth = getMaxContainerWidth(container); - text = wrapTextElement(textElement, maxWidth); + boundTextUpdates.text = wrapTextElement(textElement, maxWidth); } - const width = measureTextElement( - textElement, - { text: textElement.originalText }, - maxWidth, - ).width; - const { height, baseline } = measureTextElement(textElement, { text }); - const metrics = { width, height, baseline }; - let coordY = textElement.y; - let coordX = textElement.x; + const metrics = measureTextElement(textElement, { + text: boundTextUpdates.text, + }); + + boundTextUpdates.width = metrics.width; + boundTextUpdates.height = metrics.height; + // Maintain coordX for non left-aligned text in case the width has changed if (!container) { if (textElement.textAlign === TEXT_ALIGN.RIGHT) { - coordX += textElement.width - metrics.width; + boundTextUpdates.x += textElement.width - metrics.width; } else if (textElement.textAlign === TEXT_ALIGN.CENTER) { - coordX += textElement.width / 2 - metrics.width / 2; + boundTextUpdates.x += textElement.width / 2 - metrics.width / 2; } } - // Resize container and vertically center align the text if (container) { - if (!isArrowElement(container)) { - const containerDims = getContainerDims(container); - let nextHeight = containerDims.height; - if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) { - coordY = container.y; - } 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 (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; - } else { - coordX = container.x + containerDims.width / 2 - metrics.width / 2; - } - updateOriginalContainerCache(container.id, nextHeight); - mutateElement(container, { height: nextHeight }); - } else { + if (isArrowElement(container)) { 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; + boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2; + boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2; + } else { + const containerDims = getContainerDims(container); + let maxContainerHeight = getMaxContainerHeight(container); + + let nextHeight = containerDims.height; + if (metrics.height > maxContainerHeight) { + nextHeight = computeContainerHeightForBoundText( + container, + metrics.height, + ); + mutateElement(container, { height: nextHeight }); + maxContainerHeight = getMaxContainerHeight(container); + updateOriginalContainerCache(container.id, nextHeight); + } + const updatedTextElement = { + ...textElement, + ...boundTextUpdates, + } as ExcalidrawTextElementWithContainer; + const { x, y } = computeBoundTextPosition(container, updatedTextElement); + boundTextUpdates.x = x; + boundTextUpdates.y = y; } } - mutateElement(textElement, { - width: metrics.width, - height: metrics.height, - baseline: metrics.baseline, - y: coordY, - x: coordX, - text, - }); + + mutateElement(textElement, boundTextUpdates); }; export const bindTextToShapeAfterDuplication = ( @@ -212,23 +202,21 @@ export const handleBindTextResize = ( const maxWidth = getMaxContainerWidth(container); const maxHeight = getMaxContainerHeight(container); let containerHeight = containerDims.height; - let nextBaseLine = textElement.baseline; if (transformHandleType !== "n" && transformHandleType !== "s") { if (text) { text = wrapTextElement(textElement, maxWidth); } - const dimensions = measureTextElement( - textElement, - { text }, - container.width, - ); + const dimensions = measureTextElement(textElement, { text }); 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; + containerHeight = computeContainerHeightForBoundText( + container, + nextHeight, + ); + const diff = containerHeight - containerDims.height; // fix the y coord when resizing from ne/nw/n const updatedY = @@ -248,94 +236,64 @@ export const handleBindTextResize = ( text, width: nextWidth, height: nextHeight, - - baseline: nextBaseLine, }); + if (!isArrowElement(container)) { - updateBoundTextPosition( - container, - textElement as ExcalidrawTextElementWithContainer, + mutateElement( + textElement, + computeBoundTextPosition( + container, + textElement as ExcalidrawTextElementWithContainer, + ), ); } } }; -const updateBoundTextPosition = ( +const computeBoundTextPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, ) => { - const containerDims = getContainerDims(container); - const boundTextElementPadding = getBoundTextElementOffset(boundTextElement); + const containerCoords = getContainerCoords(container); + const maxContainerHeight = getMaxContainerHeight(container); + const maxContainerWidth = getMaxContainerWidth(container); + + let x; let y; if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) { - y = container.y + boundTextElementPadding; + y = containerCoords.y; } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { - y = - container.y + - containerDims.height - - boundTextElement.height - - boundTextElementPadding; + y = containerCoords.y + (maxContainerHeight - boundTextElement.height); } else { - y = container.y + containerDims.height / 2 - boundTextElement.height / 2; + y = + containerCoords.y + + (maxContainerHeight / 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 }); + if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) { + x = containerCoords.x; + } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) { + x = containerCoords.x + (maxContainerWidth - boundTextElement.width); + } else { + x = + containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); + } + return { x, y }; }; + // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js -export const measureText = ( - text: string, - font: FontString, - maxWidth?: number | null, -) => { + +export const measureText = (text: string, font: FontString) => { text = text .split("\n") // replace empty lines with single space because leading/trailing empty // lines would be stripped from computation .map((x) => x || " ") .join("\n"); - const container = document.createElement("div"); - container.style.position = "absolute"; - container.style.whiteSpace = "pre"; - container.style.font = font; - container.style.minHeight = "1em"; - if (maxWidth) { - const lineHeight = getApproxLineHeight(font); - // since we are adding a span of width 1px later - container.style.maxWidth = `${maxWidth + 1}px`; - container.style.overflow = "hidden"; - container.style.wordBreak = "break-word"; - container.style.lineHeight = `${String(lineHeight)}px`; - container.style.whiteSpace = "pre-wrap"; - } - document.body.appendChild(container); - container.innerText = text; + const height = getTextHeight(text, font); + const width = getTextWidth(text, font); - const span = document.createElement("span"); - span.style.display = "inline-block"; - span.style.overflow = "hidden"; - span.style.width = "1px"; - span.style.height = "1px"; - container.appendChild(span); - // Baseline is important for positioning text on canvas - const baseline = span.offsetTop + span.offsetHeight; - // since we are adding a span of width 1px - const width = container.offsetWidth + 1; - const height = container.offsetHeight; - document.body.removeChild(container); - if (isTestEnv()) { - return { width, height, baseline, container }; - } - return { width, height, baseline }; + return { width, height }; }; const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); @@ -345,40 +303,47 @@ export const getApproxLineHeight = (font: FontString) => { if (cacheApproxLineHeight[font]) { return cacheApproxLineHeight[font]; } - cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height; + const fontSize = parseInt(font); + + // Calculate line height relative to font size + cacheApproxLineHeight[font] = fontSize * 1.2; return cacheApproxLineHeight[font]; }; let canvas: HTMLCanvasElement | undefined; + const getLineWidth = (text: string, font: FontString) => { if (!canvas) { canvas = document.createElement("canvas"); } const canvas2dContext = canvas.getContext("2d")!; canvas2dContext.font = font; + const width = canvas2dContext.measureText(text).width; - const metrics = canvas2dContext.measureText(text); // since in test env the canvas measureText algo // doesn't measure text and instead just returns number of // characters hence we assume that each letteris 10px if (isTestEnv()) { - return metrics.width * 10; + return width * 10; } - // Since measureText behaves differently in different browsers - // OS so considering a adjustment factor of 0.2 - const adjustmentFactor = 0.2; - - return metrics.width + adjustmentFactor; + return width; }; export const getTextWidth = (text: string, font: FontString) => { - const lines = text.split("\n"); + const lines = text.replace(/\r\n?/g, "\n").split("\n"); let width = 0; lines.forEach((line) => { width = Math.max(width, getLineWidth(line, font)); }); return width; }; + +export const getTextHeight = (text: string, font: FontString) => { + const lines = text.replace(/\r\n?/g, "\n").split("\n"); + const lineHeight = getApproxLineHeight(font); + return lineHeight * lines.length; +}; + export const wrapText = (text: string, font: FontString, maxWidth: number) => { const lines: Array = []; const originalLines = text.split("\n"); @@ -400,16 +365,23 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { let currentLineWidthTillNow = 0; let index = 0; + while (index < words.length) { const currentWordWidth = getLineWidth(words[index], font); + // This will only happen when single word takes entire width + if (currentWordWidth === maxWidth) { + push(words[index]); + index++; + } // Start breaking longer words exceeding max width - if (currentWordWidth >= maxWidth) { + else if (currentWordWidth > maxWidth) { // push current line since the current word exceeds the max width // so will be appended in next line push(currentLine); currentLine = ""; currentLineWidthTillNow = 0; + while (words[index].length > 0) { const currentChar = String.fromCodePoint( words[index].codePointAt(0)!, @@ -510,9 +482,9 @@ export const charWidth = (() => { getCache, }; })(); + export const getApproxMinLineWidth = (font: FontString) => { const maxCharWidth = getMaxCharWidth(font); - if (maxCharWidth === 0) { return ( measureText(DUMMY_TEXT.split("").join("\n"), font).width + @@ -652,6 +624,26 @@ export const getContainerCenter = ( return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; }; +export const getContainerCoords = (container: NonDeletedExcalidrawElement) => { + let offsetX = BOUND_TEXT_PADDING; + let offsetY = BOUND_TEXT_PADDING; + + if (container.type === "ellipse") { + // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172 + offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2); + offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2); + } + // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265 + if (container.type === "diamond") { + offsetX += container.width / 4; + offsetY += container.height / 4; + } + return { + x: container.x + offsetX, + y: container.y + offsetY, + }; +}; + export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { const container = getContainerElement(textElement); if (!container || isArrowElement(container)) { @@ -664,12 +656,13 @@ export const getBoundTextElementOffset = ( boundTextElement: ExcalidrawTextElement | null, ) => { const container = getContainerElement(boundTextElement); - if (!container) { + if (!container || !boundTextElement) { return 0; } if (isArrowElement(container)) { return BOUND_TEXT_PADDING * 8; } + return BOUND_TEXT_PADDING; }; @@ -754,3 +747,76 @@ export const isValidTextContainer = (element: ExcalidrawElement) => { isArrowElement(element) ); }; + +export const computeContainerHeightForBoundText = ( + container: NonDeletedExcalidrawElement, + boundTextElementHeight: number, +) => { + if (container.type === "ellipse") { + return Math.round( + ((boundTextElementHeight + BOUND_TEXT_PADDING * 2) / Math.sqrt(2)) * 2, + ); + } + if (isArrowElement(container)) { + return boundTextElementHeight + BOUND_TEXT_PADDING * 8 * 2; + } + if (container.type === "diamond") { + return 2 * (boundTextElementHeight + BOUND_TEXT_PADDING * 2); + } + return boundTextElementHeight + BOUND_TEXT_PADDING * 2; +}; + +export const getMaxContainerWidth = (container: ExcalidrawElement) => { + 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; + } + + if (container.type === "ellipse") { + // The width of the largest rectangle inscribed inside an ellipse is + // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from + // equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172 + return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2; + } + if (container.type === "diamond") { + // The width of the largest rectangle inscribed inside a rhombus is + // Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265 + return Math.round(width / 2) - BOUND_TEXT_PADDING * 2; + } + return width - BOUND_TEXT_PADDING * 2; +}; + +export const getMaxContainerHeight = (container: ExcalidrawElement) => { + 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; + } + if (container.type === "ellipse") { + // The height of the largest rectangle inscribed inside an ellipse is + // Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from + // equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172 + return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2; + } + if (container.type === "diamond") { + // The height of the largest rectangle inscribed inside a rhombus is + // Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265 + return Math.round(height / 2) - BOUND_TEXT_PADDING * 2; + } + return height - BOUND_TEXT_PADDING * 2; +}; diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index cf6911b16..f234991da 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -6,14 +6,11 @@ import { CODES, KEYS } from "../keys"; import { fireEvent } from "../tests/test-utils"; import { queryByText } from "@testing-library/react"; -import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; +import { FONT_FAMILY } from "../constants"; import { ExcalidrawTextElement, ExcalidrawTextElementWithContainer, - FontString, } from "./types"; -import * as textElementUtils from "./textElement"; -import { getFontString } from "../utils"; import { API } from "../tests/helpers/api"; import { mutateElement } from "./mutateElement"; import { resize } from "../tests/utils"; @@ -442,17 +439,6 @@ describe("textWysiwyg", () => { let rectangle: any; const { h } = window; - const DUMMY_HEIGHT = 240; - const DUMMY_WIDTH = 160; - const APPROX_LINE_HEIGHT = 25; - const INITIAL_WIDTH = 10; - - beforeAll(() => { - jest - .spyOn(textElementUtils, "getApproxLineHeight") - .mockReturnValue(APPROX_LINE_HEIGHT); - }); - beforeEach(async () => { await render(); h.elements = []; @@ -734,53 +720,6 @@ describe("textWysiwyg", () => { }); it("should wrap text and vertcially center align once text submitted", async () => { - const mockMeasureText = ( - text: string, - font: FontString, - maxWidth?: number | null, - ) => { - let width = INITIAL_WIDTH; - let height = APPROX_LINE_HEIGHT; - let baseline = 10; - if (!text) { - return { - width, - height, - baseline, - }; - } - baseline = 30; - width = DUMMY_WIDTH; - if (text === "Hello \nWorld!") { - height = APPROX_LINE_HEIGHT * 2; - } - if (maxWidth) { - width = maxWidth; - // To capture cases where maxWidth passed is initial width - // due to which the text is not wrapped correctly - if (maxWidth === INITIAL_WIDTH) { - height = DUMMY_HEIGHT; - } - } - return { - width, - height, - baseline, - }; - }; - - jest - .spyOn(textElementUtils, "measureText") - .mockImplementation(mockMeasureText); - jest - .spyOn(textElementUtils, "measureTextElement") - .mockImplementation((element, next, maxWidth) => { - return mockMeasureText( - next?.text ?? element.text, - getFontString(element), - maxWidth, - ); - }); expect(h.elements.length).toBe(1); Keyboard.keyDown(KEYS.ENTER); @@ -789,11 +728,6 @@ describe("textWysiwyg", () => { ".excalidraw-textEditorContainer > textarea", ) as HTMLTextAreaElement; - // mock scroll height - jest - .spyOn(editor, "scrollHeight", "get") - .mockImplementation(() => APPROX_LINE_HEIGHT * 2); - fireEvent.change(editor, { target: { value: "Hello World!", @@ -808,11 +742,11 @@ describe("textWysiwyg", () => { expect(text.text).toBe("Hello \nWorld!"); expect(text.originalText).toBe("Hello World!"); expect(text.y).toBe( - rectangle.y + rectangle.height / 2 - (APPROX_LINE_HEIGHT * 2) / 2, + rectangle.y + h.elements[0].height / 2 - text.height / 2, ); - expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); - expect(text.height).toBe(APPROX_LINE_HEIGHT * 2); - expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); + expect(text.x).toBe(25); + expect(text.height).toBe(48); + expect(text.width).toBe(60); // Edit and text by removing second line and it should // still vertically align correctly @@ -829,11 +763,6 @@ describe("textWysiwyg", () => { }, }); - // mock scroll height - jest - .spyOn(editor, "scrollHeight", "get") - .mockImplementation(() => APPROX_LINE_HEIGHT); - editor.style.height = "25px"; editor.dispatchEvent(new Event("input")); await new Promise((r) => setTimeout(r, 0)); @@ -843,12 +772,12 @@ describe("textWysiwyg", () => { expect(text.text).toBe("Hello"); expect(text.originalText).toBe("Hello"); + expect(text.height).toBe(24); + expect(text.width).toBe(50); expect(text.y).toBe( - rectangle.y + rectangle.height / 2 - APPROX_LINE_HEIGHT / 2, + rectangle.y + h.elements[0].height / 2 - text.height / 2, ); - expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); - expect(text.height).toBe(APPROX_LINE_HEIGHT); - expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); + expect(text.x).toBe(30); }); it("should unbind bound text when unbind action from context menu is triggered", async () => { @@ -935,8 +864,8 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 109.5, - 17, + 85, + 5, ] `); @@ -950,6 +879,8 @@ describe("textWysiwyg", () => { editor.select(); fireEvent.click(screen.getByTitle("Left")); + await new Promise((r) => setTimeout(r, 0)); + fireEvent.click(screen.getByTitle("Align bottom")); await new Promise((r) => setTimeout(r, 0)); @@ -960,7 +891,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 15, - 90, + 66, ] `); @@ -983,7 +914,7 @@ describe("textWysiwyg", () => { resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 424, + 375, -539, ] `); @@ -1098,9 +1029,9 @@ describe("textWysiwyg", () => { mouse.moveTo(rectangle.x + 100, rectangle.y + 50); mouse.up(rectangle.x + 100, rectangle.y + 50); expect(rectangle.x).toBe(80); - expect(rectangle.y).toBe(85); - expect(text.x).toBe(89.5); - expect(text.y).toBe(90); + expect(rectangle.y).toBe(-35); + expect(text.x).toBe(85); + expect(text.y).toBe(-30); Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.Z); @@ -1130,43 +1061,6 @@ describe("textWysiwyg", () => { }); it("should restore original container height and clear cache once text is unbind", async () => { - const mockMeasureText = ( - text: string, - font: FontString, - maxWidth?: number | null, - ) => { - let width = INITIAL_WIDTH; - let height = APPROX_LINE_HEIGHT; - let baseline = 10; - if (!text) { - return { - width, - height, - baseline, - }; - } - baseline = 30; - width = DUMMY_WIDTH; - height = APPROX_LINE_HEIGHT * 5; - - return { - width, - height, - baseline, - }; - }; - jest - .spyOn(textElementUtils, "measureText") - .mockImplementation(mockMeasureText); - jest - .spyOn(textElementUtils, "measureTextElement") - .mockImplementation((element, next, maxWidth) => { - return mockMeasureText( - next?.text ?? element.text, - getFontString(element), - maxWidth, - ); - }); const originalRectHeight = rectangle.height; expect(rectangle.height).toBe(originalRectHeight); @@ -1180,7 +1074,7 @@ describe("textWysiwyg", () => { target: { value: "Online whiteboard collaboration made easy" }, }); editor.blur(); - expect(rectangle.height).toBe(135); + expect(rectangle.height).toBe(178); mouse.select(rectangle); fireEvent.contextMenu(GlobalTestState.canvas, { button: 2, @@ -1206,7 +1100,7 @@ describe("textWysiwyg", () => { editor.blur(); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); - expect(rectangle.height).toBe(215); + expect(rectangle.height).toBe(156); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); mouse.select(rectangle); @@ -1218,13 +1112,12 @@ describe("textWysiwyg", () => { await new Promise((r) => setTimeout(r, 0)); editor.blur(); - expect(rectangle.height).toBe(215); + expect(rectangle.height).toBe(156); // cache updated again - expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156); }); - //@todo fix this test later once measureText is mocked correctly - it.skip("should reset the container height cache when font properties updated", async () => { + it("should reset the container height cache when font properties updated", async () => { Keyboard.keyPress(KEYS.ENTER); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); @@ -1250,7 +1143,9 @@ describe("textWysiwyg", () => { expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, ).toEqual(36); - expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe( + 96.39999999999999, + ); }); describe("should align correctly", () => { @@ -1278,7 +1173,7 @@ describe("textWysiwyg", () => { expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ 15, - 20, + 25, ] `); }); @@ -1288,8 +1183,8 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Align top")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` Array [ - 94.5, - 20, + 30, + 25, ] `); }); @@ -1299,22 +1194,22 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Align top")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 174, - 20, - ] - `); + Array [ + 45, + 25, + ] + `); }); it("when center left", async () => { fireEvent.click(screen.getByTitle("Center vertically")); fireEvent.click(screen.getByTitle("Left")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 15, - 25, - ] - `); + Array [ + 15, + 45.5, + ] + `); }); it("when center center", async () => { @@ -1322,11 +1217,11 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Center vertically")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - -25, - 25, - ] - `); + Array [ + 30, + 45.5, + ] + `); }); it("when center right", async () => { @@ -1334,11 +1229,11 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Center vertically")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 174, - 25, - ] - `); + Array [ + 45, + 45.5, + ] + `); }); it("when bottom left", async () => { @@ -1346,33 +1241,33 @@ describe("textWysiwyg", () => { fireEvent.click(screen.getByTitle("Align bottom")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 15, - 25, - ] - `); + Array [ + 15, + 66, + ] + `); }); it("when bottom center", async () => { fireEvent.click(screen.getByTitle("Center")); fireEvent.click(screen.getByTitle("Align bottom")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 94.5, - 25, - ] - `); + Array [ + 30, + 66, + ] + `); }); it("when bottom right", async () => { fireEvent.click(screen.getByTitle("Right")); fireEvent.click(screen.getByTitle("Align bottom")); expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` - Array [ - 174, - 25, - ] - `); + Array [ + 45, + 66, + ] + `); }); }); }); diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 96e532e7e..3d43aa87a 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -11,7 +11,7 @@ import { isBoundToContainer, isTextElement, } from "./typeChecks"; -import { CLASSES, VERTICAL_ALIGN } from "../constants"; +import { CLASSES, isFirefox, isSafari, VERTICAL_ALIGN } from "../constants"; import { ExcalidrawElement, ExcalidrawLinearElement, @@ -24,14 +24,17 @@ import { mutateElement } from "./mutateElement"; import { getApproxLineHeight, getBoundTextElementId, - getBoundTextElementOffset, + getContainerCoords, getContainerDims, getContainerElement, getTextElementAngle, - measureText, getTextWidth, + measureText, normalizeText, + redrawTextBoundingBox, wrapText, + getMaxContainerHeight, + getMaxContainerWidth, } from "./textElement"; import { actionDecreaseFontSize, @@ -39,7 +42,6 @@ import { } from "../actions/actionProperties"; import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import App from "../components/App"; -import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement"; import { LinearElementEditor } from "./linearElementEditor"; import { parseClipboard } from "../clipboard"; @@ -155,19 +157,23 @@ export const textWysiwyg = ({ if (updatedTextElement && isTextElement(updatedTextElement)) { let coordX = updatedTextElement.x; let coordY = updatedTextElement.y; - let eCoordY = coordY; const container = getContainerElement(updatedTextElement); + let maxWidth = updatedTextElement.width; // Editing metrics const eMetrics = measureText( - updatedTextElement.originalText, + container && updatedTextElement.containerId + ? wrapText( + updatedTextElement.originalText, + getFontString(updatedTextElement), + getMaxContainerWidth(container), + ) + : updatedTextElement.originalText, getFontString(updatedTextElement), - container ? getContainerDims(container).width : null, ); - let maxWidth = eMetrics.width; let maxHeight = eMetrics.height; - const width = eMetrics.width; + let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width); // Set to element height by default since that's // what is going to be used for unbounded text let textElementHeight = Math.max(updatedTextElement.height, maxHeight); @@ -181,7 +187,6 @@ export const textWysiwyg = ({ ); coordX = boundTextCoords.x; coordY = boundTextCoords.y; - eCoordY = coordY; } const propertiesUpdated = textPropertiesUpdated( updatedTextElement, @@ -198,7 +203,11 @@ export const textWysiwyg = ({ const font = getFontString(updatedTextElement); textElementHeight = getApproxLineHeight(font) * - updatedTextElement.text.split("\n").length; + wrapText( + updatedTextElement.originalText, + font, + getMaxContainerWidth(container), + ).split("\n").length; textElementHeight = Math.max( textElementHeight, updatedTextElement.height, @@ -248,25 +257,21 @@ export const textWysiwyg = ({ // Start pushing text upward until a diff of 30px (padding) // is reached else { + const containerCoords = getContainerCoords(container); + // vertically center align the text if (verticalAlign === VERTICAL_ALIGN.MIDDLE) { if (!isArrowElement(container)) { coordY = - container.y + containerDims.height / 2 - textElementHeight / 2; - eCoordY = coordY + textElementHeight / 2 - eMetrics.height / 2; + containerCoords.y + maxHeight / 2 - textElementHeight / 2; } } if (verticalAlign === VERTICAL_ALIGN.BOTTOM) { - coordY = - container.y + - containerDims.height - - textElementHeight - - getBoundTextElementOffset(updatedTextElement); - eCoordY = coordY + textElementHeight - eMetrics.height; + coordY = containerCoords.y + (maxHeight - textElementHeight); } } } - const [viewportX, viewportY] = getViewportCoords(coordX, eCoordY); + const [viewportX, viewportY] = getViewportCoords(coordX, coordY); const initialSelectionStart = editable.selectionStart; const initialSelectionEnd = editable.selectionEnd; const initialLength = editable.value.length; @@ -308,6 +313,12 @@ export const textWysiwyg = ({ : 0; const { width: w, height: h } = updatedTextElement; + let transformWidth = updatedTextElement.width; + // As firefox, Safari needs little higher dimensions on DOM + if (isFirefox || isSafari) { + textElementWidth += 0.5; + transformWidth += 0.5; + } // Make sure text editor height doesn't go beyond viewport const editorMaxHeight = (appState.height - viewportY) / appState.zoom.value; @@ -315,14 +326,14 @@ export const textWysiwyg = ({ font: getFontString(updatedTextElement), // must be defined *after* font ¯\_(ツ)_/¯ lineHeight: `${lineHeight}px`, - width: `${Math.min(width, maxWidth)}px`, + width: `${Math.min(textElementWidth, maxWidth)}px`, height: `${textElementHeight}px`, left: `${viewportX}px`, top: `${viewportY}px`, transformOrigin: `${w / 2}px ${h / 2}px`, transform: getTransform( offsetX, - updatedTextElement.width, + transformWidth, updatedTextElement.height, getTextElementAngle(updatedTextElement), appState, @@ -415,55 +426,16 @@ export const textWysiwyg = ({ id, ) as ExcalidrawTextElement; const font = getFontString(updatedTextElement); - // using scrollHeight here since we need to calculate - // number of lines so cannot use editable.style.height - // as that gets updated below - // Rounding here so that the lines calculated is more accurate in all browsers. - // The scrollHeight and approxLineHeight differs in diff browsers - // eg it gives 1.05 in firefox for handewritten small font due to which - // height gets updated as lines > 1 and leads to jumping text for first line in bound container - // hence rounding here to avoid that - const lines = Math.round( - editable.scrollHeight / getApproxLineHeight(font), - ); - // auto increase height only when lines > 1 so its - // measured correctly and vertically aligns for - // first line as well as setting height to "auto" - // doubles the height as soon as user starts typing - if (isBoundToContainer(element) && lines > 1) { + if (isBoundToContainer(element)) { const container = getContainerElement(element); - - let height = "auto"; - editable.style.height = "0px"; - let heightSet = false; - if (lines === 2) { - const actualLineCount = wrapText( - editable.value, - font, - getMaxContainerWidth(container!), - ).split("\n").length; - // This is browser behaviour when setting height to "auto" - // It sets the height needed for 2 lines even if actual - // line count is 1 as mentioned above as well - // hence reducing the height by half if actual line count is 1 - // so single line aligns vertically when deleting - if (actualLineCount === 1) { - height = `${editable.scrollHeight / 2}px`; - editable.style.height = height; - heightSet = true; - } - } const wrappedText = wrapText( normalizeText(editable.value), font, getMaxContainerWidth(container!), ); - const width = getTextWidth(wrappedText, font); + const { width, height } = measureText(wrappedText, font); editable.style.width = `${width}px`; - - if (!heightSet) { - editable.style.height = `${editable.scrollHeight}px`; - } + editable.style.height = `${height}px`; } onChange(normalizeText(editable.value)); }; @@ -500,7 +472,9 @@ export const textWysiwyg = ({ event.code === CODES.BRACKET_RIGHT)) ) { event.preventDefault(); - if (event.shiftKey || event.code === CODES.BRACKET_LEFT) { + if (event.isComposing) { + return; + } else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) { outdent(); } else { indent(); @@ -649,6 +623,7 @@ export const textWysiwyg = ({ ), }); } + redrawTextBoundingBox(updateElement, container); } onSubmit({ diff --git a/src/element/types.ts b/src/element/types.ts index deaf701ec..39279c55e 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -132,7 +132,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & fontSize: number; fontFamily: FontFamilyValues; text: string; - baseline: number; textAlign: TextAlign; verticalAlign: VerticalAlign; containerId: ExcalidrawGenericElement["id"] | null; diff --git a/src/excalidraw-app/app-jotai.ts b/src/excalidraw-app/app-jotai.ts new file mode 100644 index 000000000..8c6c796f6 --- /dev/null +++ b/src/excalidraw-app/app-jotai.ts @@ -0,0 +1,3 @@ +import { unstable_createStore } from "jotai"; + +export const appJotaiStore = unstable_createStore(); diff --git a/src/excalidraw-app/collab/Collab.tsx b/src/excalidraw-app/collab/Collab.tsx index 22f748773..30c9846c8 100644 --- a/src/excalidraw-app/collab/Collab.tsx +++ b/src/excalidraw-app/collab/Collab.tsx @@ -70,7 +70,7 @@ import { decryptData } from "../../data/encryption"; import { resetBrowserStateVersions } from "../data/tabSync"; import { LocalData } from "../data/LocalData"; import { atom, useAtom } from "jotai"; -import { jotaiStore } from "../../jotai"; +import { appJotaiStore } from "../app-jotai"; export const collabAPIAtom = atom(null); export const collabDialogShownAtom = atom(false); @@ -167,7 +167,7 @@ class Collab extends PureComponent { setUsername: this.setUsername, }; - jotaiStore.set(collabAPIAtom, collabAPI); + appJotaiStore.set(collabAPIAtom, collabAPI); this.onOfflineStatusToggle(); if ( @@ -185,7 +185,7 @@ class Collab extends PureComponent { } onOfflineStatusToggle = () => { - jotaiStore.set(isOfflineAtom, !window.navigator.onLine); + appJotaiStore.set(isOfflineAtom, !window.navigator.onLine); }; componentWillUnmount() { @@ -208,10 +208,10 @@ class Collab extends PureComponent { } } - isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!; + isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!; private setIsCollaborating = (isCollaborating: boolean) => { - jotaiStore.set(isCollaboratingAtom, isCollaborating); + appJotaiStore.set(isCollaboratingAtom, isCollaborating); }; private onUnload = () => { @@ -804,7 +804,7 @@ class Collab extends PureComponent { ); handleClose = () => { - jotaiStore.set(collabDialogShownAtom, false); + appJotaiStore.set(collabDialogShownAtom, false); }; setUsername = (username: string) => { diff --git a/src/excalidraw-app/collab/RoomDialog.tsx b/src/excalidraw-app/collab/RoomDialog.tsx index 2c6949aac..50f586efc 100644 --- a/src/excalidraw-app/collab/RoomDialog.tsx +++ b/src/excalidraw-app/collab/RoomDialog.tsx @@ -10,13 +10,13 @@ import { shareWindows, } from "../../components/icons"; import { ToolButton } from "../../components/ToolButton"; -import { t } from "../../i18n"; import "./RoomDialog.scss"; import Stack from "../../components/Stack"; import { AppState } from "../../types"; import { trackEvent } from "../../analytics"; import { getFrame } from "../../utils"; import DialogActionButton from "../../components/DialogActionButton"; +import { useI18n } from "../../i18n"; const getShareIcon = () => { const navigator = window.navigator as any; @@ -51,6 +51,7 @@ const RoomDialog = ({ setErrorMessage: (message: string) => void; theme: AppState["theme"]; }) => { + const { t } = useI18n(); const roomLinkInput = useRef(null); const copyRoomLink = async () => { diff --git a/src/excalidraw-app/components/AppWelcomeScreen.tsx b/src/excalidraw-app/components/AppWelcomeScreen.tsx index 9e760f734..1e34fa819 100644 --- a/src/excalidraw-app/components/AppWelcomeScreen.tsx +++ b/src/excalidraw-app/components/AppWelcomeScreen.tsx @@ -1,12 +1,13 @@ import React from "react"; import { PlusPromoIcon } from "../../components/icons"; -import { t } from "../../i18n"; +import { useI18n } from "../../i18n"; import { WelcomeScreen } from "../../packages/excalidraw/index"; import { isExcalidrawPlusSignedUser } from "../app_constants"; export const AppWelcomeScreen: React.FC<{ setCollabDialogShown: (toggle: boolean) => any; }> = React.memo((props) => { + const { t } = useI18n(); let headingContent; if (isExcalidrawPlusSignedUser) { diff --git a/src/excalidraw-app/components/EncryptedIcon.tsx b/src/excalidraw-app/components/EncryptedIcon.tsx index a3e6ff0ba..a91768917 100644 --- a/src/excalidraw-app/components/EncryptedIcon.tsx +++ b/src/excalidraw-app/components/EncryptedIcon.tsx @@ -1,17 +1,21 @@ import { shield } from "../../components/icons"; import { Tooltip } from "../../components/Tooltip"; -import { t } from "../../i18n"; +import { useI18n } from "../../i18n"; -export const EncryptedIcon = () => ( - - - {shield} - - -); +export const EncryptedIcon = () => { + const { t } = useI18n(); + + return ( + + + {shield} + + + ); +}; diff --git a/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx b/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx index 049a4ddf7..daf4b95c3 100644 --- a/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx +++ b/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx @@ -6,7 +6,7 @@ import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase"; import { FileId, NonDeletedExcalidrawElement } from "../../element/types"; import { AppState, BinaryFileData, BinaryFiles } from "../../types"; import { nanoid } from "nanoid"; -import { t } from "../../i18n"; +import { useI18n } from "../../i18n"; import { excalidrawPlusIcon } from "./icons"; import { encryptData, generateEncryptionKey } from "../../data/encryption"; import { isInitializedImageElement } from "../../element/typeChecks"; @@ -79,6 +79,7 @@ export const ExportToExcalidrawPlus: React.FC<{ files: BinaryFiles; onError: (error: Error) => void; }> = ({ elements, appState, files, onError }) => { + const { t } = useI18n(); return (
{excalidrawPlusIcon}
diff --git a/src/excalidraw-app/components/LanguageList.tsx b/src/excalidraw-app/components/LanguageList.tsx index 1b3606b57..aaa5f2137 100644 --- a/src/excalidraw-app/components/LanguageList.tsx +++ b/src/excalidraw-app/components/LanguageList.tsx @@ -1,22 +1,23 @@ -import { useAtom } from "jotai"; +import { useSetAtom } from "jotai"; import React from "react"; -import { langCodeAtom } from ".."; -import * as i18n from "../../i18n"; +import { appLangCodeAtom } from ".."; +import { defaultLang, useI18n } from "../../i18n"; import { languages } from "../../i18n"; export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { - const [langCode, setLangCode] = useAtom(langCodeAtom); + const { t, langCode } = useI18n(); + const setLangCode = useSetAtom(appLangCodeAtom); return (