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:
parent
c5a7723185
commit
8e26d5b500
@ -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 }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.textAlign")}</legend>
|
||||
<ButtonIconSelect<TextAlign | false>
|
||||
group="text-align"
|
||||
options={[
|
||||
{
|
||||
value: "left",
|
||||
text: t("labels.left"),
|
||||
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "center",
|
||||
text: t("labels.center"),
|
||||
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "right",
|
||||
text: t("labels.right"),
|
||||
icon: <TextAlignRightIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.textAlign;
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.textAlign")}</legend>
|
||||
<ButtonIconSelect<TextAlign | false>
|
||||
group="text-align"
|
||||
options={[
|
||||
{
|
||||
value: "left",
|
||||
text: t("labels.left"),
|
||||
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "center",
|
||||
text: t("labels.center"),
|
||||
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "right",
|
||||
text: t("labels.right"),
|
||||
icon: <TextAlignRightIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
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)}
|
||||
/>
|
||||
</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.textAlign;
|
||||
return boundTextElement.verticalAlign;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
appState.currentItemTextAlign,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
),
|
||||
})}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeSharpness = register({
|
||||
|
@ -17,6 +17,7 @@ export {
|
||||
actionChangeFontSize,
|
||||
actionChangeFontFamily,
|
||||
actionChangeTextAlign,
|
||||
actionChangeVerticalAlign,
|
||||
} from "./actionProperties";
|
||||
|
||||
export {
|
||||
|
@ -82,6 +82,7 @@ export type ActionName =
|
||||
| "zoomToSelection"
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "changeVerticalAlign"
|
||||
| "toggleFullScreen"
|
||||
| "toggleShortcuts"
|
||||
| "group"
|
||||
|
@ -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) => {
|
||||
|
@ -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")}</>
|
||||
|
@ -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<AppProps, AppState> {
|
||||
? "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<AppProps, AppState> {
|
||||
|
||||
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,
|
||||
|
@ -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(
|
||||
<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"
|
||||
|
@ -182,3 +182,9 @@ export const VERSIONS = {
|
||||
} as const;
|
||||
|
||||
export const BOUND_TEXT_PADDING = 5;
|
||||
|
||||
export const VERTICAL_ALIGN = {
|
||||
TOP: "top",
|
||||
MIDDLE: "middle",
|
||||
BOTTOM: "bottom",
|
||||
};
|
||||
|
@ -23,7 +23,7 @@ import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import { getContainerElement, measureText, wrapText } from "./textElement";
|
||||
import { isBoundToContainer } from "./typeChecks";
|
||||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@ -175,7 +175,7 @@ const getAdjustedDimensions = (
|
||||
let y: number;
|
||||
if (
|
||||
textAlign === "center" &&
|
||||
verticalAlign === "middle" &&
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId
|
||||
) {
|
||||
const prevMetrics = measureText(
|
||||
|
@ -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,
|
||||
|
@ -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)",
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user