diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 203c6257..d89b4093 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -30,11 +30,15 @@ import { TextAlignCenterIcon, TextAlignLeftIcon, TextAlignRightIcon, + TextAlignTopIcon, + TextAlignBottomIcon, + TextAlignMiddleIcon, } from "../components/icons"; import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, FONT_FAMILY, + VERTICAL_ALIGN, } from "../constants"; import { getNonDeletedElements, @@ -58,6 +62,7 @@ import { ExcalidrawTextElement, FontFamilyValues, TextAlign, + VerticalAlign, } from "../element/types"; import { getLanguage, t } from "../i18n"; import { KEYS } from "../keys"; @@ -713,9 +718,7 @@ export const actionChangeTextAlign = register({ if (isTextElement(oldElement)) { const newElement: ExcalidrawTextElement = newElementWith( oldElement, - { - textAlign: value, - }, + { textAlign: value }, ); redrawTextBoundingBox( newElement, @@ -736,47 +739,119 @@ export const actionChangeTextAlign = register({ commitToHistory: true, }; }, - PanelComponent: ({ elements, appState, updateData }) => ( -
- {t("labels.textAlign")} - - group="text-align" - options={[ - { - value: "left", - text: t("labels.left"), - icon: , - }, - { - value: "center", - text: t("labels.center"), - icon: , - }, - { - value: "right", - text: t("labels.right"), - icon: , - }, - ]} - value={getFormValue( - elements, - appState, - (element) => { - if (isTextElement(element)) { - return element.textAlign; + PanelComponent: ({ elements, appState, updateData }) => { + return ( +
+ {t("labels.textAlign")} + + group="text-align" + options={[ + { + value: "left", + text: t("labels.left"), + icon: , + }, + { + value: "center", + text: t("labels.center"), + icon: , + }, + { + value: "right", + text: t("labels.right"), + icon: , + }, + ]} + value={getFormValue( + elements, + appState, + (element) => { + if (isTextElement(element)) { + return element.textAlign; + } + const boundTextElement = getBoundTextElement(element); + if (boundTextElement) { + return boundTextElement.textAlign; + } + return null; + }, + appState.currentItemTextAlign, + )} + onChange={(value) => updateData(value)} + /> +
+ ); + }, +}); +export const actionChangeVerticalAlign = register({ + name: "changeVerticalAlign", + perform: (elements, appState, value) => { + return { + elements: changeProperty( + elements, + appState, + (oldElement) => { + if (isTextElement(oldElement)) { + const newElement: ExcalidrawTextElement = newElementWith( + oldElement, + { verticalAlign: value }, + ); + + redrawTextBoundingBox( + newElement, + getContainerElement(oldElement), + appState, + ); + return newElement; + } + + return oldElement; + }, + true, + ), + appState: { + ...appState, + }, + commitToHistory: true, + }; + }, + PanelComponent: ({ elements, appState, updateData }) => { + return ( +
+ + group="text-align" + options={[ + { + value: VERTICAL_ALIGN.TOP, + text: t("labels.alignTop"), + icon: , + }, + { + value: VERTICAL_ALIGN.MIDDLE, + text: t("labels.centerVertically"), + icon: , + }, + { + value: VERTICAL_ALIGN.BOTTOM, + text: t("labels.alignBottom"), + icon: , + }, + ]} + value={getFormValue(elements, appState, (element) => { + if (isTextElement(element) && element.containerId) { + return element.verticalAlign; } const boundTextElement = getBoundTextElement(element); if (boundTextElement) { - return boundTextElement.textAlign; + return boundTextElement.verticalAlign; } return null; - }, - appState.currentItemTextAlign, - )} - onChange={(value) => updateData(value)} - /> -
- ), + })} + onChange={(value) => updateData(value)} + /> +
+ ); + }, }); export const actionChangeSharpness = register({ diff --git a/src/actions/index.ts b/src/actions/index.ts index d5ba8a56..5d417f06 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -17,6 +17,7 @@ export { actionChangeFontSize, actionChangeFontFamily, actionChangeTextAlign, + actionChangeVerticalAlign, } from "./actionProperties"; export { diff --git a/src/actions/types.ts b/src/actions/types.ts index b7bb560d..23bd0c5e 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -82,6 +82,7 @@ export type ActionName = | "zoomToSelection" | "changeFontFamily" | "changeTextAlign" + | "changeVerticalAlign" | "toggleFullScreen" | "toggleShortcuts" | "group" diff --git a/src/charts.ts b/src/charts.ts index 53013a7b..1479bce6 100644 --- a/src/charts.ts +++ b/src/charts.ts @@ -1,5 +1,10 @@ import colors from "./colors"; -import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants"; +import { + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + ENV, + VERTICAL_ALIGN, +} from "./constants"; import { newElement, newLinearElement, newTextElement } from "./element"; import { NonDeletedExcalidrawElement } from "./element/types"; import { randomId } from "./random"; @@ -161,7 +166,7 @@ const commonProps = { strokeSharpness: "sharp", strokeStyle: "solid", strokeWidth: 1, - verticalAlign: "middle", + verticalAlign: VERTICAL_ALIGN.MIDDLE, } as const; const getChartDimentions = (spreadsheet: Spreadsheet) => { diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index d1f9a780..d74593d5 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -19,7 +19,7 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; import { hasStrokeColor } from "../scene/comparisons"; -import { hasBoundTextElement } from "../element/typeChecks"; +import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; export const SelectedShapeActions = ({ appState, @@ -110,6 +110,10 @@ export const SelectedShapeActions = ({ )} + {targetElements.every( + (element) => + hasBoundTextElement(element) || isBoundToContainer(element), + ) && <>{renderAction("changeVerticalAlign")}} {(canHaveArrowheads(elementType) || targetElements.some((element) => canHaveArrowheads(element.type))) && ( <>{renderAction("changeArrowhead")} diff --git a/src/components/App.tsx b/src/components/App.tsx index f62380b2..d1f0d862 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -69,6 +69,7 @@ import { TOUCH_CTX_MENU_TIMEOUT, URL_HASH_KEYS, URL_QUERY_KEYS, + VERTICAL_ALIGN, ZOOM_STEP, } from "../constants"; import { loadFromBlob } from "../data"; @@ -2225,7 +2226,7 @@ class App extends React.Component { ? "center" : this.state.currentItemTextAlign, verticalAlign: parentCenterPosition - ? "middle" + ? VERTICAL_ALIGN.MIDDLE : DEFAULT_VERTICAL_ALIGN, containerId: container?.id ?? undefined, groupIds: container?.groupIds ?? [], @@ -2233,13 +2234,7 @@ class App extends React.Component { this.setState({ editingElement: element }); - if (existingTextElement) { - // if text element is no longer centered to a container, reset - // verticalAlign to default because it's currently internal-only - if (!parentCenterPosition || element.textAlign !== "center") { - mutateElement(element, { verticalAlign: DEFAULT_VERTICAL_ALIGN }); - } - } else { + if (!existingTextElement) { this.scene.replaceAllElements([ ...this.scene.getElementsIncludingDeleted(), element, diff --git a/src/components/icons.tsx b/src/components/icons.tsx index ef4f2a31..cc371259 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -885,6 +885,40 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) => ), ); +export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) => + createIcon( + , + { width: 448, height: 512 }, + ), +); + +export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) => + createIcon( + , + { width: 448, height: 512 }, + ), +); + +export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) => + createIcon( + , + { width: 448, height: 512 }, + ), +); + export const publishIcon = createIcon( , @@ -175,7 +175,7 @@ const getAdjustedDimensions = ( let y: number; if ( textAlign === "center" && - verticalAlign === "middle" && + verticalAlign === VERTICAL_ALIGN.MIDDLE && !element.containerId ) { const prevMetrics = measureText( diff --git a/src/element/textElement.ts b/src/element/textElement.ts index 5f6a403b..3f86e0d3 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement, } from "./types"; import { mutateElement } from "./mutateElement"; -import { BOUND_TEXT_PADDING } from "../constants"; +import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; import { MaybeTransformHandleType } from "./transformHandles"; import Scene from "../scene/Scene"; import { AppState } from "../types"; @@ -39,11 +39,19 @@ export const redrawTextBoundingBox = ( let coordY = element.y; // Resize container and vertically center align the text if (container) { - coordY = container.y + container.height / 2 - metrics.height / 2; let nextHeight = container.height; - if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) { - nextHeight = metrics.height + BOUND_TEXT_PADDING * 2; - coordY = container.y + nextHeight / 2 - metrics.height / 2; + + if (element.verticalAlign === VERTICAL_ALIGN.TOP) { + coordY = container.y + BOUND_TEXT_PADDING; + } else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) { + coordY = + container.y + container.height - metrics.height - BOUND_TEXT_PADDING; + } else { + coordY = container.y + container.height / 2 - metrics.height / 2; + if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) { + nextHeight = metrics.height + BOUND_TEXT_PADDING * 2; + coordY = container.y + nextHeight / 2 - metrics.height / 2; + } } mutateElement(container, { height: nextHeight }); } @@ -142,7 +150,14 @@ export const handleBindTextResize = ( }); } - const updatedY = element.y + containerHeight / 2 - nextHeight / 2; + 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; + } mutateElement(textElement, { text, diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 7eeebe54..ffdc18cf 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -7,7 +7,7 @@ import { } from "../utils"; import Scene from "../scene/Scene"; import { isBoundToContainer, isTextElement } from "./typeChecks"; -import { CLASSES, BOUND_TEXT_PADDING } from "../constants"; +import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; import { ExcalidrawElement, ExcalidrawTextElement, @@ -105,6 +105,8 @@ export const textWysiwyg = ({ const updatedElement = Scene.getScene(element)?.getElement( id, ) as ExcalidrawTextElement; + const { textAlign, verticalAlign } = updatedElement; + const approxLineHeight = getApproxLineHeight(getFontString(updatedElement)); if (updatedElement && isTextElement(updatedElement)) { let coordX = updatedElement.x; @@ -140,7 +142,7 @@ export const textWysiwyg = ({ maxHeight = container.height - BOUND_TEXT_PADDING * 2; width = maxWidth; // The coordinates of text box set a distance of - // 30px to preserve padding + // 5px to preserve padding coordX = container.x + BOUND_TEXT_PADDING; // autogrow container height if text exceeds if (height > maxHeight) { @@ -160,11 +162,16 @@ export const textWysiwyg = ({ // is reached else { // vertically center align the text - coordY = container.y + container.height / 2 - height / 2; + if (verticalAlign === VERTICAL_ALIGN.MIDDLE) { + coordY = container.y + container.height / 2 - height / 2; + } + if (verticalAlign === VERTICAL_ALIGN.BOTTOM) { + coordY = + container.y + container.height - height - BOUND_TEXT_PADDING; + } } } const [viewportX, viewportY] = getViewportCoords(coordX, coordY); - const { textAlign } = updatedElement; const initialSelectionStart = editable.selectionStart; const initialSelectionEnd = editable.selectionEnd; const initialLength = editable.value.length; @@ -212,6 +219,7 @@ export const textWysiwyg = ({ editorMaxHeight, ), textAlign, + verticalAlign, color: updatedElement.strokeColor, opacity: updatedElement.opacity / 100, filter: "var(--theme-filter)", diff --git a/src/element/types.ts b/src/element/types.ts index 92aba772..ccc2c614 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -1,5 +1,5 @@ import { Point } from "../types"; -import { FONT_FAMILY, THEME } from "../constants"; +import { FONT_FAMILY, THEME, VERTICAL_ALIGN } from "../constants"; export type ChartType = "bar" | "line"; export type FillStyle = "hachure" | "cross-hatch" | "solid"; @@ -12,7 +12,9 @@ export type PointerType = "mouse" | "pen" | "touch"; export type StrokeSharpness = "round" | "sharp"; export type StrokeStyle = "solid" | "dashed" | "dotted"; export type TextAlign = "left" | "center" | "right"; -export type VerticalAlign = "top" | "middle"; + +type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN; +export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys]; type _ExcalidrawElementBase = Readonly<{ id: string; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 6db858d9..db6869c6 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -29,7 +29,13 @@ import { isPathALoop } from "../math"; import rough from "roughjs/bin/rough"; import { AppState, BinaryFiles, Zoom } from "../types"; import { getDefaultAppState } from "../appState"; -import { MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS } from "../constants"; +import { + BOUND_TEXT_PADDING, + MAX_DECIMALS_FOR_SVG_EXPORT, + MIME_TYPES, + SVG_NS, + VERTICAL_ALIGN, +} from "../constants"; import { getStroke, StrokeOptions } from "perfect-freehand"; import { getApproxLineHeight } from "../element/textElement"; @@ -264,7 +270,11 @@ const drawElementOnCanvas = ( const lineHeight = element.containerId ? getApproxLineHeight(getFontString(element)) : element.height / lines.length; - const verticalOffset = element.height - element.baseline; + let verticalOffset = element.height - element.baseline; + if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) { + verticalOffset = BOUND_TEXT_PADDING; + } + const horizontalOffset = element.textAlign === "center" ? element.width / 2