diff --git a/src/actions/actionAddToLibrary.ts b/src/actions/actionAddToLibrary.ts index 98ef5deb..f9b93625 100644 --- a/src/actions/actionAddToLibrary.ts +++ b/src/actions/actionAddToLibrary.ts @@ -11,6 +11,7 @@ export const actionAddToLibrary = register({ const selectedElements = getSelectedElements( getNonDeletedElements(elements), appState, + true, ); if (selectedElements.some((element) => element.type === "image")) { return { diff --git a/src/actions/actionClipboard.tsx b/src/actions/actionClipboard.tsx index aa0dbac0..1212e394 100644 --- a/src/actions/actionClipboard.tsx +++ b/src/actions/actionClipboard.tsx @@ -42,6 +42,7 @@ export const actionCopyAsSvg = register({ const selectedElements = getSelectedElements( getNonDeletedElements(elements), appState, + true, ); try { await exportCanvas( @@ -81,6 +82,7 @@ export const actionCopyAsPng = register({ const selectedElements = getSelectedElements( getNonDeletedElements(elements), appState, + true, ); try { await exportCanvas( diff --git a/src/actions/actionDeleteSelected.tsx b/src/actions/actionDeleteSelected.tsx index 1a62c6be..0e7b0d0a 100644 --- a/src/actions/actionDeleteSelected.tsx +++ b/src/actions/actionDeleteSelected.tsx @@ -11,6 +11,7 @@ import { newElementWith } from "../element/mutateElement"; import { getElementsInGroup } from "../groups"; import { LinearElementEditor } from "../element/linearElementEditor"; import { fixBindingsAfterDeletion } from "../element/binding"; +import { isBoundToContainer } from "../element/typeChecks"; const deleteSelectedElements = ( elements: readonly ExcalidrawElement[], @@ -21,6 +22,12 @@ const deleteSelectedElements = ( if (appState.selectedElementIds[el.id]) { return newElementWith(el, { isDeleted: true }); } + if ( + isBoundToContainer(el) && + appState.selectedElementIds[el.containerId] + ) { + return newElementWith(el, { isDeleted: true }); + } return el; }), appState: { @@ -113,7 +120,6 @@ export const actionDeleteSelected = register({ commitToHistory: true, }; } - let { elements: nextElements, appState: nextAppState } = deleteSelectedElements(elements, appState); fixBindingsAfterDeletion( diff --git a/src/actions/actionDuplicateSelection.tsx b/src/actions/actionDuplicateSelection.tsx index e9b7c516..73ac16de 100644 --- a/src/actions/actionDuplicateSelection.tsx +++ b/src/actions/actionDuplicateSelection.tsx @@ -2,11 +2,11 @@ import { KEYS } from "../keys"; import { register } from "./register"; import { ExcalidrawElement } from "../element/types"; import { duplicateElement, getNonDeletedElements } from "../element"; -import { isSomeElementSelected } from "../scene"; +import { getSelectedElements, isSomeElementSelected } from "../scene"; import { ToolButton } from "../components/ToolButton"; import { clone } from "../components/icons"; import { t } from "../i18n"; -import { getShortcutKey } from "../utils"; +import { arrayToMap, getShortcutKey } from "../utils"; import { LinearElementEditor } from "../element/linearElementEditor"; import { selectGroupsForSelectedElements, @@ -17,6 +17,8 @@ import { AppState } from "../types"; import { fixBindingsAfterDuplication } from "../element/binding"; import { ActionResult } from "./types"; import { GRID_SIZE } from "../constants"; +import { bindTextToShapeAfterDuplication } from "../element/textElement"; +import { isBoundToContainer } from "../element/typeChecks"; export const actionDuplicateSelection = register({ name: "duplicateSelection", @@ -85,9 +87,12 @@ const duplicateElements = ( const finalElements: ExcalidrawElement[] = []; let index = 0; + const selectedElementIds = arrayToMap( + getSelectedElements(elements, appState, true), + ); while (index < elements.length) { const element = elements[index]; - if (appState.selectedElementIds[element.id]) { + if (selectedElementIds.get(element.id)) { if (element.groupIds.length) { const groupId = getSelectedGroupForElement(appState, element); // if group selected, duplicate it atomically @@ -109,7 +114,11 @@ const duplicateElements = ( } index++; } - + bindTextToShapeAfterDuplication( + finalElements, + oldElements, + oldIdToDuplicatedId, + ); fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId); return { @@ -119,7 +128,9 @@ const duplicateElements = ( ...appState, selectedGroupIds: {}, selectedElementIds: newElements.reduce((acc, element) => { - acc[element.id] = true; + if (!isBoundToContainer(element)) { + acc[element.id] = true; + } return acc; }, {} as any), }, diff --git a/src/actions/actionGroup.tsx b/src/actions/actionGroup.tsx index 8a31b614..92de75d7 100644 --- a/src/actions/actionGroup.tsx +++ b/src/actions/actionGroup.tsx @@ -1,6 +1,6 @@ import { CODES, KEYS } from "../keys"; import { t } from "../i18n"; -import { getShortcutKey } from "../utils"; +import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; import { UngroupIcon, GroupIcon } from "../components/icons"; import { newElementWith } from "../element/mutateElement"; @@ -44,6 +44,7 @@ const enableActionGroup = ( const selectedElements = getSelectedElements( getNonDeletedElements(elements), appState, + true, ); return ( selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements) @@ -56,6 +57,7 @@ export const actionGroup = register({ const selectedElements = getSelectedElements( getNonDeletedElements(elements), appState, + true, ); if (selectedElements.length < 2) { // nothing to group @@ -83,8 +85,9 @@ export const actionGroup = register({ } } const newGroupId = randomId(); + const selectElementIds = arrayToMap(selectedElements); const updatedElements = elements.map((element) => { - if (!appState.selectedElementIds[element.id]) { + if (!selectElementIds.get(element.id)) { return element; } return newElementWith(element, { diff --git a/src/actions/actionSelectAll.ts b/src/actions/actionSelectAll.ts index a538649d..eaff8321 100644 --- a/src/actions/actionSelectAll.ts +++ b/src/actions/actionSelectAll.ts @@ -1,7 +1,7 @@ import { KEYS } from "../keys"; import { register } from "./register"; import { selectGroupsForSelectedElements } from "../groups"; -import { getNonDeletedElements } from "../element"; +import { getNonDeletedElements, isTextElement } from "../element"; export const actionSelectAll = register({ name: "selectAll", @@ -15,7 +15,10 @@ export const actionSelectAll = register({ ...appState, editingGroupId: null, selectedElementIds: elements.reduce((map, element) => { - if (!element.isDeleted) { + if ( + !element.isDeleted && + !(isTextElement(element) && element.containerId) + ) { map[element.id] = true; } return map; diff --git a/src/clipboard.ts b/src/clipboard.ts index d2aa4f36..a0d737e7 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -58,7 +58,8 @@ export const copyToClipboard = async ( appState: AppState, files: BinaryFiles, ) => { - const selectedElements = getSelectedElements(elements, appState); + // select binded text elements when copying + const selectedElements = getSelectedElements(elements, appState, true); const contents: ElementsClipboard = { type: EXPORT_DATA_TYPES.excalidrawClipboard, elements: selectedElements, diff --git a/src/components/App.tsx b/src/components/App.tsx index a722ebf0..1d0c9e2a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -120,6 +120,7 @@ import { } from "../element/mutateElement"; import { deepCopyElement, newFreeDrawElement } from "../element/newElement"; import { + hasBoundTextElement, isBindingElement, isBindingElementType, isImageElement, @@ -194,6 +195,7 @@ import { import { debounce, distance, + getFontString, getNearestScrollableContainer, isInputLike, isToolIcon, @@ -228,6 +230,12 @@ import { } from "../element/image"; import throttle from "lodash.throttle"; import { fileOpen, nativeFileSystemSupported } from "../data/filesystem"; +import { + bindTextToShapeAfterDuplication, + getApproxMinLineHeight, + getApproxMinLineWidth, + getBoundTextElementId, +} from "../element/textElement"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; const IsMobileContext = React.createContext(false); @@ -1134,7 +1142,7 @@ class App extends React.Component { } const scrolledOutside = // hide when editing text - this.state.editingElement?.type === "text" + isTextElement(this.state.editingElement) ? false : !atLeastOneVisibleElement && renderingElements.length > 0; if (this.state.scrolledOutside !== scrolledOutside) { @@ -1376,6 +1384,7 @@ class App extends React.Component { oldIdToDuplicatedId.set(element.id, newElement.id); return newElement; }); + bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId); const nextElements = [ ...this.scene.getElementsIncludingDeleted(), ...newElements, @@ -1394,7 +1403,9 @@ class App extends React.Component { ...this.state, isLibraryOpen: false, selectedElementIds: newElements.reduce((map, element) => { - map[element.id] = true; + if (isTextElement(element) && !element.containerId) { + map[element.id] = true; + } return map; }, {} as any), selectedGroupIds: {}, @@ -1710,9 +1721,11 @@ class App extends React.Component { !isLinearElement(selectedElements[0]) ) { const selectedElement = selectedElements[0]; + this.startTextEditing({ sceneX: selectedElement.x + selectedElement.width / 2, sceneY: selectedElement.y + selectedElement.height / 2, + shouldBind: true, }); event.preventDefault(); return; @@ -1867,14 +1880,24 @@ class App extends React.Component { isExistingElement?: boolean; }, ) { - const updateElement = (text: string, isDeleted = false) => { + const updateElement = ( + text: string, + originalText: string, + isDeleted = false, + updateDimensions = false, + ) => { this.scene.replaceAllElements([ ...this.scene.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id && isTextElement(_element)) { - return updateTextElement(_element, { - text, - isDeleted, - }); + return updateTextElement( + _element, + { + text, + isDeleted, + originalText, + }, + updateDimensions, + ); } return _element; }), @@ -1893,27 +1916,27 @@ class App extends React.Component { }, this.state, ); - return [ - viewportX - this.state.offsetLeft, - viewportY - this.state.offsetTop, - ]; + return [viewportX, viewportY]; }, onChange: withBatchedUpdates((text) => { - updateElement(text); + updateElement(text, text, false, !element.containerId); if (isNonDeletedElement(element)) { updateBoundElements(element); } }), - onSubmit: withBatchedUpdates(({ text, viaKeyboard }) => { + onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => { const isDeleted = !text.trim(); - updateElement(text, isDeleted); + updateElement(text, originalText, isDeleted, true); // select the created text element only if submitting via keyboard // (when submitting via click it should act as signal to deselect) if (!isDeleted && viaKeyboard) { + const elementIdToSelect = element.containerId + ? element.containerId + : element.id; this.setState((prevState) => ({ selectedElementIds: { ...prevState.selectedElementIds, - [element.id]: true, + [elementIdToSelect]: true, }, })); } @@ -1942,7 +1965,7 @@ class App extends React.Component { // do an initial update to re-initialize element position since we were // modifying element's x/y for sake of editor (case: syncing to remote) - updateElement(element.text); + updateElement(element.text, element.originalText); } private deselectElements() { @@ -1957,7 +1980,9 @@ class App extends React.Component { x: number, y: number, ): NonDeleted | null { - const element = this.getElementAtPosition(x, y); + const element = this.getElementAtPosition(x, y, { + includeBoundTextElement: true, + }); if (element && isTextElement(element) && !element.isDeleted) { return element; @@ -1972,9 +1997,14 @@ class App extends React.Component { /** if true, returns the first selected element (with highest z-index) of all hit elements */ preferSelected?: boolean; + includeBoundTextElement?: boolean; }, ): NonDeleted | null { - const allHitElements = this.getElementsAtPosition(x, y); + const allHitElements = this.getElementsAtPosition( + x, + y, + opts?.includeBoundTextElement, + ); if (allHitElements.length > 1) { if (opts?.preferSelected) { for (let index = allHitElements.length - 1; index > -1; index--) { @@ -2005,8 +2035,16 @@ class App extends React.Component { private getElementsAtPosition( x: number, y: number, + includeBoundTextElement: boolean = false, ): NonDeleted[] { - return getElementsAtPosition(this.scene.getElements(), (element) => + const elements = includeBoundTextElement + ? this.scene.getElements() + : this.scene + .getElements() + .filter( + (element) => !(isTextElement(element) && element.containerId), + ); + return getElementsAtPosition(elements, (element) => hitTest(element, this.state, x, y), ); } @@ -2014,17 +2052,17 @@ class App extends React.Component { private startTextEditing = ({ sceneX, sceneY, + shouldBind, insertAtParentCenter = true, }: { /** X position to insert text at */ sceneX: number; /** Y position to insert text at */ sceneY: number; + shouldBind: boolean; /** whether to attempt to insert at element center if applicable */ insertAtParentCenter?: boolean; }) => { - const existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); - const parentCenterPosition = insertAtParentCenter && this.getTextWysiwygSnappedToCenterPosition( @@ -2035,6 +2073,43 @@ class App extends React.Component { window.devicePixelRatio, ); + // bind to container when shouldBind is true or + // clicked on center of container + const container = + shouldBind || parentCenterPosition + ? getElementContainingPosition( + this.scene.getElements(), + sceneX, + sceneY, + "text", + ) + : null; + + let existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); + + // consider bounded text element if container present + if (container) { + const boundTextElementId = getBoundTextElementId(container); + if (boundTextElementId) { + existingTextElement = this.scene.getElement( + boundTextElementId, + ) as ExcalidrawTextElement; + } + } + if (!existingTextElement && container) { + const fontString = { + fontSize: this.state.currentItemFontSize, + fontFamily: this.state.currentItemFontFamily, + }; + const minWidth = getApproxMinLineWidth(getFontString(fontString)); + const minHeight = getApproxMinLineHeight(getFontString(fontString)); + const newHeight = Math.max(container.height, minHeight); + const newWidth = Math.max(container.width, minWidth); + mutateElement(container, { height: newHeight, width: newWidth }); + sceneX = container.x + newWidth / 2; + sceneY = container.y + newHeight / 2; + } + const element = existingTextElement ? existingTextElement : newTextElement({ @@ -2061,6 +2136,7 @@ class App extends React.Component { verticalAlign: parentCenterPosition ? "middle" : DEFAULT_VERTICAL_ALIGN, + containerId: container?.id ?? undefined, }); this.setState({ editingElement: element }); @@ -2131,7 +2207,7 @@ class App extends React.Component { resetCursor(this.canvas); - const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( + let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( event, this.state, ); @@ -2163,9 +2239,22 @@ class App extends React.Component { resetCursor(this.canvas); if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) { + const selectedElements = getSelectedElements( + this.scene.getElements(), + this.state, + ); + if (selectedElements.length === 1) { + const selectedElement = selectedElements[0]; + const canBindText = hasBoundTextElement(selectedElement); + if (canBindText) { + sceneX = selectedElement.x + selectedElement.width / 2; + sceneY = selectedElement.y + selectedElement.height / 2; + } + } this.startTextEditing({ sceneX, sceneY, + shouldBind: false, insertAtParentCenter: !event.altKey, }); } @@ -3036,13 +3125,25 @@ class App extends React.Component { // if we're currently still editing text, clicking outside // should only finalize it, not create another (irrespective // of state.elementLocked) - if (this.state.editingElement?.type === "text") { + if (isTextElement(this.state.editingElement)) { return; } + let sceneX = pointerDownState.origin.x; + let sceneY = pointerDownState.origin.y; + const element = this.getElementAtPosition(sceneX, sceneY, { + includeBoundTextElement: true, + }); + + const canBindText = hasBoundTextElement(element); + if (canBindText) { + sceneX = element.x + element.width / 2; + sceneY = element.y + element.height / 2; + } this.startTextEditing({ - sceneX: pointerDownState.origin.x, - sceneY: pointerDownState.origin.y, + sceneX, + sceneY, + shouldBind: false, insertAtParentCenter: !event.altKey, }); @@ -3442,7 +3543,6 @@ class App extends React.Component { selectedElements, dragX, dragY, - this.scene, lockDirection, dragDistanceX, dragDistanceY, @@ -3462,9 +3562,15 @@ class App extends React.Component { const groupIdMap = new Map(); const oldIdToDuplicatedId = new Map(); const hitElement = pointerDownState.hit.element; - for (const element of this.scene.getElementsIncludingDeleted()) { + const elements = this.scene.getElementsIncludingDeleted(); + const selectedElementIds: Array = + getSelectedElements(elements, this.state, true).map( + (element) => element.id, + ); + + for (const element of elements) { if ( - this.state.selectedElementIds[element.id] || + selectedElementIds.includes(element.id) || // case: the state.selectedElementIds might not have been // updated yet by the time this mousemove event is fired (element.id === hitElement?.id && @@ -3492,6 +3598,11 @@ class App extends React.Component { } } const nextSceneElements = [...nextElements, ...elementsToAppend]; + bindTextToShapeAfterDuplication( + nextElements, + elementsToAppend, + oldIdToDuplicatedId, + ); fixBindingsAfterDuplication( nextSceneElements, elementsToAppend, @@ -3942,6 +4053,7 @@ class App extends React.Component { } else { // add element to selection while // keeping prev elements selected + this.setState((_prevState) => ({ selectedElementIds: { ..._prevState.selectedElementIds, diff --git a/src/components/HintViewer.tsx b/src/components/HintViewer.tsx index b828ddf8..309f8f2a 100644 --- a/src/components/HintViewer.tsx +++ b/src/components/HintViewer.tsx @@ -7,6 +7,7 @@ import { AppState } from "../types"; import { isImageElement, isLinearElement, + isTextBindableContainer, isTextElement, } from "../element/typeChecks"; import { getShortcutKey } from "../utils"; @@ -60,13 +61,18 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => { return t("hints.rotate"); } - if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { - if (appState.editingLinearElement) { - return appState.editingLinearElement.selectedPointsIndices - ? t("hints.lineEditor_pointSelected") - : t("hints.lineEditor_nothingSelected"); + if (selectedElements.length === 1) { + if (isLinearElement(selectedElements[0])) { + if (appState.editingLinearElement) { + return appState.editingLinearElement.selectedPointsIndices + ? t("hints.lineEditor_pointSelected") + : t("hints.lineEditor_nothingSelected"); + } + return t("hints.lineEditor_info"); + } + if (isTextBindableContainer(selectedElements[0])) { + return t("hints.bindTextToElement"); } - return t("hints.lineEditor_info"); } if (selectedElements.length === 1 && isTextElement(selectedElements[0])) { diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index 213f3841..3fafec65 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -102,7 +102,7 @@ const ImageExportModal = ({ const { exportBackground, viewBackgroundColor } = appState; const exportedElements = exportSelected - ? getSelectedElements(elements, appState) + ? getSelectedElements(elements, appState, true) : elements; useEffect(() => { diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 8bc9bd03..f97030ef 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -270,7 +270,7 @@ const LayerUI = ({ const libraryMenu = appState.isLibraryOpen ? ( { const threshold = 10 / appState.zoom.value; - const check = - element.type === "text" - ? isStrictlyInside - : isElementDraggableFromInside(element) - ? isInsideCheck - : isNearCheck; + const check = isTextElement(element) + ? isStrictlyInside + : isElementDraggableFromInside(element) + ? isInsideCheck + : isNearCheck; return hitTestPointAgainstElement({ element, point, threshold, check }); }; diff --git a/src/element/dragElements.ts b/src/element/dragElements.ts index a84a90c0..bf4c851f 100644 --- a/src/element/dragElements.ts +++ b/src/element/dragElements.ts @@ -6,13 +6,13 @@ import { getPerfectElementSize } from "./sizeHelpers"; import Scene from "../scene/Scene"; import { NonDeletedExcalidrawElement } from "./types"; import { PointerDownState } from "../types"; +import { getBoundTextElementId } from "./textElement"; export const dragSelectedElements = ( pointerDownState: PointerDownState, selectedElements: NonDeletedExcalidrawElement[], pointerX: number, pointerY: number, - scene: Scene, lockDirection: boolean = false, distanceX: number = 0, distanceY: number = 0, @@ -20,30 +20,61 @@ export const dragSelectedElements = ( const [x1, y1] = getCommonBounds(selectedElements); const offset = { x: pointerX - x1, y: pointerY - y1 }; selectedElements.forEach((element) => { - let x: number; - let y: number; - if (lockDirection) { - const lockX = lockDirection && distanceX < distanceY; - const lockY = lockDirection && distanceX > distanceY; - const original = pointerDownState.originalElements.get(element.id); - x = lockX && original ? original.x : element.x + offset.x; - y = lockY && original ? original.y : element.y + offset.y; - } else { - x = element.x + offset.x; - y = element.y + offset.y; + updateElementCoords( + lockDirection, + distanceX, + distanceY, + pointerDownState, + element, + offset, + ); + if (!element.groupIds.length) { + const boundTextElementId = getBoundTextElementId(element); + if (boundTextElementId) { + const textElement = + Scene.getScene(element)!.getElement(boundTextElementId); + updateElementCoords( + lockDirection, + distanceX, + distanceY, + pointerDownState, + textElement!, + offset, + ); + } } - - mutateElement(element, { - x, - y, - }); - updateBoundElements(element, { simultaneouslyUpdated: selectedElements, }); }); }; +const updateElementCoords = ( + lockDirection: boolean, + distanceX: number, + distanceY: number, + pointerDownState: PointerDownState, + element: NonDeletedExcalidrawElement, + offset: { x: number; y: number }, +) => { + let x: number; + let y: number; + if (lockDirection) { + const lockX = lockDirection && distanceX < distanceY; + const lockY = lockDirection && distanceX > distanceY; + const original = pointerDownState.originalElements.get(element.id); + x = lockX && original ? original.x : element.x + offset.x; + y = lockY && original ? original.y : element.y + offset.y; + } else { + x = element.x + offset.x; + y = element.y + offset.y; + } + + mutateElement(element, { + x, + y, + }); +}; export const getDragOffsetXY = ( selectedElements: NonDeletedExcalidrawElement[], x: number, diff --git a/src/element/newElement.ts b/src/element/newElement.ts index abee51c9..8c995959 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -11,15 +11,20 @@ import { Arrowhead, ExcalidrawFreeDrawElement, FontFamilyValues, + ExcalidrawRectangleElement, } from "../element/types"; -import { measureText, getFontString, getUpdatedTimestamp } from "../utils"; +import { getFontString, getUpdatedTimestamp } from "../utils"; import { randomInteger, randomId } from "../random"; -import { newElementWith } from "./mutateElement"; +import { mutateElement, newElementWith } from "./mutateElement"; import { getNewGroupIdsForDuplication } from "../groups"; import { AppState } from "../types"; import { getElementAbsoluteCoords } from "."; import { adjustXYWithRotation } from "../math"; import { getResizedElementAbsoluteCoords } from "./bounds"; +import { measureText } from "./textElement"; +import { isBoundToContainer } from "./typeChecks"; +import Scene from "../scene/Scene"; +import { PADDING } from "../constants"; type ElementConstructorOpts = MarkOptional< Omit, @@ -53,30 +58,33 @@ const _newElementBase = ( boundElements = null, ...rest }: ElementConstructorOpts & Omit, "type">, -) => ({ - id: rest.id || randomId(), - type, - x, - y, - width, - height, - angle, - strokeColor, - backgroundColor, - fillStyle, - strokeWidth, - strokeStyle, - roughness, - opacity, - groupIds, - strokeSharpness, - seed: rest.seed ?? randomInteger(), - version: rest.version || 1, - versionNonce: rest.versionNonce ?? 0, - isDeleted: false as false, - boundElements, - updated: getUpdatedTimestamp(), -}); +) => { + const element = { + id: rest.id || randomId(), + type, + x, + y, + width, + height, + angle, + strokeColor, + backgroundColor, + fillStyle, + strokeWidth, + strokeStyle, + roughness, + opacity, + groupIds, + strokeSharpness, + seed: rest.seed ?? randomInteger(), + version: rest.version || 1, + versionNonce: rest.versionNonce ?? 0, + isDeleted: false as false, + boundElements, + updated: getUpdatedTimestamp(), + }; + return element; +}; export const newElement = ( opts: { @@ -114,6 +122,7 @@ export const newTextElement = ( fontFamily: FontFamilyValues; textAlign: TextAlign; verticalAlign: VerticalAlign; + containerId?: ExcalidrawRectangleElement["id"]; } & ElementConstructorOpts, ): NonDeleted => { const metrics = measureText(opts.text, getFontString(opts)); @@ -131,6 +140,8 @@ export const newTextElement = ( width: metrics.width, height: metrics.height, baseline: metrics.baseline, + containerId: opts.containerId || null, + originalText: opts.text, }, {}, ); @@ -147,18 +158,25 @@ const getAdjustedDimensions = ( height: number; baseline: number; } => { + const maxWidth = element.containerId ? element.width : null; const { width: nextWidth, height: nextHeight, baseline: nextBaseline, - } = measureText(nextText, getFontString(element)); + } = measureText(nextText, getFontString(element), maxWidth); const { textAlign, verticalAlign } = element; - let x: number; let y: number; - - if (textAlign === "center" && verticalAlign === "middle") { - const prevMetrics = measureText(element.text, getFontString(element)); + if ( + textAlign === "center" && + verticalAlign === "middle" && + !element.containerId + ) { + const prevMetrics = measureText( + element.text, + getFontString(element), + maxWidth, + ); const offsets = getTextElementPositionOffsets(element, { width: nextWidth - prevMetrics.width, height: nextHeight - prevMetrics.height, @@ -195,6 +213,22 @@ const getAdjustedDimensions = ( ); } + // make sure container dimensions are set properly when + // text editor overflows beyond viewport dimensions + if (isBoundToContainer(element)) { + const container = Scene.getScene(element)!.getElement(element.containerId)!; + let height = container.height; + let width = container.width; + if (nextHeight > height - PADDING * 2) { + height = nextHeight + PADDING * 2; + } + if (nextWidth > width - PADDING * 2) { + width = nextWidth + PADDING * 2; + } + if (height !== container.height || width !== container.width) { + mutateElement(container, { height, width }); + } + } return { width: nextWidth, height: nextHeight, @@ -206,12 +240,22 @@ const getAdjustedDimensions = ( export const updateTextElement = ( element: ExcalidrawTextElement, - { text, isDeleted }: { text: string; isDeleted?: boolean }, + { + text, + isDeleted, + originalText, + }: { text: string; isDeleted?: boolean; originalText: string }, + + updateDimensions: boolean, ): ExcalidrawTextElement => { + const dimensions = updateDimensions + ? getAdjustedDimensions(element, text) + : undefined; return newElementWith(element, { text, + originalText, isDeleted: isDeleted ?? element.isDeleted, - ...getAdjustedDimensions(element, text), + ...dimensions, }); }; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index ac2106ca..40a01f55 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -25,7 +25,7 @@ import { } from "./typeChecks"; import { mutateElement } from "./mutateElement"; import { getPerfectElementSize } from "./sizeHelpers"; -import { measureText, getFontString } from "../utils"; +import { getFontString } from "../utils"; import { updateBoundElements } from "./binding"; import { TransformHandleType, @@ -33,6 +33,13 @@ import { TransformHandleDirection, } from "./transformHandles"; import { Point, PointerDownState } from "../types"; +import Scene from "../scene/Scene"; +import { + getApproxMinLineWidth, + getBoundTextElementId, + handleBindTextResize, + measureText, +} from "./textElement"; export const normalizeAngle = (angle: number): number => { if (angle >= 2 * Math.PI) { @@ -132,6 +139,7 @@ export const transformElements = ( pointerX, pointerY, ); + handleBindTextResize(selectedElements, transformHandleType); return true; } } @@ -154,6 +162,11 @@ const rotateSingleElement = ( } angle = normalizeAngle(angle); mutateElement(element, { angle }); + const boundTextElementId = getBoundTextElementId(element); + if (boundTextElementId) { + const textElement = Scene.getScene(element)!.getElement(boundTextElementId); + mutateElement(textElement!, { angle }); + } }; // used in DEV only @@ -272,6 +285,7 @@ const measureFontSizeFromWH = ( const metrics = measureText( element.text, getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), + element.containerId ? element.width : null, ); return { size: nextFontSize, @@ -413,6 +427,9 @@ export const resizeSingleElement = ( element.width, element.height, ); + + const boundTextElementId = getBoundTextElementId(element); + const boundsCurrentWidth = esx2 - esx1; const boundsCurrentHeight = esy2 - esy1; @@ -473,6 +490,11 @@ export const resizeSingleElement = ( const newBoundsWidth = newBoundsX2 - newBoundsX1; const newBoundsHeight = newBoundsY2 - newBoundsY1; + // don't allow resize to negative dimensions when text is bounded to container + if ((newBoundsWidth < 0 || newBoundsHeight < 0) && boundTextElementId) { + return; + } + // Calculate new topLeft based on fixed corner during resize let newTopLeft = [...startTopLeft] as [number, number]; if (["n", "w", "nw"].includes(transformHandleDirection)) { @@ -565,9 +587,16 @@ export const resizeSingleElement = ( ], }); } + let minWidth = 0; + if (boundTextElementId) { + const boundTextElement = Scene.getScene(element)!.getElement( + boundTextElementId, + ) as ExcalidrawTextElement; + minWidth = getApproxMinLineWidth(getFontString(boundTextElement)); + } if ( - resizedElement.width !== 0 && + resizedElement.width > minWidth && resizedElement.height !== 0 && Number.isFinite(resizedElement.x) && Number.isFinite(resizedElement.y) @@ -576,6 +605,7 @@ export const resizeSingleElement = ( newSize: { width: resizedElement.width, height: resizedElement.height }, }); mutateElement(element, resizedElement); + handleBindTextResize([element], transformHandleDirection); } }; @@ -647,7 +677,7 @@ const resizeMultipleElements = ( const width = element.width * scale; const height = element.height * scale; let font: { fontSize?: number; baseline?: number } = {}; - if (element.type === "text") { + if (isTextElement(element)) { const nextFont = measureFontSizeFromWH(element, width, height); if (nextFont === null) { return null; @@ -728,6 +758,16 @@ const rotateMultipleElements = ( y: element.y + (rotatedCY - cy), angle: normalizeAngle(centerAngle + origAngle), }); + const boundTextElementId = getBoundTextElementId(element); + if (boundTextElementId) { + const textElement = + Scene.getScene(element)!.getElement(boundTextElementId)!; + mutateElement(textElement, { + x: textElement.x + (rotatedCX - cx), + y: textElement.y + (rotatedCY - cy), + angle: normalizeAngle(centerAngle + origAngle), + }); + } }); }; diff --git a/src/element/textElement.ts b/src/element/textElement.ts index 7a92e7c3..1b11e654 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -1,12 +1,389 @@ -import { measureText, getFontString } from "../utils"; -import { ExcalidrawTextElement } from "./types"; +import { getFontString, arrayToMap } from "../utils"; +import { + ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawTextElement, + FontString, + NonDeletedExcalidrawElement, +} from "./types"; import { mutateElement } from "./mutateElement"; +import { PADDING } from "../constants"; +import { MaybeTransformHandleType } from "./transformHandles"; +import Scene from "../scene/Scene"; export const redrawTextBoundingBox = (element: ExcalidrawTextElement) => { - const metrics = measureText(element.text, getFontString(element)); + let maxWidth; + if (element.containerId) { + maxWidth = element.width; + } + const metrics = measureText( + element.originalText, + getFontString(element), + maxWidth, + ); + mutateElement(element, { width: metrics.width, height: metrics.height, baseline: metrics.baseline, }); }; + +export const bindTextToShapeAfterDuplication = ( + sceneElements: ExcalidrawElement[], + oldElements: ExcalidrawElement[], + oldIdToDuplicatedId: Map, +): void => { + const sceneElementMap = arrayToMap(sceneElements) as Map< + ExcalidrawElement["id"], + ExcalidrawElement + >; + oldElements.forEach((element) => { + const newElementId = oldIdToDuplicatedId.get(element.id) as string; + const boundTextElementId = getBoundTextElementId(element); + + if (boundTextElementId) { + const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId)!; + mutateElement( + sceneElementMap.get(newElementId) as ExcalidrawBindableElement, + { + boundElements: element.boundElements?.concat({ + type: "text", + id: newTextElementId, + }), + }, + ); + mutateElement( + sceneElementMap.get(newTextElementId) as ExcalidrawTextElement, + { + containerId: newElementId, + }, + ); + } + }); +}; + +export const handleBindTextResize = ( + elements: readonly NonDeletedExcalidrawElement[], + transformHandleType: MaybeTransformHandleType, +) => { + elements.forEach((element) => { + const boundTextElementId = getBoundTextElementId(element); + if (boundTextElementId) { + const textElement = Scene.getScene(element)!.getElement( + boundTextElementId, + ) as ExcalidrawTextElement; + if (textElement && textElement.text) { + if (!element) { + return; + } + let text = textElement.text; + let nextHeight = textElement.height; + let containerHeight = element.height; + let nextBaseLine = textElement.baseline; + if (transformHandleType !== "n" && transformHandleType !== "s") { + let minCharWidthTillNow = 0; + if (text) { + minCharWidthTillNow = getMinCharWidth(getFontString(textElement)); + // check if the diff has exceeded min char width needed + const diff = Math.abs( + element.width - textElement.width + PADDING * 2, + ); + if (diff >= minCharWidthTillNow) { + text = wrapText( + textElement.originalText, + getFontString(textElement), + element.width, + ); + } + } + + const dimensions = measureText( + text, + getFontString(textElement), + element.width, + ); + nextHeight = dimensions.height; + nextBaseLine = dimensions.baseline; + } + // increase height in case text element height exceeds + if (nextHeight > element.height - PADDING * 2) { + containerHeight = nextHeight + PADDING * 2; + const diff = containerHeight - element.height; + // fix the y coord when resizing from ne/nw/n + const updatedY = + transformHandleType === "ne" || + transformHandleType === "nw" || + transformHandleType === "n" + ? element.y - diff + : element.y; + mutateElement(element, { + height: containerHeight, + y: updatedY, + }); + } + + const updatedY = element.y + containerHeight / 2 - nextHeight / 2; + mutateElement(textElement, { + text, + // preserve padding and set width correctly + width: element.width - PADDING * 2, + height: nextHeight, + x: element.x + PADDING, + y: updatedY, + baseline: nextBaseLine, + }); + } + } + }); +}; + +// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js +export const measureText = ( + text: string, + font: FontString, + maxWidth?: number | null, +) => { + text = 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 container = document.createElement("div"); + container.style.position = "absolute"; + container.style.whiteSpace = "pre"; + container.style.font = font; + container.style.minHeight = "1em"; + + if (maxWidth) { + const lineHeight = getApproxLineHeight(font); + container.style.width = `${String(maxWidth)}px`; + container.style.maxWidth = `${String(maxWidth)}px`; + container.style.overflow = "hidden"; + container.style.wordBreak = "break-word"; + container.style.lineHeight = `${String(lineHeight)}px`; + container.style.whiteSpace = "pre-wrap"; + } + document.body.appendChild(container); + container.innerText = text; + + const span = document.createElement("span"); + span.style.display = "inline-block"; + span.style.overflow = "hidden"; + span.style.width = "1px"; + span.style.height = "1px"; + container.appendChild(span); + // Baseline is important for positioning text on canvas + const baseline = span.offsetTop + span.offsetHeight; + const width = container.offsetWidth; + + const height = container.offsetHeight; + document.body.removeChild(container); + + return { width, height, baseline }; +}; + +const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); +export const getApproxLineHeight = (font: FontString) => { + return measureText(DUMMY_TEXT, font, null).height; +}; + +let canvas: HTMLCanvasElement | undefined; +const getTextWidth = (text: string, font: FontString) => { + if (!canvas) { + canvas = document.createElement("canvas"); + } + const canvas2dContext = canvas.getContext("2d")!; + canvas2dContext.font = font; + + const metrics = canvas2dContext.measureText(text); + + return metrics.width; +}; + +export const wrapText = ( + text: string, + font: FontString, + containerWidth: number, +) => { + const maxWidth = containerWidth - PADDING * 2; + + const lines: Array = []; + const originalLines = text.split("\n"); + const spaceWidth = getTextWidth(" ", font); + originalLines.forEach((originalLine) => { + const words = originalLine.split(" "); + // This means its newline so push it + if (words.length === 1 && words[0] === "") { + lines.push(words[0]); + } else { + let currentLine = ""; + let currentLineWidthTillNow = 0; + + let index = 0; + while (index < words.length) { + const currentWordWidth = getTextWidth(words[index], font); + + // Start breaking longer words exceeding max width + if (currentWordWidth > maxWidth) { + // push current line since the current word exceeds the max width + // so will be appended in next line + if (currentLine) { + lines.push(currentLine); + } + currentLine = ""; + currentLineWidthTillNow = 0; + while (words[index].length > 0) { + const currentChar = words[index][0]; + const width = charWidth.calculate(currentChar, font); + currentLineWidthTillNow += width; + words[index] = words[index].slice(1); + + if (currentLineWidthTillNow >= maxWidth) { + // only remove last trailing space which we have added when joining words + if (currentLine.slice(-1) === " ") { + currentLine = currentLine.slice(0, -1); + } + lines.push(currentLine); + currentLine = currentChar; + currentLineWidthTillNow = width; + if (currentLineWidthTillNow === maxWidth) { + currentLine = ""; + currentLineWidthTillNow = 0; + } + } else { + currentLine += currentChar; + } + } + // push current line if appending space exceeds max width + if (currentLineWidthTillNow + spaceWidth > maxWidth) { + lines.push(currentLine); + currentLine = ""; + currentLineWidthTillNow = 0; + } else { + // space needs to be appended before next word + // as currentLine contains chars which couldn't be appended + // to previous line + currentLine += " "; + currentLineWidthTillNow += spaceWidth; + } + + index++; + } else { + // Start appending words in a line till max width reached + while (currentLineWidthTillNow < maxWidth && index < words.length) { + const word = words[index]; + currentLineWidthTillNow = getTextWidth(currentLine + word, font); + + if (currentLineWidthTillNow >= maxWidth) { + lines.push(currentLine); + currentLineWidthTillNow = 0; + currentLine = ""; + + break; + } + index++; + currentLine += `${word} `; + } + + if (currentLineWidthTillNow === maxWidth) { + currentLine = ""; + currentLineWidthTillNow = 0; + } + } + } + if (currentLine) { + // only remove last trailing space which we have added when joining words + if (currentLine.slice(-1) === " ") { + currentLine = currentLine.slice(0, -1); + } + lines.push(currentLine); + } + } + }); + return lines.join("\n"); +}; + +export const charWidth = (() => { + const cachedCharWidth: { [key: FontString]: Array } = {}; + + const calculate = (char: string, font: FontString) => { + const ascii = char.charCodeAt(0); + if (!cachedCharWidth[font]) { + cachedCharWidth[font] = []; + } + if (!cachedCharWidth[font][ascii]) { + const width = getTextWidth(char, font); + cachedCharWidth[font][ascii] = width; + } + return cachedCharWidth[font][ascii]; + }; + + const updateCache = (char: string, font: FontString) => { + const ascii = char.charCodeAt(0); + + if (!cachedCharWidth[font][ascii]) { + cachedCharWidth[font][ascii] = calculate(char, font); + } + }; + + const clearCacheforFont = (font: FontString) => { + cachedCharWidth[font] = []; + }; + + const getCache = (font: FontString) => { + return cachedCharWidth[font]; + }; + return { + calculate, + updateCache, + clearCacheforFont, + getCache, + }; +})(); +export const getApproxMinLineWidth = (font: FontString) => { + return measureText(DUMMY_TEXT.split("").join("\n"), font).width + PADDING * 2; +}; + +export const getApproxMinLineHeight = (font: FontString) => { + return getApproxLineHeight(font) + PADDING * 2; +}; + +export const getMinCharWidth = (font: FontString) => { + const cache = charWidth.getCache(font); + if (!cache) { + return 0; + } + const cacheWithOutEmpty = cache.filter((val) => val !== undefined); + + return Math.min(...cacheWithOutEmpty); +}; + +export const getApproxCharsToFitInWidth = (font: FontString, width: number) => { + // Generally lower case is used so converting to lower case + const dummyText = DUMMY_TEXT.toLocaleLowerCase(); + const batchLength = 6; + let index = 0; + let widthTillNow = 0; + let str = ""; + while (widthTillNow <= width) { + const batch = dummyText.substr(index, index + batchLength); + str += batch; + widthTillNow += getTextWidth(str, font); + if (index === dummyText.length - 1) { + index = 0; + } + index = index + batchLength; + } + + while (widthTillNow > width) { + str = str.substr(0, str.length - 1); + widthTillNow = getTextWidth(str, font); + } + return str.length; +}; + +export const getBoundTextElementId = (container: ExcalidrawElement | null) => { + return container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id; +}; diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 7fc8999c..c6f2a5c0 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -1,10 +1,25 @@ import { CODES, KEYS } from "../keys"; -import { isWritableElement, getFontString } from "../utils"; +import { + isWritableElement, + getFontString, + viewportCoordsToSceneCoords, + getFontFamilyString, +} from "../utils"; import Scene from "../scene/Scene"; import { isTextElement } from "./typeChecks"; -import { CLASSES } from "../constants"; -import { ExcalidrawElement } from "./types"; +import { CLASSES, PADDING } from "../constants"; +import { + ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawTextElement, +} from "./types"; import { AppState } from "../types"; +import { mutateElement } from "./mutateElement"; +import { + getApproxLineHeight, + getBoundTextElementId, + wrapText, +} from "./textElement"; const normalizeText = (text: string) => { return ( @@ -48,55 +63,154 @@ export const textWysiwyg = ({ id: ExcalidrawElement["id"]; appState: AppState; onChange?: (text: string) => void; - onSubmit: (data: { text: string; viaKeyboard: boolean }) => void; + onSubmit: (data: { + text: string; + viaKeyboard: boolean; + originalText: string; + }) => void; getViewportCoords: (x: number, y: number) => [number, number]; element: ExcalidrawElement; canvas: HTMLCanvasElement | null; excalidrawContainer: HTMLDivElement | null; }) => { + const textPropertiesUpdated = ( + updatedElement: ExcalidrawTextElement, + editable: HTMLTextAreaElement, + ) => { + const currentFont = editable.style.fontFamily.replaceAll('"', ""); + if ( + getFontFamilyString({ fontFamily: updatedElement.fontFamily }) !== + currentFont + ) { + return true; + } + if (`${updatedElement.fontSize}px` !== editable.style.fontSize) { + return true; + } + return false; + }; + let originalContainerHeight: number; + let approxLineHeight = isTextElement(element) + ? getApproxLineHeight(getFontString(element)) + : 0; + const updateWysiwygStyle = () => { const updatedElement = Scene.getScene(element)?.getElement(id); if (updatedElement && isTextElement(updatedElement)) { - const [viewportX, viewportY] = getViewportCoords( - updatedElement.x, - updatedElement.y, - ); + let coordX = updatedElement.x; + let coordY = updatedElement.y; + let container = updatedElement?.containerId + ? Scene.getScene(updatedElement)!.getElement(updatedElement.containerId) + : null; + let maxWidth = updatedElement.width; + + let maxHeight = updatedElement.height; + let width = updatedElement.width; + let height = updatedElement.height; + if (container && updatedElement.containerId) { + const propertiesUpdated = textPropertiesUpdated( + updatedElement, + editable, + ); + if (propertiesUpdated) { + const currentContainer = Scene.getScene(updatedElement)?.getElement( + updatedElement.containerId, + ) as ExcalidrawBindableElement; + approxLineHeight = isTextElement(updatedElement) + ? getApproxLineHeight(getFontString(updatedElement)) + : 0; + if (updatedElement.height > currentContainer.height - PADDING * 2) { + const nextHeight = updatedElement.height + PADDING * 2; + originalContainerHeight = nextHeight; + mutateElement(container, { height: nextHeight }); + container = { ...container, height: nextHeight }; + } + editable.style.height = `${updatedElement.height}px`; + } + if (!originalContainerHeight) { + originalContainerHeight = container.height; + } + maxWidth = container.width - PADDING * 2; + maxHeight = container.height - PADDING * 2; + width = maxWidth; + height = Math.min(height, maxHeight); + // The coordinates of text box set a distance of + // 30px to preserve padding + coordX = container.x + PADDING; + + // autogrow container height if text exceeds + if (editable.clientHeight > maxHeight) { + const diff = Math.min( + editable.clientHeight - maxHeight, + approxLineHeight, + ); + mutateElement(container, { height: container.height + diff }); + return; + } else if ( + // autoshrink container height until original container height + // is reached when text is removed + container.height > originalContainerHeight && + editable.clientHeight < maxHeight + ) { + const diff = Math.min( + maxHeight - editable.clientHeight, + approxLineHeight, + ); + mutateElement(container, { height: container.height - diff }); + } + // Start pushing text upward until a diff of 30px (padding) + // is reached + else { + const lines = editable.clientHeight / approxLineHeight; + // For some reason the scrollHeight gets set to twice the lineHeight + // when you start typing for first time and thus line count is 2 + // hence this check + if (lines > 2 || propertiesUpdated) { + // vertically center align the text + coordY = + container.y + container.height / 2 - editable.clientHeight / 2; + } + } + } + + const [viewportX, viewportY] = getViewportCoords(coordX, coordY); 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; - const maxWidth = - (appState.offsetLeft + appState.width - viewportX - 8) / - appState.zoom.value - - // margin-right of parent if any - Number( - getComputedStyle( - excalidrawContainer?.parentNode as Element, - ).marginRight.slice(0, -2), - ); - + editable.value = updatedElement.originalText || updatedElement.text; + const lines = updatedElement.originalText.split("\n"); + const lineHeight = updatedElement.containerId + ? approxLineHeight + : updatedElement.height / lines.length; + if (!container) { + maxWidth = + (appState.offsetLeft + appState.width - viewportX - 8) / + appState.zoom.value - + // margin-right of parent if any + Number( + getComputedStyle( + excalidrawContainer?.parentNode as Element, + ).marginRight.slice(0, -2), + ); + } + // Make sure text editor height doesn't go beyond viewport + const editorMaxHeight = + (appState.offsetTop + appState.height - viewportY) / + appState.zoom.value; Object.assign(editable.style, { font: getFontString(updatedElement), // must be defined *after* font ¯\_(ツ)_/¯ lineHeight: `${lineHeight}px`, - width: `${updatedElement.width}px`, - height: `${updatedElement.height}px`, + width: `${width}px`, + height: `${Math.max(editable.clientHeight, updatedElement.height)}px`, left: `${viewportX}px`, top: `${viewportY}px`, - transform: getTransform( - updatedElement.width, - updatedElement.height, - angle, - appState, - maxWidth, - ), + transform: getTransform(width, height, angle, appState, maxWidth), textAlign, color: updatedElement.strokeColor, opacity: updatedElement.opacity / 100, filter: "var(--theme-filter)", maxWidth: `${maxWidth}px`, + maxHeight: `${editorMaxHeight}px`, }); } }; @@ -110,6 +224,10 @@ export const textWysiwyg = ({ editable.wrap = "off"; editable.classList.add("excalidraw-wysiwyg"); + let whiteSpace = "pre"; + if (isTextElement(element)) { + whiteSpace = element.containerId ? "pre-wrap" : "pre"; + } Object.assign(editable.style, { position: "absolute", display: "inline-block", @@ -122,16 +240,19 @@ export const textWysiwyg = ({ resize: "none", background: "transparent", overflow: "hidden", - // prevent line wrapping (`whitespace: nowrap` doesn't work on FF) - whiteSpace: "pre", // must be specified because in dark mode canvas creates a stacking context zIndex: "var(--zIndex-wysiwyg)", + wordBreak: "break-word", + // prevent line wrapping (`whitespace: nowrap` doesn't work on FF) + whiteSpace, + overflowWrap: "break-word", }); - updateWysiwygStyle(); if (onChange) { editable.oninput = () => { + editable.style.height = "auto"; + editable.style.height = `${editable.scrollHeight}px`; onChange(normalizeText(editable.value)); }; } @@ -174,7 +295,7 @@ export const textWysiwyg = ({ const linesStartIndices = getSelectedLinesStartIndices(); let value = editable.value; - linesStartIndices.forEach((startIndex) => { + linesStartIndices.forEach((startIndex: number) => { const startValue = value.slice(0, startIndex); const endValue = value.slice(startIndex); @@ -274,9 +395,63 @@ export const textWysiwyg = ({ // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the // wysiwyg on update cleanup(); + const updateElement = Scene.getScene(element)?.getElement(element.id); + if (!updateElement) { + return; + } + let wrappedText = ""; + if (isTextElement(updateElement) && updateElement?.containerId) { + const container = Scene.getScene(updateElement)!.getElement( + updateElement.containerId, + ) as ExcalidrawBindableElement; + + if (container) { + wrappedText = wrapText( + editable.value, + getFontString(updateElement), + container.width, + ); + const { x, y } = viewportCoordsToSceneCoords( + { + clientX: Number(editable.style.left.slice(0, -2)), + clientY: Number(editable.style.top.slice(0, -2)), + }, + appState, + ); + if (isTextElement(updateElement) && updateElement.containerId) { + if (editable.value) { + mutateElement(updateElement, { + y, + height: Number(editable.style.height.slice(0, -2)), + width: Number(editable.style.width.slice(0, -2)), + x, + }); + const boundTextElementId = getBoundTextElementId(container); + if (!boundTextElementId || boundTextElementId !== element.id) { + mutateElement(container, { + boundElements: (container.boundElements || []).concat({ + type: "text", + id: element.id, + }), + }); + } + } else { + mutateElement(container, { + boundElements: container.boundElements?.filter( + (ele) => ele.type !== "text", + ), + }); + } + } + } + } else { + wrappedText = editable.value; + } + onSubmit({ - text: normalizeText(editable.value), + text: normalizeText(wrappedText), viaKeyboard: submittedViaKeyboard, + originalText: editable.value, }); }; diff --git a/src/element/transformHandles.ts b/src/element/transformHandles.ts index b259509f..4350891b 100644 --- a/src/element/transformHandles.ts +++ b/src/element/transformHandles.ts @@ -3,6 +3,7 @@ import { ExcalidrawElement, PointerType } from "./types"; import { getElementAbsoluteCoords, Bounds } from "./bounds"; import { rotate } from "../math"; import { Zoom } from "../types"; +import { isTextElement } from "."; export type TransformHandleDirection = | "n" @@ -242,7 +243,7 @@ export const getTransformHandles = ( omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH; } } - } else if (element.type === "text") { + } else if (isTextElement(element)) { omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; } diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts index 63cb0ec7..ea5b11ac 100644 --- a/src/element/typeChecks.ts +++ b/src/element/typeChecks.ts @@ -7,6 +7,7 @@ import { ExcalidrawFreeDrawElement, InitializedExcalidrawImageElement, ExcalidrawImageElement, + ExcalidrawTextElementWithContainer, } from "./types"; export const isGenericElement = ( @@ -86,7 +87,17 @@ export const isBindableElement = ( element.type === "diamond" || element.type === "ellipse" || element.type === "image" || - element.type === "text") + (element.type === "text" && !element.containerId)) + ); +}; + +export const isTextBindableContainer = (element: ExcalidrawElement | null) => { + return ( + element != null && + (element.type === "rectangle" || + element.type === "diamond" || + element.type === "ellipse" || + element.type === "image") ); }; @@ -101,3 +112,20 @@ export const isExcalidrawElement = (element: any): boolean => { element?.type === "line" ); }; + +export const hasBoundTextElement = ( + element: ExcalidrawElement | null, +): element is ExcalidrawBindableElement => { + return ( + isBindableElement(element) && + !!element.boundElements?.some(({ type }) => type === "text") + ); +}; + +export const isBoundToContainer = ( + element: ExcalidrawElement | null, +): element is ExcalidrawTextElementWithContainer => { + return ( + element !== null && isTextElement(element) && element.containerId !== null + ); +}; diff --git a/src/element/types.ts b/src/element/types.ts index 4403f5e2..48259771 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -121,6 +121,8 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & baseline: number; textAlign: TextAlign; verticalAlign: VerticalAlign; + containerId: ExcalidrawGenericElement["id"] | null; + originalText: string; }>; export type ExcalidrawBindableElement = @@ -130,6 +132,10 @@ export type ExcalidrawBindableElement = | ExcalidrawTextElement | ExcalidrawImageElement; +export type ExcalidrawTextElementWithContainer = { + containerId: ExcalidrawGenericElement["id"]; +} & ExcalidrawTextElement; + export type PointBinding = { elementId: ExcalidrawBindableElement["id"]; focus: number; diff --git a/src/locales/en.json b/src/locales/en.json index 738379fa..a8a04455 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -207,7 +207,8 @@ "lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move", "lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points", "placeImage": "Click to place the image, or click and drag to set its size manually", - "publishLibrary": "Publish your own library" + "publishLibrary": "Publish your own library", + "bindTextToElement": "Press enter to add text" }, "canvasError": { "cannotShowPreview": "Cannot show preview", diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index ad186f09..63f164ed 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -22,6 +22,7 @@ import { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable, Options } from "roughjs/bin/core"; import { RoughSVG } from "roughjs/bin/svg"; import { RoughGenerator } from "roughjs/bin/generator"; + import { RenderConfig } from "../scene/types"; import { distance, getFontString, getFontFamilyString, isRTL } from "../utils"; import { isPathALoop } from "../math"; @@ -30,6 +31,7 @@ import { AppState, BinaryFiles, Zoom } from "../types"; import { getDefaultAppState } from "../appState"; import { MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS } from "../constants"; import { getStroke, StrokeOptions } from "perfect-freehand"; +import { getApproxLineHeight } from "../element/textElement"; // using a stronger invert (100% vs our regular 93%) and saturate // as a temp hack to make images in dark theme look closer to original @@ -252,7 +254,9 @@ const drawElementOnCanvas = ( // Canvas does not support multiline text by default const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); - const lineHeight = element.height / lines.length; + const lineHeight = element.containerId + ? getApproxLineHeight(getFontString(element)) + : element.height / lines.length; const verticalOffset = element.height - element.baseline; const horizontalOffset = element.textAlign === "center" diff --git a/src/scene/comparisons.ts b/src/scene/comparisons.ts index 618f2966..30f33971 100644 --- a/src/scene/comparisons.ts +++ b/src/scene/comparisons.ts @@ -75,6 +75,7 @@ export const getElementContainingPosition = ( elements: readonly ExcalidrawElement[], x: number, y: number, + excludedType?: ExcalidrawElement["type"], ) => { let hitElement = null; // We need to to hit testing from front (end of the array) to back (beginning of the array) @@ -83,7 +84,13 @@ export const getElementContainingPosition = ( continue; } const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]); - if (x1 < x && x < x2 && y1 < y && y < y2) { + if ( + x1 < x && + x < x2 && + y1 < y && + y < y2 && + elements[index].type !== excludedType + ) { hitElement = elements[index]; break; } diff --git a/src/scene/selection.ts b/src/scene/selection.ts index 3c883d8a..da8acc77 100644 --- a/src/scene/selection.ts +++ b/src/scene/selection.ts @@ -4,6 +4,7 @@ import { } from "../element/types"; import { getElementAbsoluteCoords, getElementBounds } from "../element"; import { AppState } from "../types"; +import { isBoundToContainer } from "../element/typeChecks"; export const getElementsWithinSelection = ( elements: readonly NonDeletedExcalidrawElement[], @@ -17,6 +18,7 @@ export const getElementsWithinSelection = ( return ( element.type !== "selection" && + !isBoundToContainer(element) && selectionX1 <= elementX1 && selectionY1 <= elementY1 && selectionX2 >= elementX2 && @@ -53,7 +55,21 @@ export const getCommonAttributeOfSelectedElements = ( export const getSelectedElements = ( elements: readonly NonDeletedExcalidrawElement[], appState: AppState, -) => elements.filter((element) => appState.selectedElementIds[element.id]); + includeBoundTextElement: boolean = false, +) => + elements.filter((element) => { + if (appState.selectedElementIds[element.id]) { + return element; + } + if ( + includeBoundTextElement && + isBoundToContainer(element) && + appState.selectedElementIds[element?.containerId] + ) { + return element; + } + return null; + }); export const getTargetElements = ( elements: readonly NonDeletedExcalidrawElement[], diff --git a/src/tests/data/__snapshots__/restore.test.ts.snap b/src/tests/data/__snapshots__/restore.test.ts.snap index 226d22ee..b9b89f8b 100644 --- a/src/tests/data/__snapshots__/restore.test.ts.snap +++ b/src/tests/data/__snapshots__/restore.test.ts.snap @@ -256,6 +256,7 @@ Object { "backgroundColor": "transparent", "baseline": 0, "boundElements": Array [], + "containerId": null, "fillStyle": "hachure", "fontFamily": 1, "fontSize": 14, @@ -264,6 +265,7 @@ Object { "id": "id-text01", "isDeleted": false, "opacity": 100, + "originalText": "text", "roughness": 1, "seed": Any, "strokeColor": "#000000", @@ -289,6 +291,7 @@ Object { "backgroundColor": "transparent", "baseline": 0, "boundElements": Array [], + "containerId": null, "fillStyle": "hachure", "fontFamily": 1, "fontSize": 10, @@ -297,6 +300,7 @@ Object { "id": "id-text01", "isDeleted": false, "opacity": 100, + "originalText": "test", "roughness": 1, "seed": Any, "strokeColor": "#000000", diff --git a/src/utils.ts b/src/utils.ts index dd6e6573..f085bf3c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -90,37 +90,6 @@ export const getFontString = ({ return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString; }; -// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js -export const measureText = (text: string, font: FontString) => { - const line = document.createElement("div"); - const body = document.body; - line.style.position = "absolute"; - line.style.whiteSpace = "pre"; - line.style.font = font; - body.appendChild(line); - 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 - // to calculate baseline shift - const span = document.createElement("span"); - span.style.display = "inline-block"; - span.style.overflow = "hidden"; - span.style.width = "1px"; - span.style.height = "1px"; - line.appendChild(span); - // Baseline is important for positioning text on canvas - const baseline = span.offsetTop + span.offsetHeight; - document.body.removeChild(line); - - return { width, height, baseline }; -}; - export const debounce = ( fn: (...args: T) => void, timeout: number,