feat: Support binding text to container via context menu (#4935)
* feat: Support binding text to closest container * Bind text to selected container * show bind action in canvas and selected container after binding * allow binding if container has no bound text * fix * move logic to show/hide bind actions to contextMenuPredicate * don't show bind action when clicking on bounding box and not elemnts
This commit is contained in:
parent
ceb43ed8fb
commit
625ecc64ed
134
src/actions/actionBoundText.tsx
Normal file
134
src/actions/actionBoundText.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { VERTICAL_ALIGN } from "../constants";
|
||||||
|
import { getNonDeletedElements, isTextElement } from "../element";
|
||||||
|
import { mutateElement } from "../element/mutateElement";
|
||||||
|
import {
|
||||||
|
getBoundTextElement,
|
||||||
|
measureText,
|
||||||
|
redrawTextBoundingBox,
|
||||||
|
} from "../element/textElement";
|
||||||
|
import {
|
||||||
|
hasBoundTextElement,
|
||||||
|
isTextBindableContainer,
|
||||||
|
} from "../element/typeChecks";
|
||||||
|
import {
|
||||||
|
ExcalidrawTextContainer,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
} from "../element/types";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { getFontString } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionUnbindText = register({
|
||||||
|
name: "unbindText",
|
||||||
|
contextItemLabel: "labels.unbindText",
|
||||||
|
contextItemPredicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||||
|
},
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
selectedElements.forEach((element) => {
|
||||||
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
if (boundTextElement) {
|
||||||
|
const { width, height, baseline } = measureText(
|
||||||
|
boundTextElement.originalText,
|
||||||
|
getFontString(boundTextElement),
|
||||||
|
);
|
||||||
|
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||||
|
containerId: null,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
baseline,
|
||||||
|
text: boundTextElement.originalText,
|
||||||
|
});
|
||||||
|
mutateElement(element, {
|
||||||
|
boundElements: element.boundElements?.filter(
|
||||||
|
(ele) => ele.id !== boundTextElement.id,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionBindText = register({
|
||||||
|
name: "bindText",
|
||||||
|
contextItemLabel: "labels.bindText",
|
||||||
|
contextItemPredicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, 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) === null
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
mutateElement(container, {
|
||||||
|
boundElements: (container.boundElements || []).concat({
|
||||||
|
type: "text",
|
||||||
|
id: textElement.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
redrawTextBoundingBox(textElement, container);
|
||||||
|
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);
|
||||||
|
return {
|
||||||
|
elements: updatedElements,
|
||||||
|
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@ -166,11 +166,7 @@ const changeFontSize = (
|
|||||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||||
fontSize: newFontSize,
|
fontSize: newFontSize,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
newElement,
|
|
||||||
getContainerElement(oldElement),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||||
|
|
||||||
@ -637,11 +633,7 @@ export const actionChangeFontFamily = register({
|
|||||||
fontFamily: value,
|
fontFamily: value,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
newElement,
|
|
||||||
getContainerElement(oldElement),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -720,11 +712,7 @@ export const actionChangeTextAlign = register({
|
|||||||
oldElement,
|
oldElement,
|
||||||
{ textAlign: value },
|
{ textAlign: value },
|
||||||
);
|
);
|
||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
newElement,
|
|
||||||
getContainerElement(oldElement),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -797,11 +785,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
{ verticalAlign: value },
|
{ verticalAlign: value },
|
||||||
);
|
);
|
||||||
|
|
||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
newElement,
|
|
||||||
getContainerElement(oldElement),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,11 +63,7 @@ export const actionPasteStyles = register({
|
|||||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
});
|
});
|
||||||
|
|
||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(newElement, getContainerElement(newElement));
|
||||||
newElement,
|
|
||||||
getContainerElement(newElement),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
|
@ -1,44 +0,0 @@
|
|||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import { getBoundTextElement, measureText } from "../element/textElement";
|
|
||||||
import { ExcalidrawTextElement } from "../element/types";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { getFontString } from "../utils";
|
|
||||||
import { register } from "./register";
|
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
|
||||||
name: "unbindText",
|
|
||||||
contextItemLabel: "labels.unbindText",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
selectedElements.forEach((element) => {
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
if (boundTextElement) {
|
|
||||||
const { width, height, baseline } = measureText(
|
|
||||||
boundTextElement.originalText,
|
|
||||||
getFontString(boundTextElement),
|
|
||||||
);
|
|
||||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
|
||||||
containerId: null,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
baseline,
|
|
||||||
text: boundTextElement.originalText,
|
|
||||||
});
|
|
||||||
mutateElement(element, {
|
|
||||||
boundElements: element.boundElements?.filter(
|
|
||||||
(ele) => ele.id !== boundTextElement.id,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
@ -81,5 +81,5 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
|
|||||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||||
|
|
||||||
export { actionToggleStats } from "./actionToggleStats";
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
export { actionUnbindText } from "./actionUnbindText";
|
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||||
export { actionLink } from "../element/Hyperlink";
|
export { actionLink } from "../element/Hyperlink";
|
||||||
|
@ -107,7 +107,8 @@ export type ActionName =
|
|||||||
| "decreaseFontSize"
|
| "decreaseFontSize"
|
||||||
| "unbindText"
|
| "unbindText"
|
||||||
| "hyperlink"
|
| "hyperlink"
|
||||||
| "eraser";
|
| "eraser"
|
||||||
|
| "bindText";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
@ -27,6 +27,7 @@ import {
|
|||||||
actionToggleStats,
|
actionToggleStats,
|
||||||
actionToggleZenMode,
|
actionToggleZenMode,
|
||||||
actionUnbindText,
|
actionUnbindText,
|
||||||
|
actionBindText,
|
||||||
actionUngroup,
|
actionUngroup,
|
||||||
actionLink,
|
actionLink,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
@ -5391,6 +5392,16 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.actionManager.getAppState(),
|
this.actionManager.getAppState(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mayBeAllowUnbinding = actionUnbindText.contextItemPredicate(
|
||||||
|
this.actionManager.getElementsIncludingDeleted(),
|
||||||
|
this.actionManager.getAppState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mayBeAllowBinding = actionBindText.contextItemPredicate(
|
||||||
|
this.actionManager.getElementsIncludingDeleted(),
|
||||||
|
this.actionManager.getAppState(),
|
||||||
|
);
|
||||||
|
|
||||||
const separator = "separator";
|
const separator = "separator";
|
||||||
|
|
||||||
const elements = this.scene.getElements();
|
const elements = this.scene.getElements();
|
||||||
@ -5467,10 +5478,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (type === "element") {
|
} else if (type === "element") {
|
||||||
const elementsWithUnbindedText = getSelectedElements(
|
|
||||||
elements,
|
|
||||||
this.state,
|
|
||||||
).some((element) => !hasBoundTextElement(element));
|
|
||||||
if (this.state.viewModeEnabled) {
|
if (this.state.viewModeEnabled) {
|
||||||
ContextMenu.push({
|
ContextMenu.push({
|
||||||
options: [navigator.clipboard && actionCopy, ...options],
|
options: [navigator.clipboard && actionCopy, ...options],
|
||||||
@ -5504,7 +5511,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
actionPasteStyles,
|
actionPasteStyles,
|
||||||
separator,
|
separator,
|
||||||
maybeGroupAction && actionGroup,
|
maybeGroupAction && actionGroup,
|
||||||
!elementsWithUnbindedText && actionUnbindText,
|
mayBeAllowUnbinding && actionUnbindText,
|
||||||
|
mayBeAllowBinding && actionBindText,
|
||||||
maybeUngroupAction && actionUngroup,
|
maybeUngroupAction && actionUngroup,
|
||||||
(maybeGroupAction || maybeUngroupAction) && separator,
|
(maybeGroupAction || maybeUngroupAction) && separator,
|
||||||
actionAddToLibrary,
|
actionAddToLibrary,
|
||||||
|
@ -10,13 +10,11 @@ import { mutateElement } from "./mutateElement";
|
|||||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||||
import { MaybeTransformHandleType } from "./transformHandles";
|
import { MaybeTransformHandleType } from "./transformHandles";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { AppState } from "../types";
|
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
|
|
||||||
export const redrawTextBoundingBox = (
|
export const redrawTextBoundingBox = (
|
||||||
element: ExcalidrawTextElement,
|
element: ExcalidrawTextElement,
|
||||||
container: ExcalidrawElement | null,
|
container: ExcalidrawElement | null,
|
||||||
appState: AppState,
|
|
||||||
) => {
|
) => {
|
||||||
const maxWidth = container
|
const maxWidth = container
|
||||||
? container.width - BOUND_TEXT_PADDING * 2
|
? container.width - BOUND_TEXT_PADDING * 2
|
||||||
@ -35,12 +33,12 @@ export const redrawTextBoundingBox = (
|
|||||||
getFontString(element),
|
getFontString(element),
|
||||||
maxWidth,
|
maxWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
let coordY = element.y;
|
let coordY = element.y;
|
||||||
|
let coordX = element.x;
|
||||||
// Resize container and vertically center align the text
|
// Resize container and vertically center align the text
|
||||||
if (container) {
|
if (container) {
|
||||||
let nextHeight = container.height;
|
let nextHeight = container.height;
|
||||||
|
coordX = container.x + BOUND_TEXT_PADDING;
|
||||||
if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
|
if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||||
coordY = container.y + BOUND_TEXT_PADDING;
|
coordY = container.y + BOUND_TEXT_PADDING;
|
||||||
} else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
} else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||||
@ -55,12 +53,12 @@ export const redrawTextBoundingBox = (
|
|||||||
}
|
}
|
||||||
mutateElement(container, { height: nextHeight });
|
mutateElement(container, { height: nextHeight });
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
width: metrics.width,
|
width: metrics.width,
|
||||||
height: metrics.height,
|
height: metrics.height,
|
||||||
baseline: metrics.baseline,
|
baseline: metrics.baseline,
|
||||||
y: coordY,
|
y: coordY,
|
||||||
|
x: coordX,
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -107,6 +107,7 @@
|
|||||||
"decreaseFontSize": "Decrease font size",
|
"decreaseFontSize": "Decrease font size",
|
||||||
"increaseFontSize": "Increase font size",
|
"increaseFontSize": "Increase font size",
|
||||||
"unbindText": "Unbind text",
|
"unbindText": "Unbind text",
|
||||||
|
"bindText": "Bind text to the container",
|
||||||
"link": {
|
"link": {
|
||||||
"edit": "Edit link",
|
"edit": "Edit link",
|
||||||
"create": "Create link",
|
"create": "Create link",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user