diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/actions/actionBoundText.tsx | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/actions/actionBoundText.tsx')
| -rw-r--r-- | packages/excalidraw/actions/actionBoundText.tsx | 329 |
1 files changed, 329 insertions, 0 deletions
diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx new file mode 100644 index 0000000..b72ddee --- /dev/null +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -0,0 +1,329 @@ +import { + BOUND_TEXT_PADDING, + ROUNDNESS, + TEXT_ALIGN, + VERTICAL_ALIGN, +} from "../constants"; +import { isTextElement, newElement } from "../element"; +import { mutateElement } from "../element/mutateElement"; +import { + computeBoundTextPosition, + computeContainerDimensionForBoundText, + getBoundTextElement, + redrawTextBoundingBox, +} from "../element/textElement"; +import { + getOriginalContainerHeightFromCache, + resetOriginalContainerCache, + updateOriginalContainerCache, +} from "../element/containerCache"; +import { + hasBoundTextElement, + isTextBindableContainer, + isUsingAdaptiveRadius, +} from "../element/typeChecks"; +import type { + ExcalidrawElement, + ExcalidrawLinearElement, + ExcalidrawTextContainer, + ExcalidrawTextElement, +} from "../element/types"; +import type { AppState } from "../types"; +import type { Mutable } from "../utility-types"; +import { arrayToMap, getFontString } from "../utils"; +import { register } from "./register"; +import { syncMovedIndices } from "../fractionalIndex"; +import { CaptureUpdateAction } from "../store"; +import { measureText } from "../element/textMeasurements"; + +export const actionUnbindText = register({ + name: "unbindText", + label: "labels.unbindText", + trackEvent: { category: "element" }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + + return selectedElements.some((element) => hasBoundTextElement(element)); + }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + const elementsMap = app.scene.getNonDeletedElementsMap(); + selectedElements.forEach((element) => { + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + const { width, height } = measureText( + boundTextElement.originalText, + getFontString(boundTextElement), + boundTextElement.lineHeight, + ); + const originalContainerHeight = getOriginalContainerHeightFromCache( + element.id, + ); + resetOriginalContainerCache(element.id); + const { x, y } = computeBoundTextPosition( + element, + boundTextElement, + elementsMap, + ); + mutateElement(boundTextElement as ExcalidrawTextElement, { + containerId: null, + width, + height, + text: boundTextElement.originalText, + x, + y, + }); + mutateElement(element, { + boundElements: element.boundElements?.filter( + (ele) => ele.id !== boundTextElement.id, + ), + height: originalContainerHeight + ? originalContainerHeight + : element.height, + }); + } + }); + return { + elements, + appState, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, +}); + +export const actionBindText = register({ + name: "bindText", + label: "labels.bindText", + trackEvent: { category: "element" }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + + if (selectedElements.length === 2) { + const textElement = + isTextElement(selectedElements[0]) || + isTextElement(selectedElements[1]); + + let bindingContainer; + if (isTextBindableContainer(selectedElements[0])) { + bindingContainer = selectedElements[0]; + } else if (isTextBindableContainer(selectedElements[1])) { + bindingContainer = selectedElements[1]; + } + if ( + textElement && + bindingContainer && + getBoundTextElement( + bindingContainer, + app.scene.getNonDeletedElementsMap(), + ) === null + ) { + return true; + } + } + return false; + }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + + let textElement: ExcalidrawTextElement; + let container: ExcalidrawTextContainer; + + if ( + isTextElement(selectedElements[0]) && + isTextBindableContainer(selectedElements[1]) + ) { + textElement = selectedElements[0]; + container = selectedElements[1]; + } else { + textElement = selectedElements[1] as ExcalidrawTextElement; + container = selectedElements[0] as ExcalidrawTextContainer; + } + mutateElement(textElement, { + containerId: container.id, + verticalAlign: VERTICAL_ALIGN.MIDDLE, + textAlign: TEXT_ALIGN.CENTER, + autoResize: true, + }); + mutateElement(container, { + boundElements: (container.boundElements || []).concat({ + type: "text", + id: textElement.id, + }), + }); + const originalContainerHeight = container.height; + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); + // overwritting the cache with original container height so + // it can be restored when unbind + updateOriginalContainerCache(container.id, originalContainerHeight); + + return { + elements: pushTextAboveContainer(elements, container, textElement), + appState: { ...appState, selectedElementIds: { [container.id]: true } }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, +}); + +const pushTextAboveContainer = ( + elements: readonly ExcalidrawElement[], + container: ExcalidrawElement, + textElement: ExcalidrawTextElement, +) => { + const updatedElements = elements.slice(); + const textElementIndex = updatedElements.findIndex( + (ele) => ele.id === textElement.id, + ); + updatedElements.splice(textElementIndex, 1); + + const containerIndex = updatedElements.findIndex( + (ele) => ele.id === container.id, + ); + updatedElements.splice(containerIndex + 1, 0, textElement); + syncMovedIndices(updatedElements, arrayToMap([container, textElement])); + + return updatedElements; +}; + +const pushContainerBelowText = ( + elements: readonly ExcalidrawElement[], + container: ExcalidrawElement, + textElement: ExcalidrawTextElement, +) => { + const updatedElements = elements.slice(); + const containerIndex = updatedElements.findIndex( + (ele) => ele.id === container.id, + ); + updatedElements.splice(containerIndex, 1); + + const textElementIndex = updatedElements.findIndex( + (ele) => ele.id === textElement.id, + ); + updatedElements.splice(textElementIndex, 0, container); + syncMovedIndices(updatedElements, arrayToMap([container, textElement])); + + return updatedElements; +}; + +export const actionWrapTextInContainer = register({ + name: "wrapTextInContainer", + label: "labels.createContainerFromText", + trackEvent: { category: "element" }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + const areTextElements = selectedElements.every((el) => isTextElement(el)); + return selectedElements.length > 0 && areTextElements; + }, + perform: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + let updatedElements: readonly ExcalidrawElement[] = elements.slice(); + const containerIds: Mutable<AppState["selectedElementIds"]> = {}; + + for (const textElement of selectedElements) { + if (isTextElement(textElement)) { + const container = newElement({ + type: "rectangle", + backgroundColor: appState.currentItemBackgroundColor, + boundElements: [ + ...(textElement.boundElements || []), + { id: textElement.id, type: "text" }, + ], + angle: textElement.angle, + fillStyle: appState.currentItemFillStyle, + strokeColor: appState.currentItemStrokeColor, + roughness: appState.currentItemRoughness, + strokeWidth: appState.currentItemStrokeWidth, + strokeStyle: appState.currentItemStrokeStyle, + roundness: + appState.currentItemRoundness === "round" + ? { + type: isUsingAdaptiveRadius("rectangle") + ? ROUNDNESS.ADAPTIVE_RADIUS + : ROUNDNESS.PROPORTIONAL_RADIUS, + } + : null, + opacity: 100, + locked: false, + x: textElement.x - BOUND_TEXT_PADDING, + y: textElement.y - BOUND_TEXT_PADDING, + width: computeContainerDimensionForBoundText( + textElement.width, + "rectangle", + ), + height: computeContainerDimensionForBoundText( + textElement.height, + "rectangle", + ), + groupIds: textElement.groupIds, + frameId: textElement.frameId, + }); + + // update bindings + if (textElement.boundElements?.length) { + const linearElementIds = textElement.boundElements + .filter((ele) => ele.type === "arrow") + .map((el) => el.id); + const linearElements = updatedElements.filter((ele) => + linearElementIds.includes(ele.id), + ) as ExcalidrawLinearElement[]; + linearElements.forEach((ele) => { + let startBinding = ele.startBinding; + let endBinding = ele.endBinding; + + if (startBinding?.elementId === textElement.id) { + startBinding = { + ...startBinding, + elementId: container.id, + }; + } + + if (endBinding?.elementId === textElement.id) { + endBinding = { ...endBinding, elementId: container.id }; + } + + if (startBinding || endBinding) { + mutateElement(ele, { startBinding, endBinding }, false); + } + }); + } + + mutateElement( + textElement, + { + containerId: container.id, + verticalAlign: VERTICAL_ALIGN.MIDDLE, + boundElements: null, + textAlign: TEXT_ALIGN.CENTER, + autoResize: true, + }, + false, + ); + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); + + updatedElements = pushContainerBelowText( + [...updatedElements, container], + container, + textElement, + ); + + containerIds[container.id] = true; + } + } + + return { + elements: updatedElements, + appState: { + ...appState, + selectedElementIds: containerIds, + }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }; + }, +}); |
