do not center text when not applicable (#1783)

This commit is contained in:
David Luzar 2020-06-25 21:21:27 +02:00 committed by GitHub
parent 9c89504b6f
commit cd87bd6901
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 418 additions and 321 deletions

View file

@ -8,6 +8,7 @@ import { isInvisiblySmallElement } from "./sizeHelpers";
export {
newElement,
newTextElement,
updateTextElement,
newLinearElement,
duplicateElement,
} from "./newElement";

View file

@ -81,6 +81,7 @@ it("clones text element", () => {
fontSize: 20,
fontFamily: 1,
textAlign: "left",
verticalAlign: "top",
});
const copy = duplicateElement(null, new Map(), element);

View file

@ -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"];

View file

@ -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 &&

View file

@ -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();
};

View file

@ -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" };