snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Has been cancelled
Some checks failed
Tests / test (push) Has been cancelled
This commit is contained in:
377
packages/excalidraw/actions/actionBoundText.tsx
Normal file
377
packages/excalidraw/actions/actionBoundText.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
ROUNDNESS,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
getStrokeWidthByKey,
|
||||
isTransparent,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
isBoundToContainer,
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
DEFAULT_BOUND_TEXT_LABEL_POSITION,
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
normalizeStickyNoteFontSize,
|
||||
redrawTextBoundingBox,
|
||||
updateStickyNoteLayout,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isStickyNoteElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { measureText } from "@excalidraw/element";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
|
||||
import { newElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
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,
|
||||
);
|
||||
app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
text: boundTextElement.originalText,
|
||||
baseFontSize: null,
|
||||
x,
|
||||
y,
|
||||
labelPosition: null,
|
||||
});
|
||||
app.scene.mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
});
|
||||
if (isStickyNoteElement(element)) {
|
||||
// an empty note sits at its base height; bound arrows follow
|
||||
updateStickyNoteLayout(element, app.scene);
|
||||
} else {
|
||||
app.scene.mutateElement(element, {
|
||||
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;
|
||||
}
|
||||
// a note and its label share one ink: the text the user styled wins,
|
||||
// unless it is transparent (a note's label never is)
|
||||
const stickyInk = isStickyNoteElement(container)
|
||||
? isTransparent(textElement.strokeColor)
|
||||
? container.strokeColor
|
||||
: textElement.strokeColor
|
||||
: null;
|
||||
app.scene.mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
|
||||
labelPosition: isArrowElement(container)
|
||||
? DEFAULT_BOUND_TEXT_LABEL_POSITION
|
||||
: null,
|
||||
...(stickyInk
|
||||
? {
|
||||
baseFontSize: normalizeStickyNoteFontSize(
|
||||
textElement.baseFontSize ?? textElement.fontSize,
|
||||
),
|
||||
strokeColor: stickyInk,
|
||||
}
|
||||
: null),
|
||||
});
|
||||
app.scene.mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
// the footer paints with the note's ink
|
||||
...(stickyInk ? { strokeColor: stickyInk } : null),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(textElement, container, app.scene);
|
||||
// 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 someTextElements = selectedElements.some(
|
||||
(el) => isTextElement(el) && !isBoundToContainer(el),
|
||||
);
|
||||
return selectedElements.length > 0 && someTextElements;
|
||||
},
|
||||
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) && !isBoundToContainer(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: getStrokeWidthByKey(
|
||||
"rectangle",
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
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) {
|
||||
app.scene.mutateElement(ele, {
|
||||
startBinding,
|
||||
endBinding,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.scene.mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container, app.scene);
|
||||
|
||||
updatedElements = pushContainerBelowText(
|
||||
[...updatedElements, container],
|
||||
container,
|
||||
textElement,
|
||||
);
|
||||
|
||||
containerIds[container.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: updatedElements,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user