initial implementation

This commit is contained in:
zsviczian
2025-09-21 19:09:57 +00:00
parent f55ecb96cc
commit 14ab68af54
22 changed files with 438 additions and 97 deletions
+2
View File
@@ -448,6 +448,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,
@@ -457,6 +458,7 @@ export const DEFAULT_ELEMENT_PROPS: {
roughness: ROUGHNESS.artist, roughness: ROUGHNESS.artist,
opacity: 100, opacity: 100,
locked: false, locked: false,
containerBehavior: "growing",
}; };
export const LIBRARY_SIDEBAR_TAB = "library"; export const LIBRARY_SIDEBAR_TAB = "library";
+3
View File
@@ -39,6 +39,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";
+9 -5
View File
@@ -2056,11 +2056,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
} }
private static stripIrrelevantProps( private static stripIrrelevantProps(partial: ElementPartial): ElementPartial {
partial: Partial<OrderedExcalidrawElement>, // ElementPartial already excludes id, updated, seed; defensively strip if present
): ElementPartial { const {
const { id, updated, ...strippedPartial } = partial; 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, 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(
+119 -4
View File
@@ -21,11 +21,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 +48,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 +160,23 @@ 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: opts.containerBehavior ?? "growing",
} as NonDeleted<ExcalidrawFlowchartNodeElement>;
}
return _newElementBase<ExcalidrawGenericElement>(
opts.type,
opts,
) as NonDeleted<ExcalidrawGenericElement>;
};
export const newEmbeddableElement = ( export const newEmbeddableElement = (
opts: { opts: {
@@ -417,6 +433,103 @@ 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,
/**
* Upper cap to which the font size is allowed to grow back during this
* editing session (snapped). If not provided defaults to global max.
*/
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.min(
STICKY_NOTE_MAX_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,
@@ -435,6 +548,8 @@ export const refreshTextDimensions = (
: textElement.width, : textElement.width,
); );
} }
// NOTE: sticky note font auto-sizing is handled during editing (WYSIWYG)
// so we keep refresh logic unchanged to avoid side-effects elsewhere.
const dimensions = getAdjustedDimensions(textElement, elementsMap, text); const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
return { text, ...dimensions }; return { text, ...dimensions };
}; };
+4 -7
View File
@@ -274,15 +274,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,
+9 -3
View File
@@ -27,6 +27,7 @@ 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 = "growing" | "stickyNote";
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 +80,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: "stickyNote";
};
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";
}; };
@@ -270,6 +270,7 @@ export const actionWrapTextInContainer = register({
), ),
groupIds: textElement.groupIds, groupIds: textElement.groupIds,
frameId: textElement.frameId, frameId: textElement.frameId,
containerBehavior: appState.currentItemContainerBehavior,
}); });
// update bindings // update bindings
@@ -23,7 +23,11 @@ import {
reduceToCommonValue, reduceToCommonValue,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; import {
canBecomePolygon,
getNonDeletedElements,
isFlowchartNodeElement,
} from "@excalidraw/element";
import { import {
bindLinearElement, bindLinearElement,
@@ -126,6 +130,8 @@ import {
ArrowheadCrowfootIcon, ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon, ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon, ArrowheadCrowfootOneOrManyIcon,
stickyNoteIcon,
growingContainerIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -1505,6 +1511,65 @@ export const actionChangeRoundness = register({
}, },
}); });
export const actionChangeContainerBehavior = register({
name: "changeContainerBehavior",
label: "labels.container",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
containerBehavior: value,
}),
),
appState: { ...appState, currentItemContainerBehavior: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.container")}</legend>
)}
<div className="buttonList">
<RadioSelection
group="container"
options={[
{
value: "growing",
text: t("labels.container_growing"),
icon: growingContainerIcon,
},
{
value: "stickyNote",
text: t("labels.container_sticky"),
icon: stickyNoteIcon,
},
]}
value={getFormValue(
elements,
app,
(element) => element.containerBehavior ?? "growing",
(element) =>
isFlowchartNodeElement(element) &&
Boolean(
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
),
),
(hasSelection) =>
hasSelection
? null
: appState.currentItemContainerBehavior ?? "growing",
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
});
const getArrowheadOptions = (flip: boolean) => { const getArrowheadOptions = (flip: boolean) => {
return [ return [
{ {
+1
View File
@@ -19,6 +19,7 @@ export {
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign, actionChangeVerticalAlign,
actionChangeArrowProperties, actionChangeArrowProperties,
actionChangeContainerBehavior,
} from "./actionProperties"; } from "./actionProperties";
export { export {
+1
View File
@@ -67,6 +67,7 @@ export type ActionName =
| "changeStrokeShape" | "changeStrokeShape"
| "changeSloppiness" | "changeSloppiness"
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeContainerBehavior"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType" | "changeArrowType"
| "changeArrowProperties" | "changeArrowProperties"
+2
View File
@@ -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,
@@ -169,6 +170,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 },
+3
View File
@@ -334,6 +334,7 @@ const chartBaseElements = (
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
fillStyle: "solid", fillStyle: "solid",
opacity: 6, opacity: 6,
containerBehavior: "growing",
}) })
: null; : null;
@@ -366,6 +367,7 @@ const chartTypeBar = (
y: y - barHeight - BAR_GAP, y: y - barHeight - BAR_GAP,
width: BAR_WIDTH, width: BAR_WIDTH,
height: barHeight, height: barHeight,
containerBehavior: "growing",
}); });
}); });
@@ -432,6 +434,7 @@ 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: "growing",
}); });
}); });
@@ -10,6 +10,8 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
hasContainerBehavior,
isFlowchartNodeElement,
shouldAllowVerticalAlign, shouldAllowVerticalAlign,
suppportsHorizontalAlign, suppportsHorizontalAlign,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -210,6 +212,12 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</> <>{renderAction("changeRoundness")}</>
)} )}
{hasContainerBehavior(appState.activeTool.type) ||
(targetElements.some(
(element) =>
isFlowchartNodeElement(element) && hasBoundTextElement(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")}</>
@@ -443,6 +451,12 @@ export const CompactShapeActions = ({
canChangeRoundness(element.type), canChangeRoundness(element.type),
)) && )) &&
renderAction("changeRoundness")} renderAction("changeRoundness")}
{hasContainerBehavior(appState.activeTool.type) ||
(targetElements.some(
(element) =>
isFlowchartNodeElement(element) &&
hasBoundTextElement(element),
) && <>{renderAction("changeContainerBehavior")}</>)}
{renderAction("changeOpacity")} {renderAction("changeOpacity")}
</div> </div>
</PropertiesPopover> </PropertiesPopover>
+4
View File
@@ -238,6 +238,7 @@ import {
StoreDelta, StoreDelta,
type ApplyToOptions, type ApplyToOptions,
positionElementsOnGrid, positionElementsOnGrid,
isFlowchartType,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -8118,6 +8119,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;
+35
View File
@@ -2338,3 +2338,38 @@ export const strokeIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const stickyNoteIcon = createIcon(
<g
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
>
<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 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,
);
+36 -17
View File
@@ -18,7 +18,11 @@ import {
normalizeLink, normalizeLink,
getLineHeight, getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element"; import {
getNonDeletedElements,
isFlowchartType,
isValidPolygon,
} from "@excalidraw/element";
import { normalizeFixedPoint } from "@excalidraw/element"; import { normalizeFixedPoint } from "@excalidraw/element";
import { import {
updateElbowArrowPoints, updateElbowArrowPoints,
@@ -155,29 +159,41 @@ const repairBinding = <T extends ExcalidrawLinearElement>(
: PointBinding | FixedPointBinding | null; : PointBinding | FixedPointBinding | 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,
extra: Pick< extra: Pick<
T, T,
// This extra Pick<T, keyof K> ensure no excess properties are passed. // @ts-ignore
// @ts-ignore TS complains here but type checks the call sites fine.
keyof K keyof K
> & > &
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,
// all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements
version: element.version || 1, version: element.version || 1,
versionNonce: element.versionNonce ?? 0, versionNonce: element.versionNonce ?? 0,
index: element.index ?? null, index: element.index ?? null,
@@ -206,7 +222,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,
} }
@@ -217,26 +233,29 @@ 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:
element.containerBehavior ??
DEFAULT_ELEMENT_PROPS.containerBehavior,
}
: {}),
}; };
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;
} }
const ret = { const ret = {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
...element, ...element,
// normalized properties
...base, ...base,
...getNormalizedDimensions(base), ...getNormalizedDimensions(base),
...extra, ...extra,
} as unknown as T; } as unknown as T;
// strip legacy props (migrated in previous steps) delete (ret as any).strokeSharpness;
delete ret.strokeSharpness; delete (ret as any).boundElementIds;
delete ret.boundElementIds;
return ret; return ret;
}; };
+3
View File
@@ -31,6 +31,9 @@
"strokeStyle_dashed": "Dashed", "strokeStyle_dashed": "Dashed",
"strokeStyle_dotted": "Dotted", "strokeStyle_dotted": "Dotted",
"sloppiness": "Sloppiness", "sloppiness": "Sloppiness",
"container": "Container",
"container_sticky": "Sticky note",
"container_growing": "Fit to text",
"opacity": "Opacity", "opacity": "Opacity",
"textAlign": "Text align", "textAlign": "Text align",
"edges": "Edges", "edges": "Edges",
+8 -4
View File
@@ -34,25 +34,29 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
export const rectangleFixture: ExcalidrawElement = { export const rectangleFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
containerBehavior: "growing",
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: "growing",
type: "ellipse", type: "ellipse",
}; } as unknown as ExcalidrawElement;
export const diamondFixture: ExcalidrawElement = { export const diamondFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
containerBehavior: "growing",
type: "diamond", type: "diamond",
}; } as unknown as ExcalidrawElement;
export const rectangleWithLinkFixture: ExcalidrawElement = { export const rectangleWithLinkFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
containerBehavior: "growing",
type: "rectangle", type: "rectangle",
link: "excalidraw.com", link: "excalidraw.com",
}; } as unknown as ExcalidrawElement;
export const textFixture: ExcalidrawElement = { export const textFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
+5
View File
@@ -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,7 @@ export class API {
element = newElement({ element = newElement({
type: type as "rectangle" | "diamond" | "ellipse", type: type as "rectangle" | "diamond" | "ellipse",
...base, ...base,
containerBehavior: rest.containerBehavior ?? "growing",
}); });
break; break;
case "embeddable": case "embeddable":
+1
View File
@@ -337,6 +337,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;
+110 -56
View File
@@ -36,6 +36,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 +131,9 @@ export const textWysiwyg = ({
return false; return false;
}; };
// Sticky note: remember initial font size for this edit session
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 === "stickyNote") {
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,79 @@ 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 !== "stickyNote") {
// autogrow container height if text exceeds
if (!isArrowElement(container) && height > maxHeight) {
const targetContainerHeight =
computeContainerDimensionForBoundText(height, container.type);
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);
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);