|
@@ -27,6 +27,7 @@ import {
|
|
|
getResizeArrowDirection,
|
|
|
getResizeHandlerFromCoords,
|
|
|
isNonDeletedElement,
|
|
|
+ updateTextElement,
|
|
|
dragSelectedElements,
|
|
|
getDragOffsetXY,
|
|
|
dragNewElement,
|
|
@@ -55,7 +56,11 @@ import Portal from "./Portal";
|
|
|
|
|
|
import { renderScene } from "../renderer";
|
|
|
import { AppState, GestureEvent, Gesture } from "../types";
|
|
|
-import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
|
|
+import {
|
|
|
+ ExcalidrawElement,
|
|
|
+ ExcalidrawTextElement,
|
|
|
+ NonDeleted,
|
|
|
+} from "../element/types";
|
|
|
|
|
|
import { distance2d, isPathALoop, getGridPoint } from "../math";
|
|
|
|
|
@@ -113,6 +118,7 @@ import {
|
|
|
EVENT,
|
|
|
ENV,
|
|
|
CANVAS_ONLY_ACTIONS,
|
|
|
+ DEFAULT_VERTICAL_ALIGN,
|
|
|
GRID_SIZE,
|
|
|
} from "../constants";
|
|
|
import {
|
|
@@ -583,7 +589,11 @@ class App extends React.Component<any, AppState> {
|
|
|
if (scrollBars) {
|
|
|
currentScrollBars = scrollBars;
|
|
|
}
|
|
|
- const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
|
|
|
+ const scrolledOutside =
|
|
|
+ // hide when editing text
|
|
|
+ this.state.editingElement?.type === "text"
|
|
|
+ ? false
|
|
|
+ : !atLeastOneVisibleElement && elements.length > 0;
|
|
|
if (this.state.scrolledOutside !== scrolledOutside) {
|
|
|
this.setState({ scrolledOutside: scrolledOutside });
|
|
|
}
|
|
@@ -790,6 +800,7 @@ class App extends React.Component<any, AppState> {
|
|
|
fontSize: this.state.currentItemFontSize,
|
|
|
fontFamily: this.state.currentItemFontFamily,
|
|
|
textAlign: this.state.currentItemTextAlign,
|
|
|
+ verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
|
|
});
|
|
|
|
|
|
globalSceneState.replaceAllElements([
|
|
@@ -1250,12 +1261,9 @@ class App extends React.Component<any, AppState> {
|
|
|
!isLinearElement(selectedElements[0])
|
|
|
) {
|
|
|
const selectedElement = selectedElements[0];
|
|
|
- const x = selectedElement.x + selectedElement.width / 2;
|
|
|
- const y = selectedElement.y + selectedElement.height / 2;
|
|
|
-
|
|
|
this.startTextEditing({
|
|
|
- x: x,
|
|
|
- y: y,
|
|
|
+ sceneX: selectedElement.x + selectedElement.width / 2,
|
|
|
+ sceneY: selectedElement.y + selectedElement.height / 2,
|
|
|
});
|
|
|
event.preventDefault();
|
|
|
return;
|
|
@@ -1346,10 +1354,10 @@ class App extends React.Component<any, AppState> {
|
|
|
private handleTextWysiwyg(
|
|
|
element: ExcalidrawTextElement,
|
|
|
{
|
|
|
- x,
|
|
|
- y,
|
|
|
isExistingElement = false,
|
|
|
- }: { x: number; y: number; isExistingElement?: boolean },
|
|
|
+ }: {
|
|
|
+ isExistingElement?: boolean;
|
|
|
+ },
|
|
|
) {
|
|
|
const resetSelection = () => {
|
|
|
this.setState({
|
|
@@ -1358,26 +1366,13 @@ class App extends React.Component<any, AppState> {
|
|
|
});
|
|
|
};
|
|
|
|
|
|
- const deleteElement = () => {
|
|
|
- globalSceneState.replaceAllElements([
|
|
|
- ...globalSceneState.getElementsIncludingDeleted().map((_element) => {
|
|
|
- if (_element.id === element.id) {
|
|
|
- return newElementWith(_element, { isDeleted: true });
|
|
|
- }
|
|
|
- return _element;
|
|
|
- }),
|
|
|
- ]);
|
|
|
- };
|
|
|
-
|
|
|
const updateElement = (text: string) => {
|
|
|
globalSceneState.replaceAllElements([
|
|
|
...globalSceneState.getElementsIncludingDeleted().map((_element) => {
|
|
|
- if (_element.id === element.id) {
|
|
|
- return newTextElement({
|
|
|
- ...(_element as ExcalidrawTextElement),
|
|
|
- x: element.x,
|
|
|
- y: element.y,
|
|
|
+ if (_element.id === element.id && isTextElement(_element)) {
|
|
|
+ return updateTextElement(_element, {
|
|
|
text,
|
|
|
+ isDeleted: !text.trim(),
|
|
|
});
|
|
|
}
|
|
|
return _element;
|
|
@@ -1387,22 +1382,18 @@ class App extends React.Component<any, AppState> {
|
|
|
|
|
|
textWysiwyg({
|
|
|
id: element.id,
|
|
|
- x,
|
|
|
- y,
|
|
|
- initText: element.text,
|
|
|
- strokeColor: element.strokeColor,
|
|
|
- opacity: element.opacity,
|
|
|
- fontSize: element.fontSize,
|
|
|
- fontFamily: element.fontFamily,
|
|
|
- angle: element.angle,
|
|
|
- textAlign: element.textAlign,
|
|
|
zoom: this.state.zoom,
|
|
|
+ getViewportCoords: (x, y) => {
|
|
|
+ const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
|
|
+ { sceneX: x, sceneY: y },
|
|
|
+ this.state,
|
|
|
+ this.canvas,
|
|
|
+ window.devicePixelRatio,
|
|
|
+ );
|
|
|
+ return [viewportX, viewportY];
|
|
|
+ },
|
|
|
onChange: withBatchedUpdates((text) => {
|
|
|
- if (text) {
|
|
|
- updateElement(text);
|
|
|
- } else {
|
|
|
- deleteElement();
|
|
|
- }
|
|
|
+ updateElement(text);
|
|
|
}),
|
|
|
onSubmit: withBatchedUpdates((text) => {
|
|
|
updateElement(text);
|
|
@@ -1419,7 +1410,7 @@ class App extends React.Component<any, AppState> {
|
|
|
resetSelection();
|
|
|
}),
|
|
|
onCancel: withBatchedUpdates(() => {
|
|
|
- deleteElement();
|
|
|
+ updateElement("");
|
|
|
if (isExistingElement) {
|
|
|
history.resumeRecording();
|
|
|
}
|
|
@@ -1438,20 +1429,11 @@ class App extends React.Component<any, AppState> {
|
|
|
updateElement(element.text);
|
|
|
}
|
|
|
|
|
|
- private startTextEditing = ({
|
|
|
- x,
|
|
|
- y,
|
|
|
- clientX,
|
|
|
- clientY,
|
|
|
- centerIfPossible = true,
|
|
|
- }: {
|
|
|
- x: number;
|
|
|
- y: number;
|
|
|
- clientX?: number;
|
|
|
- clientY?: number;
|
|
|
- centerIfPossible?: boolean;
|
|
|
- }) => {
|
|
|
- const elementAtPosition = getElementAtPosition(
|
|
|
+ private getTextElementAtPosition(
|
|
|
+ x: number,
|
|
|
+ y: number,
|
|
|
+ ): NonDeleted<ExcalidrawTextElement> | null {
|
|
|
+ const element = getElementAtPosition(
|
|
|
globalSceneState.getElements(),
|
|
|
this.state,
|
|
|
x,
|
|
@@ -1459,78 +1441,83 @@ class App extends React.Component<any, AppState> {
|
|
|
this.state.zoom,
|
|
|
);
|
|
|
|
|
|
- const element =
|
|
|
- elementAtPosition && isTextElement(elementAtPosition)
|
|
|
- ? elementAtPosition
|
|
|
- : newTextElement({
|
|
|
- x: x,
|
|
|
- y: y,
|
|
|
- strokeColor: this.state.currentItemStrokeColor,
|
|
|
- backgroundColor: this.state.currentItemBackgroundColor,
|
|
|
- fillStyle: this.state.currentItemFillStyle,
|
|
|
- strokeWidth: this.state.currentItemStrokeWidth,
|
|
|
- strokeStyle: this.state.currentItemStrokeStyle,
|
|
|
- roughness: this.state.currentItemRoughness,
|
|
|
- opacity: this.state.currentItemOpacity,
|
|
|
- text: "",
|
|
|
- fontSize: this.state.currentItemFontSize,
|
|
|
- fontFamily: this.state.currentItemFontFamily,
|
|
|
- textAlign: this.state.currentItemTextAlign,
|
|
|
- });
|
|
|
-
|
|
|
- this.setState({ editingElement: element });
|
|
|
-
|
|
|
- let textX = clientX || x;
|
|
|
- let textY = clientY || y;
|
|
|
-
|
|
|
- let isExistingTextElement = false;
|
|
|
+ if (element && isTextElement(element) && !element.isDeleted) {
|
|
|
+ return element;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
|
|
|
- if (elementAtPosition && isTextElement(elementAtPosition)) {
|
|
|
- isExistingTextElement = true;
|
|
|
- const centerElementX = elementAtPosition.x + elementAtPosition.width / 2;
|
|
|
- const centerElementY = elementAtPosition.y + elementAtPosition.height / 2;
|
|
|
+ private startTextEditing = ({
|
|
|
+ sceneX,
|
|
|
+ sceneY,
|
|
|
+ insertAtParentCenter = true,
|
|
|
+ }: {
|
|
|
+ /** X position to insert text at */
|
|
|
+ sceneX: number;
|
|
|
+ /** Y position to insert text at */
|
|
|
+ sceneY: number;
|
|
|
+ /** whether to attempt to insert at element center if applicable */
|
|
|
+ insertAtParentCenter?: boolean;
|
|
|
+ }) => {
|
|
|
+ const existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
|
|
|
|
|
|
- const {
|
|
|
- x: centerElementXInViewport,
|
|
|
- y: centerElementYInViewport,
|
|
|
- } = sceneCoordsToViewportCoords(
|
|
|
- { sceneX: centerElementX, sceneY: centerElementY },
|
|
|
+ const parentCenterPosition =
|
|
|
+ insertAtParentCenter &&
|
|
|
+ this.getTextWysiwygSnappedToCenterPosition(
|
|
|
+ sceneX,
|
|
|
+ sceneY,
|
|
|
this.state,
|
|
|
this.canvas,
|
|
|
window.devicePixelRatio,
|
|
|
);
|
|
|
|
|
|
- textX = centerElementXInViewport;
|
|
|
- textY = centerElementYInViewport;
|
|
|
+ const element = existingTextElement
|
|
|
+ ? existingTextElement
|
|
|
+ : newTextElement({
|
|
|
+ x: parentCenterPosition
|
|
|
+ ? parentCenterPosition.elementCenterX
|
|
|
+ : sceneX,
|
|
|
+ y: parentCenterPosition
|
|
|
+ ? parentCenterPosition.elementCenterY
|
|
|
+ : sceneY,
|
|
|
+ strokeColor: this.state.currentItemStrokeColor,
|
|
|
+ backgroundColor: this.state.currentItemBackgroundColor,
|
|
|
+ fillStyle: this.state.currentItemFillStyle,
|
|
|
+ strokeWidth: this.state.currentItemStrokeWidth,
|
|
|
+ strokeStyle: this.state.currentItemStrokeStyle,
|
|
|
+ roughness: this.state.currentItemRoughness,
|
|
|
+ opacity: this.state.currentItemOpacity,
|
|
|
+ text: "",
|
|
|
+ fontSize: this.state.currentItemFontSize,
|
|
|
+ fontFamily: this.state.currentItemFontFamily,
|
|
|
+ textAlign: parentCenterPosition
|
|
|
+ ? "center"
|
|
|
+ : this.state.currentItemTextAlign,
|
|
|
+ verticalAlign: parentCenterPosition
|
|
|
+ ? "middle"
|
|
|
+ : DEFAULT_VERTICAL_ALIGN,
|
|
|
+ });
|
|
|
|
|
|
- // x and y will change after calling newTextElement function
|
|
|
- mutateElement(element, {
|
|
|
- x: centerElementX,
|
|
|
- y: centerElementY,
|
|
|
- });
|
|
|
+ this.setState({ editingElement: element });
|
|
|
+
|
|
|
+ if (existingTextElement) {
|
|
|
+ // if text element is no longer centered to a container, reset
|
|
|
+ // verticalAlign to default because it's currently internal-only
|
|
|
+ if (!parentCenterPosition || element.textAlign !== "center") {
|
|
|
+ mutateElement(element, { verticalAlign: DEFAULT_VERTICAL_ALIGN });
|
|
|
+ }
|
|
|
} else {
|
|
|
globalSceneState.replaceAllElements([
|
|
|
...globalSceneState.getElementsIncludingDeleted(),
|
|
|
element,
|
|
|
]);
|
|
|
|
|
|
- if (centerIfPossible) {
|
|
|
- const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
|
|
|
- x,
|
|
|
- y,
|
|
|
- this.state,
|
|
|
- this.canvas,
|
|
|
- window.devicePixelRatio,
|
|
|
- );
|
|
|
-
|
|
|
- if (snappedToCenterPosition) {
|
|
|
- mutateElement(element, {
|
|
|
- x: snappedToCenterPosition.elementCenterX,
|
|
|
- y: snappedToCenterPosition.elementCenterY,
|
|
|
- });
|
|
|
- textX = snappedToCenterPosition.wysiwygX;
|
|
|
- textY = snappedToCenterPosition.wysiwygY;
|
|
|
- }
|
|
|
+ // case: creating new text not centered to parent elemenent → offset Y
|
|
|
+ // so that the text is centered to cursor position
|
|
|
+ if (!parentCenterPosition) {
|
|
|
+ mutateElement(element, {
|
|
|
+ y: element.y - element.baseline / 2,
|
|
|
+ });
|
|
|
}
|
|
|
}
|
|
|
|
|
@@ -1539,9 +1526,7 @@ class App extends React.Component<any, AppState> {
|
|
|
});
|
|
|
|
|
|
this.handleTextWysiwyg(element, {
|
|
|
- x: textX,
|
|
|
- y: textY,
|
|
|
- isExistingElement: isExistingTextElement,
|
|
|
+ isExistingElement: !!existingTextElement,
|
|
|
});
|
|
|
};
|
|
|
|
|
@@ -1574,7 +1559,7 @@ class App extends React.Component<any, AppState> {
|
|
|
|
|
|
resetCursor();
|
|
|
|
|
|
- const { x, y } = viewportCoordsToSceneCoords(
|
|
|
+ const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
|
|
event,
|
|
|
this.state,
|
|
|
this.canvas,
|
|
@@ -1588,8 +1573,8 @@ class App extends React.Component<any, AppState> {
|
|
|
const hitElement = getElementAtPosition(
|
|
|
elements,
|
|
|
this.state,
|
|
|
- x,
|
|
|
- y,
|
|
|
+ sceneX,
|
|
|
+ sceneY,
|
|
|
this.state.zoom,
|
|
|
);
|
|
|
|
|
@@ -1616,11 +1601,9 @@ class App extends React.Component<any, AppState> {
|
|
|
resetCursor();
|
|
|
|
|
|
this.startTextEditing({
|
|
|
- x: x,
|
|
|
- y: y,
|
|
|
- clientX: event.clientX,
|
|
|
- clientY: event.clientY,
|
|
|
- centerIfPossible: !event.altKey,
|
|
|
+ sceneX,
|
|
|
+ sceneY,
|
|
|
+ insertAtParentCenter: !event.altKey,
|
|
|
});
|
|
|
};
|
|
|
|
|
@@ -2213,19 +2196,10 @@ class App extends React.Component<any, AppState> {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- const { x, y } = viewportCoordsToSceneCoords(
|
|
|
- event,
|
|
|
- this.state,
|
|
|
- this.canvas,
|
|
|
- window.devicePixelRatio,
|
|
|
- );
|
|
|
-
|
|
|
this.startTextEditing({
|
|
|
- x: x,
|
|
|
- y: y,
|
|
|
- clientX: event.clientX,
|
|
|
- clientY: event.clientY,
|
|
|
- centerIfPossible: !event.altKey,
|
|
|
+ sceneX: x,
|
|
|
+ sceneY: y,
|
|
|
+ insertAtParentCenter: !event.altKey,
|
|
|
});
|
|
|
|
|
|
resetCursor();
|
|
@@ -2640,7 +2614,12 @@ class App extends React.Component<any, AppState> {
|
|
|
resizingElement: null,
|
|
|
selectionElement: null,
|
|
|
cursorButton: "up",
|
|
|
- editingElement: multiElement ? this.state.editingElement : null,
|
|
|
+ // text elements are reset on finalize, and resetting on pointerup
|
|
|
+ // may cause issues with double taps
|
|
|
+ editingElement:
|
|
|
+ multiElement || isTextElement(this.state.editingElement)
|
|
|
+ ? this.state.editingElement
|
|
|
+ : null,
|
|
|
});
|
|
|
|
|
|
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
|
|
@@ -3006,7 +2985,9 @@ class App extends React.Component<any, AppState> {
|
|
|
scale: number,
|
|
|
) {
|
|
|
const elementClickedInside = getElementContainingPosition(
|
|
|
- globalSceneState.getElementsIncludingDeleted(),
|
|
|
+ globalSceneState
|
|
|
+ .getElementsIncludingDeleted()
|
|
|
+ .filter((element) => !isTextElement(element)),
|
|
|
x,
|
|
|
y,
|
|
|
);
|
|
@@ -3022,13 +3003,13 @@ class App extends React.Component<any, AppState> {
|
|
|
const isSnappedToCenter =
|
|
|
distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
|
|
|
if (isSnappedToCenter) {
|
|
|
- const { x: wysiwygX, y: wysiwygY } = sceneCoordsToViewportCoords(
|
|
|
+ const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
|
|
{ sceneX: elementCenterX, sceneY: elementCenterY },
|
|
|
state,
|
|
|
canvas,
|
|
|
scale,
|
|
|
);
|
|
|
- return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
|
|
|
+ return { viewportX, viewportY, elementCenterX, elementCenterY };
|
|
|
}
|
|
|
}
|
|
|
}
|