feat: support vertical text align for bound containers (#4852)

* feat: support vertical text align for bound containers

* update icons

* use const

* fix lint

* rename to  and show when text editor active

* don't update vertical align if not center

* fix svgs

* fix y coords when vertical align bottm

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2022-03-02 20:06:07 +05:30 committed by GitHub
parent c5a7723185
commit 8e26d5b500
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 222 additions and 66 deletions

View File

@ -30,11 +30,15 @@ import {
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignRightIcon, TextAlignRightIcon,
TextAlignTopIcon,
TextAlignBottomIcon,
TextAlignMiddleIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { import {
getNonDeletedElements, getNonDeletedElements,
@ -58,6 +62,7 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
FontFamilyValues, FontFamilyValues,
TextAlign, TextAlign,
VerticalAlign,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@ -713,9 +718,7 @@ export const actionChangeTextAlign = register({
if (isTextElement(oldElement)) { if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith( const newElement: ExcalidrawTextElement = newElementWith(
oldElement, oldElement,
{ { textAlign: value },
textAlign: value,
},
); );
redrawTextBoundingBox( redrawTextBoundingBox(
newElement, newElement,
@ -736,7 +739,8 @@ export const actionChangeTextAlign = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset> <fieldset>
<legend>{t("labels.textAlign")}</legend> <legend>{t("labels.textAlign")}</legend>
<ButtonIconSelect<TextAlign | false> <ButtonIconSelect<TextAlign | false>
@ -776,7 +780,78 @@ export const actionChangeTextAlign = register({
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
</fieldset> </fieldset>
);
},
});
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 (
<fieldset>
<ButtonIconSelect<VerticalAlign | false>
group="text-align"
options={[
{
value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />,
},
{
value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />,
},
{
value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />,
},
]}
value={getFormValue(elements, appState, (element) => {
if (isTextElement(element) && element.containerId) {
return element.verticalAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.verticalAlign;
}
return null;
})}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
}); });
export const actionChangeSharpness = register({ export const actionChangeSharpness = register({

View File

@ -17,6 +17,7 @@ export {
actionChangeFontSize, actionChangeFontSize,
actionChangeFontFamily, actionChangeFontFamily,
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign,
} from "./actionProperties"; } from "./actionProperties";
export { export {

View File

@ -82,6 +82,7 @@ export type ActionName =
| "zoomToSelection" | "zoomToSelection"
| "changeFontFamily" | "changeFontFamily"
| "changeTextAlign" | "changeTextAlign"
| "changeVerticalAlign"
| "toggleFullScreen" | "toggleFullScreen"
| "toggleShortcuts" | "toggleShortcuts"
| "group" | "group"

View File

@ -1,5 +1,10 @@
import colors from "./colors"; 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 { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types"; import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random"; import { randomId } from "./random";
@ -161,7 +166,7 @@ const commonProps = {
strokeSharpness: "sharp", strokeSharpness: "sharp",
strokeStyle: "solid", strokeStyle: "solid",
strokeWidth: 1, strokeWidth: 1,
verticalAlign: "middle", verticalAlign: VERTICAL_ALIGN.MIDDLE,
} as const; } as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => { const getChartDimentions = (spreadsheet: Spreadsheet) => {

View File

@ -19,7 +19,7 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { hasBoundTextElement } from "../element/typeChecks"; import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@ -110,6 +110,10 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{targetElements.every(
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
) && <>{renderAction("changeVerticalAlign")}</>}
{(canHaveArrowheads(elementType) || {(canHaveArrowheads(elementType) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>

View File

@ -69,6 +69,7 @@ import {
TOUCH_CTX_MENU_TIMEOUT, TOUCH_CTX_MENU_TIMEOUT,
URL_HASH_KEYS, URL_HASH_KEYS,
URL_QUERY_KEYS, URL_QUERY_KEYS,
VERTICAL_ALIGN,
ZOOM_STEP, ZOOM_STEP,
} from "../constants"; } from "../constants";
import { loadFromBlob } from "../data"; import { loadFromBlob } from "../data";
@ -2225,7 +2226,7 @@ class App extends React.Component<AppProps, AppState> {
? "center" ? "center"
: this.state.currentItemTextAlign, : this.state.currentItemTextAlign,
verticalAlign: parentCenterPosition verticalAlign: parentCenterPosition
? "middle" ? VERTICAL_ALIGN.MIDDLE
: DEFAULT_VERTICAL_ALIGN, : DEFAULT_VERTICAL_ALIGN,
containerId: container?.id ?? undefined, containerId: container?.id ?? undefined,
groupIds: container?.groupIds ?? [], groupIds: container?.groupIds ?? [],
@ -2233,13 +2234,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ editingElement: element }); this.setState({ editingElement: element });
if (existingTextElement) { 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 {
this.scene.replaceAllElements([ this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(), ...this.scene.getElementsIncludingDeleted(),
element, element,

View File

@ -885,6 +885,40 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
), ),
); );
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="m16,132l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16zm0,160l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="M16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292ZM16,452L432,452C440.837,452 448,444.837 448,436L448,396C448,387.163 440.837,380 432,380L16,380C7.163,380 0,387.163 0,396L0,436C0,444.837 7.163,452 16,452Z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
transform="matrix(1,0,0,1,0,80)"
d="M16,132L432,132C440.837,132 448,124.837 448,116L448,76C448,67.163 440.837,60 432,60L16,60C7.163,60 0,67.163 0,76L0,116C0,124.837 7.163,132 16,132ZM16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292Z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const publishIcon = createIcon( export const publishIcon = createIcon(
<path <path
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z" d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"

View File

@ -182,3 +182,9 @@ export const VERSIONS = {
} as const; } as const;
export const BOUND_TEXT_PADDING = 5; export const BOUND_TEXT_PADDING = 5;
export const VERTICAL_ALIGN = {
TOP: "top",
MIDDLE: "middle",
BOTTOM: "bottom",
};

View File

@ -23,7 +23,7 @@ import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { getContainerElement, measureText, wrapText } from "./textElement"; import { getContainerElement, measureText, wrapText } from "./textElement";
import { isBoundToContainer } from "./typeChecks"; import { isBoundToContainer } from "./typeChecks";
import { BOUND_TEXT_PADDING } from "../constants"; import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
type ElementConstructorOpts = MarkOptional< type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@ -175,7 +175,7 @@ const getAdjustedDimensions = (
let y: number; let y: number;
if ( if (
textAlign === "center" && textAlign === "center" &&
verticalAlign === "middle" && verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId !element.containerId
) { ) {
const prevMetrics = measureText( const prevMetrics = measureText(

View File

@ -8,7 +8,7 @@ import {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { BOUND_TEXT_PADDING } from "../constants"; import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import { MaybeTransformHandleType } from "./transformHandles"; import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { AppState } from "../types"; import { AppState } from "../types";
@ -39,12 +39,20 @@ export const redrawTextBoundingBox = (
let coordY = element.y; let coordY = element.y;
// Resize container and vertically center align the text // Resize container and vertically center align the text
if (container) { if (container) {
coordY = container.y + container.height / 2 - metrics.height / 2;
let nextHeight = container.height; let nextHeight = container.height;
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) { if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) {
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2; nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
coordY = container.y + nextHeight / 2 - metrics.height / 2; coordY = container.y + nextHeight / 2 - metrics.height / 2;
} }
}
mutateElement(container, { height: nextHeight }); 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, { mutateElement(textElement, {
text, text,

View File

@ -7,7 +7,7 @@ import {
} from "../utils"; } from "../utils";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isBoundToContainer, isTextElement } from "./typeChecks"; import { isBoundToContainer, isTextElement } from "./typeChecks";
import { CLASSES, BOUND_TEXT_PADDING } from "../constants"; import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@ -105,6 +105,8 @@ export const textWysiwyg = ({
const updatedElement = Scene.getScene(element)?.getElement( const updatedElement = Scene.getScene(element)?.getElement(
id, id,
) as ExcalidrawTextElement; ) as ExcalidrawTextElement;
const { textAlign, verticalAlign } = updatedElement;
const approxLineHeight = getApproxLineHeight(getFontString(updatedElement)); const approxLineHeight = getApproxLineHeight(getFontString(updatedElement));
if (updatedElement && isTextElement(updatedElement)) { if (updatedElement && isTextElement(updatedElement)) {
let coordX = updatedElement.x; let coordX = updatedElement.x;
@ -140,7 +142,7 @@ export const textWysiwyg = ({
maxHeight = container.height - BOUND_TEXT_PADDING * 2; maxHeight = container.height - BOUND_TEXT_PADDING * 2;
width = maxWidth; width = maxWidth;
// The coordinates of text box set a distance of // The coordinates of text box set a distance of
// 30px to preserve padding // 5px to preserve padding
coordX = container.x + BOUND_TEXT_PADDING; coordX = container.x + BOUND_TEXT_PADDING;
// autogrow container height if text exceeds // autogrow container height if text exceeds
if (height > maxHeight) { if (height > maxHeight) {
@ -160,11 +162,16 @@ export const textWysiwyg = ({
// is reached // is reached
else { else {
// vertically center align the text // vertically center align the text
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
coordY = container.y + container.height / 2 - height / 2; 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 [viewportX, viewportY] = getViewportCoords(coordX, coordY);
const { textAlign } = updatedElement;
const initialSelectionStart = editable.selectionStart; const initialSelectionStart = editable.selectionStart;
const initialSelectionEnd = editable.selectionEnd; const initialSelectionEnd = editable.selectionEnd;
const initialLength = editable.value.length; const initialLength = editable.value.length;
@ -212,6 +219,7 @@ export const textWysiwyg = ({
editorMaxHeight, editorMaxHeight,
), ),
textAlign, textAlign,
verticalAlign,
color: updatedElement.strokeColor, color: updatedElement.strokeColor,
opacity: updatedElement.opacity / 100, opacity: updatedElement.opacity / 100,
filter: "var(--theme-filter)", filter: "var(--theme-filter)",

View File

@ -1,5 +1,5 @@
import { Point } from "../types"; 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 ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid"; export type FillStyle = "hachure" | "cross-hatch" | "solid";
@ -12,7 +12,9 @@ export type PointerType = "mouse" | "pen" | "touch";
export type StrokeSharpness = "round" | "sharp"; export type StrokeSharpness = "round" | "sharp";
export type StrokeStyle = "solid" | "dashed" | "dotted"; export type StrokeStyle = "solid" | "dashed" | "dotted";
export type TextAlign = "left" | "center" | "right"; 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<{ type _ExcalidrawElementBase = Readonly<{
id: string; id: string;

View File

@ -29,7 +29,13 @@ import { isPathALoop } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { AppState, BinaryFiles, Zoom } from "../types"; import { AppState, BinaryFiles, Zoom } from "../types";
import { getDefaultAppState } from "../appState"; 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 { getStroke, StrokeOptions } from "perfect-freehand";
import { getApproxLineHeight } from "../element/textElement"; import { getApproxLineHeight } from "../element/textElement";
@ -264,7 +270,11 @@ const drawElementOnCanvas = (
const lineHeight = element.containerId const lineHeight = element.containerId
? getApproxLineHeight(getFontString(element)) ? getApproxLineHeight(getFontString(element))
: element.height / lines.length; : 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 = const horizontalOffset =
element.textAlign === "center" element.textAlign === "center"
? element.width / 2 ? element.width / 2