aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/actions/actionBoundText.tsx
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/actions/actionBoundText.tsx
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/actions/actionBoundText.tsx')
-rw-r--r--packages/excalidraw/actions/actionBoundText.tsx329
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,
+ };
+ },
+});