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

@ -20,7 +20,7 @@ import { AppState } from "../../src/types";
import { t } from "../i18n";
import { register } from "./register";
import { newElementWith } from "../element/mutateElement";
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../appState";
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
const changeProperty = (
elements: readonly ExcalidrawElement[],

View File

@ -4,13 +4,13 @@ import {
redrawTextBoundingBox,
} from "../element";
import { KEYS } from "../keys";
import { register } from "./register";
import { mutateElement, newElementWith } from "../element/mutateElement";
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../appState";
import { register } from "./register";
import { mutateElement, newElementWith } from "../element/mutateElement";
} from "../constants";
let copiedStyles: string = "{}";

View File

@ -2,11 +2,11 @@ import oc from "open-color";
import { AppState, FlooredNumber } from "./types";
import { getDateTime } from "./utils";
import { t } from "./i18n";
import { FontFamily } from "./element/types";
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
export const DEFAULT_TEXT_ALIGN = "left";
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "./constants";
export const getDefaultAppState = (): AppState => {
return {

View File

@ -2,6 +2,7 @@ import { ExcalidrawElement } from "./element/types";
import { newElement, newTextElement } from "./element";
import { AppState } from "./types";
import { t } from "./i18n";
import { DEFAULT_VERTICAL_ALIGN } from "./constants";
interface Spreadsheet {
yAxisLabel: string | null;
@ -167,6 +168,7 @@ export function renderSpreadsheet(
fontSize: 16,
fontFamily: appState.currentItemFontFamily,
textAlign: appState.currentItemTextAlign,
verticalAlign: DEFAULT_VERTICAL_ALIGN,
});
const maxYLabel = newTextElement({
@ -183,6 +185,7 @@ export function renderSpreadsheet(
fontSize: 16,
fontFamily: appState.currentItemFontFamily,
textAlign: appState.currentItemTextAlign,
verticalAlign: DEFAULT_VERTICAL_ALIGN,
});
const bars = spreadsheet.values.map((value, i) => {
@ -226,6 +229,7 @@ export function renderSpreadsheet(
fontSize: 16,
fontFamily: appState.currentItemFontFamily,
textAlign: "center",
verticalAlign: DEFAULT_VERTICAL_ALIGN,
width: BAR_WIDTH,
angle: ANGLE,
});
@ -246,6 +250,7 @@ export function renderSpreadsheet(
fontSize: 20,
fontFamily: appState.currentItemFontFamily,
textAlign: "center",
verticalAlign: DEFAULT_VERTICAL_ALIGN,
width: BAR_WIDTH,
angle: ANGLE,
})

View File

@ -27,6 +27,7 @@ import {
getResizeArrowDirection,
getResizeHandlerFromCoords,
isNonDeletedElement,
updateTextElement,
dragSelectedElements,
getDragOffsetXY,
dragNewElement,
@ -55,7 +56,11 @@ import Portal from "./Portal";
import { renderScene } from "../renderer";
import { AppState, GestureEvent, Gesture } from "../types";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawTextElement,
NonDeleted,
} from "../element/types";
import { distance2d, isPathALoop, getGridPoint } from "../math";
@ -113,6 +118,7 @@ import {
EVENT,
ENV,
CANVAS_ONLY_ACTIONS,
DEFAULT_VERTICAL_ALIGN,
GRID_SIZE,
} from "../constants";
import {
@ -583,7 +589,11 @@ class App extends React.Component<any, AppState> {
if (scrollBars) {
currentScrollBars = scrollBars;
}
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
const scrolledOutside =
// hide when editing text
this.state.editingElement?.type === "text"
? false
: !atLeastOneVisibleElement && elements.length > 0;
if (this.state.scrolledOutside !== scrolledOutside) {
this.setState({ scrolledOutside: scrolledOutside });
}
@ -790,6 +800,7 @@ class App extends React.Component<any, AppState> {
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
textAlign: this.state.currentItemTextAlign,
verticalAlign: DEFAULT_VERTICAL_ALIGN,
});
globalSceneState.replaceAllElements([
@ -1250,12 +1261,9 @@ class App extends React.Component<any, AppState> {
!isLinearElement(selectedElements[0])
) {
const selectedElement = selectedElements[0];
const x = selectedElement.x + selectedElement.width / 2;
const y = selectedElement.y + selectedElement.height / 2;
this.startTextEditing({
x: x,
y: y,
sceneX: selectedElement.x + selectedElement.width / 2,
sceneY: selectedElement.y + selectedElement.height / 2,
});
event.preventDefault();
return;
@ -1346,10 +1354,10 @@ class App extends React.Component<any, AppState> {
private handleTextWysiwyg(
element: ExcalidrawTextElement,
{
x,
y,
isExistingElement = false,
}: { x: number; y: number; isExistingElement?: boolean },
}: {
isExistingElement?: boolean;
},
) {
const resetSelection = () => {
this.setState({
@ -1358,26 +1366,13 @@ class App extends React.Component<any, AppState> {
});
};
const deleteElement = () => {
globalSceneState.replaceAllElements([
...globalSceneState.getElementsIncludingDeleted().map((_element) => {
if (_element.id === element.id) {
return newElementWith(_element, { isDeleted: true });
}
return _element;
}),
]);
};
const updateElement = (text: string) => {
globalSceneState.replaceAllElements([
...globalSceneState.getElementsIncludingDeleted().map((_element) => {
if (_element.id === element.id) {
return newTextElement({
...(_element as ExcalidrawTextElement),
x: element.x,
y: element.y,
if (_element.id === element.id && isTextElement(_element)) {
return updateTextElement(_element, {
text,
isDeleted: !text.trim(),
});
}
return _element;
@ -1387,22 +1382,18 @@ class App extends React.Component<any, AppState> {
textWysiwyg({
id: element.id,
x,
y,
initText: element.text,
strokeColor: element.strokeColor,
opacity: element.opacity,
fontSize: element.fontSize,
fontFamily: element.fontFamily,
angle: element.angle,
textAlign: element.textAlign,
zoom: this.state.zoom,
getViewportCoords: (x, y) => {
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{ sceneX: x, sceneY: y },
this.state,
this.canvas,
window.devicePixelRatio,
);
return [viewportX, viewportY];
},
onChange: withBatchedUpdates((text) => {
if (text) {
updateElement(text);
} else {
deleteElement();
}
}),
onSubmit: withBatchedUpdates((text) => {
updateElement(text);
@ -1419,7 +1410,7 @@ class App extends React.Component<any, AppState> {
resetSelection();
}),
onCancel: withBatchedUpdates(() => {
deleteElement();
updateElement("");
if (isExistingElement) {
history.resumeRecording();
}
@ -1438,20 +1429,11 @@ class App extends React.Component<any, AppState> {
updateElement(element.text);
}
private startTextEditing = ({
x,
y,
clientX,
clientY,
centerIfPossible = true,
}: {
x: number;
y: number;
clientX?: number;
clientY?: number;
centerIfPossible?: boolean;
}) => {
const elementAtPosition = getElementAtPosition(
private getTextElementAtPosition(
x: number,
y: number,
): NonDeleted<ExcalidrawTextElement> | null {
const element = getElementAtPosition(
globalSceneState.getElements(),
this.state,
x,
@ -1459,12 +1441,45 @@ class App extends React.Component<any, AppState> {
this.state.zoom,
);
const element =
elementAtPosition && isTextElement(elementAtPosition)
? elementAtPosition
if (element && isTextElement(element) && !element.isDeleted) {
return element;
}
return null;
}
private startTextEditing = ({
sceneX,
sceneY,
insertAtParentCenter = true,
}: {
/** X position to insert text at */
sceneX: number;
/** Y position to insert text at */
sceneY: number;
/** whether to attempt to insert at element center if applicable */
insertAtParentCenter?: boolean;
}) => {
const existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
const parentCenterPosition =
insertAtParentCenter &&
this.getTextWysiwygSnappedToCenterPosition(
sceneX,
sceneY,
this.state,
this.canvas,
window.devicePixelRatio,
);
const element = existingTextElement
? existingTextElement
: newTextElement({
x: x,
y: y,
x: parentCenterPosition
? parentCenterPosition.elementCenterX
: sceneX,
y: parentCenterPosition
? parentCenterPosition.elementCenterY
: sceneY,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
@ -1475,62 +1490,34 @@ class App extends React.Component<any, AppState> {
text: "",
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
textAlign: this.state.currentItemTextAlign,
textAlign: parentCenterPosition
? "center"
: this.state.currentItemTextAlign,
verticalAlign: parentCenterPosition
? "middle"
: DEFAULT_VERTICAL_ALIGN,
});
this.setState({ editingElement: element });
let textX = clientX || x;
let textY = clientY || y;
let isExistingTextElement = false;
if (elementAtPosition && isTextElement(elementAtPosition)) {
isExistingTextElement = true;
const centerElementX = elementAtPosition.x + elementAtPosition.width / 2;
const centerElementY = elementAtPosition.y + elementAtPosition.height / 2;
const {
x: centerElementXInViewport,
y: centerElementYInViewport,
} = sceneCoordsToViewportCoords(
{ sceneX: centerElementX, sceneY: centerElementY },
this.state,
this.canvas,
window.devicePixelRatio,
);
textX = centerElementXInViewport;
textY = centerElementYInViewport;
// x and y will change after calling newTextElement function
mutateElement(element, {
x: centerElementX,
y: centerElementY,
});
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 {
globalSceneState.replaceAllElements([
...globalSceneState.getElementsIncludingDeleted(),
element,
]);
if (centerIfPossible) {
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
x,
y,
this.state,
this.canvas,
window.devicePixelRatio,
);
if (snappedToCenterPosition) {
// case: creating new text not centered to parent elemenent → offset Y
// so that the text is centered to cursor position
if (!parentCenterPosition) {
mutateElement(element, {
x: snappedToCenterPosition.elementCenterX,
y: snappedToCenterPosition.elementCenterY,
y: element.y - element.baseline / 2,
});
textX = snappedToCenterPosition.wysiwygX;
textY = snappedToCenterPosition.wysiwygY;
}
}
}
@ -1539,9 +1526,7 @@ class App extends React.Component<any, AppState> {
});
this.handleTextWysiwyg(element, {
x: textX,
y: textY,
isExistingElement: isExistingTextElement,
isExistingElement: !!existingTextElement,
});
};
@ -1574,7 +1559,7 @@ class App extends React.Component<any, AppState> {
resetCursor();
const { x, y } = viewportCoordsToSceneCoords(
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
this.canvas,
@ -1588,8 +1573,8 @@ class App extends React.Component<any, AppState> {
const hitElement = getElementAtPosition(
elements,
this.state,
x,
y,
sceneX,
sceneY,
this.state.zoom,
);
@ -1616,11 +1601,9 @@ class App extends React.Component<any, AppState> {
resetCursor();
this.startTextEditing({
x: x,
y: y,
clientX: event.clientX,
clientY: event.clientY,
centerIfPossible: !event.altKey,
sceneX,
sceneY,
insertAtParentCenter: !event.altKey,
});
};
@ -2213,19 +2196,10 @@ class App extends React.Component<any, AppState> {
return;
}
const { x, y } = viewportCoordsToSceneCoords(
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
this.startTextEditing({
x: x,
y: y,
clientX: event.clientX,
clientY: event.clientY,
centerIfPossible: !event.altKey,
sceneX: x,
sceneY: y,
insertAtParentCenter: !event.altKey,
});
resetCursor();
@ -2640,7 +2614,12 @@ class App extends React.Component<any, AppState> {
resizingElement: null,
selectionElement: null,
cursorButton: "up",
editingElement: multiElement ? this.state.editingElement : null,
// text elements are reset on finalize, and resetting on pointerup
// may cause issues with double taps
editingElement:
multiElement || isTextElement(this.state.editingElement)
? this.state.editingElement
: null,
});
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
@ -3006,7 +2985,9 @@ class App extends React.Component<any, AppState> {
scale: number,
) {
const elementClickedInside = getElementContainingPosition(
globalSceneState.getElementsIncludingDeleted(),
globalSceneState
.getElementsIncludingDeleted()
.filter((element) => !isTextElement(element)),
x,
y,
);
@ -3022,13 +3003,13 @@ class App extends React.Component<any, AppState> {
const isSnappedToCenter =
distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
if (isSnappedToCenter) {
const { x: wysiwygX, y: wysiwygY } = sceneCoordsToViewportCoords(
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{ sceneX: elementCenterX, sceneY: elementCenterY },
state,
canvas,
scale,
);
return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
return { viewportX, viewportY, elementCenterX, elementCenterY };
}
}
}

View File

@ -1,3 +1,5 @@
import { FontFamily } from "./element/types";
export const DRAGGING_THRESHOLD = 10; // 10px
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
@ -67,6 +69,11 @@ export const FONT_FAMILY = {
3: "Cascadia",
} as const;
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable?

View File

@ -8,8 +8,12 @@ import { DataState } from "./types";
import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
import { calculateScrollCenter } from "../scene";
import { randomId } from "../random";
import { DEFAULT_TEXT_ALIGN, DEFAULT_FONT_FAMILY } from "../appState";
import { FONT_FAMILY } from "../constants";
import {
FONT_FAMILY,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
} from "../constants";
const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
@ -75,7 +79,8 @@ const migrateElement = (
fontFamily,
text: element.text ?? "",
baseline: element.baseline,
textAlign: element.textAlign ?? DEFAULT_TEXT_ALIGN,
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
});
case "draw":
case "line":

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";
}
editable.dir = "auto";
editable.tabIndex = 0;
editable.innerText = initText;
editable.dataset.type = "wysiwyg";
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;
const degree = (180 * angle) / Math.PI;
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, {
color: strokeColor,
position: "fixed",
opacity: opacity / 100,
top: `${y}px`,
left: `${x}px`,
transform: `translate(-50%, -50%) scale(${zoom}) rotate(${degree}deg)`,
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.dataset.type = "wysiwyg";
// prevent line wrapping on Safari
editable.wrap = "off";
Object.assign(editable.style, {
position: "fixed",
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" };

View File

@ -57,7 +57,12 @@ export const rotate = (
];
export const adjustXYWithRotation = (
side: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
sides: {
n?: boolean;
e?: boolean;
s?: boolean;
w?: boolean;
},
x: number,
y: number,
angle: number,
@ -65,49 +70,35 @@ export const adjustXYWithRotation = (
deltaY1: number,
deltaX2: number,
deltaY2: number,
isResizeFromCenter: boolean,
): [number, number] => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
if (side === "e" || side === "ne" || side === "se") {
if (isResizeFromCenter) {
if (sides.e && sides.w) {
x += deltaX1 + deltaX2;
} else {
} else if (sides.e) {
x += deltaX1 * (1 + cos);
y += deltaX1 * sin;
x += deltaX2 * (1 - cos);
y += deltaX2 * -sin;
}
}
if (side === "s" || side === "sw" || side === "se") {
if (isResizeFromCenter) {
y += deltaY1 + deltaY2;
} else {
x += deltaY1 * -sin;
y += deltaY1 * (1 + cos);
x += deltaY2 * sin;
y += deltaY2 * (1 - cos);
}
}
if (side === "w" || side === "nw" || side === "sw") {
if (isResizeFromCenter) {
x += deltaX1 + deltaX2;
} else {
} else if (sides.w) {
x += deltaX1 * (1 - cos);
y += deltaX1 * -sin;
x += deltaX2 * (1 + cos);
y += deltaX2 * sin;
}
}
if (side === "n" || side === "nw" || side === "ne") {
if (isResizeFromCenter) {
if (sides.n && sides.s) {
y += deltaY1 + deltaY2;
} else {
} else if (sides.n) {
x += deltaY1 * sin;
y += deltaY1 * (1 - cos);
x += deltaY2 * -sin;
y += deltaY2 * (1 + cos);
}
} else if (sides.s) {
x += deltaY1 * -sin;
y += deltaY1 * (1 + cos);
x += deltaY2 * sin;
y += deltaY2 * (1 - cos);
}
return [x, y];
};

View File

@ -4,11 +4,11 @@ import { newTextElement } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getCommonBounds } from "../element/bounds";
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
import { distance, SVG_NS, measureText, getFontString } from "../utils";
import { distance, SVG_NS } from "../utils";
import { normalizeScroll } from "./scroll";
import { AppState } from "../types";
import { t } from "../i18n";
import { DEFAULT_FONT_FAMILY } from "../appState";
import { DEFAULT_FONT_FAMILY, DEFAULT_VERTICAL_ALIGN } from "../constants";
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@ -150,20 +150,13 @@ export const exportToSvg = (
};
const getWatermarkElement = (maxX: number, maxY: number) => {
const text = t("labels.madeWithExcalidraw");
const fontSize = 16;
const fontFamily = DEFAULT_FONT_FAMILY;
const { width: textWidth } = measureText(
text,
getFontString({ fontSize, fontFamily }),
);
return newTextElement({
text,
fontSize,
fontFamily,
textAlign: "center",
x: maxX - textWidth / 2,
text: t("labels.madeWithExcalidraw"),
fontSize: 16,
fontFamily: DEFAULT_FONT_FAMILY,
textAlign: "right",
verticalAlign: DEFAULT_VERTICAL_ALIGN,
x: maxX,
y: maxY + 16,
strokeColor: oc.gray[5],
backgroundColor: "transparent",

View File

@ -88,8 +88,12 @@ export const measureText = (text: string, font: FontString) => {
line.style.whiteSpace = "pre";
line.style.font = font;
body.appendChild(line);
// Now we can measure width and height of the letter
line.innerText = text;
line.innerText = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const width = line.offsetWidth;
const height = line.offsetHeight;
// Now creating 1px sized item that will be aligned to baseline
@ -214,13 +218,8 @@ export const sceneCoordsToViewportCoords = (
scale: number,
) => {
const zoomOrigin = getZoomOrigin(canvas, scale);
const sceneXWithZoomAndScroll =
zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
const sceneYWithZoomAndScroll =
zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
const x = sceneXWithZoomAndScroll;
const y = sceneYWithZoomAndScroll;
const x = zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
const y = zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
return { x, y };
};