feat: create new text with width ()

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
pull/8078/head
Ryan Di committed by GitHub
parent 4eb9463f26
commit 860308eb27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -330,6 +330,7 @@ import {
getContainerElement,
getDefaultLineHeight,
getLineHeightInPx,
getMinTextElementWidth,
isMeasureTextSupported,
isValidTextContainer,
measureText,
@ -1696,6 +1697,7 @@ class App extends React.Component<AppProps, AppState> {
canvas={this.interactiveCanvas}
elementsMap={elementsMap}
visibleElements={visibleElements}
allElementsMap={allElementsMap}
selectedElements={selectedElements}
sceneNonce={sceneNonce}
selectionNonce={
@ -4718,6 +4720,7 @@ class App extends React.Component<AppProps, AppState> {
sceneY,
insertAtParentCenter = true,
container,
autoEdit = true,
}: {
/** X position to insert text at */
sceneX: number;
@ -4726,6 +4729,7 @@ class App extends React.Component<AppProps, AppState> {
/** whether to attempt to insert at element center if applicable */
insertAtParentCenter?: boolean;
container?: ExcalidrawTextContainer | null;
autoEdit?: boolean;
}) => {
let shouldBindToContainer = false;
@ -4858,13 +4862,16 @@ class App extends React.Component<AppProps, AppState> {
}
}
this.setState({
editingElement: element,
});
this.handleTextWysiwyg(element, {
isExistingElement: !!existingTextElement,
});
if (autoEdit || existingTextElement || container) {
this.handleTextWysiwyg(element, {
isExistingElement: !!existingTextElement,
});
} else {
this.setState({
draggingElement: element,
multiElement: null,
});
}
};
private handleCanvasDoubleClick = (
@ -5899,7 +5906,6 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.activeTool.type === "text") {
this.handleTextOnPointerDown(event, pointerDownState);
return;
} else if (
this.state.activeTool.type === "arrow" ||
this.state.activeTool.type === "line"
@ -6020,6 +6026,7 @@ class App extends React.Component<AppProps, AppState> {
);
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.device.editor.isMobile && clicklength < 300) {
const hitElement = this.getElementAtPosition(
scenePointer.x,
@ -6693,6 +6700,7 @@ class App extends React.Component<AppProps, AppState> {
sceneY,
insertAtParentCenter: !event.altKey,
container,
autoEdit: false,
});
resetCursor(this.interactiveCanvas);
@ -8043,6 +8051,28 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (isTextElement(draggingElement)) {
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: draggingElement.fontSize,
fontFamily: draggingElement.fontFamily,
}),
draggingElement.lineHeight,
);
if (draggingElement.width < minWidth) {
mutateElement(draggingElement, {
autoResize: true,
});
}
this.resetCursor();
this.handleTextWysiwyg(draggingElement, {
isExistingElement: true,
});
}
if (
activeTool.type !== "selection" &&
draggingElement &&
@ -9410,6 +9440,7 @@ class App extends React.Component<AppProps, AppState> {
distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
this.state.zoom.value,
);
} else {
let [gridX, gridY] = getGridPoint(
@ -9467,6 +9498,7 @@ class App extends React.Component<AppProps, AppState> {
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
this.state.zoom.value,
aspectRatio,
this.state.originSnapOffset,
);

@ -9,7 +9,10 @@ import type {
RenderableElementsMap,
RenderInteractiveSceneCallback,
} from "../../scene/types";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import type {
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { isRenderThrottlingEnabled } from "../../reactUtils";
import { renderInteractiveScene } from "../../renderer/interactiveScene";
@ -19,6 +22,7 @@ type InteractiveCanvasProps = {
elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[];
allElementsMap: NonDeletedSceneElementsMap;
sceneNonce: number | undefined;
selectionNonce: number | undefined;
scale: number;
@ -122,6 +126,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
elementsMap: props.elementsMap,
visibleElements: props.visibleElements,
selectedElements: props.selectedElements,
allElementsMap: props.allElementsMap,
scale: window.devicePixelRatio,
appState: props.appState,
renderConfig: {
@ -197,6 +202,7 @@ const getRelevantAppStateProps = (
activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
editingElement: appState.editingElement,
});
const areEqual = (

@ -25,6 +25,11 @@ export const supportsResizeObserver =
export const APP_NAME = "Excalidraw";
// distance when creating text before it's considered `autoResize: false`
// we're using higher threshold so that clicks that end up being drags
// don't unintentionally create text elements that are wrapped to a few chars
// (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;

@ -4,11 +4,17 @@ import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import type { NonDeletedExcalidrawElement } from "./types";
import type { AppState, PointerDownState } from "../types";
import { getBoundTextElement } from "./textElement";
import type { AppState, NormalizedZoomValue, PointerDownState } from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
import { getGridPoint } from "../math";
import type Scene from "../scene/Scene";
import { isArrowElement, isFrameLikeElement } from "./typeChecks";
import {
isArrowElement,
isFrameLikeElement,
isTextElement,
} from "./typeChecks";
import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@ -140,6 +146,7 @@ export const dragNewElement = (
height: number,
shouldMaintainAspectRatio: boolean,
shouldResizeFromCenter: boolean,
zoom: NormalizedZoomValue,
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null,
@ -185,12 +192,41 @@ export const dragNewElement = (
newY = originY - height / 2;
}
let textAutoResize = null;
// NOTE this should apply only to creating text elements, not existing
// (once we rewrite appState.draggingElement to actually mean dragging
// elements)
if (isTextElement(draggingElement)) {
height = draggingElement.height;
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: draggingElement.fontSize,
fontFamily: draggingElement.fontFamily,
}),
draggingElement.lineHeight,
);
width = Math.max(width, minWidth);
if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
textAutoResize = {
autoResize: false,
};
}
newY = originY;
if (shouldResizeFromCenter) {
newX = originX - width / 2;
}
}
if (width !== 0 && height !== 0) {
mutateElement(draggingElement, {
x: newX + (originOffset?.x ?? 0),
y: newY + (originOffset?.y ?? 0),
width,
height,
...textAutoResize,
});
}
};

@ -1,8 +1,4 @@
import {
BOUND_TEXT_PADDING,
MIN_FONT_SIZE,
SHIFT_LOCKING_ANGLE,
} from "../constants";
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import { rotate, centerPoint, rotatePoint } from "../math";
@ -51,7 +47,7 @@ import {
getApproxMinLineHeight,
wrapText,
measureText,
getMinCharWidth,
getMinTextElementWidth,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
@ -352,8 +348,13 @@ const resizeSingleTextElement = (
const boundsCurrentWidth = esx2 - esx1;
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
const minWidth =
getMinCharWidth(getFontString(element)) + BOUND_TEXT_PADDING * 2;
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: element.fontSize,
fontFamily: element.fontFamily,
}),
element.lineHeight,
);
let scaleX = atStartBoundsWidth / boundsCurrentWidth;

@ -938,3 +938,10 @@ export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
}
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
};

@ -576,7 +576,7 @@ describe("textWysiwyg", () => {
it("text should never go beyond max width", async () => {
UI.clickTool("text");
mouse.clickAt(750, 300);
mouse.click(0, 0);
textarea = await getTextEditor(textEditorSelector, true);
updateTextEditor(

@ -47,13 +47,18 @@ import {
getNormalizedCanvasDimensions,
} from "./helpers";
import oc from "open-color";
import { isFrameLikeElement, isLinearElement } from "../element/typeChecks";
import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
GroupId,
NonDeleted,
} from "../element/types";
@ -303,7 +308,6 @@ const renderSelectionBorder = (
cy: number;
activeEmbeddable: boolean;
},
padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2,
) => {
const {
angle,
@ -320,6 +324,8 @@ const renderSelectionBorder = (
const elementWidth = elementX2 - elementX1;
const elementHeight = elementY2 - elementY1;
const padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2;
const linePadding = padding / appState.zoom.value;
const lineWidth = 8 / appState.zoom.value;
const spaceWidth = 4 / appState.zoom.value;
@ -570,11 +576,34 @@ const renderTransformHandles = (
});
};
const renderTextBox = (
text: NonDeleted<ExcalidrawTextElement>,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => {
context.save();
const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
const width = text.width + padding * 2;
const height = text.height + padding * 2;
const cx = text.x + width / 2;
const cy = text.y + height / 2;
const shiftX = -(width / 2 + padding);
const shiftY = -(height / 2 + padding);
context.translate(cx + appState.scrollX, cy + appState.scrollY);
context.rotate(text.angle);
context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = selectionColor;
context.strokeRect(shiftX, shiftY, width, height);
context.restore();
};
const _renderInteractiveScene = ({
canvas,
elementsMap,
visibleElements,
selectedElements,
allElementsMap,
scale,
appState,
renderConfig,
@ -626,12 +655,31 @@ const _renderInteractiveScene = ({
// Paint selection element
if (appState.selectionElement) {
try {
renderSelectionElement(appState.selectionElement, context, appState);
renderSelectionElement(
appState.selectionElement,
context,
appState,
renderConfig.selectionColor,
);
} catch (error: any) {
console.error(error);
}
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
const textElement = allElementsMap.get(appState.editingElement.id) as
| ExcalidrawTextElement
| undefined;
if (textElement && !textElement.autoResize) {
renderTextBox(
textElement,
context,
appState,
renderConfig.selectionColor,
);
}
}
if (appState.isBindingEnabled) {
appState.suggestedBindings
.filter((binding) => binding != null)
@ -810,7 +858,12 @@ const _renderInteractiveScene = ({
"mouse", // when we render we don't know which pointer type so use mouse,
getOmitSidesForDevice(device),
);
if (!appState.viewModeEnabled && showBoundingBox) {
if (
!appState.viewModeEnabled &&
showBoundingBox &&
// do not show transform handles when text is being edited
!isTextElement(appState.editingElement)
) {
renderTransformHandles(
context,
renderConfig,

@ -24,6 +24,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
import type {
StaticCanvasRenderConfig,
RenderableElementsMap,
InteractiveCanvasRenderConfig,
} from "../scene/types";
import { distance, getFontString, isRTL } from "../utils";
import { getCornerRadius, isRightAngle } from "../math";
@ -618,6 +619,7 @@ export const renderSelectionElement = (
element: NonDeletedExcalidrawElement,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => {
context.save();
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
@ -631,7 +633,7 @@ export const renderSelectionElement = (
context.fillRect(offset, offset, element.width, element.height);
context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = " rgb(105, 101, 219)";
context.strokeStyle = selectionColor;
context.strokeRect(offset, offset, element.width, element.height);
context.restore();

@ -55,7 +55,7 @@ export type InteractiveCanvasRenderConfig = {
remotePointerUserStates: Map<SocketId, UserIdleState>;
remotePointerUsernames: Map<SocketId, string>;
remotePointerButton: Map<SocketId, string | undefined>;
selectionColor?: string;
selectionColor: string;
// extra options passed to the renderer
// ---------------------------------------------------------------------------
renderScrollbars?: boolean;
@ -83,6 +83,7 @@ export type InteractiveSceneRenderConfig = {
elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[];
allElementsMap: NonDeletedSceneElementsMap;
scale: number;
appState: InteractiveCanvasAppState;
renderConfig: InteractiveCanvasRenderConfig;

@ -1375,6 +1375,7 @@ export const isActiveToolNonLinearSnappable = (
activeToolType === TOOL_TYPE.diamond ||
activeToolType === TOOL_TYPE.frame ||
activeToolType === TOOL_TYPE.magicframe ||
activeToolType === TOOL_TYPE.image
activeToolType === TOOL_TYPE.image ||
activeToolType === TOOL_TYPE.text
);
};

@ -1051,11 +1051,11 @@ describe("Test Linear Elements", () => {
arrayToMap(h.elements),
),
).toMatchInlineSnapshot(`
{
"x": 75,
"y": 60,
}
`);
{
"x": 75,
"y": 60,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made

@ -197,6 +197,7 @@ export type InteractiveCanvasAppState = Readonly<
// SnapLines
snapLines: AppState["snapLines"];
zenModeEnabled: AppState["zenModeEnabled"];
editingElement: AppState["editingElement"];
}
>;

Loading…
Cancel
Save