mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
do not center text when not applicable (#1783)
This commit is contained in:
parent
9c89504b6f
commit
cd87bd6901
16 changed files with 418 additions and 321 deletions
|
@ -8,6 +8,7 @@ import { isInvisiblySmallElement } from "./sizeHelpers";
|
|||
export {
|
||||
newElement,
|
||||
newTextElement,
|
||||
updateTextElement,
|
||||
newLinearElement,
|
||||
duplicateElement,
|
||||
} from "./newElement";
|
||||
|
|
|
@ -81,6 +81,7 @@ it("clones text element", () => {
|
|||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
|
|
@ -7,12 +7,16 @@ import {
|
|||
TextAlign,
|
||||
FontFamily,
|
||||
GroupId,
|
||||
VerticalAlign,
|
||||
} from "../element/types";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted">,
|
||||
|
@ -72,15 +76,39 @@ export const newElement = (
|
|||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
function getTextElementPositionOffsets(
|
||||
opts: {
|
||||
textAlign: ExcalidrawTextElement["textAlign"];
|
||||
verticalAlign: ExcalidrawTextElement["verticalAlign"];
|
||||
},
|
||||
metrics: {
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
x:
|
||||
opts.textAlign === "center"
|
||||
? metrics.width / 2
|
||||
: opts.textAlign === "right"
|
||||
? metrics.width
|
||||
: 0,
|
||||
y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const newTextElement = (
|
||||
opts: {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fontFamily: FontFamily;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const metrics = measureText(opts.text, getFontString(opts));
|
||||
const offsets = getTextElementPositionOffsets(opts, metrics);
|
||||
const textElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||
|
@ -88,9 +116,9 @@ export const newTextElement = (
|
|||
fontSize: opts.fontSize,
|
||||
fontFamily: opts.fontFamily,
|
||||
textAlign: opts.textAlign,
|
||||
// Center the text
|
||||
x: opts.x - metrics.width / 2,
|
||||
y: opts.y - metrics.height / 2,
|
||||
verticalAlign: opts.verticalAlign,
|
||||
x: opts.x - offsets.x,
|
||||
y: opts.y - offsets.y,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
|
@ -101,6 +129,84 @@ export const newTextElement = (
|
|||
return textElement;
|
||||
};
|
||||
|
||||
const getAdjustedDimensions = (
|
||||
element: ExcalidrawTextElement,
|
||||
nextText: string,
|
||||
): {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseline,
|
||||
} = measureText(nextText, getFontString(element));
|
||||
|
||||
const { textAlign, verticalAlign } = element;
|
||||
|
||||
let x, y;
|
||||
|
||||
if (textAlign === "center" && verticalAlign === "middle") {
|
||||
const prevMetrics = measureText(element.text, getFontString(element));
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
width: nextWidth - prevMetrics.width,
|
||||
height: nextHeight - prevMetrics.height,
|
||||
});
|
||||
|
||||
x = element.x - offsets.x;
|
||||
y = element.y - offsets.y;
|
||||
} else {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
|
||||
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
||||
element,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
);
|
||||
const deltaX1 = (x1 - nextX1) / 2;
|
||||
const deltaY1 = (y1 - nextY1) / 2;
|
||||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
|
||||
[x, y] = adjustXYWithRotation(
|
||||
{
|
||||
s: true,
|
||||
e: textAlign === "center" || textAlign === "left",
|
||||
w: textAlign === "center" || textAlign === "right",
|
||||
},
|
||||
element.x,
|
||||
element.y,
|
||||
element.angle,
|
||||
deltaX1,
|
||||
deltaY1,
|
||||
deltaX2,
|
||||
deltaY2,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x: Number.isFinite(x) ? x : element.x,
|
||||
y: Number.isFinite(y) ? y : element.y,
|
||||
baseline: nextBaseline,
|
||||
};
|
||||
};
|
||||
|
||||
export const updateTextElement = (
|
||||
element: ExcalidrawTextElement,
|
||||
{ text, isDeleted }: { text: string; isDeleted?: boolean },
|
||||
): ExcalidrawTextElement => {
|
||||
return newElementWith(element, {
|
||||
text,
|
||||
isDeleted: isDeleted ?? element.isDeleted,
|
||||
...getAdjustedDimensions(element, text),
|
||||
});
|
||||
};
|
||||
|
||||
export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
|
|
|
@ -248,6 +248,26 @@ const measureFontSizeFromWH = (
|
|||
return null;
|
||||
};
|
||||
|
||||
const getSidesForResizeHandle = (
|
||||
resizeHandle: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
|
||||
isResizeFromCenter: boolean,
|
||||
) => {
|
||||
return {
|
||||
n:
|
||||
/^(n|ne|nw)$/.test(resizeHandle) ||
|
||||
(isResizeFromCenter && /^(s|se|sw)$/.test(resizeHandle)),
|
||||
s:
|
||||
/^(s|se|sw)$/.test(resizeHandle) ||
|
||||
(isResizeFromCenter && /^(n|ne|nw)$/.test(resizeHandle)),
|
||||
w:
|
||||
/^(w|nw|sw)$/.test(resizeHandle) ||
|
||||
(isResizeFromCenter && /^(e|ne|se)$/.test(resizeHandle)),
|
||||
e:
|
||||
/^(e|ne|se)$/.test(resizeHandle) ||
|
||||
(isResizeFromCenter && /^(w|nw|sw)$/.test(resizeHandle)),
|
||||
};
|
||||
};
|
||||
|
||||
const resizeSingleTextElement = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
resizeHandle: "nw" | "ne" | "sw" | "se",
|
||||
|
@ -310,7 +330,7 @@ const resizeSingleTextElement = (
|
|||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
||||
resizeHandle,
|
||||
getSidesForResizeHandle(resizeHandle, isResizeFromCenter),
|
||||
element.x,
|
||||
element.y,
|
||||
element.angle,
|
||||
|
@ -318,7 +338,6 @@ const resizeSingleTextElement = (
|
|||
deltaY1,
|
||||
deltaX2,
|
||||
deltaY2,
|
||||
isResizeFromCenter,
|
||||
);
|
||||
mutateElement(element, {
|
||||
fontSize: nextFont.size,
|
||||
|
@ -403,7 +422,7 @@ const resizeSingleElement = (
|
|||
element.angle,
|
||||
);
|
||||
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
||||
resizeHandle,
|
||||
getSidesForResizeHandle(resizeHandle, isResizeFromCenter),
|
||||
element.x - flipDiffX,
|
||||
element.y - flipDiffY,
|
||||
element.angle,
|
||||
|
@ -411,7 +430,6 @@ const resizeSingleElement = (
|
|||
deltaY1,
|
||||
deltaX2,
|
||||
deltaY2,
|
||||
isResizeFromCenter,
|
||||
);
|
||||
if (
|
||||
nextWidth !== 0 &&
|
||||
|
|
|
@ -1,116 +1,111 @@
|
|||
import { KEYS } from "../keys";
|
||||
import { selectNode, isWritableElement, getFontString } from "../utils";
|
||||
import { isWritableElement, getFontString } from "../utils";
|
||||
import { globalSceneState } from "../scene";
|
||||
import { isTextElement } from "./typeChecks";
|
||||
import { CLASSES } from "../constants";
|
||||
import { FontFamily } from "./types";
|
||||
import { ExcalidrawElement } from "./types";
|
||||
|
||||
const trimText = (text: string) => {
|
||||
// whitespace only → trim all because we'd end up inserting invisible element
|
||||
if (!text.trim()) {
|
||||
return "";
|
||||
}
|
||||
// replace leading/trailing newlines (only) otherwise it messes up bounding
|
||||
// box calculation (there's also a bug in FF which inserts trailing newline
|
||||
// for multiline texts)
|
||||
return text.replace(/^\n+|\n+$/g, "");
|
||||
const normalizeText = (text: string) => {
|
||||
return (
|
||||
text
|
||||
// replace tabs with spaces so they render and measure correctly
|
||||
.replace(/\t/g, " ")
|
||||
// normalize newlines
|
||||
.replace(/\r?\n|\r/g, "\n")
|
||||
);
|
||||
};
|
||||
|
||||
type TextWysiwygParams = {
|
||||
id: string;
|
||||
initText: string;
|
||||
x: number;
|
||||
y: number;
|
||||
strokeColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: FontFamily;
|
||||
opacity: number;
|
||||
zoom: number;
|
||||
angle: number;
|
||||
textAlign: string;
|
||||
onChange?: (text: string) => void;
|
||||
onSubmit: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
const getTransform = (
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number,
|
||||
zoom: number,
|
||||
) => {
|
||||
const degree = (180 * angle) / Math.PI;
|
||||
return `translate(${(width * (zoom - 1)) / 2}px, ${
|
||||
(height * (zoom - 1)) / 2
|
||||
}px) scale(${zoom}) rotate(${degree}deg)`;
|
||||
};
|
||||
|
||||
export const textWysiwyg = ({
|
||||
id,
|
||||
initText,
|
||||
x,
|
||||
y,
|
||||
strokeColor,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
opacity,
|
||||
zoom,
|
||||
angle,
|
||||
onChange,
|
||||
textAlign,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: TextWysiwygParams) => {
|
||||
const editable = document.createElement("div");
|
||||
try {
|
||||
editable.contentEditable = "plaintext-only";
|
||||
} catch {
|
||||
editable.contentEditable = "true";
|
||||
getViewportCoords,
|
||||
}: {
|
||||
id: ExcalidrawElement["id"];
|
||||
zoom: number;
|
||||
onChange?: (text: string) => void;
|
||||
onSubmit: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
getViewportCoords: (x: number, y: number) => [number, number];
|
||||
}) => {
|
||||
function updateWysiwygStyle() {
|
||||
const updatedElement = globalSceneState.getElement(id);
|
||||
if (isTextElement(updatedElement)) {
|
||||
const [viewportX, viewportY] = getViewportCoords(
|
||||
updatedElement.x,
|
||||
updatedElement.y,
|
||||
);
|
||||
const { textAlign, angle } = updatedElement;
|
||||
|
||||
editable.value = updatedElement.text;
|
||||
|
||||
const lines = updatedElement.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = updatedElement.height / lines.length;
|
||||
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: `${lineHeight}px`,
|
||||
width: `${updatedElement.width}px`,
|
||||
height: `${updatedElement.height}px`,
|
||||
left: `${viewportX}px`,
|
||||
top: `${viewportY}px`,
|
||||
transform: getTransform(
|
||||
updatedElement.width,
|
||||
updatedElement.height,
|
||||
angle,
|
||||
zoom,
|
||||
),
|
||||
textAlign: textAlign,
|
||||
color: updatedElement.strokeColor,
|
||||
opacity: updatedElement.opacity / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const editable = document.createElement("textarea");
|
||||
|
||||
editable.dir = "auto";
|
||||
editable.tabIndex = 0;
|
||||
editable.innerText = initText;
|
||||
editable.dataset.type = "wysiwyg";
|
||||
|
||||
const degree = (180 * angle) / Math.PI;
|
||||
// prevent line wrapping on Safari
|
||||
editable.wrap = "off";
|
||||
|
||||
Object.assign(editable.style, {
|
||||
color: strokeColor,
|
||||
position: "fixed",
|
||||
opacity: opacity / 100,
|
||||
top: `${y}px`,
|
||||
left: `${x}px`,
|
||||
transform: `translate(-50%, -50%) scale(${zoom}) rotate(${degree}deg)`,
|
||||
textAlign: textAlign,
|
||||
display: "inline-block",
|
||||
font: getFontString({ fontSize, fontFamily }),
|
||||
padding: "4px",
|
||||
// This needs to have "1px solid" otherwise the carret doesn't show up
|
||||
// the first time on Safari and Chrome!
|
||||
outline: "1px solid transparent",
|
||||
whiteSpace: "nowrap",
|
||||
minHeight: "1em",
|
||||
backfaceVisibility: "hidden",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
outline: 0,
|
||||
resize: "none",
|
||||
background: "transparent",
|
||||
overflow: "hidden",
|
||||
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
|
||||
whiteSpace: "pre",
|
||||
});
|
||||
|
||||
editable.onpaste = (event) => {
|
||||
try {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) {
|
||||
return;
|
||||
}
|
||||
selection.deleteFromDocument();
|
||||
|
||||
const text = event.clipboardData!.getData("text").replace(/\r\n?/g, "\n");
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.innerText = text;
|
||||
const range = selection.getRangeAt(0);
|
||||
range.insertNode(span);
|
||||
|
||||
// deselect
|
||||
window.getSelection()!.removeAllRanges();
|
||||
range.setStart(span, span.childNodes.length);
|
||||
range.setEnd(span, span.childNodes.length);
|
||||
selection.addRange(range);
|
||||
|
||||
event.preventDefault();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
updateWysiwygStyle();
|
||||
|
||||
if (onChange) {
|
||||
editable.oninput = () => {
|
||||
onChange(trimText(editable.innerText));
|
||||
onChange(normalizeText(editable.value));
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -134,8 +129,8 @@ export const textWysiwyg = ({
|
|||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editable.innerText) {
|
||||
onSubmit(trimText(editable.innerText));
|
||||
if (editable.value) {
|
||||
onSubmit(normalizeText(editable.value));
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
|
@ -149,10 +144,10 @@ export const textWysiwyg = ({
|
|||
isDestroyed = true;
|
||||
// remove events to ensure they don't late-fire
|
||||
editable.onblur = null;
|
||||
editable.onpaste = null;
|
||||
editable.oninput = null;
|
||||
editable.onkeydown = null;
|
||||
|
||||
window.removeEventListener("resize", updateWysiwygStyle);
|
||||
window.removeEventListener("wheel", stopEvent, true);
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
window.removeEventListener("pointerup", rebindBlur);
|
||||
|
@ -191,26 +186,19 @@ export const textWysiwyg = ({
|
|||
|
||||
// handle updates of textElement properties of editing element
|
||||
const unbindUpdate = globalSceneState.addCallback(() => {
|
||||
const editingElement = globalSceneState
|
||||
.getElementsIncludingDeleted()
|
||||
.find((element) => element.id === id);
|
||||
if (editingElement && isTextElement(editingElement)) {
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(editingElement),
|
||||
textAlign: editingElement.textAlign,
|
||||
color: editingElement.strokeColor,
|
||||
opacity: editingElement.opacity / 100,
|
||||
});
|
||||
}
|
||||
updateWysiwygStyle();
|
||||
editable.focus();
|
||||
});
|
||||
|
||||
let isDestroyed = false;
|
||||
|
||||
editable.onblur = handleSubmit;
|
||||
// reposition wysiwyg in case of window resize. Happens on mobile when
|
||||
// device keyboard is opened.
|
||||
window.addEventListener("resize", updateWysiwygStyle);
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
window.addEventListener("wheel", stopEvent, true);
|
||||
document.body.appendChild(editable);
|
||||
editable.focus();
|
||||
selectNode(editable);
|
||||
editable.select();
|
||||
};
|
||||
|
|
|
@ -60,6 +60,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
|||
text: string;
|
||||
baseline: number;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
|
@ -72,6 +73,7 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
|||
export type PointerType = "mouse" | "pen" | "touch";
|
||||
|
||||
export type TextAlign = "left" | "center" | "right";
|
||||
export type VerticalAlign = "top" | "middle";
|
||||
|
||||
export type FontFamily = keyof typeof FONT_FAMILY;
|
||||
export type FontString = string & { _brand: "fontString" };
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue