From 70980136717a7c7cab22be3997554a1d5d3dad14 Mon Sep 17 00:00:00 2001 From: "Daniel J. Geiger" <1852529+DanielJGeiger@users.noreply.github.com> Date: Fri, 14 Apr 2023 18:57:37 -0500 Subject: [PATCH] feat: Allow non-WYSIWYG measurements and wrapping for text elements. --- src/actions/actionBoundText.tsx | 12 ++-- src/data/restore.ts | 16 ++--- src/element/newElement.ts | 32 +++++---- src/element/textElement.ts | 59 +++++++++++------ src/element/textWysiwyg.tsx | 66 ++++++++++++++++--- src/element/types.ts | 2 + src/subtypes.ts | 52 +++++++++++++++ .../linearElementEditor.test.tsx.snap | 2 +- 8 files changed, 184 insertions(+), 57 deletions(-) create mode 100644 src/subtypes.ts diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index 990f98d41..cc675615a 100644 --- a/src/actions/actionBoundText.tsx +++ b/src/actions/actionBoundText.tsx @@ -5,7 +5,7 @@ import { computeBoundTextPosition, computeContainerDimensionForBoundText, getBoundTextElement, - measureText, + measureTextElement, redrawTextBoundingBox, } from "../element/textElement"; import { @@ -25,7 +25,6 @@ import { } from "../element/types"; import { getSelectedElements } from "../scene"; import { AppState } from "../types"; -import { getFontString } from "../utils"; import { register } from "./register"; export const actionUnbindText = register({ @@ -45,10 +44,11 @@ export const actionUnbindText = register({ selectedElements.forEach((element) => { const boundTextElement = getBoundTextElement(element); if (boundTextElement) { - const { width, height, baseline } = measureText( - boundTextElement.originalText, - getFontString(boundTextElement), - boundTextElement.lineHeight, + const { width, height, baseline } = measureTextElement( + boundTextElement, + { + text: boundTextElement.originalText, + }, ); const originalContainerHeight = getOriginalContainerHeightFromCache( element.id, diff --git a/src/data/restore.ts b/src/data/restore.ts index 2735d91d2..67a8e07d3 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -31,14 +31,14 @@ import { import { getDefaultAppState } from "../appState"; import { LinearElementEditor } from "../element/linearElementEditor"; import { bumpVersion } from "../element/mutateElement"; -import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils"; +import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import oc from "open-color"; import { MarkOptional, Mutable } from "../utility-types"; import { detectLineHeight, getDefaultLineHeight, - measureBaseline, + measureTextElement, } from "../element/textElement"; type RestoredAppState = Omit< @@ -80,7 +80,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => { }; const restoreElementWithProperties = < - T extends Required> & { + T extends Required> & { + subtype?: ExcalidrawElement["subtype"]; customData?: ExcalidrawElement["customData"]; /** @deprecated */ boundElementIds?: readonly ExcalidrawElement["id"][]; @@ -143,6 +144,9 @@ const restoreElementWithProperties = < locked: element.locked ?? false, }; + if ("subtype" in element) { + base.subtype = element.subtype; + } if ("customData" in element) { base.customData = element.customData; } @@ -188,11 +192,7 @@ const restoreElement = ( : // no element height likely means programmatic use, so default // to a fixed line height getDefaultLineHeight(element.fontFamily)); - const baseline = measureBaseline( - element.text, - getFontString(element), - lineHeight, - ); + const baseline = measureTextElement(element, { text }).baseline; element = restoreElementWithProperties(element, { fontSize, fontFamily, diff --git a/src/element/newElement.ts b/src/element/newElement.ts index b6b6cad2d..658d5d0de 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -13,7 +13,7 @@ import { FontFamilyValues, ExcalidrawTextContainer, } from "../element/types"; -import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils"; +import { getUpdatedTimestamp, isTestEnv } from "../utils"; import { randomInteger, randomId } from "../random"; import { mutateElement, newElementWith } from "./mutateElement"; import { getNewGroupIdsForDuplication } from "../groups"; @@ -25,9 +25,9 @@ import { getBoundTextElementOffset, getContainerDims, getContainerElement, - measureText, + measureTextElement, normalizeText, - wrapText, + wrapTextElement, getMaxContainerWidth, getDefaultLineHeight, } from "./textElement"; @@ -46,6 +46,8 @@ type ElementConstructorOpts = MarkOptional< | "version" | "versionNonce" | "link" + | "subtype" + | "customData" >; const _newElementBase = ( @@ -143,7 +145,13 @@ export const newTextElement = ( ): NonDeleted => { const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily); const text = normalizeText(opts.text); - const metrics = measureText(text, getFontString(opts), lineHeight); + const metrics = measureTextElement( + { ...opts, lineHeight }, + { + text, + customData: opts.customData, + }, + ); const offsets = getTextElementPositionOffsets(opts, metrics); const textElement = newElementWith( @@ -184,7 +192,9 @@ const getAdjustedDimensions = ( width: nextWidth, height: nextHeight, baseline: nextBaseline, - } = measureText(nextText, getFontString(element), element.lineHeight); + } = measureTextElement(element, { + text: nextText, + }); const { textAlign, verticalAlign } = element; let x: number; let y: number; @@ -193,11 +203,7 @@ const getAdjustedDimensions = ( verticalAlign === VERTICAL_ALIGN.MIDDLE && !element.containerId ) { - const prevMetrics = measureText( - element.text, - getFontString(element), - element.lineHeight, - ); + const prevMetrics = measureTextElement(element); const offsets = getTextElementPositionOffsets(element, { width: nextWidth - prevMetrics.width, height: nextHeight - prevMetrics.height, @@ -274,11 +280,9 @@ export const refreshTextDimensions = ( } const container = getContainerElement(textElement); if (container) { - text = wrapText( + text = wrapTextElement(textElement, getMaxContainerWidth(container), { text, - getFontString(textElement), - getMaxContainerWidth(container), - ); + }); } const dimensions = getAdjustedDimensions(textElement, text); return { text, ...dimensions }; diff --git a/src/element/textElement.ts b/src/element/textElement.ts index 38da5df5a..cef793b35 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -1,3 +1,4 @@ +import { getSubtypeMethods, SubtypeMethods } from "../subtypes"; import { getFontString, arrayToMap, isTestEnv } from "../utils"; import { ExcalidrawElement, @@ -34,6 +35,30 @@ import { } from "./textWysiwyg"; import { ExtractSetType } from "../utility-types"; +export const measureTextElement = function (element, next) { + const map = getSubtypeMethods(element.subtype); + if (map?.measureText) { + 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, element.lineHeight); +} as SubtypeMethods["measureText"]; + +export const wrapTextElement = function (element, containerWidth, next) { + const map = getSubtypeMethods(element.subtype); + if (map?.wrapText) { + return map.wrapText(element, containerWidth, next); + } + + const fontSize = next?.fontSize ?? element.fontSize; + const font = getFontString({ fontSize, fontFamily: element.fontFamily }); + const text = next?.text ?? element.originalText; + return wrapText(text, font, containerWidth); +} as SubtypeMethods["wrapText"]; + export const normalizeText = (text: string) => { return ( text @@ -66,22 +91,24 @@ export const redrawTextBoundingBox = ( if (container) { maxWidth = getMaxContainerWidth(container); - boundTextUpdates.text = wrapText( - textElement.originalText, - getFontString(textElement), - maxWidth, - ); + boundTextUpdates.text = wrapTextElement(textElement, maxWidth); } - const metrics = measureText( - boundTextUpdates.text, - getFontString(textElement), - textElement.lineHeight, - ); + const metrics = measureTextElement(textElement, { + text: boundTextUpdates.text, + }); boundTextUpdates.width = metrics.width; boundTextUpdates.height = metrics.height; boundTextUpdates.baseline = metrics.baseline; + // Maintain coordX for non left-aligned text in case the width has changed + if (!container) { + if (textElement.textAlign === TEXT_ALIGN.RIGHT) { + boundTextUpdates.x += textElement.width - metrics.width; + } else if (textElement.textAlign === TEXT_ALIGN.CENTER) { + boundTextUpdates.x += textElement.width / 2 - metrics.width / 2; + } + } if (container) { if (isArrowElement(container)) { const centerX = textElement.x + textElement.width / 2; @@ -189,17 +216,9 @@ export const handleBindTextResize = ( let nextBaseLine = textElement.baseline; if (transformHandleType !== "n" && transformHandleType !== "s") { if (text) { - text = wrapText( - textElement.originalText, - getFontString(textElement), - maxWidth, - ); + text = wrapTextElement(textElement, maxWidth); } - const metrics = measureText( - text, - getFontString(textElement), - textElement.lineHeight, - ); + const metrics = measureTextElement(textElement, { text }); nextHeight = metrics.height; nextWidth = metrics.width; nextBaseLine = metrics.baseline; diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index ef4f7c926..1e78b9188 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -47,6 +47,7 @@ import { LinearElementEditor } from "./linearElementEditor"; import { parseClipboard } from "../clipboard"; const getTransform = ( + offsetX: number, width: number, height: number, angle: number, @@ -64,7 +65,7 @@ const getTransform = ( if (height > maxHeight && zoom.value !== 1) { translateY = (maxHeight * (zoom.value - 1)) / 2; } - return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; + return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg) translate(${offsetX}px, 0px)`; }; const originalContainerCache: { @@ -158,13 +159,30 @@ export const textWysiwyg = ({ const container = getContainerElement(updatedTextElement); let maxWidth = updatedTextElement.width; - let maxHeight = updatedTextElement.height; - let textElementWidth = updatedTextElement.width; + // Editing metrics + const eMetrics = measureText( + container && updatedTextElement.containerId + ? wrapText( + updatedTextElement.originalText, + getFontString(updatedTextElement), + getMaxContainerWidth(container), + ) + : updatedTextElement.originalText, + getFontString(updatedTextElement), + updatedTextElement.lineHeight, + ); + + let maxHeight = eMetrics.height; + 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 = updatedTextElement.height; + let textElementHeight = Math.max(updatedTextElement.height, maxHeight); if (container && updatedTextElement.containerId) { + textElementHeight = Math.min( + getMaxContainerHeight(container), + textElementHeight, + ); if (isArrowElement(container)) { const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( @@ -173,6 +191,8 @@ export const textWysiwyg = ({ ); coordX = boundTextCoords.x; coordY = boundTextCoords.y; + } else { + coordX = Math.max(coordX, getContainerCoords(container).x); } const propertiesUpdated = textPropertiesUpdated( updatedTextElement, @@ -186,7 +206,18 @@ export const textWysiwyg = ({ } if (propertiesUpdated) { // update height of the editor after properties updated - textElementHeight = updatedTextElement.height; + const font = getFontString(updatedTextElement); + textElementHeight = + updatedTextElement.lineHeight * + wrapText( + updatedTextElement.originalText, + font, + getMaxContainerWidth(container), + ).split("\n").length; + textElementHeight = Math.max( + textElementHeight, + updatedTextElement.height, + ); } let originalContainerData; @@ -266,12 +297,29 @@ export const textWysiwyg = ({ editable.selectionEnd = editable.value.length - diff; } + let transformWidth = updatedTextElement.width; if (!container) { maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; textElementWidth = Math.min(textElementWidth, maxWidth); } else { textElementWidth += 0.5; + transformWidth += 0.5; } + // Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype + const offWidth = container + ? Math.min( + 0, + updatedTextElement.width - Math.min(maxWidth, eMetrics.width), + ) + : Math.min(maxWidth, updatedTextElement.width) - + Math.min(maxWidth, eMetrics.width); + const offsetX = + textAlign === "right" + ? offWidth + : textAlign === "center" + ? offWidth / 2 + : 0; + const { width: w, height: h } = updatedTextElement; let lineHeight = updatedTextElement.lineHeight; @@ -290,13 +338,15 @@ export const textWysiwyg = ({ font: getFontString(updatedTextElement), // must be defined *after* font ¯\_(ツ)_/¯ lineHeight, - width: `${textElementWidth}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( - textElementWidth, - textElementHeight, + offsetX, + transformWidth, + updatedTextElement.height, getTextElementAngle(updatedTextElement), appState, maxWidth, diff --git a/src/element/types.ts b/src/element/types.ts index 4a4db7e8b..c11cd838c 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -1,3 +1,4 @@ +import { Subtype } from "../subtypes"; import { Point } from "../types"; import { FONT_FAMILY, @@ -64,6 +65,7 @@ type _ExcalidrawElementBase = Readonly<{ updated: number; link: string | null; locked: boolean; + subtype?: Subtype; customData?: Record; }>; diff --git a/src/subtypes.ts b/src/subtypes.ts new file mode 100644 index 000000000..5fd57f873 --- /dev/null +++ b/src/subtypes.ts @@ -0,0 +1,52 @@ +import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types"; + +// Subtype Names +export type Subtype = string; + +// Subtype Methods +export type SubtypeMethods = { + measureText: ( + element: Pick< + ExcalidrawTextElement, + | "subtype" + | "customData" + | "fontSize" + | "fontFamily" + | "text" + | "lineHeight" + >, + next?: { + fontSize?: number; + text?: string; + customData?: ExcalidrawElement["customData"]; + }, + ) => { width: number; height: number; baseline: number }; + wrapText: ( + element: Pick< + ExcalidrawTextElement, + | "subtype" + | "customData" + | "fontSize" + | "fontFamily" + | "originalText" + | "lineHeight" + >, + containerWidth: number, + next?: { + fontSize?: number; + text?: string; + customData?: ExcalidrawElement["customData"]; + }, + ) => string; +}; + +type MethodMap = { subtype: Subtype; methods: Partial }; +const methodMaps = [] as Array; + +// Use `getSubtypeMethods` to call subtype-specialized methods, like `render`. +export const getSubtypeMethods = ( + subtype: Subtype | undefined, +): Partial | undefined => { + const map = methodMaps.find((method) => method.subtype === subtype); + return map?.methods; +}; diff --git a/src/tests/__snapshots__/linearElementEditor.test.tsx.snap b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap index a2f142b66..edfcb6552 100644 --- a/src/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te class="excalidraw-wysiwyg" data-type="wysiwyg" dir="auto" - style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;" + style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform-origin: 5px 12.5px; transform: translate(0px, 0px) scale(1) rotate(0deg) translate(0px, 0px); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;" tabindex="0" wrap="off" />