From cd87bd6901b47430a692a06a8928b0f732d77097 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Thu, 25 Jun 2020 21:21:27 +0200 Subject: [PATCH] do not center text when not applicable (#1783) --- src/actions/actionProperties.tsx | 2 +- src/actions/actionStyles.ts | 6 +- src/appState.ts | 10 +- src/charts.ts | 5 + src/components/App.tsx | 261 ++++++++++++++----------------- src/constants.ts | 7 + src/data/restore.ts | 11 +- src/element/index.ts | 1 + src/element/newElement.test.ts | 1 + src/element/newElement.ts | 112 ++++++++++++- src/element/resizeElements.ts | 26 ++- src/element/textWysiwyg.tsx | 184 ++++++++++------------ src/element/types.ts | 2 + src/math.ts | 71 ++++----- src/scene/export.ts | 23 +-- src/utils.ts | 17 +- 16 files changed, 418 insertions(+), 321 deletions(-) diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 72f2ffb6..c8a93208 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -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[], diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index dfffb8ff..2e1e72ee 100644 --- a/src/actions/actionStyles.ts +++ b/src/actions/actionStyles.ts @@ -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 = "{}"; diff --git a/src/appState.ts b/src/appState.ts index 61c0d4f5..9111ca2a 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -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 { diff --git a/src/charts.ts b/src/charts.ts index 85913c4d..575572ed 100644 --- a/src/charts.ts +++ b/src/charts.ts @@ -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, }) diff --git a/src/components/App.tsx b/src/components/App.tsx index 3446fcb3..76352cbd 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 { 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 { 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 { !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 { 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 { }); }; - 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 { 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(); - } + updateElement(text); }), onSubmit: withBatchedUpdates((text) => { updateElement(text); @@ -1419,7 +1410,7 @@ class App extends React.Component { resetSelection(); }), onCancel: withBatchedUpdates(() => { - deleteElement(); + updateElement(""); if (isExistingElement) { history.resumeRecording(); } @@ -1438,20 +1429,11 @@ class App extends React.Component { 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 | null { + const element = getElementAtPosition( globalSceneState.getElements(), this.state, x, @@ -1459,78 +1441,83 @@ class App extends React.Component { this.state.zoom, ); - const element = - elementAtPosition && isTextElement(elementAtPosition) - ? elementAtPosition - : newTextElement({ - 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, - }); + if (element && isTextElement(element) && !element.isDeleted) { + return element; + } + return null; + } - 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; - 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 }, + const parentCenterPosition = + insertAtParentCenter && + this.getTextWysiwygSnappedToCenterPosition( + sceneX, + sceneY, this.state, this.canvas, window.devicePixelRatio, ); - textX = centerElementXInViewport; - textY = centerElementYInViewport; + const element = existingTextElement + ? 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 - mutateElement(element, { - x: centerElementX, - y: centerElementY, - }); + this.setState({ editingElement: element }); + + if (existingTextElement) { + // if text element is no longer centered to a container, reset + // verticalAlign to default because it's currently internal-only + if (!parentCenterPosition || element.textAlign !== "center") { + mutateElement(element, { verticalAlign: DEFAULT_VERTICAL_ALIGN }); + } } else { globalSceneState.replaceAllElements([ ...globalSceneState.getElementsIncludingDeleted(), element, ]); - if (centerIfPossible) { - const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition( - x, - y, - this.state, - this.canvas, - window.devicePixelRatio, - ); - - if (snappedToCenterPosition) { - mutateElement(element, { - x: snappedToCenterPosition.elementCenterX, - y: snappedToCenterPosition.elementCenterY, - }); - textX = snappedToCenterPosition.wysiwygX; - textY = snappedToCenterPosition.wysiwygY; - } + // case: creating new text not centered to parent elemenent → offset Y + // so that the text is centered to cursor position + if (!parentCenterPosition) { + mutateElement(element, { + y: element.y - element.baseline / 2, + }); } } @@ -1539,9 +1526,7 @@ class App extends React.Component { }); this.handleTextWysiwyg(element, { - x: textX, - y: textY, - isExistingElement: isExistingTextElement, + isExistingElement: !!existingTextElement, }); }; @@ -1574,7 +1559,7 @@ class App extends React.Component { 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 { const hitElement = getElementAtPosition( elements, this.state, - x, - y, + sceneX, + sceneY, this.state.zoom, ); @@ -1616,11 +1601,9 @@ class App extends React.Component { 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 { 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 { 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 { scale: number, ) { const elementClickedInside = getElementContainingPosition( - globalSceneState.getElementsIncludingDeleted(), + globalSceneState + .getElementsIncludingDeleted() + .filter((element) => !isTextElement(element)), x, y, ); @@ -3022,13 +3003,13 @@ class App extends React.Component { 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 }; } } } diff --git a/src/constants.ts b/src/constants.ts index 2d468aff..57e067f9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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? diff --git a/src/data/restore.ts b/src/data/restore.ts index e98d9acb..0413e914 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -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": diff --git a/src/element/index.ts b/src/element/index.ts index 165bb0d4..ec3c5df4 100644 --- a/src/element/index.ts +++ b/src/element/index.ts @@ -8,6 +8,7 @@ import { isInvisiblySmallElement } from "./sizeHelpers"; export { newElement, newTextElement, + updateTextElement, newLinearElement, duplicateElement, } from "./newElement"; diff --git a/src/element/newElement.test.ts b/src/element/newElement.test.ts index a7de6066..f1ae7748 100644 --- a/src/element/newElement.test.ts +++ b/src/element/newElement.test.ts @@ -81,6 +81,7 @@ it("clones text element", () => { fontSize: 20, fontFamily: 1, textAlign: "left", + verticalAlign: "top", }); const copy = duplicateElement(null, new Map(), element); diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 77f282a5..bae1792f 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -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, @@ -72,15 +76,39 @@ export const newElement = ( ): NonDeleted => _newElementBase(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 => { const metrics = measureText(opts.text, getFontString(opts)); + const offsets = getTextElementPositionOffsets(opts, metrics); const textElement = newElementWith( { ..._newElementBase("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"]; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 159921a7..83559195 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -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, 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 && diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 7454c698..d3b47d86 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -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(); }; diff --git a/src/element/types.ts b/src/element/types.ts index cae69b32..de5a9f3c 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -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" }; diff --git a/src/math.ts b/src/math.ts index 11ca66bb..ae5ade72 100644 --- a/src/math.ts +++ b/src/math.ts @@ -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) { - x += deltaX1 + deltaX2; - } else { - x += deltaX1 * (1 + cos); - y += deltaX1 * sin; - x += deltaX2 * (1 - cos); - y += deltaX2 * -sin; - } + if (sides.e && sides.w) { + x += deltaX1 + deltaX2; + } else if (sides.e) { + x += deltaX1 * (1 + cos); + y += deltaX1 * sin; + x += deltaX2 * (1 - cos); + 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) { - 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 { - 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); - } + + if (sides.n && sides.s) { + y += deltaY1 + deltaY2; + } 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]; }; diff --git a/src/scene/export.ts b/src/scene/export.ts index 2e294046..917d585e 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -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 = ``; @@ -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", diff --git a/src/utils.ts b/src/utils.ts index 9c2f6389..8ed7bad2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 }; };