Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 685f76099a | |||
| 04b4c2239f | |||
| c93688226f | |||
| 5760c2b30d | |||
| d668eaa060 | |||
| 0ff0efe2a7 | |||
| f3a60a5ef6 | |||
| f90bf59ecd | |||
| f5f3b61779 | |||
| dee5c2ea8e | |||
| 14ab68af54 |
@@ -410,6 +410,7 @@ export const DEFAULT_ELEMENT_PROPS: {
|
|||||||
roughness: ExcalidrawElement["roughness"];
|
roughness: ExcalidrawElement["roughness"];
|
||||||
opacity: ExcalidrawElement["opacity"];
|
opacity: ExcalidrawElement["opacity"];
|
||||||
locked: ExcalidrawElement["locked"];
|
locked: ExcalidrawElement["locked"];
|
||||||
|
containerBehavior: ExcalidrawElement["containerBehavior"];
|
||||||
} = {
|
} = {
|
||||||
strokeColor: COLOR_PALETTE.black,
|
strokeColor: COLOR_PALETTE.black,
|
||||||
backgroundColor: COLOR_PALETTE.transparent,
|
backgroundColor: COLOR_PALETTE.transparent,
|
||||||
@@ -419,6 +420,10 @@ export const DEFAULT_ELEMENT_PROPS: {
|
|||||||
roughness: ROUGHNESS.artist,
|
roughness: ROUGHNESS.artist,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
locked: false,
|
locked: false,
|
||||||
|
containerBehavior: {
|
||||||
|
textFlow: "growing",
|
||||||
|
margin: BOUND_TEXT_PADDING,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LIBRARY_SIDEBAR_TAB = "library";
|
export const LIBRARY_SIDEBAR_TAB = "library";
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
|
|||||||
type === "diamond" ||
|
type === "diamond" ||
|
||||||
type === "image";
|
type === "image";
|
||||||
|
|
||||||
|
export const hasContainerBehavior = (type: ElementOrToolType) =>
|
||||||
|
type === "rectangle" || type === "diamond" || type === "ellipse";
|
||||||
|
|
||||||
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
||||||
|
|
||||||
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
|
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
|
||||||
|
|||||||
@@ -2056,11 +2056,14 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static stripIrrelevantProps(
|
private static stripIrrelevantProps(partial: ElementPartial): ElementPartial {
|
||||||
partial: Partial<OrderedExcalidrawElement>,
|
const {
|
||||||
): ElementPartial {
|
id: _id,
|
||||||
const { id, updated, ...strippedPartial } = partial;
|
updated: _updated,
|
||||||
|
seed: _seed,
|
||||||
|
...strippedPartial
|
||||||
|
} = partial as any;
|
||||||
|
|
||||||
return strippedPartial;
|
return strippedPartial as ElementPartial;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ const addNewNode = (
|
|||||||
opacity: element.opacity,
|
opacity: element.opacity,
|
||||||
fillStyle: element.fillStyle,
|
fillStyle: element.fillStyle,
|
||||||
strokeStyle: element.strokeStyle,
|
strokeStyle: element.strokeStyle,
|
||||||
|
containerBehavior: element.containerBehavior,
|
||||||
});
|
});
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
@@ -346,6 +347,7 @@ export const addNewNodes = (
|
|||||||
opacity: startNode.opacity,
|
opacity: startNode.opacity,
|
||||||
fillStyle: startNode.fillStyle,
|
fillStyle: startNode.fillStyle,
|
||||||
strokeStyle: startNode.strokeStyle,
|
strokeStyle: startNode.strokeStyle,
|
||||||
|
containerBehavior: startNode.containerBehavior,
|
||||||
});
|
});
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getFontString,
|
getFontString,
|
||||||
getUpdatedTimestamp,
|
getUpdatedTimestamp,
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
@@ -21,11 +22,11 @@ import {
|
|||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import { newElementWith } from "./mutateElement";
|
import { newElementWith } from "./mutateElement";
|
||||||
import { getBoundTextMaxWidth } from "./textElement";
|
import { getBoundTextMaxWidth, getBoundTextMaxHeight } from "./textElement";
|
||||||
import { normalizeText, measureText } from "./textMeasurements";
|
import { normalizeText, measureText } from "./textMeasurements";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
|
|
||||||
import { isLineElement } from "./typeChecks";
|
import { isFlowchartType, isLineElement } from "./typeChecks";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -48,6 +49,8 @@ import type {
|
|||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
ExcalidrawLineElement,
|
ExcalidrawLineElement,
|
||||||
|
ExcalidrawFlowchartNodeElement,
|
||||||
|
ContainerBehavior,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export type ElementConstructorOpts = MarkOptional<
|
export type ElementConstructorOpts = MarkOptional<
|
||||||
@@ -158,9 +161,26 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
export const newElement = (
|
export const newElement = (
|
||||||
opts: {
|
opts: {
|
||||||
type: ExcalidrawGenericElement["type"];
|
type: ExcalidrawGenericElement["type"];
|
||||||
|
containerBehavior?: ContainerBehavior;
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawGenericElement> =>
|
): NonDeleted<ExcalidrawGenericElement> => {
|
||||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
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 = (
|
export const newEmbeddableElement = (
|
||||||
opts: {
|
opts: {
|
||||||
@@ -417,6 +437,96 @@ const adjustXYWithRotation = (
|
|||||||
return [x, y];
|
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 = (
|
export const refreshTextDimensions = (
|
||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
container: ExcalidrawTextContainer | null,
|
container: ExcalidrawTextContainer | null,
|
||||||
|
|||||||
@@ -329,16 +329,24 @@ const generateElementCanvas = (
|
|||||||
boundTextCanvasContext.translate(-shiftX, -shiftY);
|
boundTextCanvasContext.translate(-shiftX, -shiftY);
|
||||||
// Clear the bound text area
|
// Clear the bound text area
|
||||||
boundTextCanvasContext.clearRect(
|
boundTextCanvasContext.clearRect(
|
||||||
-(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
|
-(
|
||||||
|
boundTextElement.width / 2 +
|
||||||
|
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING)
|
||||||
|
) *
|
||||||
window.devicePixelRatio *
|
window.devicePixelRatio *
|
||||||
scale,
|
scale,
|
||||||
-(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
|
-(
|
||||||
|
boundTextElement.height / 2 +
|
||||||
|
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING)
|
||||||
|
) *
|
||||||
window.devicePixelRatio *
|
window.devicePixelRatio *
|
||||||
scale,
|
scale,
|
||||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
|
(boundTextElement.width +
|
||||||
|
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING) * 2) *
|
||||||
window.devicePixelRatio *
|
window.devicePixelRatio *
|
||||||
scale,
|
scale,
|
||||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
|
(boundTextElement.height +
|
||||||
|
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING) * 2) *
|
||||||
window.devicePixelRatio *
|
window.devicePixelRatio *
|
||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SHIFT_LOCKING_ANGLE,
|
SHIFT_LOCKING_ANGLE,
|
||||||
rescalePoints,
|
rescalePoints,
|
||||||
getFontString,
|
getFontString,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { GlobalPoint } from "@excalidraw/math";
|
import type { GlobalPoint } from "@excalidraw/math";
|
||||||
@@ -784,10 +785,12 @@ export const resizeSingleElement = (
|
|||||||
const minWidth = getApproxMinLineWidth(
|
const minWidth = getApproxMinLineWidth(
|
||||||
getFontString(boundTextElement),
|
getFontString(boundTextElement),
|
||||||
boundTextElement.lineHeight,
|
boundTextElement.lineHeight,
|
||||||
|
latestElement.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
);
|
);
|
||||||
const minHeight = getApproxMinLineHeight(
|
const minHeight = getApproxMinLineHeight(
|
||||||
boundTextElement.fontSize,
|
boundTextElement.fontSize,
|
||||||
boundTextElement.lineHeight,
|
boundTextElement.lineHeight,
|
||||||
|
latestElement.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
);
|
);
|
||||||
nextWidth = Math.max(nextWidth, minWidth);
|
nextWidth = Math.max(nextWidth, minWidth);
|
||||||
nextHeight = Math.max(nextHeight, minHeight);
|
nextHeight = Math.max(nextHeight, minHeight);
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const redrawTextBoundingBox = (
|
|||||||
const nextHeight = computeContainerDimensionForBoundText(
|
const nextHeight = computeContainerDimensionForBoundText(
|
||||||
metrics.height,
|
metrics.height,
|
||||||
container.type,
|
container.type,
|
||||||
|
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
);
|
);
|
||||||
scene.mutateElement(container, { height: nextHeight });
|
scene.mutateElement(container, { height: nextHeight });
|
||||||
updateOriginalContainerCache(container.id, nextHeight);
|
updateOriginalContainerCache(container.id, nextHeight);
|
||||||
@@ -117,6 +118,7 @@ export const redrawTextBoundingBox = (
|
|||||||
const nextWidth = computeContainerDimensionForBoundText(
|
const nextWidth = computeContainerDimensionForBoundText(
|
||||||
metrics.width,
|
metrics.width,
|
||||||
container.type,
|
container.type,
|
||||||
|
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
);
|
);
|
||||||
scene.mutateElement(container, { width: nextWidth });
|
scene.mutateElement(container, { width: nextWidth });
|
||||||
}
|
}
|
||||||
@@ -187,6 +189,7 @@ export const handleBindTextResize = (
|
|||||||
containerHeight = computeContainerDimensionForBoundText(
|
containerHeight = computeContainerDimensionForBoundText(
|
||||||
nextHeight,
|
nextHeight,
|
||||||
container.type,
|
container.type,
|
||||||
|
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
);
|
);
|
||||||
|
|
||||||
const diff = containerHeight - container.height;
|
const diff = containerHeight - container.height;
|
||||||
@@ -353,8 +356,8 @@ export const getContainerCenter = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
||||||
let offsetX = BOUND_TEXT_PADDING;
|
let offsetX = container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
|
||||||
let offsetY = BOUND_TEXT_PADDING;
|
let offsetY = container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
|
||||||
|
|
||||||
if (container.type === "ellipse") {
|
if (container.type === "ellipse") {
|
||||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
// 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 = (
|
export const computeContainerDimensionForBoundText = (
|
||||||
dimension: number,
|
dimension: number,
|
||||||
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
|
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
|
||||||
|
boundTextPadding: number,
|
||||||
) => {
|
) => {
|
||||||
dimension = Math.ceil(dimension);
|
dimension = Math.ceil(dimension);
|
||||||
const padding = BOUND_TEXT_PADDING * 2;
|
const padding = boundTextPadding * 2;
|
||||||
|
|
||||||
if (containerType === "ellipse") {
|
if (containerType === "ellipse") {
|
||||||
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
|
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
|
||||||
@@ -467,6 +471,8 @@ export const getBoundTextMaxWidth = (
|
|||||||
boundTextElement: ExcalidrawTextElement | null,
|
boundTextElement: ExcalidrawTextElement | null,
|
||||||
) => {
|
) => {
|
||||||
const { width } = container;
|
const { width } = container;
|
||||||
|
const boundTextPadding =
|
||||||
|
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const minWidth =
|
const minWidth =
|
||||||
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
||||||
@@ -477,14 +483,14 @@ export const getBoundTextMaxWidth = (
|
|||||||
// The width of the largest rectangle inscribed inside an ellipse is
|
// The width of the largest rectangle inscribed inside an ellipse is
|
||||||
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
||||||
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
|
// 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") {
|
if (container.type === "diamond") {
|
||||||
// The width of the largest rectangle inscribed inside a rhombus is
|
// The width of the largest rectangle inscribed inside a rhombus is
|
||||||
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
// 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 = (
|
export const getBoundTextMaxHeight = (
|
||||||
@@ -492,8 +498,10 @@ export const getBoundTextMaxHeight = (
|
|||||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||||
) => {
|
) => {
|
||||||
const { height } = container;
|
const { height } = container;
|
||||||
|
const boundTextPadding =
|
||||||
|
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
const containerHeight = height - boundTextPadding * 8 * 2;
|
||||||
if (containerHeight <= 0) {
|
if (containerHeight <= 0) {
|
||||||
return boundTextElement.height;
|
return boundTextElement.height;
|
||||||
}
|
}
|
||||||
@@ -503,14 +511,14 @@ export const getBoundTextMaxHeight = (
|
|||||||
// The height of the largest rectangle inscribed inside an ellipse is
|
// The height of the largest rectangle inscribed inside an ellipse is
|
||||||
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
|
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
|
||||||
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
|
// 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") {
|
if (container.type === "diamond") {
|
||||||
// The height of the largest rectangle inscribed inside a rhombus is
|
// The height of the largest rectangle inscribed inside a rhombus is
|
||||||
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
// 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 */
|
/** retrieves text from text elements and concatenates to a single string */
|
||||||
|
|||||||
@@ -32,22 +32,24 @@ const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
|||||||
export const getApproxMinLineWidth = (
|
export const getApproxMinLineWidth = (
|
||||||
font: FontString,
|
font: FontString,
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
boundTextPadding: number = BOUND_TEXT_PADDING,
|
||||||
) => {
|
) => {
|
||||||
const maxCharWidth = getMaxCharWidth(font);
|
const maxCharWidth = getMaxCharWidth(font);
|
||||||
if (maxCharWidth === 0) {
|
if (maxCharWidth === 0) {
|
||||||
return (
|
return (
|
||||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
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 = (
|
export const getMinTextElementWidth = (
|
||||||
font: FontString,
|
font: FontString,
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
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 = () => {
|
export const isMeasureTextSupported = () => {
|
||||||
@@ -99,8 +101,9 @@ export const getLineHeightInPx = (
|
|||||||
export const getApproxMinLineHeight = (
|
export const getApproxMinLineHeight = (
|
||||||
fontSize: ExcalidrawTextElement["fontSize"],
|
fontSize: ExcalidrawTextElement["fontSize"],
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
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;
|
let textMetricsProvider: TextMetricsProvider | undefined;
|
||||||
|
|||||||
@@ -272,15 +272,12 @@ export const isExcalidrawElement = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isFlowchartType = (type: string): boolean =>
|
||||||
|
["rectangle", "ellipse", "diamond"].includes(type);
|
||||||
|
|
||||||
export const isFlowchartNodeElement = (
|
export const isFlowchartNodeElement = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
): element is ExcalidrawFlowchartNodeElement => {
|
): element is ExcalidrawFlowchartNodeElement => isFlowchartType(element.type);
|
||||||
return (
|
|
||||||
element.type === "rectangle" ||
|
|
||||||
element.type === "ellipse" ||
|
|
||||||
element.type === "diamond"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hasBoundTextElement = (
|
export const hasBoundTextElement = (
|
||||||
element: ExcalidrawElement | null,
|
element: ExcalidrawElement | null,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export type StrokeRoundness = "round" | "sharp";
|
|||||||
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
|
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
|
||||||
export type StrokeStyle = "solid" | "dashed" | "dotted";
|
export type StrokeStyle = "solid" | "dashed" | "dotted";
|
||||||
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
||||||
|
export type ContainerBehavior = {
|
||||||
|
textFlow: "growing" | "fixed";
|
||||||
|
margin?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
||||||
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
||||||
@@ -79,21 +83,26 @@ type _ExcalidrawElementBase = Readonly<{
|
|||||||
link: string | null;
|
link: string | null;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
customData?: Record<string, any>;
|
customData?: Record<string, any>;
|
||||||
|
containerBehavior?: ContainerBehavior;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||||
type: "selection";
|
type: "selection";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
|
type _ExcalidrawStickyNoteContainer = _ExcalidrawElementBase & {
|
||||||
|
containerBehavior: ContainerBehavior;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExcalidrawRectangleElement = _ExcalidrawStickyNoteContainer & {
|
||||||
type: "rectangle";
|
type: "rectangle";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
|
export type ExcalidrawDiamondElement = _ExcalidrawStickyNoteContainer & {
|
||||||
type: "diamond";
|
type: "diamond";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
export type ExcalidrawEllipseElement = _ExcalidrawStickyNoteContainer & {
|
||||||
type: "ellipse";
|
type: "ellipse";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||||
@@ -63,9 +63,13 @@ describe("Test measureText", () => {
|
|||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
expect(
|
||||||
160,
|
computeContainerDimensionForBoundText(
|
||||||
);
|
150,
|
||||||
|
element.type,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
|
),
|
||||||
|
).toEqual(160);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should compute container height correctly for ellipse", () => {
|
it("should compute container height correctly for ellipse", () => {
|
||||||
@@ -73,9 +77,13 @@ describe("Test measureText", () => {
|
|||||||
type: "ellipse",
|
type: "ellipse",
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
expect(
|
||||||
226,
|
computeContainerDimensionForBoundText(
|
||||||
);
|
150,
|
||||||
|
element.type,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
|
),
|
||||||
|
).toEqual(226);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should compute container height correctly for diamond", () => {
|
it("should compute container height correctly for diamond", () => {
|
||||||
@@ -83,9 +91,13 @@ describe("Test measureText", () => {
|
|||||||
type: "diamond",
|
type: "diamond",
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
expect(
|
||||||
320,
|
computeContainerDimensionForBoundText(
|
||||||
);
|
150,
|
||||||
|
element.type,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
|
),
|
||||||
|
).toEqual(320);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ export const actionWrapTextInContainer = register({
|
|||||||
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
||||||
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
||||||
|
|
||||||
|
const boundTextPadding =
|
||||||
|
appState.currentItemContainerBehavior?.margin ?? BOUND_TEXT_PADDING;
|
||||||
|
|
||||||
for (const textElement of selectedElements) {
|
for (const textElement of selectedElements) {
|
||||||
if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
|
if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
|
||||||
const container = newElement({
|
const container = newElement({
|
||||||
@@ -261,18 +264,21 @@ export const actionWrapTextInContainer = register({
|
|||||||
: null,
|
: null,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
locked: false,
|
locked: false,
|
||||||
x: textElement.x - BOUND_TEXT_PADDING,
|
x: textElement.x - boundTextPadding,
|
||||||
y: textElement.y - BOUND_TEXT_PADDING,
|
y: textElement.y - boundTextPadding,
|
||||||
width: computeContainerDimensionForBoundText(
|
width: computeContainerDimensionForBoundText(
|
||||||
textElement.width,
|
textElement.width,
|
||||||
"rectangle",
|
"rectangle",
|
||||||
|
boundTextPadding,
|
||||||
),
|
),
|
||||||
height: computeContainerDimensionForBoundText(
|
height: computeContainerDimensionForBoundText(
|
||||||
textElement.height,
|
textElement.height,
|
||||||
"rectangle",
|
"rectangle",
|
||||||
|
boundTextPadding,
|
||||||
),
|
),
|
||||||
groupIds: textElement.groupIds,
|
groupIds: textElement.groupIds,
|
||||||
frameId: textElement.frameId,
|
frameId: textElement.frameId,
|
||||||
|
containerBehavior: appState.currentItemContainerBehavior,
|
||||||
});
|
});
|
||||||
|
|
||||||
// update bindings
|
// update bindings
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ import {
|
|||||||
getLineHeight,
|
getLineHeight,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
reduceToCommonValue,
|
reduceToCommonValue,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
invariant,
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
|
import {
|
||||||
|
canBecomePolygon,
|
||||||
|
getNonDeletedElements,
|
||||||
|
hasContainerBehavior,
|
||||||
|
isFlowchartNodeElement,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bindBindingElement,
|
bindBindingElement,
|
||||||
@@ -65,6 +71,7 @@ import type { LocalPoint, Radians } from "@excalidraw/math";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
|
ContainerBehavior,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -126,6 +133,11 @@ import {
|
|||||||
ArrowheadCrowfootIcon,
|
ArrowheadCrowfootIcon,
|
||||||
ArrowheadCrowfootOneIcon,
|
ArrowheadCrowfootOneIcon,
|
||||||
ArrowheadCrowfootOneOrManyIcon,
|
ArrowheadCrowfootOneOrManyIcon,
|
||||||
|
stickyNoteIcon,
|
||||||
|
growingContainerIcon,
|
||||||
|
marginLargeIcon,
|
||||||
|
marginMediumIcon,
|
||||||
|
marginSmallIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
|
|
||||||
import { Fonts } from "../fonts";
|
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) => {
|
const getArrowheadOptions = (flip: boolean) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export {
|
|||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
actionChangeVerticalAlign,
|
actionChangeVerticalAlign,
|
||||||
actionChangeArrowProperties,
|
actionChangeArrowProperties,
|
||||||
|
actionChangeContainerBehavior,
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export type ActionName =
|
|||||||
| "changeStrokeShape"
|
| "changeStrokeShape"
|
||||||
| "changeSloppiness"
|
| "changeSloppiness"
|
||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
|
| "changeContainerBehavior"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
| "changeArrowType"
|
| "changeArrowType"
|
||||||
| "changeArrowProperties"
|
| "changeArrowProperties"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||||
|
currentItemContainerBehavior: DEFAULT_ELEMENT_PROPS.containerBehavior,
|
||||||
currentHoveredFontFamily: null,
|
currentHoveredFontFamily: null,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
@@ -173,6 +174,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||||
currentItemTextAlign: { 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 },
|
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
||||||
cursorButton: { browser: true, export: false, server: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
activeEmbeddable: { browser: false, export: false, server: false },
|
activeEmbeddable: { browser: false, export: false, server: false },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
randomId,
|
randomId,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -334,6 +335,10 @@ const chartBaseElements = (
|
|||||||
strokeColor: COLOR_PALETTE.black,
|
strokeColor: COLOR_PALETTE.black,
|
||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
opacity: 6,
|
opacity: 6,
|
||||||
|
containerBehavior: {
|
||||||
|
textFlow: "growing",
|
||||||
|
margin: BOUND_TEXT_PADDING,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -366,6 +371,10 @@ const chartTypeBar = (
|
|||||||
y: y - barHeight - BAR_GAP,
|
y: y - barHeight - BAR_GAP,
|
||||||
width: BAR_WIDTH,
|
width: BAR_WIDTH,
|
||||||
height: barHeight,
|
height: barHeight,
|
||||||
|
containerBehavior: {
|
||||||
|
textFlow: "growing",
|
||||||
|
margin: BOUND_TEXT_PADDING,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -432,6 +441,10 @@ const chartTypeLine = (
|
|||||||
y: y + cy - BAR_GAP * 2,
|
y: y + cy - BAR_GAP * 2,
|
||||||
width: BAR_GAP,
|
width: BAR_GAP,
|
||||||
height: BAR_GAP,
|
height: BAR_GAP,
|
||||||
|
containerBehavior: {
|
||||||
|
textFlow: "growing",
|
||||||
|
margin: BOUND_TEXT_PADDING,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getContainerElement,
|
||||||
|
hasContainerBehavior,
|
||||||
|
isFlowchartNodeElement,
|
||||||
shouldAllowVerticalAlign,
|
shouldAllowVerticalAlign,
|
||||||
suppportsHorizontalAlign,
|
suppportsHorizontalAlign,
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
@@ -155,6 +158,15 @@ export const SelectedShapeActions = ({
|
|||||||
) {
|
) {
|
||||||
isSingleElementBoundContainer = true;
|
isSingleElementBoundContainer = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textContainer =
|
||||||
|
targetElements.length === 1 && isTextElement(targetElements[0])
|
||||||
|
? getContainerElement(targetElements[0], elementsMap)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isStickyNoteContainer =
|
||||||
|
textContainer && isFlowchartNodeElement(textContainer);
|
||||||
|
|
||||||
const isEditingTextOrNewElement = Boolean(
|
const isEditingTextOrNewElement = Boolean(
|
||||||
appState.editingTextElement || appState.newElement,
|
appState.editingTextElement || appState.newElement,
|
||||||
);
|
);
|
||||||
@@ -218,6 +230,11 @@ export const SelectedShapeActions = ({
|
|||||||
<>{renderAction("changeRoundness")}</>
|
<>{renderAction("changeRoundness")}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(hasContainerBehavior(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => isFlowchartNodeElement(element))) && (
|
||||||
|
<>{renderAction("changeContainerBehavior")}</>
|
||||||
|
)}
|
||||||
|
|
||||||
{(toolIsArrow(appState.activeTool.type) ||
|
{(toolIsArrow(appState.activeTool.type) ||
|
||||||
targetElements.some((element) => toolIsArrow(element.type))) && (
|
targetElements.some((element) => toolIsArrow(element.type))) && (
|
||||||
<>{renderAction("changeArrowType")}</>
|
<>{renderAction("changeArrowType")}</>
|
||||||
@@ -241,6 +258,8 @@ export const SelectedShapeActions = ({
|
|||||||
<>{renderAction("changeArrowhead")}</>
|
<>{renderAction("changeArrowhead")}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isStickyNoteContainer && <>{renderAction("changeContainerBehavior")}</>}
|
||||||
|
|
||||||
{renderAction("changeOpacity")}
|
{renderAction("changeOpacity")}
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -408,6 +427,10 @@ const CombinedShapeProperties = ({
|
|||||||
canChangeRoundness(element.type),
|
canChangeRoundness(element.type),
|
||||||
)) &&
|
)) &&
|
||||||
renderAction("changeRoundness")}
|
renderAction("changeRoundness")}
|
||||||
|
{(hasContainerBehavior(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) =>
|
||||||
|
isFlowchartNodeElement(element),
|
||||||
|
)) && <>{renderAction("changeContainerBehavior")}</>}
|
||||||
{renderAction("changeOpacity")}
|
{renderAction("changeOpacity")}
|
||||||
</div>
|
</div>
|
||||||
</PropertiesPopover>
|
</PropertiesPopover>
|
||||||
@@ -532,6 +555,14 @@ const CombinedTextProperties = ({
|
|||||||
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
|
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
|
||||||
const isOpen = appState.openPopup === "compactTextProperties";
|
const isOpen = appState.openPopup === "compactTextProperties";
|
||||||
|
|
||||||
|
const textContainer =
|
||||||
|
targetElements.length === 1 && isTextElement(targetElements[0])
|
||||||
|
? getContainerElement(targetElements[0], elementsMap)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isStickyNoteContainer =
|
||||||
|
textContainer && isFlowchartNodeElement(textContainer);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="compact-action-item">
|
<div className="compact-action-item">
|
||||||
<Popover.Root
|
<Popover.Root
|
||||||
@@ -597,6 +628,9 @@ const CombinedTextProperties = ({
|
|||||||
renderAction("changeTextAlign")}
|
renderAction("changeTextAlign")}
|
||||||
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
|
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
|
||||||
renderAction("changeVerticalAlign")}
|
renderAction("changeVerticalAlign")}
|
||||||
|
{isStickyNoteContainer && (
|
||||||
|
<>{renderAction("changeContainerBehavior")}</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PropertiesPopover>
|
</PropertiesPopover>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ import {
|
|||||||
MINIMUM_ARROW_SIZE,
|
MINIMUM_ARROW_SIZE,
|
||||||
DOUBLE_TAP_POSITION_THRESHOLD,
|
DOUBLE_TAP_POSITION_THRESHOLD,
|
||||||
BIND_MODE_TIMEOUT,
|
BIND_MODE_TIMEOUT,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
invariant,
|
invariant,
|
||||||
getFeatureFlag,
|
getFeatureFlag,
|
||||||
createUserAgentDescriptor,
|
createUserAgentDescriptor,
|
||||||
@@ -246,6 +247,7 @@ import {
|
|||||||
mutateElement,
|
mutateElement,
|
||||||
getElementBounds,
|
getElementBounds,
|
||||||
doBoundsIntersect,
|
doBoundsIntersect,
|
||||||
|
isFlowchartType,
|
||||||
isPointInElement,
|
isPointInElement,
|
||||||
maxBindingDistance_simple,
|
maxBindingDistance_simple,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
@@ -5776,8 +5778,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
const minWidth = getApproxMinLineWidth(
|
const minWidth = getApproxMinLineWidth(
|
||||||
getFontString(fontString),
|
getFontString(fontString),
|
||||||
lineHeight,
|
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 newHeight = Math.max(container.height, minHeight);
|
||||||
const newWidth = Math.max(container.width, minWidth);
|
const newWidth = Math.max(container.width, minWidth);
|
||||||
this.scene.mutateElement(container, {
|
this.scene.mutateElement(container, {
|
||||||
@@ -8721,6 +8728,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
roundness: this.getCurrentItemRoundness(elementType),
|
roundness: this.getCurrentItemRoundness(elementType),
|
||||||
locked: false,
|
locked: false,
|
||||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||||
|
...(isFlowchartType(elementType) && {
|
||||||
|
containerBehavior: this.state.currentItemContainerBehavior,
|
||||||
|
}),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
let element;
|
let element;
|
||||||
|
|||||||
@@ -2373,3 +2373,101 @@ export const presentationIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
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,
|
||||||
|
);
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import {
|
|||||||
getSizeFromPoints,
|
getSizeFromPoints,
|
||||||
normalizeLink,
|
normalizeLink,
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
calculateFixedPointForNonElbowArrowBinding,
|
calculateFixedPointForNonElbowArrowBinding,
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
|
isFlowchartType,
|
||||||
isPointInElement,
|
isPointInElement,
|
||||||
isValidPolygon,
|
isValidPolygon,
|
||||||
projectFixedPointOntoDiagonal,
|
projectFixedPointOntoDiagonal,
|
||||||
@@ -60,6 +62,7 @@ import type {
|
|||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
ExcalidrawElementType,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@@ -196,14 +199,27 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreElementWithProperties = <
|
type _ElementForRestoreBase = Required<
|
||||||
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
Omit<ExcalidrawElement, "customData" | "containerBehavior">
|
||||||
|
> &
|
||||||
|
Pick<ExcalidrawElement, "containerBehavior"> & {
|
||||||
customData?: ExcalidrawElement["customData"];
|
customData?: ExcalidrawElement["customData"];
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
strokeSharpness?: StrokeRoundness;
|
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>>,
|
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||||
>(
|
>(
|
||||||
element: T,
|
element: T,
|
||||||
@@ -215,11 +231,13 @@ const restoreElementWithProperties = <
|
|||||||
> &
|
> &
|
||||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
|
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
|
||||||
): T => {
|
): T => {
|
||||||
|
const nextType = (extra.type || element.type) as ExcalidrawElementType;
|
||||||
|
|
||||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
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
|
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||||
// newly added elements
|
// newly added elements
|
||||||
version: element.version || 1,
|
|
||||||
versionNonce: element.versionNonce ?? 0,
|
versionNonce: element.versionNonce ?? 0,
|
||||||
index: element.index ?? null,
|
index: element.index ?? null,
|
||||||
isDeleted: element.isDeleted ?? false,
|
isDeleted: element.isDeleted ?? false,
|
||||||
@@ -247,7 +265,7 @@ const restoreElementWithProperties = <
|
|||||||
? {
|
? {
|
||||||
// for old elements that would now use adaptive radius algo,
|
// for old elements that would now use adaptive radius algo,
|
||||||
// use legacy algo instead
|
// use legacy algo instead
|
||||||
type: isUsingAdaptiveRadius(element.type)
|
type: isUsingAdaptiveRadius(nextType)
|
||||||
? ROUNDNESS.LEGACY
|
? ROUNDNESS.LEGACY
|
||||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
}
|
}
|
||||||
@@ -258,10 +276,18 @@ const restoreElementWithProperties = <
|
|||||||
updated: element.updated ?? getUpdatedTimestamp(),
|
updated: element.updated ?? getUpdatedTimestamp(),
|
||||||
link: element.link ? normalizeLink(element.link) : null,
|
link: element.link ? normalizeLink(element.link) : null,
|
||||||
locked: element.locked ?? false,
|
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) {
|
if ("customData" in element || "customData" in extra) {
|
||||||
base.customData =
|
(base as any).customData =
|
||||||
"customData" in extra ? extra.customData : element.customData;
|
"customData" in extra ? extra.customData : element.customData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +302,8 @@ const restoreElementWithProperties = <
|
|||||||
} as unknown as T;
|
} as unknown as T;
|
||||||
|
|
||||||
// strip legacy props (migrated in previous steps)
|
// strip legacy props (migrated in previous steps)
|
||||||
delete ret.strokeSharpness;
|
delete (ret as any).strokeSharpness;
|
||||||
delete ret.boundElementIds;
|
delete (ret as any).boundElementIds;
|
||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
"strokeStyle_dashed": "Dashed",
|
"strokeStyle_dashed": "Dashed",
|
||||||
"strokeStyle_dotted": "Dotted",
|
"strokeStyle_dotted": "Dotted",
|
||||||
"sloppiness": "Sloppiness",
|
"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",
|
"opacity": "Opacity",
|
||||||
"textAlign": "Text align",
|
"textAlign": "Text align",
|
||||||
"edges": "Edges",
|
"edges": "Edges",
|
||||||
|
|||||||
+9
-5
@@ -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";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
@@ -34,25 +34,29 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
|
|||||||
|
|
||||||
export const rectangleFixture: ExcalidrawElement = {
|
export const rectangleFixture: ExcalidrawElement = {
|
||||||
...elementBase,
|
...elementBase,
|
||||||
|
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
};
|
} as unknown as ExcalidrawElement;
|
||||||
export const embeddableFixture: ExcalidrawElement = {
|
export const embeddableFixture: ExcalidrawElement = {
|
||||||
...elementBase,
|
...elementBase,
|
||||||
type: "embeddable",
|
type: "embeddable",
|
||||||
};
|
};
|
||||||
export const ellipseFixture: ExcalidrawElement = {
|
export const ellipseFixture: ExcalidrawElement = {
|
||||||
...elementBase,
|
...elementBase,
|
||||||
|
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
|
||||||
type: "ellipse",
|
type: "ellipse",
|
||||||
};
|
} as unknown as ExcalidrawElement;
|
||||||
export const diamondFixture: ExcalidrawElement = {
|
export const diamondFixture: ExcalidrawElement = {
|
||||||
...elementBase,
|
...elementBase,
|
||||||
|
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
|
||||||
type: "diamond",
|
type: "diamond",
|
||||||
};
|
} as unknown as ExcalidrawElement;
|
||||||
export const rectangleWithLinkFixture: ExcalidrawElement = {
|
export const rectangleWithLinkFixture: ExcalidrawElement = {
|
||||||
...elementBase,
|
...elementBase,
|
||||||
|
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
link: "excalidraw.com",
|
link: "excalidraw.com",
|
||||||
};
|
} as unknown as ExcalidrawElement;
|
||||||
|
|
||||||
export const textFixture: ExcalidrawElement = {
|
export const textFixture: ExcalidrawElement = {
|
||||||
...elementBase,
|
...elementBase,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import util from "util";
|
|||||||
|
|
||||||
import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
|
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 {
|
import {
|
||||||
newArrowElement,
|
newArrowElement,
|
||||||
@@ -217,6 +217,10 @@ export class API {
|
|||||||
: never;
|
: never;
|
||||||
elbowed?: boolean;
|
elbowed?: boolean;
|
||||||
fixedSegments?: FixedSegment[] | null;
|
fixedSegments?: FixedSegment[] | null;
|
||||||
|
containerBehavior?: T extends "rectangle" | "diamond" | "ellipse"
|
||||||
|
? ExcalidrawGenericElement["containerBehavior"]
|
||||||
|
: never;
|
||||||
|
} = {
|
||||||
}): T extends "arrow" | "line"
|
}): T extends "arrow" | "line"
|
||||||
? ExcalidrawLinearElement
|
? ExcalidrawLinearElement
|
||||||
: T extends "freedraw"
|
: T extends "freedraw"
|
||||||
@@ -282,6 +286,10 @@ export class API {
|
|||||||
element = newElement({
|
element = newElement({
|
||||||
type: type as "rectangle" | "diamond" | "ellipse",
|
type: type as "rectangle" | "diamond" | "ellipse",
|
||||||
...base,
|
...base,
|
||||||
|
containerBehavior: {
|
||||||
|
textFlow: rest.containerBehavior?.textFlow ?? "growing",
|
||||||
|
margin: rest.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "embeddable":
|
case "embeddable":
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ export interface AppState {
|
|||||||
currentHoveredFontFamily: FontFamilyValues | null;
|
currentHoveredFontFamily: FontFamilyValues | null;
|
||||||
currentItemRoundness: StrokeRoundness;
|
currentItemRoundness: StrokeRoundness;
|
||||||
currentItemArrowType: "sharp" | "round" | "elbow";
|
currentItemArrowType: "sharp" | "round" | "elbow";
|
||||||
|
currentItemContainerBehavior: ExcalidrawElement["containerBehavior"];
|
||||||
viewBackgroundColor: string;
|
viewBackgroundColor: string;
|
||||||
scrollX: number;
|
scrollX: number;
|
||||||
scrollY: number;
|
scrollY: number;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getFontFamilyString,
|
getFontFamilyString,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
import { computeStickyNoteFontSize } from "@excalidraw/element";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -130,6 +132,8 @@ export const textWysiwyg = ({
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let stickyNoteInitialFontSize: number | null = null;
|
||||||
|
|
||||||
const updateWysiwygStyle = () => {
|
const updateWysiwygStyle = () => {
|
||||||
const appState = app.state;
|
const appState = app.state;
|
||||||
const updatedTextElement = app.scene.getElement<ExcalidrawTextElement>(id);
|
const updatedTextElement = app.scene.getElement<ExcalidrawTextElement>(id);
|
||||||
@@ -157,65 +161,42 @@ export const textWysiwyg = ({
|
|||||||
let maxHeight = updatedTextElement.height;
|
let maxHeight = updatedTextElement.height;
|
||||||
|
|
||||||
if (container && updatedTextElement.containerId) {
|
if (container && updatedTextElement.containerId) {
|
||||||
if (isArrowElement(container)) {
|
if ((container as any).containerBehavior?.textFlow === "fixed") {
|
||||||
const boundTextCoords =
|
if (stickyNoteInitialFontSize == null) {
|
||||||
LinearElementEditor.getBoundTextElementPosition(
|
stickyNoteInitialFontSize = updatedTextElement.fontSize;
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
|
||||||
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
|
maxHeight = getBoundTextMaxHeight(
|
||||||
maxHeight = getBoundTextMaxHeight(
|
container,
|
||||||
container,
|
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
|
||||||
);
|
|
||||||
|
|
||||||
// autogrow container height if text exceeds
|
|
||||||
if (!isArrowElement(container) && height > maxHeight) {
|
|
||||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
|
||||||
height,
|
|
||||||
container.type,
|
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
app.scene.mutateElement(container, { height: targetContainerHeight });
|
fontSize,
|
||||||
return;
|
width: fittedW,
|
||||||
} else if (
|
height: fittedH,
|
||||||
// autoshrink container height until original container height
|
wrappedText,
|
||||||
// is reached when text is removed
|
} = computeStickyNoteFontSize(
|
||||||
!isArrowElement(container) &&
|
editable.value,
|
||||||
container.height > originalContainerData.height &&
|
updatedTextElement,
|
||||||
height < maxHeight
|
container,
|
||||||
) {
|
stickyNoteInitialFontSize,
|
||||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
|
||||||
height,
|
|
||||||
container.type,
|
|
||||||
);
|
);
|
||||||
app.scene.mutateElement(container, { height: targetContainerHeight });
|
const needsUpdate =
|
||||||
} else {
|
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(
|
const { x, y } = computeBoundTextPosition(
|
||||||
container,
|
container,
|
||||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||||
@@ -223,6 +204,87 @@ export const textWysiwyg = ({
|
|||||||
);
|
);
|
||||||
coordX = x;
|
coordX = x;
|
||||||
coordY = y;
|
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);
|
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||||
|
|||||||
Reference in New Issue
Block a user