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 { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { newElementWith } from "../element/mutateElement"; 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 = ( const changeProperty = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],

View File

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

View File

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

View File

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

View File

@ -27,6 +27,7 @@ import {
getResizeArrowDirection, getResizeArrowDirection,
getResizeHandlerFromCoords, getResizeHandlerFromCoords,
isNonDeletedElement, isNonDeletedElement,
updateTextElement,
dragSelectedElements, dragSelectedElements,
getDragOffsetXY, getDragOffsetXY,
dragNewElement, dragNewElement,
@ -55,7 +56,11 @@ import Portal from "./Portal";
import { renderScene } from "../renderer"; import { renderScene } from "../renderer";
import { AppState, GestureEvent, Gesture } from "../types"; 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"; import { distance2d, isPathALoop, getGridPoint } from "../math";
@ -113,6 +118,7 @@ import {
EVENT, EVENT,
ENV, ENV,
CANVAS_ONLY_ACTIONS, CANVAS_ONLY_ACTIONS,
DEFAULT_VERTICAL_ALIGN,
GRID_SIZE, GRID_SIZE,
} from "../constants"; } from "../constants";
import { import {
@ -583,7 +589,11 @@ class App extends React.Component<any, AppState> {
if (scrollBars) { if (scrollBars) {
currentScrollBars = 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) { if (this.state.scrolledOutside !== scrolledOutside) {
this.setState({ scrolledOutside: scrolledOutside }); this.setState({ scrolledOutside: scrolledOutside });
} }
@ -790,6 +800,7 @@ class App extends React.Component<any, AppState> {
fontSize: this.state.currentItemFontSize, fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily, fontFamily: this.state.currentItemFontFamily,
textAlign: this.state.currentItemTextAlign, textAlign: this.state.currentItemTextAlign,
verticalAlign: DEFAULT_VERTICAL_ALIGN,
}); });
globalSceneState.replaceAllElements([ globalSceneState.replaceAllElements([
@ -1250,12 +1261,9 @@ class App extends React.Component<any, AppState> {
!isLinearElement(selectedElements[0]) !isLinearElement(selectedElements[0])
) { ) {
const selectedElement = selectedElements[0]; const selectedElement = selectedElements[0];
const x = selectedElement.x + selectedElement.width / 2;
const y = selectedElement.y + selectedElement.height / 2;
this.startTextEditing({ this.startTextEditing({
x: x, sceneX: selectedElement.x + selectedElement.width / 2,
y: y, sceneY: selectedElement.y + selectedElement.height / 2,
}); });
event.preventDefault(); event.preventDefault();
return; return;
@ -1346,10 +1354,10 @@ class App extends React.Component<any, AppState> {
private handleTextWysiwyg( private handleTextWysiwyg(
element: ExcalidrawTextElement, element: ExcalidrawTextElement,
{ {
x,
y,
isExistingElement = false, isExistingElement = false,
}: { x: number; y: number; isExistingElement?: boolean }, }: {
isExistingElement?: boolean;
},
) { ) {
const resetSelection = () => { const resetSelection = () => {
this.setState({ 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) => { const updateElement = (text: string) => {
globalSceneState.replaceAllElements([ globalSceneState.replaceAllElements([
...globalSceneState.getElementsIncludingDeleted().map((_element) => { ...globalSceneState.getElementsIncludingDeleted().map((_element) => {
if (_element.id === element.id) { if (_element.id === element.id && isTextElement(_element)) {
return newTextElement({ return updateTextElement(_element, {
...(_element as ExcalidrawTextElement),
x: element.x,
y: element.y,
text, text,
isDeleted: !text.trim(),
}); });
} }
return _element; return _element;
@ -1387,22 +1382,18 @@ class App extends React.Component<any, AppState> {
textWysiwyg({ textWysiwyg({
id: element.id, 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, 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) => { onChange: withBatchedUpdates((text) => {
if (text) { updateElement(text);
updateElement(text);
} else {
deleteElement();
}
}), }),
onSubmit: withBatchedUpdates((text) => { onSubmit: withBatchedUpdates((text) => {
updateElement(text); updateElement(text);
@ -1419,7 +1410,7 @@ class App extends React.Component<any, AppState> {
resetSelection(); resetSelection();
}), }),
onCancel: withBatchedUpdates(() => { onCancel: withBatchedUpdates(() => {
deleteElement(); updateElement("");
if (isExistingElement) { if (isExistingElement) {
history.resumeRecording(); history.resumeRecording();
} }
@ -1438,20 +1429,11 @@ class App extends React.Component<any, AppState> {
updateElement(element.text); updateElement(element.text);
} }
private startTextEditing = ({ private getTextElementAtPosition(
x, x: number,
y, y: number,
clientX, ): NonDeleted<ExcalidrawTextElement> | null {
clientY, const element = getElementAtPosition(
centerIfPossible = true,
}: {
x: number;
y: number;
clientX?: number;
clientY?: number;
centerIfPossible?: boolean;
}) => {
const elementAtPosition = getElementAtPosition(
globalSceneState.getElements(), globalSceneState.getElements(),
this.state, this.state,
x, x,
@ -1459,78 +1441,83 @@ class App extends React.Component<any, AppState> {
this.state.zoom, this.state.zoom,
); );
const element = if (element && isTextElement(element) && !element.isDeleted) {
elementAtPosition && isTextElement(elementAtPosition) return element;
? elementAtPosition }
: newTextElement({ return null;
x: x, }
y: y,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
text: "",
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
textAlign: this.state.currentItemTextAlign,
});
this.setState({ editingElement: element }); 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);
let textX = clientX || x; const parentCenterPosition =
let textY = clientY || y; insertAtParentCenter &&
this.getTextWysiwygSnappedToCenterPosition(
let isExistingTextElement = false; sceneX,
sceneY,
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.state,
this.canvas, this.canvas,
window.devicePixelRatio, window.devicePixelRatio,
); );
textX = centerElementXInViewport; const element = existingTextElement
textY = centerElementYInViewport; ? existingTextElement
: newTextElement({
x: parentCenterPosition
? parentCenterPosition.elementCenterX
: sceneX,
y: parentCenterPosition
? parentCenterPosition.elementCenterY
: sceneY,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
text: "",
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
textAlign: parentCenterPosition
? "center"
: this.state.currentItemTextAlign,
verticalAlign: parentCenterPosition
? "middle"
: DEFAULT_VERTICAL_ALIGN,
});
// x and y will change after calling newTextElement function this.setState({ editingElement: element });
mutateElement(element, {
x: centerElementX, if (existingTextElement) {
y: centerElementY, // 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 { } else {
globalSceneState.replaceAllElements([ globalSceneState.replaceAllElements([
...globalSceneState.getElementsIncludingDeleted(), ...globalSceneState.getElementsIncludingDeleted(),
element, element,
]); ]);
if (centerIfPossible) { // case: creating new text not centered to parent elemenent → offset Y
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition( // so that the text is centered to cursor position
x, if (!parentCenterPosition) {
y, mutateElement(element, {
this.state, y: element.y - element.baseline / 2,
this.canvas, });
window.devicePixelRatio,
);
if (snappedToCenterPosition) {
mutateElement(element, {
x: snappedToCenterPosition.elementCenterX,
y: snappedToCenterPosition.elementCenterY,
});
textX = snappedToCenterPosition.wysiwygX;
textY = snappedToCenterPosition.wysiwygY;
}
} }
} }
@ -1539,9 +1526,7 @@ class App extends React.Component<any, AppState> {
}); });
this.handleTextWysiwyg(element, { this.handleTextWysiwyg(element, {
x: textX, isExistingElement: !!existingTextElement,
y: textY,
isExistingElement: isExistingTextElement,
}); });
}; };
@ -1574,7 +1559,7 @@ class App extends React.Component<any, AppState> {
resetCursor(); resetCursor();
const { x, y } = viewportCoordsToSceneCoords( const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event, event,
this.state, this.state,
this.canvas, this.canvas,
@ -1588,8 +1573,8 @@ class App extends React.Component<any, AppState> {
const hitElement = getElementAtPosition( const hitElement = getElementAtPosition(
elements, elements,
this.state, this.state,
x, sceneX,
y, sceneY,
this.state.zoom, this.state.zoom,
); );
@ -1616,11 +1601,9 @@ class App extends React.Component<any, AppState> {
resetCursor(); resetCursor();
this.startTextEditing({ this.startTextEditing({
x: x, sceneX,
y: y, sceneY,
clientX: event.clientX, insertAtParentCenter: !event.altKey,
clientY: event.clientY,
centerIfPossible: !event.altKey,
}); });
}; };
@ -2213,19 +2196,10 @@ class App extends React.Component<any, AppState> {
return; return;
} }
const { x, y } = viewportCoordsToSceneCoords(
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
this.startTextEditing({ this.startTextEditing({
x: x, sceneX: x,
y: y, sceneY: y,
clientX: event.clientX, insertAtParentCenter: !event.altKey,
clientY: event.clientY,
centerIfPossible: !event.altKey,
}); });
resetCursor(); resetCursor();
@ -2640,7 +2614,12 @@ class App extends React.Component<any, AppState> {
resizingElement: null, resizingElement: null,
selectionElement: null, selectionElement: null,
cursorButton: "up", 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"); this.savePointer(childEvent.clientX, childEvent.clientY, "up");
@ -3006,7 +2985,9 @@ class App extends React.Component<any, AppState> {
scale: number, scale: number,
) { ) {
const elementClickedInside = getElementContainingPosition( const elementClickedInside = getElementContainingPosition(
globalSceneState.getElementsIncludingDeleted(), globalSceneState
.getElementsIncludingDeleted()
.filter((element) => !isTextElement(element)),
x, x,
y, y,
); );
@ -3022,13 +3003,13 @@ class App extends React.Component<any, AppState> {
const isSnappedToCenter = const isSnappedToCenter =
distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD; distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
if (isSnappedToCenter) { if (isSnappedToCenter) {
const { x: wysiwygX, y: wysiwygY } = sceneCoordsToViewportCoords( const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{ sceneX: elementCenterX, sceneY: elementCenterY }, { sceneX: elementCenterX, sceneY: elementCenterY },
state, state,
canvas, canvas,
scale, 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 DRAGGING_THRESHOLD = 10; // 10px
export const LINE_CONFIRM_THRESHOLD = 10; // 10px export const LINE_CONFIRM_THRESHOLD = 10; // 10px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
@ -67,6 +69,11 @@ export const FONT_FAMILY = {
3: "Cascadia", 3: "Cascadia",
} as const; } 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 CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable? 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 { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { randomId } from "../random"; import { randomId } from "../random";
import { DEFAULT_TEXT_ALIGN, DEFAULT_FONT_FAMILY } from "../appState"; import {
import { FONT_FAMILY } from "../constants"; FONT_FAMILY,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
} from "../constants";
const getFontFamilyByName = (fontFamilyName: string): FontFamily => { const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) { for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
@ -75,7 +79,8 @@ const migrateElement = (
fontFamily, fontFamily,
text: element.text ?? "", text: element.text ?? "",
baseline: element.baseline, baseline: element.baseline,
textAlign: element.textAlign ?? DEFAULT_TEXT_ALIGN, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
}); });
case "draw": case "draw":
case "line": case "line":

View File

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

View File

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

View File

@ -7,12 +7,16 @@ import {
TextAlign, TextAlign,
FontFamily, FontFamily,
GroupId, GroupId,
VerticalAlign,
} from "../element/types"; } from "../element/types";
import { measureText, getFontString } from "../utils"; import { measureText, getFontString } from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { newElementWith } from "./mutateElement"; import { newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
import { AppState } from "../types"; import { AppState } from "../types";
import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
type ElementConstructorOpts = MarkOptional< type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted">,
@ -72,15 +76,39 @@ export const newElement = (
): NonDeleted<ExcalidrawGenericElement> => ): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts); _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 = ( export const newTextElement = (
opts: { opts: {
text: string; text: string;
fontSize: number; fontSize: number;
fontFamily: FontFamily; fontFamily: FontFamily;
textAlign: TextAlign; textAlign: TextAlign;
verticalAlign: VerticalAlign;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const metrics = measureText(opts.text, getFontString(opts)); const metrics = measureText(opts.text, getFontString(opts));
const offsets = getTextElementPositionOffsets(opts, metrics);
const textElement = newElementWith( const textElement = newElementWith(
{ {
..._newElementBase<ExcalidrawTextElement>("text", opts), ..._newElementBase<ExcalidrawTextElement>("text", opts),
@ -88,9 +116,9 @@ export const newTextElement = (
fontSize: opts.fontSize, fontSize: opts.fontSize,
fontFamily: opts.fontFamily, fontFamily: opts.fontFamily,
textAlign: opts.textAlign, textAlign: opts.textAlign,
// Center the text verticalAlign: opts.verticalAlign,
x: opts.x - metrics.width / 2, x: opts.x - offsets.x,
y: opts.y - metrics.height / 2, y: opts.y - offsets.y,
width: metrics.width, width: metrics.width,
height: metrics.height, height: metrics.height,
baseline: metrics.baseline, baseline: metrics.baseline,
@ -101,6 +129,84 @@ export const newTextElement = (
return textElement; 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 = ( export const newLinearElement = (
opts: { opts: {
type: ExcalidrawLinearElement["type"]; type: ExcalidrawLinearElement["type"];

View File

@ -248,6 +248,26 @@ const measureFontSizeFromWH = (
return null; 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 = ( const resizeSingleTextElement = (
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
resizeHandle: "nw" | "ne" | "sw" | "se", resizeHandle: "nw" | "ne" | "sw" | "se",
@ -310,7 +330,7 @@ const resizeSingleTextElement = (
const deltaX2 = (x2 - nextX2) / 2; const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2; const deltaY2 = (y2 - nextY2) / 2;
const [nextElementX, nextElementY] = adjustXYWithRotation( const [nextElementX, nextElementY] = adjustXYWithRotation(
resizeHandle, getSidesForResizeHandle(resizeHandle, isResizeFromCenter),
element.x, element.x,
element.y, element.y,
element.angle, element.angle,
@ -318,7 +338,6 @@ const resizeSingleTextElement = (
deltaY1, deltaY1,
deltaX2, deltaX2,
deltaY2, deltaY2,
isResizeFromCenter,
); );
mutateElement(element, { mutateElement(element, {
fontSize: nextFont.size, fontSize: nextFont.size,
@ -403,7 +422,7 @@ const resizeSingleElement = (
element.angle, element.angle,
); );
const [nextElementX, nextElementY] = adjustXYWithRotation( const [nextElementX, nextElementY] = adjustXYWithRotation(
resizeHandle, getSidesForResizeHandle(resizeHandle, isResizeFromCenter),
element.x - flipDiffX, element.x - flipDiffX,
element.y - flipDiffY, element.y - flipDiffY,
element.angle, element.angle,
@ -411,7 +430,6 @@ const resizeSingleElement = (
deltaY1, deltaY1,
deltaX2, deltaX2,
deltaY2, deltaY2,
isResizeFromCenter,
); );
if ( if (
nextWidth !== 0 && nextWidth !== 0 &&

View File

@ -1,116 +1,111 @@
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { selectNode, isWritableElement, getFontString } from "../utils"; import { isWritableElement, getFontString } from "../utils";
import { globalSceneState } from "../scene"; import { globalSceneState } from "../scene";
import { isTextElement } from "./typeChecks"; import { isTextElement } from "./typeChecks";
import { CLASSES } from "../constants"; import { CLASSES } from "../constants";
import { FontFamily } from "./types"; import { ExcalidrawElement } from "./types";
const trimText = (text: string) => { const normalizeText = (text: string) => {
// whitespace only → trim all because we'd end up inserting invisible element return (
if (!text.trim()) { text
return ""; // replace tabs with spaces so they render and measure correctly
} .replace(/\t/g, " ")
// replace leading/trailing newlines (only) otherwise it messes up bounding // normalize newlines
// box calculation (there's also a bug in FF which inserts trailing newline .replace(/\r?\n|\r/g, "\n")
// for multiline texts) );
return text.replace(/^\n+|\n+$/g, "");
}; };
type TextWysiwygParams = { const getTransform = (
id: string; width: number,
initText: string; height: number,
x: number; angle: number,
y: number; zoom: number,
strokeColor: string; ) => {
fontSize: number; const degree = (180 * angle) / Math.PI;
fontFamily: FontFamily; return `translate(${(width * (zoom - 1)) / 2}px, ${
opacity: number; (height * (zoom - 1)) / 2
zoom: number; }px) scale(${zoom}) rotate(${degree}deg)`;
angle: number;
textAlign: string;
onChange?: (text: string) => void;
onSubmit: (text: string) => void;
onCancel: () => void;
}; };
export const textWysiwyg = ({ export const textWysiwyg = ({
id, id,
initText,
x,
y,
strokeColor,
fontSize,
fontFamily,
opacity,
zoom, zoom,
angle,
onChange, onChange,
textAlign,
onSubmit, onSubmit,
onCancel, onCancel,
}: TextWysiwygParams) => { getViewportCoords,
const editable = document.createElement("div"); }: {
try { id: ExcalidrawElement["id"];
editable.contentEditable = "plaintext-only"; zoom: number;
} catch { onChange?: (text: string) => void;
editable.contentEditable = "true"; 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.dir = "auto";
editable.tabIndex = 0; editable.tabIndex = 0;
editable.innerText = initText;
editable.dataset.type = "wysiwyg"; editable.dataset.type = "wysiwyg";
// prevent line wrapping on Safari
const degree = (180 * angle) / Math.PI; editable.wrap = "off";
Object.assign(editable.style, { Object.assign(editable.style, {
color: strokeColor,
position: "fixed", 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", 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", minHeight: "1em",
backfaceVisibility: "hidden", 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) => { updateWysiwygStyle();
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);
}
};
if (onChange) { if (onChange) {
editable.oninput = () => { editable.oninput = () => {
onChange(trimText(editable.innerText)); onChange(normalizeText(editable.value));
}; };
} }
@ -134,8 +129,8 @@ export const textWysiwyg = ({
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (editable.innerText) { if (editable.value) {
onSubmit(trimText(editable.innerText)); onSubmit(normalizeText(editable.value));
} else { } else {
onCancel(); onCancel();
} }
@ -149,10 +144,10 @@ export const textWysiwyg = ({
isDestroyed = true; isDestroyed = true;
// remove events to ensure they don't late-fire // remove events to ensure they don't late-fire
editable.onblur = null; editable.onblur = null;
editable.onpaste = null;
editable.oninput = null; editable.oninput = null;
editable.onkeydown = null; editable.onkeydown = null;
window.removeEventListener("resize", updateWysiwygStyle);
window.removeEventListener("wheel", stopEvent, true); window.removeEventListener("wheel", stopEvent, true);
window.removeEventListener("pointerdown", onPointerDown); window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", rebindBlur); window.removeEventListener("pointerup", rebindBlur);
@ -191,26 +186,19 @@ export const textWysiwyg = ({
// handle updates of textElement properties of editing element // handle updates of textElement properties of editing element
const unbindUpdate = globalSceneState.addCallback(() => { const unbindUpdate = globalSceneState.addCallback(() => {
const editingElement = globalSceneState updateWysiwygStyle();
.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,
});
}
editable.focus(); editable.focus();
}); });
let isDestroyed = false; let isDestroyed = false;
editable.onblur = handleSubmit; 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("pointerdown", onPointerDown);
window.addEventListener("wheel", stopEvent, true); window.addEventListener("wheel", stopEvent, true);
document.body.appendChild(editable); document.body.appendChild(editable);
editable.focus(); editable.focus();
selectNode(editable); editable.select();
}; };

View File

@ -60,6 +60,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
text: string; text: string;
baseline: number; baseline: number;
textAlign: TextAlign; textAlign: TextAlign;
verticalAlign: VerticalAlign;
}>; }>;
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
@ -72,6 +73,7 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
export type PointerType = "mouse" | "pen" | "touch"; export type PointerType = "mouse" | "pen" | "touch";
export type TextAlign = "left" | "center" | "right"; export type TextAlign = "left" | "center" | "right";
export type VerticalAlign = "top" | "middle";
export type FontFamily = keyof typeof FONT_FAMILY; export type FontFamily = keyof typeof FONT_FAMILY;
export type FontString = string & { _brand: "fontString" }; export type FontString = string & { _brand: "fontString" };

View File

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

View File

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

View File

@ -88,8 +88,12 @@ export const measureText = (text: string, font: FontString) => {
line.style.whiteSpace = "pre"; line.style.whiteSpace = "pre";
line.style.font = font; line.style.font = font;
body.appendChild(line); 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 width = line.offsetWidth;
const height = line.offsetHeight; const height = line.offsetHeight;
// Now creating 1px sized item that will be aligned to baseline // Now creating 1px sized item that will be aligned to baseline
@ -214,13 +218,8 @@ export const sceneCoordsToViewportCoords = (
scale: number, scale: number,
) => { ) => {
const zoomOrigin = getZoomOrigin(canvas, scale); const zoomOrigin = getZoomOrigin(canvas, scale);
const sceneXWithZoomAndScroll = const x = zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom; const y = zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
const sceneYWithZoomAndScroll =
zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
const x = sceneXWithZoomAndScroll;
const y = sceneYWithZoomAndScroll;
return { x, y }; return { x, y };
}; };