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 }) => (
-
+ );
+ },
});
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