Compare commits

..

11 Commits

Author SHA1 Message Date
zsviczian 685f76099a Merge remote-tracking branch 'origin/master' into zsviczian-stickynote 2025-12-01 11:50:12 +00:00
zsviczian 04b4c2239f fix: when editing text action buttons did not function 2025-12-01 11:47:43 +00:00
zsviczian c93688226f lint 2025-12-01 11:24:23 +00:00
zsviczian 5760c2b30d Merge remote-tracking branch 'origin/master' into zsviczian-stickynote 2025-12-01 11:20:08 +00:00
zsviczian d668eaa060 Initial implementation of containerBehavior.margin 2025-11-01 11:48:26 +00:00
zsviczian 0ff0efe2a7 Merge remote-tracking branch 'origin/master' into zsviczian-stickynote 2025-11-01 09:37:17 +00:00
zsviczian f3a60a5ef6 changed ContainerBehavior into an object {textFlow: "growing"|"fixed", margin: number} 2025-09-22 17:14:16 +00:00
zsviczian f90bf59ecd lint 2025-09-22 11:54:02 +00:00
zsviczian f5f3b61779 display Container settings when text is editing 2025-09-21 20:35:05 +00:00
zsviczian dee5c2ea8e minor cleanup 2025-09-21 19:51:02 +00:00
zsviczian 14ab68af54 initial implementation 2025-09-21 19:09:57 +00:00
47 changed files with 953 additions and 271 deletions
+2 -2
View File
@@ -441,7 +441,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
private decryptPayload = async (
iv: Uint8Array<ArrayBuffer>,
iv: Uint8Array,
encryptedData: ArrayBuffer,
decryptionKey: string,
): Promise<ValueOf<SocketUpdateDataSource>> => {
@@ -562,7 +562,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// All socket listeners are moving to Portal
this.portal.socket.on(
"client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array<ArrayBuffer>) => {
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) {
return;
}
+2 -2
View File
@@ -105,8 +105,8 @@ const decryptElements = async (
data: FirebaseStoredScene,
roomKey: string,
): Promise<readonly ExcalidrawElement[]> => {
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>;
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>;
const ciphertext = data.ciphertext.toUint8Array();
const iv = data.iv.toUint8Array();
const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode(
+1 -1
View File
@@ -34,7 +34,7 @@
"prettier": "2.6.2",
"rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "5.9.3",
"typescript": "4.9.4",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0",
+5
View File
@@ -410,6 +410,7 @@ export const DEFAULT_ELEMENT_PROPS: {
roughness: ExcalidrawElement["roughness"];
opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"];
containerBehavior: ExcalidrawElement["containerBehavior"];
} = {
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
@@ -419,6 +420,10 @@ export const DEFAULT_ELEMENT_PROPS: {
roughness: ROUGHNESS.artist,
opacity: 100,
locked: false,
containerBehavior: {
textFlow: "growing",
margin: BOUND_TEXT_PADDING,
},
};
export const LIBRARY_SIDEBAR_TAB = "library";
+4 -7
View File
@@ -1,5 +1,3 @@
import mobile from "is-mobile";
export type StylesPanelMode = "compact" | "full" | "mobile";
export type EditorInterface = Readonly<{
@@ -141,13 +139,12 @@ export const getFormFactor = (
editorWidth: number,
editorHeight: number,
): EditorInterface["formFactor"] => {
if (mobile()) {
if (isMobileBreakpoint(editorWidth, editorHeight)) {
return "phone";
} else if (mobile({ tablet: true })) {
}
if (isTabletBreakpoint(editorWidth, editorHeight)) {
return "tablet";
} else if (isMobileBreakpoint(editorWidth, editorHeight)) {
// NOTE: Very small editor sizes should be treated as phone
return "phone";
}
return "desktop";
+3
View File
@@ -45,6 +45,9 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
type === "diamond" ||
type === "image";
export const hasContainerBehavior = (type: ElementOrToolType) =>
type === "rectangle" || type === "diamond" || type === "ellipse";
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
+8 -5
View File
@@ -2056,11 +2056,14 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
}
}
private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { id, updated, ...strippedPartial } = partial;
private static stripIrrelevantProps(partial: ElementPartial): ElementPartial {
const {
id: _id,
updated: _updated,
seed: _seed,
...strippedPartial
} = partial as any;
return strippedPartial;
return strippedPartial as ElementPartial;
}
}
+2
View File
@@ -268,6 +268,7 @@ const addNewNode = (
opacity: element.opacity,
fillStyle: element.fillStyle,
strokeStyle: element.strokeStyle,
containerBehavior: element.containerBehavior,
});
invariant(
@@ -346,6 +347,7 @@ export const addNewNodes = (
opacity: startNode.opacity,
fillStyle: startNode.fillStyle,
strokeStyle: startNode.strokeStyle,
containerBehavior: startNode.containerBehavior,
});
invariant(
+114 -4
View File
@@ -10,6 +10,7 @@ import {
getFontString,
getUpdatedTimestamp,
getLineHeight,
BOUND_TEXT_PADDING,
} from "@excalidraw/common";
import type { Radians } from "@excalidraw/math";
@@ -21,11 +22,11 @@ import {
getResizedElementAbsoluteCoords,
} from "./bounds";
import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth } from "./textElement";
import { getBoundTextMaxWidth, getBoundTextMaxHeight } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import { isLineElement } from "./typeChecks";
import { isFlowchartType, isLineElement } from "./typeChecks";
import type {
ExcalidrawElement,
@@ -48,6 +49,8 @@ import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
ExcalidrawFlowchartNodeElement,
ContainerBehavior,
} from "./types";
export type ElementConstructorOpts = MarkOptional<
@@ -158,9 +161,26 @@ const _newElementBase = <T extends ExcalidrawElement>(
export const newElement = (
opts: {
type: ExcalidrawGenericElement["type"];
containerBehavior?: ContainerBehavior;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
): NonDeleted<ExcalidrawGenericElement> => {
if (isFlowchartType(opts.type)) {
return {
..._newElementBase<ExcalidrawFlowchartNodeElement>(
opts.type as ExcalidrawFlowchartNodeElement["type"],
opts,
),
containerBehavior: {
textFlow: opts.containerBehavior?.textFlow ?? "growing",
margin: opts.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
} as NonDeleted<ExcalidrawFlowchartNodeElement>;
}
return _newElementBase<ExcalidrawGenericElement>(
opts.type,
opts,
) as NonDeleted<ExcalidrawGenericElement>;
};
export const newEmbeddableElement = (
opts: {
@@ -417,6 +437,96 @@ const adjustXYWithRotation = (
return [x, y];
};
// Sticky note font sizing constants
export const STICKY_NOTE_FONT_STEP = 2;
export const STICKY_NOTE_MIN_FONT_SIZE = 8;
export const STICKY_NOTE_MAX_FONT_SIZE = 72;
export interface StickyNoteFontComputationResult {
fontSize: number;
width: number;
height: number;
wrappedText: string;
}
/**
* Computes the appropriate font size (snapped to step) so that the text fits
* inside the sticky note container without resizing the container.
* It first tries to shrink if overflowing, otherwise opportunistically enlarges
* (still snapped) while it fits. Width is constrained by wrap at container width.
*/
export const computeStickyNoteFontSize = (
text: string,
element: ExcalidrawTextElement,
container: ExcalidrawTextContainer,
maxGrowFontSize?: number,
): StickyNoteFontComputationResult => {
const step = STICKY_NOTE_FONT_STEP;
const maxH = getBoundTextMaxHeight(container, element as any);
const maxW = getBoundTextMaxWidth(container, element);
const snap = (size: number) =>
Math.max(
STICKY_NOTE_MIN_FONT_SIZE,
Math.max(step, Math.floor(size / step) * step),
);
let size = snap(element.fontSize);
const growthCap = snap(
maxGrowFontSize != null ? maxGrowFontSize : STICKY_NOTE_MAX_FONT_SIZE,
);
const lineHeight = element.lineHeight;
const fontFamily = element.fontFamily;
const measure = (fontSize: number) => {
const font = getFontString({ fontFamily, fontSize });
const wrappedText = wrapText(text, font, maxW);
const metrics = measureText(wrappedText, font, lineHeight);
return {
wrappedText,
width: Math.min(metrics.width, maxW),
height: metrics.height,
};
};
let { wrappedText, width, height } = measure(size);
if (height > maxH) {
// shrink until fits or min
while (size > STICKY_NOTE_MIN_FONT_SIZE) {
const next = snap(size - step);
if (next === size) {
break;
}
size = next;
({ wrappedText, width, height } = measure(size));
if (height <= maxH) {
break;
}
}
} else {
// grow back only up to growthCap (initial session font size)
while (size < growthCap) {
const next = snap(Math.min(size + step, growthCap));
if (next === size) {
break;
}
const m = measure(next);
if (m.height <= maxH) {
size = next;
wrappedText = m.wrappedText;
width = m.width;
height = m.height;
} else {
break;
}
}
}
return { fontSize: size, width, height, wrappedText };
};
export const refreshTextDimensions = (
textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,
+12 -4
View File
@@ -329,16 +329,24 @@ const generateElementCanvas = (
boundTextCanvasContext.translate(-shiftX, -shiftY);
// Clear the bound text area
boundTextCanvasContext.clearRect(
-(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
-(
boundTextElement.width / 2 +
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING)
) *
window.devicePixelRatio *
scale,
-(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
-(
boundTextElement.height / 2 +
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING)
) *
window.devicePixelRatio *
scale,
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
(boundTextElement.width +
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING) * 2) *
window.devicePixelRatio *
scale,
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
(boundTextElement.height +
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING) * 2) *
window.devicePixelRatio *
scale,
);
+3
View File
@@ -12,6 +12,7 @@ import {
SHIFT_LOCKING_ANGLE,
rescalePoints,
getFontString,
BOUND_TEXT_PADDING,
} from "@excalidraw/common";
import type { GlobalPoint } from "@excalidraw/math";
@@ -784,10 +785,12 @@ export const resizeSingleElement = (
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
latestElement.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
latestElement.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
+18 -10
View File
@@ -108,6 +108,7 @@ export const redrawTextBoundingBox = (
const nextHeight = computeContainerDimensionForBoundText(
metrics.height,
container.type,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
);
scene.mutateElement(container, { height: nextHeight });
updateOriginalContainerCache(container.id, nextHeight);
@@ -117,6 +118,7 @@ export const redrawTextBoundingBox = (
const nextWidth = computeContainerDimensionForBoundText(
metrics.width,
container.type,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
);
scene.mutateElement(container, { width: nextWidth });
}
@@ -187,6 +189,7 @@ export const handleBindTextResize = (
containerHeight = computeContainerDimensionForBoundText(
nextHeight,
container.type,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
);
const diff = containerHeight - container.height;
@@ -353,8 +356,8 @@ export const getContainerCenter = (
};
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
let offsetX = BOUND_TEXT_PADDING;
let offsetY = BOUND_TEXT_PADDING;
let offsetX = container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
let offsetY = container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
if (container.type === "ellipse") {
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
@@ -446,9 +449,10 @@ export const isValidTextContainer = (element: {
export const computeContainerDimensionForBoundText = (
dimension: number,
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
boundTextPadding: number,
) => {
dimension = Math.ceil(dimension);
const padding = BOUND_TEXT_PADDING * 2;
const padding = boundTextPadding * 2;
if (containerType === "ellipse") {
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
@@ -467,6 +471,8 @@ export const getBoundTextMaxWidth = (
boundTextElement: ExcalidrawTextElement | null,
) => {
const { width } = container;
const boundTextPadding =
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
if (isArrowElement(container)) {
const minWidth =
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
@@ -477,14 +483,14 @@ export const getBoundTextMaxWidth = (
// The width of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
return Math.round((width / 2) * Math.sqrt(2)) - boundTextPadding * 2;
}
if (container.type === "diamond") {
// The width of the largest rectangle inscribed inside a rhombus is
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
return Math.round(width / 2) - boundTextPadding * 2;
}
return width - BOUND_TEXT_PADDING * 2;
return width - boundTextPadding * 2;
};
export const getBoundTextMaxHeight = (
@@ -492,8 +498,10 @@ export const getBoundTextMaxHeight = (
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
const { height } = container;
const boundTextPadding =
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
const containerHeight = height - boundTextPadding * 8 * 2;
if (containerHeight <= 0) {
return boundTextElement.height;
}
@@ -503,14 +511,14 @@ export const getBoundTextMaxHeight = (
// The height of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
return Math.round((height / 2) * Math.sqrt(2)) - boundTextPadding * 2;
}
if (container.type === "diamond") {
// The height of the largest rectangle inscribed inside a rhombus is
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
return Math.round(height / 2) - boundTextPadding * 2;
}
return height - BOUND_TEXT_PADDING * 2;
return height - boundTextPadding * 2;
};
/** retrieves text from text elements and concatenates to a single string */
+7 -4
View File
@@ -32,22 +32,24 @@ const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
boundTextPadding: number = BOUND_TEXT_PADDING,
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2
boundTextPadding * 2
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
return maxCharWidth + boundTextPadding * 2;
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
boundTextPadding: number = BOUND_TEXT_PADDING,
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
return measureText("", font, lineHeight).width + boundTextPadding * 2;
};
export const isMeasureTextSupported = () => {
@@ -99,8 +101,9 @@ export const getLineHeightInPx = (
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
boundTextPadding: number = BOUND_TEXT_PADDING,
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
return getLineHeightInPx(fontSize, lineHeight) + boundTextPadding * 2;
};
let textMetricsProvider: TextMetricsProvider | undefined;
+4 -7
View File
@@ -272,15 +272,12 @@ export const isExcalidrawElement = (
}
};
export const isFlowchartType = (type: string): boolean =>
["rectangle", "ellipse", "diamond"].includes(type);
export const isFlowchartNodeElement = (
element: ExcalidrawElement,
): element is ExcalidrawFlowchartNodeElement => {
return (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond"
);
};
): element is ExcalidrawFlowchartNodeElement => isFlowchartType(element.type);
export const hasBoundTextElement = (
element: ExcalidrawElement | null,
+12 -3
View File
@@ -27,6 +27,10 @@ export type StrokeRoundness = "round" | "sharp";
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
export type StrokeStyle = "solid" | "dashed" | "dotted";
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
export type ContainerBehavior = {
textFlow: "growing" | "fixed";
margin?: number;
};
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
@@ -79,21 +83,26 @@ type _ExcalidrawElementBase = Readonly<{
link: string | null;
locked: boolean;
customData?: Record<string, any>;
containerBehavior?: ContainerBehavior;
}>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
type: "selection";
};
export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
type _ExcalidrawStickyNoteContainer = _ExcalidrawElementBase & {
containerBehavior: ContainerBehavior;
};
export type ExcalidrawRectangleElement = _ExcalidrawStickyNoteContainer & {
type: "rectangle";
};
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
export type ExcalidrawDiamondElement = _ExcalidrawStickyNoteContainer & {
type: "diamond";
};
export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
export type ExcalidrawEllipseElement = _ExcalidrawStickyNoteContainer & {
type: "ellipse";
};
-1
View File
@@ -493,7 +493,6 @@ describe("binding for simple arrows", () => {
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
mouse.downAt(-100, -100);
mouse.moveTo(0, 0);
mouse.moveTo(650, 750);
mouse.up(0, 0);
+22 -10
View File
@@ -1,4 +1,4 @@
import { getLineHeight } from "@excalidraw/common";
import { BOUND_TEXT_PADDING, getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
@@ -63,9 +63,13 @@ describe("Test measureText", () => {
type: "rectangle",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
160,
);
expect(
computeContainerDimensionForBoundText(
150,
element.type,
BOUND_TEXT_PADDING,
),
).toEqual(160);
});
it("should compute container height correctly for ellipse", () => {
@@ -73,9 +77,13 @@ describe("Test measureText", () => {
type: "ellipse",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
226,
);
expect(
computeContainerDimensionForBoundText(
150,
element.type,
BOUND_TEXT_PADDING,
),
).toEqual(226);
});
it("should compute container height correctly for diamond", () => {
@@ -83,9 +91,13 @@ describe("Test measureText", () => {
type: "diamond",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
320,
);
expect(
computeContainerDimensionForBoundText(
150,
element.type,
BOUND_TEXT_PADDING,
),
).toEqual(320);
});
});
@@ -236,6 +236,9 @@ export const actionWrapTextInContainer = register({
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
const boundTextPadding =
appState.currentItemContainerBehavior?.margin ?? BOUND_TEXT_PADDING;
for (const textElement of selectedElements) {
if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
const container = newElement({
@@ -261,18 +264,21 @@ export const actionWrapTextInContainer = register({
: null,
opacity: 100,
locked: false,
x: textElement.x - BOUND_TEXT_PADDING,
y: textElement.y - BOUND_TEXT_PADDING,
x: textElement.x - boundTextPadding,
y: textElement.y - boundTextPadding,
width: computeContainerDimensionForBoundText(
textElement.width,
"rectangle",
boundTextPadding,
),
height: computeContainerDimensionForBoundText(
textElement.height,
"rectangle",
boundTextPadding,
),
groupIds: textElement.groupIds,
frameId: textElement.frameId,
containerBehavior: appState.currentItemContainerBehavior,
});
// update bindings
@@ -21,10 +21,16 @@ import {
getLineHeight,
isTransparent,
reduceToCommonValue,
BOUND_TEXT_PADDING,
invariant,
} from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
import {
canBecomePolygon,
getNonDeletedElements,
hasContainerBehavior,
isFlowchartNodeElement,
} from "@excalidraw/element";
import {
bindBindingElement,
@@ -65,6 +71,7 @@ import type { LocalPoint, Radians } from "@excalidraw/math";
import type {
Arrowhead,
ContainerBehavior,
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
@@ -126,6 +133,11 @@ import {
ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon,
stickyNoteIcon,
growingContainerIcon,
marginLargeIcon,
marginMediumIcon,
marginSmallIcon,
} from "../components/icons";
import { Fonts } from "../fonts";
@@ -1548,6 +1560,275 @@ export const actionChangeRoundness = register<"sharp" | "round">({
},
});
const getMargin = (value: "small" | "medium" | "large") => {
switch (value) {
case "small":
return BOUND_TEXT_PADDING;
case "medium":
return 15;
case "large":
return 25;
default:
return BOUND_TEXT_PADDING;
}
};
const getMarginValue = (margin: number | null) => {
if (margin === null) {
return null;
}
switch (margin) {
case BOUND_TEXT_PADDING:
return "small";
case 15:
return "medium";
case 25:
return "large";
default:
return null;
}
};
export const actionChangeContainerBehavior = register<
| { textFlow: ContainerBehavior["textFlow"] }
| { margin: NonNullable<ReturnType<typeof getMarginValue>> }
>({
name: "changeContainerBehavior",
label: "labels.container",
trackEvent: false,
perform: (elements, appState, value, app) => {
invariant(value, "actionChangeContainerBehavior: value must be defined");
const elementsMap = app.scene.getNonDeletedElementsMap();
let selected = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
if (selected.length === 0 && appState.editingTextElement) {
selected = [appState.editingTextElement];
}
const containerIdsToUpdate = new Set<string>();
// collect directly selected eligible containers
for (const el of selected) {
if (isFlowchartNodeElement(el)) {
containerIdsToUpdate.add(el.id);
}
}
// if none, and exactly one selected text element -> use its container if eligible
if (
containerIdsToUpdate.size === 0 &&
selected.length === 1 &&
isTextElement(selected[0]) &&
selected[0].containerId
) {
const container = elementsMap.get(selected[0].containerId);
if (
container &&
isFlowchartNodeElement(container) &&
getBoundTextElement(container, elementsMap)
) {
containerIdsToUpdate.add(container.id);
}
}
if ("margin" in value) {
const marginSize = getMargin(value.margin);
if (containerIdsToUpdate.size === 0) {
return {
appState: {
...appState,
currentItemContainerBehavior: {
textFlow:
appState.currentItemContainerBehavior?.textFlow ?? "growing",
margin: marginSize,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
const nextElements = elements.map((el) =>
containerIdsToUpdate.has(el.id)
? newElementWith(el, {
containerBehavior: {
textFlow: el.containerBehavior?.textFlow ?? "growing",
margin: marginSize,
},
})
: el,
);
// Invalidate containers to trigger re-render
containerIdsToUpdate.forEach((id) => {
const container = nextElements.find((el) => el.id === id);
if (container) {
const boundText = getBoundTextElement(
container,
arrayToMap(nextElements),
);
if (boundText) {
redrawTextBoundingBox(boundText, container, app.scene);
}
}
});
return {
elements: nextElements,
appState: {
...appState,
currentItemContainerBehavior: {
textFlow:
appState.currentItemContainerBehavior?.textFlow ?? "growing",
margin: marginSize,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
const textFlow = value.textFlow;
const nextElements = elements.map((el) =>
containerIdsToUpdate.has(el.id)
? newElementWith(el, {
containerBehavior: {
textFlow,
margin: el.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
})
: el,
);
return {
elements: nextElements,
appState: {
...appState,
currentItemContainerBehavior: {
textFlow,
margin:
appState.currentItemContainerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
let selected = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
if (selected.length === 0 && appState.editingTextElement) {
selected = [appState.editingTextElement];
}
let targetContainers: ExcalidrawElement[] = [];
// case 1: one text element selected -> target its container if eligible
if (
selected.length === 1 &&
isTextElement(selected[0]) &&
selected[0].containerId
) {
const container = elementsMap.get(selected[0].containerId);
if (
container &&
isFlowchartNodeElement(container) &&
getBoundTextElement(container, elementsMap)
) {
targetContainers = [container];
}
} else {
// case 2: any eligible containers directly selected
targetContainers = selected.filter((el) => isFlowchartNodeElement(el));
}
if (
targetContainers.length === 0 &&
!hasContainerBehavior(appState.activeTool.type)
) {
return null;
}
const textFlow =
targetContainers.length === 0
? appState.currentItemContainerBehavior?.textFlow ?? "growing"
: reduceToCommonValue(
targetContainers,
(el) => el.containerBehavior?.textFlow ?? "growing",
) ??
// mixed selection -> show null so nothing appears selected
null;
const marginValue =
targetContainers.length === 0
? appState.currentItemContainerBehavior?.margin ?? BOUND_TEXT_PADDING
: reduceToCommonValue(
targetContainers,
(el) => el.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
) ??
// mixed selection -> show null so nothing appears selected
null;
return (
<fieldset>
<legend>{t("labels.container")}</legend>
<div className="buttonList">
<RadioSelection
group="container"
options={[
{
value: "growing",
text: t("labels.container_growing"),
icon: growingContainerIcon,
},
{
value: "fixed",
text: t("labels.container_fixed"),
icon: stickyNoteIcon,
},
]}
value={
textFlow ??
(targetContainers.length
? null
: appState.currentItemContainerBehavior?.textFlow ?? "growing")
}
onChange={(val) => updateData({ textFlow: val })}
/>
</div>
<div className="buttonList">
<RadioSelection
group="container"
options={[
{
value: "small",
text: t("labels.container_margin_small"),
icon: marginSmallIcon,
},
{
value: "medium",
text: t("labels.container_margin_medium"),
icon: marginMediumIcon,
},
{
value: "large",
text: t("labels.container_margin_large"),
icon: marginLargeIcon,
},
]}
value={getMarginValue(
marginValue ??
(targetContainers.length
? null
: appState.currentItemContainerBehavior?.margin ??
BOUND_TEXT_PADDING),
)}
onChange={(val) => updateData({ margin: val })}
/>
</div>
</fieldset>
);
},
});
const getArrowheadOptions = (flip: boolean) => {
return [
{
+1
View File
@@ -19,6 +19,7 @@ export {
actionChangeTextAlign,
actionChangeVerticalAlign,
actionChangeArrowProperties,
actionChangeContainerBehavior,
} from "./actionProperties";
export {
+1
View File
@@ -67,6 +67,7 @@ export type ActionName =
| "changeStrokeShape"
| "changeSloppiness"
| "changeStrokeStyle"
| "changeContainerBehavior"
| "changeArrowhead"
| "changeArrowType"
| "changeArrowProperties"
+2
View File
@@ -42,6 +42,7 @@ export const getDefaultAppState = (): Omit<
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentItemContainerBehavior: DEFAULT_ELEMENT_PROPS.containerBehavior,
currentHoveredFontFamily: null,
cursorButton: "up",
activeEmbeddable: null,
@@ -173,6 +174,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
currentItemContainerBehavior: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
+13
View File
@@ -9,6 +9,7 @@ import {
VERTICAL_ALIGN,
randomId,
isDevEnv,
BOUND_TEXT_PADDING,
} from "@excalidraw/common";
import {
@@ -334,6 +335,10 @@ const chartBaseElements = (
strokeColor: COLOR_PALETTE.black,
fillStyle: "solid",
opacity: 6,
containerBehavior: {
textFlow: "growing",
margin: BOUND_TEXT_PADDING,
},
})
: null;
@@ -366,6 +371,10 @@ const chartTypeBar = (
y: y - barHeight - BAR_GAP,
width: BAR_WIDTH,
height: barHeight,
containerBehavior: {
textFlow: "growing",
margin: BOUND_TEXT_PADDING,
},
});
});
@@ -432,6 +441,10 @@ const chartTypeLine = (
y: y + cy - BAR_GAP * 2,
width: BAR_GAP,
height: BAR_GAP,
containerBehavior: {
textFlow: "growing",
margin: BOUND_TEXT_PADDING,
},
});
});
@@ -10,6 +10,9 @@ import {
} from "@excalidraw/common";
import {
getContainerElement,
hasContainerBehavior,
isFlowchartNodeElement,
shouldAllowVerticalAlign,
suppportsHorizontalAlign,
hasBoundTextElement,
@@ -155,6 +158,15 @@ export const SelectedShapeActions = ({
) {
isSingleElementBoundContainer = true;
}
const textContainer =
targetElements.length === 1 && isTextElement(targetElements[0])
? getContainerElement(targetElements[0], elementsMap)
: null;
const isStickyNoteContainer =
textContainer && isFlowchartNodeElement(textContainer);
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
@@ -218,6 +230,11 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</>
)}
{(hasContainerBehavior(appState.activeTool.type) ||
targetElements.some((element) => isFlowchartNodeElement(element))) && (
<>{renderAction("changeContainerBehavior")}</>
)}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<>{renderAction("changeArrowType")}</>
@@ -241,6 +258,8 @@ export const SelectedShapeActions = ({
<>{renderAction("changeArrowhead")}</>
)}
{isStickyNoteContainer && <>{renderAction("changeContainerBehavior")}</>}
{renderAction("changeOpacity")}
<fieldset>
@@ -408,6 +427,10 @@ const CombinedShapeProperties = ({
canChangeRoundness(element.type),
)) &&
renderAction("changeRoundness")}
{(hasContainerBehavior(appState.activeTool.type) ||
targetElements.some((element) =>
isFlowchartNodeElement(element),
)) && <>{renderAction("changeContainerBehavior")}</>}
{renderAction("changeOpacity")}
</div>
</PropertiesPopover>
@@ -532,6 +555,14 @@ const CombinedTextProperties = ({
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
const isOpen = appState.openPopup === "compactTextProperties";
const textContainer =
targetElements.length === 1 && isTextElement(targetElements[0])
? getContainerElement(targetElements[0], elementsMap)
: null;
const isStickyNoteContainer =
textContainer && isFlowchartNodeElement(textContainer);
return (
<div className="compact-action-item">
<Popover.Root
@@ -597,6 +628,9 @@ const CombinedTextProperties = ({
renderAction("changeTextAlign")}
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
renderAction("changeVerticalAlign")}
{isStickyNoteContainer && (
<>{renderAction("changeContainerBehavior")}</>
)}
</div>
</PropertiesPopover>
)}
+31 -27
View File
@@ -96,6 +96,7 @@ import {
MINIMUM_ARROW_SIZE,
DOUBLE_TAP_POSITION_THRESHOLD,
BIND_MODE_TIMEOUT,
BOUND_TEXT_PADDING,
invariant,
getFeatureFlag,
createUserAgentDescriptor,
@@ -246,6 +247,7 @@ import {
mutateElement,
getElementBounds,
doBoundsIntersect,
isFlowchartType,
isPointInElement,
maxBindingDistance_simple,
} from "@excalidraw/element";
@@ -5776,8 +5778,13 @@ class App extends React.Component<AppProps, AppState> {
const minWidth = getApproxMinLineWidth(
getFontString(fontString),
lineHeight,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
);
const minHeight = getApproxMinLineHeight(
fontSize,
lineHeight,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
);
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
const newHeight = Math.max(container.height, minHeight);
const newWidth = Math.max(container.width, minWidth);
this.scene.mutateElement(container, {
@@ -8721,6 +8728,9 @@ class App extends React.Component<AppProps, AppState> {
roundness: this.getCurrentItemRoundness(elementType),
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
...(isFlowchartType(elementType) && {
containerBehavior: this.state.currentItemContainerBehavior,
}),
} as const;
let element;
@@ -11463,11 +11473,7 @@ class App extends React.Component<AppProps, AppState> {
): void => {
const selectionElement = this.state.selectionElement;
const pointerCoords = pointerDownState.lastCoords;
if (
selectionElement &&
pointerDownState.boxSelection.hasOccurred &&
this.state.activeTool.type !== "eraser"
) {
if (selectionElement && this.state.activeTool.type !== "eraser") {
dragNewElement({
newElement: selectionElement,
elementType: this.state.activeTool.type,
@@ -11531,27 +11537,25 @@ class App extends React.Component<AppProps, AppState> {
snapLines,
});
if (!isBindingElement(newElement)) {
dragNewElement({
newElement,
elementType: this.state.activeTool.type,
originX: pointerDownState.originInGrid.x,
originY: pointerDownState.originInGrid.y,
x: gridX,
y: gridY,
width: distance(pointerDownState.originInGrid.x, gridX),
height: distance(pointerDownState.originInGrid.y, gridY),
shouldMaintainAspectRatio: isImageElement(newElement)
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: shouldResizeFromCenter(event),
zoom: this.state.zoom.value,
scene: this.scene,
widthAspectRatio: aspectRatio,
originOffset: this.state.originSnapOffset,
informMutation,
});
}
dragNewElement({
newElement,
elementType: this.state.activeTool.type,
originX: pointerDownState.originInGrid.x,
originY: pointerDownState.originInGrid.y,
x: gridX,
y: gridY,
width: distance(pointerDownState.originInGrid.x, gridX),
height: distance(pointerDownState.originInGrid.y, gridY),
shouldMaintainAspectRatio: isImageElement(newElement)
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: shouldResizeFromCenter(event),
zoom: this.state.zoom.value,
scene: this.scene,
widthAspectRatio: aspectRatio,
originOffset: this.state.originSnapOffset,
informMutation,
});
this.setState({
newElement,
+98
View File
@@ -2373,3 +2373,101 @@ export const presentationIcon = createIcon(
</g>,
tablerIconProps,
);
export const stickyNoteIcon = createIcon(
<g>
<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z" />
<path d="M15 3v4a2 2 0 0 0 2 2h4" />
</g>,
tablerIconProps,
);
export const marginSmallIcon = createIcon(
<g fill="none">
<rect
x="0"
y="0"
width="24"
height="24"
stroke="currentColor"
stroke-width="1.5"
/>
<rect
x="2"
y="2"
width="20"
height="20"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="1 1"
/>
</g>,
tablerIconProps,
);
export const marginMediumIcon = createIcon(
<g fill="none">
<rect
x="0"
y="0"
width="24"
height="24"
stroke="currentColor"
stroke-width="1.5"
/>
<rect
x="4"
y="4"
width="16"
height="16"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="1 1"
/>
</g>,
tablerIconProps,
);
export const marginLargeIcon = createIcon(
<g fill="none">
<rect
x="0"
y="0"
width="24"
height="24"
stroke="currentColor"
stroke-width="1.5"
/>
<rect
x="6"
y="6"
width="12"
height="12"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="1 1"
/>
</g>,
tablerIconProps,
);
export const growingContainerIcon = createIcon(
<g
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M14 21h1" />
<path d="M21 14v1" />
<path d="M21 19a2 2 0 0 1-2 2" />
<path d="M21 9v1" />
<path d="M3 14v1" />
<path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2" />
<path d="M3 9v1" />
<path d="M5 21a2 2 0 0 1-2-2" />
<path d="M9 21h1" />
</g>,
tablerIconProps,
);
+6 -6
View File
@@ -222,7 +222,7 @@ function dataView(
*
* @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
*/
const concatBuffers = (...buffers: Uint8Array[]): Uint8Array<ArrayBuffer> => {
const concatBuffers = (...buffers: Uint8Array[]) => {
const bufferView = new Uint8Array(
VERSION_DATAVIEW_BYTES +
NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
@@ -295,12 +295,12 @@ const splitBuffers = (concatenatedBuffer: Uint8Array) => {
/** @private */
const _encryptAndCompress = async (
data: Uint8Array<ArrayBuffer> | string,
data: Uint8Array | string,
encryptionKey: string,
) => {
const { encryptedBuffer, iv } = await encryptData(
encryptionKey,
deflate(data) as Uint8Array<ArrayBuffer>,
deflate(data),
);
return { iv, buffer: new Uint8Array(encryptedBuffer) };
@@ -330,7 +330,7 @@ export const compressData = async <T extends Record<string, any> = never>(
: {
metadata: T;
}),
): Promise<Uint8Array<ArrayBuffer>> => {
): Promise<Uint8Array> => {
const fileInfo: FileEncodingInfo = {
version: 2,
compression: "pako@1",
@@ -355,8 +355,8 @@ export const compressData = async <T extends Record<string, any> = never>(
/** @private */
const _decryptAndDecompress = async (
iv: Uint8Array<ArrayBuffer>,
decryptedBuffer: Uint8Array<ArrayBuffer>,
iv: Uint8Array,
decryptedBuffer: Uint8Array,
decryptionKey: string,
isCompressed: boolean,
) => {
+7 -7
View File
@@ -4,7 +4,7 @@ import { blobToArrayBuffer } from "./blob";
export const IV_LENGTH_BYTES = 12;
export const createIV = (): Uint8Array<ArrayBuffer> => {
export const createIV = () => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
@@ -49,12 +49,12 @@ export const getCryptoKey = (key: string, usage: KeyUsage) =>
export const encryptData = async (
key: string | CryptoKey,
data: Uint8Array<ArrayBuffer> | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array<ArrayBuffer> }> => {
data: Uint8Array | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV();
const buffer: ArrayBuffer | Uint8Array<ArrayBuffer> =
const buffer: ArrayBuffer | Uint8Array =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
@@ -71,15 +71,15 @@ export const encryptData = async (
iv,
},
importedKey,
buffer,
buffer as ArrayBuffer | Uint8Array,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
iv: Uint8Array<ArrayBuffer>,
encrypted: Uint8Array<ArrayBuffer> | ArrayBuffer,
iv: Uint8Array,
encrypted: Uint8Array | ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getCryptoKey(privateKey, "decrypt");
+35 -9
View File
@@ -17,10 +17,12 @@ import {
getSizeFromPoints,
normalizeLink,
getLineHeight,
BOUND_TEXT_PADDING,
} from "@excalidraw/common";
import {
calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements,
isFlowchartType,
isPointInElement,
isValidPolygon,
projectFixedPointOntoDiagonal,
@@ -60,6 +62,7 @@ import type {
ExcalidrawBindableElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLinearElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
@@ -196,14 +199,27 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
return null;
};
const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & {
type _ElementForRestoreBase = Required<
Omit<ExcalidrawElement, "customData" | "containerBehavior">
> &
Pick<ExcalidrawElement, "containerBehavior"> & {
customData?: ExcalidrawElement["customData"];
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
/** @deprecated */
strokeSharpness?: StrokeRoundness;
},
};
const restoreElementWithProperties = <
T extends _ElementForRestoreBase &
Omit<
any,
| keyof ExcalidrawElement
| "boundElementIds"
| "strokeSharpness"
| "customData"
| "containerBehavior"
>,
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
>(
element: T,
@@ -215,11 +231,13 @@ const restoreElementWithProperties = <
> &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
): T => {
const nextType = (extra.type || element.type) as ExcalidrawElementType;
const base: Pick<T, keyof ExcalidrawElement> = {
type: extra.type || element.type,
type: nextType,
version: element.version || 1,
// all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements
version: element.version || 1,
versionNonce: element.versionNonce ?? 0,
index: element.index ?? null,
isDeleted: element.isDeleted ?? false,
@@ -247,7 +265,7 @@ const restoreElementWithProperties = <
? {
// for old elements that would now use adaptive radius algo,
// use legacy algo instead
type: isUsingAdaptiveRadius(element.type)
type: isUsingAdaptiveRadius(nextType)
? ROUNDNESS.LEGACY
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
@@ -258,10 +276,18 @@ const restoreElementWithProperties = <
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ? normalizeLink(element.link) : null,
locked: element.locked ?? false,
...(isFlowchartType(nextType)
? {
containerBehavior: {
textFlow: element.containerBehavior?.textFlow ?? "growing",
margin: element.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
}
: {}),
};
if ("customData" in element || "customData" in extra) {
base.customData =
(base as any).customData =
"customData" in extra ? extra.customData : element.customData;
}
@@ -276,8 +302,8 @@ const restoreElementWithProperties = <
} as unknown as T;
// strip legacy props (migrated in previous steps)
delete ret.strokeSharpness;
delete ret.boundElementIds;
delete (ret as any).strokeSharpness;
delete (ret as any).boundElementIds;
return ret;
};
+1 -1
View File
@@ -42,7 +42,7 @@ declare module "png-chunk-text" {
function decode(data: Uint8Array): { keyword: string; text: string };
}
declare module "png-chunks-encode" {
function encode(chunks: TEXtChunk[]): Uint8Array<ArrayBuffer>;
function encode(chunks: TEXtChunk[]): Uint8Array;
export = encode;
}
declare module "png-chunks-extract" {
+1 -1
View File
@@ -18,7 +18,7 @@ export function useOutsideClick<T extends HTMLElement>(
* Returning `undefined` will fallback to the default behavior.
*/
isInside?: (
event: Event & { target: T },
event: Event & { target: HTMLElement },
/** the element of the passed ref */
container: T,
) => boolean | undefined,
+6
View File
@@ -31,6 +31,12 @@
"strokeStyle_dashed": "Dashed",
"strokeStyle_dotted": "Dotted",
"sloppiness": "Sloppiness",
"container": "Container",
"container_fixed": "Sticky note",
"container_growing": "Fit to text",
"container_margin_small": "Small margin",
"container_margin_medium": "Medium margin",
"container_margin_large": "Large margin",
"opacity": "Opacity",
"textAlign": "Text align",
"edges": "Edges",
+1 -2
View File
@@ -95,7 +95,6 @@
"fractional-indexing": "3.2.0",
"fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1",
"is-mobile": "5.0.0",
"jotai": "2.11.0",
"jotai-scope": "0.7.2",
"lodash.debounce": "4.0.8",
@@ -134,6 +133,6 @@
"fonteditor-core": "2.4.1",
"harfbuzzjs": "0.3.6",
"jest-diff": "29.7.0",
"typescript": "5.9.3"
"typescript": "4.9.4"
}
}
@@ -20,7 +20,7 @@ const load = (): Promise<{
subset: (
fontBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
) => Uint8Array<ArrayBuffer>;
) => Uint8Array;
}> => {
return new Promise(async (resolve, reject) => {
try {
@@ -18,7 +18,7 @@ type Vector = any;
let loadedWasm: ReturnType<typeof load> | null = null;
// re-map from internal vector into byte array
function convertFromVecToUint8Array(vector: Vector): Uint8Array<ArrayBuffer> {
function convertFromVecToUint8Array(vector: Vector): Uint8Array {
const arr = [];
for (let i = 0, l = vector.size(); i < l; i++) {
arr.push(vector.get(i));
@@ -29,8 +29,8 @@ function convertFromVecToUint8Array(vector: Vector): Uint8Array<ArrayBuffer> {
// TODO: consider adding support for fetching the wasm from an URL (external CDN, data URL, etc.)
const load = (): Promise<{
compress: (buffer: ArrayBuffer) => Uint8Array<ArrayBuffer>;
decompress: (buffer: ArrayBuffer) => Uint8Array<ArrayBuffer>;
compress: (buffer: ArrayBuffer) => Uint8Array;
decompress: (buffer: ArrayBuffer) => Uint8Array;
}> => {
return new Promise((resolve, reject) => {
try {
@@ -1223,7 +1223,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 2019559783,
"width": 10,
"x": -20,
"y": -10,
@@ -1431,14 +1431,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1014066025,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 1604849351,
"versionNonce": 1505387817,
"width": 20,
"x": 20,
"y": 30,
@@ -1463,14 +1463,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 493213705,
"versionNonce": 915032327,
"width": 20,
"x": -10,
"y": 0,
@@ -1765,14 +1765,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1014066025,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 1604849351,
"versionNonce": 1505387817,
"width": 20,
"x": 20,
"y": 30,
@@ -1797,14 +1797,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 493213705,
"versionNonce": 915032327,
"width": 20,
"x": -10,
"y": 0,
@@ -2108,7 +2108,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 2019559783,
"width": 10,
"x": -20,
"y": -10,
@@ -2321,7 +2321,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1116226695,
"versionNonce": 1014066025,
"width": 10,
"x": -20,
"y": -10,
@@ -2567,7 +2567,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 2019559783,
"width": 10,
"x": -20,
"y": -10,
@@ -2592,14 +2592,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1014066025,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 400692809,
"versionNonce": 1604849351,
"width": 10,
"x": -10,
"y": 0,
@@ -2868,14 +2868,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 915032327,
"versionNonce": 81784553,
"width": 20,
"x": -10,
"y": 0,
@@ -2902,14 +2902,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1014066025,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 81784553,
"versionNonce": 747212839,
"width": 20,
"x": 20,
"y": 30,
@@ -3238,14 +3238,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60,
"roughness": 2,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1402203177,
"versionNonce": 1359939303,
"width": 20,
"x": -10,
"y": 0,
@@ -3270,14 +3270,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60,
"roughness": 2,
"roundness": null,
"seed": 1898319239,
"seed": 640725609,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 9,
"versionNonce": 941653321,
"versionNonce": 908564423,
"width": 20,
"x": 20,
"y": 30,
@@ -3732,14 +3732,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 238820263,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 915032327,
"versionNonce": 81784553,
"width": 20,
"x": 20,
"y": 30,
@@ -3764,14 +3764,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 2019559783,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"y": 0,
@@ -4058,14 +4058,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1014066025,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 493213705,
"versionNonce": 915032327,
"width": 20,
"x": 20,
"y": 30,
@@ -4090,14 +4090,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 2019559783,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"y": 0,
@@ -4387,14 +4387,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 760410951,
"versionNonce": 1006504105,
"width": 20,
"x": -10,
"y": 0,
@@ -4419,14 +4419,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 238820263,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 1006504105,
"versionNonce": 289600103,
"width": 20,
"x": 20,
"y": 30,
@@ -5675,14 +5675,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 453191,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 1150084233,
"versionNonce": 1014066025,
"width": 10,
"x": -10,
"y": 0,
@@ -5707,14 +5707,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 400692809,
"seed": 1505387817,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 23633383,
"versionNonce": 915032327,
"width": 10,
"x": 12,
"y": 0,
@@ -6899,14 +6899,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 747212839,
"versionNonce": 1723083209,
"width": 10,
"x": -10,
"y": 0,
@@ -6933,14 +6933,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 238820263,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1723083209,
"versionNonce": 760410951,
"width": 10,
"x": 12,
"y": 0,
@@ -9837,7 +9837,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 2019559783,
"width": 10,
"x": -20,
"y": -10,
@@ -13935,7 +13935,7 @@ exports[`regression tests > switches from group of selected elements to another
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1006504105,
"seed": 289600103,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -41,8 +41,8 @@ exports[`select single element on the scene > arrow 1`] = `
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 5,
"versionNonce": 1116226695,
"version": 4,
"versionNonce": 2019559783,
"width": 30,
"x": 10,
"y": 10,
@@ -88,8 +88,8 @@ exports[`select single element on the scene > arrow escape 1`] = `
"strokeWidth": 2,
"type": "line",
"updated": 1,
"version": 5,
"versionNonce": 1116226695,
"version": 4,
"versionNonce": 2019559783,
"width": 30,
"x": 10,
"y": 10,
@@ -120,8 +120,8 @@ exports[`select single element on the scene > diamond 1`] = `
"strokeWidth": 2,
"type": "diamond",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"version": 3,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
@@ -152,8 +152,8 @@ exports[`select single element on the scene > ellipse 1`] = `
"strokeWidth": 2,
"type": "ellipse",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"version": 3,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
@@ -184,8 +184,8 @@ exports[`select single element on the scene > rectangle 1`] = `
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 2019559783,
"version": 3,
"versionNonce": 401146281,
"width": 30,
"x": 10,
"y": 10,
+9 -5
View File
@@ -1,4 +1,4 @@
import { DEFAULT_FONT_FAMILY } from "@excalidraw/common";
import { BOUND_TEXT_PADDING, DEFAULT_FONT_FAMILY } from "@excalidraw/common";
import type { Radians } from "@excalidraw/math";
@@ -34,25 +34,29 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
export const rectangleFixture: ExcalidrawElement = {
...elementBase,
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
type: "rectangle",
};
} as unknown as ExcalidrawElement;
export const embeddableFixture: ExcalidrawElement = {
...elementBase,
type: "embeddable",
};
export const ellipseFixture: ExcalidrawElement = {
...elementBase,
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
type: "ellipse",
};
} as unknown as ExcalidrawElement;
export const diamondFixture: ExcalidrawElement = {
...elementBase,
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
type: "diamond",
};
} as unknown as ExcalidrawElement;
export const rectangleWithLinkFixture: ExcalidrawElement = {
...elementBase,
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
type: "rectangle",
link: "excalidraw.com",
};
} as unknown as ExcalidrawElement;
export const textFixture: ExcalidrawElement = {
...elementBase,
+10 -2
View File
@@ -4,7 +4,7 @@ import util from "util";
import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/common";
import { BOUND_TEXT_PADDING, DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/common";
import {
newArrowElement,
@@ -217,6 +217,10 @@ export class API {
: never;
elbowed?: boolean;
fixedSegments?: FixedSegment[] | null;
containerBehavior?: T extends "rectangle" | "diamond" | "ellipse"
? ExcalidrawGenericElement["containerBehavior"]
: never;
} = {
}): T extends "arrow" | "line"
? ExcalidrawLinearElement
: T extends "freedraw"
@@ -282,6 +286,10 @@ export class API {
element = newElement({
type: type as "rectangle" | "diamond" | "ellipse",
...base,
containerBehavior: {
textFlow: rest.containerBehavior?.textFlow ?? "growing",
margin: rest.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
});
break;
case "embeddable":
@@ -464,7 +472,7 @@ export class API {
static readFile = async <T extends "utf8" | null>(
filepath: string,
encoding?: T,
): Promise<T extends "utf8" ? string : ArrayBuffer> => {
): Promise<T extends "utf8" ? string : Buffer> => {
filepath = path.isAbsolute(filepath)
? filepath
: path.resolve(path.join(__dirname, "../", filepath));
@@ -315,7 +315,6 @@ describe("history", () => {
]);
mouse.downAt(0, 0);
mouse.moveTo(25, 25);
mouse.moveTo(50, 50);
mouse.upAt(50, 50);
expect(API.getUndoStack().length).toBe(3);
@@ -467,7 +467,6 @@ describe("regression tests", () => {
mouse.reset();
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end);
mouse.up();
@@ -518,7 +517,6 @@ describe("regression tests", () => {
mouse.reset();
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end);
mouse.up();
@@ -536,7 +534,6 @@ describe("regression tests", () => {
mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end);
mouse.up();
+6 -20
View File
@@ -65,7 +65,6 @@ describe("box-selection", () => {
API.setElements([rect1, rect2]);
mouse.downAt(175, -20);
mouse.move(-1000, -1000);
mouse.moveTo(85, 70);
mouse.up();
@@ -73,7 +72,6 @@ describe("box-selection", () => {
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.downAt(75, -20);
mouse.move(-1000, -1000);
mouse.moveTo(-15, 70);
mouse.up();
});
@@ -95,7 +93,6 @@ describe("box-selection", () => {
API.setElements([rect1]);
mouse.downAt(75, -20);
mouse.move(-1000, -1000);
mouse.moveTo(-15, 70);
assertSelectedElements([rect1.id]);
@@ -141,7 +138,6 @@ describe("inner box-selection", () => {
API.setElements([rect1, rect2, rect3]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(40, 40);
mouse.move(-1000, -1000);
mouse.moveTo(290, 290);
mouse.up();
@@ -179,7 +175,6 @@ describe("inner box-selection", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(40, 40);
mouse.move(-1000, -1000);
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
mouse.up();
@@ -217,7 +212,6 @@ describe("inner box-selection", () => {
API.setElements([rect1, rect2, rect3]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(rect2.x - 20, rect2.y - 20);
mouse.move(-1000, -1000);
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
@@ -259,10 +253,9 @@ describe("selection element", () => {
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderInteractiveScene).toHaveBeenCalledTimes(4);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull();
@@ -282,11 +275,10 @@ describe("selection element", () => {
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(3);
expect(h.state.selectionElement).toBeNull();
});
@@ -311,7 +303,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("rectangle");
fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, {
@@ -344,7 +335,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("diamond");
fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, {
@@ -377,7 +367,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("ellipse");
fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, {
@@ -410,7 +399,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("arrow");
fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, {
@@ -437,8 +425,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -455,7 +443,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("line");
fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, {
@@ -482,8 +469,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -538,7 +525,6 @@ describe("selectedElementIds stability", () => {
expect(h.state.selectedElementIds).toBe(selectedElementIds_1);
mouse.downAt(-50, -50);
mouse.move(-1000, -1000);
mouse.moveTo(50, 50);
const selectedElementIds_2 = h.state.selectedElementIds;
+1
View File
@@ -348,6 +348,7 @@ export interface AppState {
currentHoveredFontFamily: FontFamilyValues | null;
currentItemRoundness: StrokeRoundness;
currentItemArrowType: "sharp" | "round" | "elbow";
currentItemContainerBehavior: ExcalidrawElement["containerBehavior"];
viewBackgroundColor: string;
scrollX: number;
scrollY: number;
+118 -56
View File
@@ -8,6 +8,7 @@ import {
getFontFamilyString,
isTestEnv,
MIME_TYPES,
BOUND_TEXT_PADDING,
} from "@excalidraw/common";
import {
@@ -36,6 +37,7 @@ import {
isBoundToContainer,
isTextElement,
} from "@excalidraw/element";
import { computeStickyNoteFontSize } from "@excalidraw/element";
import type {
ExcalidrawElement,
@@ -130,6 +132,8 @@ export const textWysiwyg = ({
return false;
};
let stickyNoteInitialFontSize: number | null = null;
const updateWysiwygStyle = () => {
const appState = app.state;
const updatedTextElement = app.scene.getElement<ExcalidrawTextElement>(id);
@@ -157,65 +161,42 @@ export const textWysiwyg = ({
let maxHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
const boundTextCoords =
LinearElementEditor.getBoundTextElementPosition(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
elementsMap,
);
coordX = boundTextCoords.x;
coordY = boundTextCoords.y;
}
const propertiesUpdated = textPropertiesUpdated(
updatedTextElement,
editable,
);
let originalContainerData;
if (propertiesUpdated) {
originalContainerData = updateOriginalContainerCache(
container.id,
container.height,
);
} else {
originalContainerData = originalContainerCache[container.id];
if (!originalContainerData) {
originalContainerData = updateOriginalContainerCache(
container.id,
container.height,
);
if ((container as any).containerBehavior?.textFlow === "fixed") {
if (stickyNoteInitialFontSize == null) {
stickyNoteInitialFontSize = updatedTextElement.fontSize;
}
}
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
maxHeight = getBoundTextMaxHeight(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
);
// autogrow container height if text exceeds
if (!isArrowElement(container) && height > maxHeight) {
const targetContainerHeight = computeContainerDimensionForBoundText(
height,
container.type,
const elementsMap = app.scene.getNonDeletedElementsMap();
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
maxHeight = getBoundTextMaxHeight(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
);
app.scene.mutateElement(container, { height: targetContainerHeight });
return;
} else if (
// autoshrink container height until original container height
// is reached when text is removed
!isArrowElement(container) &&
container.height > originalContainerData.height &&
height < maxHeight
) {
const targetContainerHeight = computeContainerDimensionForBoundText(
height,
container.type,
const {
fontSize,
width: fittedW,
height: fittedH,
wrappedText,
} = computeStickyNoteFontSize(
editable.value,
updatedTextElement,
container,
stickyNoteInitialFontSize,
);
app.scene.mutateElement(container, { height: targetContainerHeight });
} else {
const needsUpdate =
fontSize !== updatedTextElement.fontSize ||
fittedW !== updatedTextElement.width ||
fittedH !== updatedTextElement.height ||
wrappedText !== updatedTextElement.text;
if (needsUpdate) {
app.scene.mutateElement(updatedTextElement, {
fontSize,
width: fittedW,
height: fittedH,
text: wrappedText,
});
}
const { x, y } = computeBoundTextPosition(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
@@ -223,6 +204,87 @@ export const textWysiwyg = ({
);
coordX = x;
coordY = y;
width = fittedW;
height = fittedH;
} else {
if (isArrowElement(container)) {
const boundTextCoords =
LinearElementEditor.getBoundTextElementPosition(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
elementsMap,
);
coordX = boundTextCoords.x;
coordY = boundTextCoords.y;
}
const propertiesUpdated = textPropertiesUpdated(
updatedTextElement,
editable,
);
let originalContainerData;
if (propertiesUpdated) {
originalContainerData = updateOriginalContainerCache(
container.id,
container.height,
);
} else {
originalContainerData = originalContainerCache[container.id];
if (!originalContainerData) {
originalContainerData = updateOriginalContainerCache(
container.id,
container.height,
);
}
}
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
maxHeight = getBoundTextMaxHeight(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
);
// autogrow / autoshrink only for non-sticky behaviors
if ((container as any).containerBehavior?.textFlow !== "fixed") {
// autogrow container height if text exceeds
if (!isArrowElement(container) && height > maxHeight) {
const targetContainerHeight =
computeContainerDimensionForBoundText(
height,
container.type,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
);
app.scene.mutateElement(container, {
height: targetContainerHeight,
});
return;
} else if (
// autoshrink container height until original container height
// is reached when text is removed
!isArrowElement(container) &&
container.height > originalContainerData.height &&
height < maxHeight
) {
const targetContainerHeight =
computeContainerDimensionForBoundText(
height,
container.type,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
);
app.scene.mutateElement(container, {
height: targetContainerHeight,
});
} else {
const { x, y } = computeBoundTextPosition(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
elementsMap,
);
coordX = x;
coordY = y;
}
}
}
}
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
+1 -1
View File
@@ -62,7 +62,7 @@
"devDependencies": {
"cross-env": "7.0.3",
"fonteditor-core": "2.4.0",
"typescript": "5.9.3",
"typescript": "4.9.4",
"wawoff2": "2.0.1",
"which": "4.0.0"
},
+4 -9
View File
@@ -6398,11 +6398,6 @@ is-map@^2.0.3:
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e"
integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==
is-mobile@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/is-mobile/-/is-mobile-5.0.0.tgz#1e08a0ef2c38a67bff84a52af68d67bcef445333"
integrity sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==
is-module@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
@@ -9295,10 +9290,10 @@ typed-array-length@^1.0.7:
possible-typed-array-names "^1.0.0"
reflect.getprototypeof "^1.0.6"
typescript@5.9.3:
version "5.9.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
typescript@4.9.4:
version "4.9.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78"
integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==
typescript@^5:
version "5.8.2"