From 14ab68af54a75db35261ce9ce703eeece6f24d95 Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sun, 21 Sep 2025 19:09:57 +0000 Subject: [PATCH] initial implementation --- packages/common/src/constants.ts | 2 + packages/element/src/comparisons.ts | 3 + packages/element/src/delta.ts | 14 +- packages/element/src/flowchart.ts | 2 + packages/element/src/newElement.ts | 123 ++++++++++++- packages/element/src/typeChecks.ts | 11 +- packages/element/src/types.ts | 12 +- .../excalidraw/actions/actionBoundText.tsx | 1 + .../excalidraw/actions/actionProperties.tsx | 67 ++++++- packages/excalidraw/actions/index.ts | 1 + packages/excalidraw/actions/types.ts | 1 + packages/excalidraw/appState.ts | 2 + packages/excalidraw/charts.ts | 3 + packages/excalidraw/components/Actions.tsx | 14 ++ packages/excalidraw/components/App.tsx | 4 + packages/excalidraw/components/icons.tsx | 35 ++++ packages/excalidraw/data/restore.ts | 53 ++++-- packages/excalidraw/locales/en.json | 3 + .../tests/fixtures/elementFixture.ts | 12 +- packages/excalidraw/tests/helpers/api.ts | 5 + packages/excalidraw/types.ts | 1 + packages/excalidraw/wysiwyg/textWysiwyg.tsx | 166 ++++++++++++------ 22 files changed, 438 insertions(+), 97 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index b41fb1a37d..c8f4f7c4da 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -448,6 +448,7 @@ export const DEFAULT_ELEMENT_PROPS: { roughness: ExcalidrawElement["roughness"]; opacity: ExcalidrawElement["opacity"]; locked: ExcalidrawElement["locked"]; + containerBehavior: ExcalidrawElement["containerBehavior"]; } = { strokeColor: COLOR_PALETTE.black, backgroundColor: COLOR_PALETTE.transparent, @@ -457,6 +458,7 @@ export const DEFAULT_ELEMENT_PROPS: { roughness: ROUGHNESS.artist, opacity: 100, locked: false, + containerBehavior: "growing", }; export const LIBRARY_SIDEBAR_TAB = "library"; diff --git a/packages/element/src/comparisons.ts b/packages/element/src/comparisons.ts index 75fac889dc..88b21081e4 100644 --- a/packages/element/src/comparisons.ts +++ b/packages/element/src/comparisons.ts @@ -39,6 +39,9 @@ export const canChangeRoundness = (type: ElementOrToolType) => type === "diamond" || type === "image"; +export const hasContainerBehavior = (type: ElementOrToolType) => + type === "rectangle" || type === "diamond" || type === "ellipse"; + export const toolIsArrow = (type: ElementOrToolType) => type === "arrow"; export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow"; diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index 97b9403bcc..668f44bbf4 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -2056,11 +2056,15 @@ export class ElementsDelta implements DeltaContainer { } } - private static stripIrrelevantProps( - partial: Partial, - ): ElementPartial { - const { id, updated, ...strippedPartial } = partial; + private static stripIrrelevantProps(partial: ElementPartial): ElementPartial { + // ElementPartial already excludes id, updated, seed; defensively strip if present + const { + id: _id, + updated: _updated, + seed: _seed, + ...strippedPartial + } = partial as any; - return strippedPartial; + return strippedPartial as ElementPartial; } } diff --git a/packages/element/src/flowchart.ts b/packages/element/src/flowchart.ts index 6cffb56a83..6b4e4e2c0e 100644 --- a/packages/element/src/flowchart.ts +++ b/packages/element/src/flowchart.ts @@ -268,6 +268,7 @@ const addNewNode = ( opacity: element.opacity, fillStyle: element.fillStyle, strokeStyle: element.strokeStyle, + containerBehavior: element.containerBehavior, }); invariant( @@ -346,6 +347,7 @@ export const addNewNodes = ( opacity: startNode.opacity, fillStyle: startNode.fillStyle, strokeStyle: startNode.strokeStyle, + containerBehavior: startNode.containerBehavior, }); invariant( diff --git a/packages/element/src/newElement.ts b/packages/element/src/newElement.ts index 69ccaf595f..7504f015da 100644 --- a/packages/element/src/newElement.ts +++ b/packages/element/src/newElement.ts @@ -21,11 +21,11 @@ import { getResizedElementAbsoluteCoords, } from "./bounds"; import { newElementWith } from "./mutateElement"; -import { getBoundTextMaxWidth } from "./textElement"; +import { getBoundTextMaxWidth, getBoundTextMaxHeight } from "./textElement"; import { normalizeText, measureText } from "./textMeasurements"; import { wrapText } from "./textWrapping"; -import { isLineElement } from "./typeChecks"; +import { isFlowchartType, isLineElement } from "./typeChecks"; import type { ExcalidrawElement, @@ -48,6 +48,8 @@ import type { ExcalidrawArrowElement, ExcalidrawElbowArrowElement, ExcalidrawLineElement, + ExcalidrawFlowchartNodeElement, + ContainerBehavior, } from "./types"; export type ElementConstructorOpts = MarkOptional< @@ -158,9 +160,23 @@ const _newElementBase = ( export const newElement = ( opts: { type: ExcalidrawGenericElement["type"]; + containerBehavior?: ContainerBehavior; } & ElementConstructorOpts, -): NonDeleted => - _newElementBase(opts.type, opts); +): NonDeleted => { + if (isFlowchartType(opts.type)) { + return { + ..._newElementBase( + opts.type as ExcalidrawFlowchartNodeElement["type"], + opts, + ), + containerBehavior: opts.containerBehavior ?? "growing", + } as NonDeleted; + } + return _newElementBase( + opts.type, + opts, + ) as NonDeleted; +}; export const newEmbeddableElement = ( opts: { @@ -417,6 +433,103 @@ const adjustXYWithRotation = ( return [x, y]; }; +// Sticky note font sizing constants +export const STICKY_NOTE_FONT_STEP = 2; +export const STICKY_NOTE_MIN_FONT_SIZE = 8; +export const STICKY_NOTE_MAX_FONT_SIZE = 72; + +export interface StickyNoteFontComputationResult { + fontSize: number; + width: number; + height: number; + wrappedText: string; +} + +/** + * Computes the appropriate font size (snapped to step) so that the text fits + * inside the sticky note container without resizing the container. + * It first tries to shrink if overflowing, otherwise opportunistically enlarges + * (still snapped) while it fits. Width is constrained by wrap at container width. + */ +export const computeStickyNoteFontSize = ( + text: string, + element: ExcalidrawTextElement, + container: ExcalidrawTextContainer, + /** + * 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 = ( textElement: ExcalidrawTextElement, container: ExcalidrawTextContainer | null, @@ -435,6 +548,8 @@ export const refreshTextDimensions = ( : 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); return { text, ...dimensions }; }; diff --git a/packages/element/src/typeChecks.ts b/packages/element/src/typeChecks.ts index ab7a1935f5..8433863f29 100644 --- a/packages/element/src/typeChecks.ts +++ b/packages/element/src/typeChecks.ts @@ -274,15 +274,12 @@ export const isExcalidrawElement = ( } }; +export const isFlowchartType = (type: string): boolean => + ["rectangle", "ellipse", "diamond"].includes(type); + export const isFlowchartNodeElement = ( element: ExcalidrawElement, -): element is ExcalidrawFlowchartNodeElement => { - return ( - element.type === "rectangle" || - element.type === "ellipse" || - element.type === "diamond" - ); -}; +): element is ExcalidrawFlowchartNodeElement => isFlowchartType(element.type); export const hasBoundTextElement = ( element: ExcalidrawElement | null, diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index c2becd3e6c..f5b5bff5c6 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -27,6 +27,7 @@ export type StrokeRoundness = "round" | "sharp"; export type RoundnessType = ValueOf; export type StrokeStyle = "solid" | "dashed" | "dotted"; export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN]; +export type ContainerBehavior = "growing" | "stickyNote"; type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN; export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys]; @@ -79,21 +80,26 @@ type _ExcalidrawElementBase = Readonly<{ link: string | null; locked: boolean; customData?: Record; + containerBehavior?: ContainerBehavior; }>; export type ExcalidrawSelectionElement = _ExcalidrawElementBase & { type: "selection"; }; -export type ExcalidrawRectangleElement = _ExcalidrawElementBase & { +type _ExcalidrawStickyNoteContainer = _ExcalidrawElementBase & { + containerBehavior: "stickyNote"; +}; + +export type ExcalidrawRectangleElement = _ExcalidrawStickyNoteContainer & { type: "rectangle"; }; -export type ExcalidrawDiamondElement = _ExcalidrawElementBase & { +export type ExcalidrawDiamondElement = _ExcalidrawStickyNoteContainer & { type: "diamond"; }; -export type ExcalidrawEllipseElement = _ExcalidrawElementBase & { +export type ExcalidrawEllipseElement = _ExcalidrawStickyNoteContainer & { type: "ellipse"; }; diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 606770dde5..80ae90b3aa 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -270,6 +270,7 @@ export const actionWrapTextInContainer = register({ ), groupIds: textElement.groupIds, frameId: textElement.frameId, + containerBehavior: appState.currentItemContainerBehavior, }); // update bindings diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index c03309e9cc..69d064001d 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -23,7 +23,11 @@ import { reduceToCommonValue, } from "@excalidraw/common"; -import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; +import { + canBecomePolygon, + getNonDeletedElements, + isFlowchartNodeElement, +} from "@excalidraw/element"; import { bindLinearElement, @@ -126,6 +130,8 @@ import { ArrowheadCrowfootIcon, ArrowheadCrowfootOneIcon, ArrowheadCrowfootOneOrManyIcon, + stickyNoteIcon, + growingContainerIcon, } from "../components/icons"; 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 }) => ( +
+ {appState.stylesPanelMode === "full" && ( + {t("labels.container")} + )} +
+ element.containerBehavior ?? "growing", + (element) => + isFlowchartNodeElement(element) && + Boolean( + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ), + ), + (hasSelection) => + hasSelection + ? null + : appState.currentItemContainerBehavior ?? "growing", + )} + onChange={(value) => updateData(value)} + /> +
+
+ ), +}); + const getArrowheadOptions = (flip: boolean) => { return [ { diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index 2719a5d0a2..2e06f1c752 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -19,6 +19,7 @@ export { actionChangeTextAlign, actionChangeVerticalAlign, actionChangeArrowProperties, + actionChangeContainerBehavior, } from "./actionProperties"; export { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 302a76fb4e..3aeb0b158e 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -67,6 +67,7 @@ export type ActionName = | "changeStrokeShape" | "changeSloppiness" | "changeStrokeStyle" + | "changeContainerBehavior" | "changeArrowhead" | "changeArrowType" | "changeArrowProperties" diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 2a37b138d8..f72d313594 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -42,6 +42,7 @@ export const getDefaultAppState = (): Omit< currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemTextAlign: DEFAULT_TEXT_ALIGN, + currentItemContainerBehavior: DEFAULT_ELEMENT_PROPS.containerBehavior, currentHoveredFontFamily: null, cursorButton: "up", activeEmbeddable: null, @@ -169,6 +170,7 @@ const APP_STATE_STORAGE_CONF = (< currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false }, + currentItemContainerBehavior: { browser: true, export: false, server: false }, currentHoveredFontFamily: { browser: false, export: false, server: false }, cursorButton: { browser: true, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false }, diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts index 97ca081553..4329c231c8 100644 --- a/packages/excalidraw/charts.ts +++ b/packages/excalidraw/charts.ts @@ -334,6 +334,7 @@ const chartBaseElements = ( strokeColor: COLOR_PALETTE.black, fillStyle: "solid", opacity: 6, + containerBehavior: "growing", }) : null; @@ -366,6 +367,7 @@ const chartTypeBar = ( y: y - barHeight - BAR_GAP, width: BAR_WIDTH, height: barHeight, + containerBehavior: "growing", }); }); @@ -432,6 +434,7 @@ const chartTypeLine = ( y: y + cy - BAR_GAP * 2, width: BAR_GAP, height: BAR_GAP, + containerBehavior: "growing", }); }); diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index f43a4925de..9d42732a11 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -10,6 +10,8 @@ import { } from "@excalidraw/common"; import { + hasContainerBehavior, + isFlowchartNodeElement, shouldAllowVerticalAlign, suppportsHorizontalAlign, } from "@excalidraw/element"; @@ -210,6 +212,12 @@ export const SelectedShapeActions = ({ <>{renderAction("changeRoundness")} )} + {hasContainerBehavior(appState.activeTool.type) || + (targetElements.some( + (element) => + isFlowchartNodeElement(element) && hasBoundTextElement(element), + ) && <>{renderAction("changeContainerBehavior")})} + {(toolIsArrow(appState.activeTool.type) || targetElements.some((element) => toolIsArrow(element.type))) && ( <>{renderAction("changeArrowType")} @@ -443,6 +451,12 @@ export const CompactShapeActions = ({ canChangeRoundness(element.type), )) && renderAction("changeRoundness")} + {hasContainerBehavior(appState.activeTool.type) || + (targetElements.some( + (element) => + isFlowchartNodeElement(element) && + hasBoundTextElement(element), + ) && <>{renderAction("changeContainerBehavior")})} {renderAction("changeOpacity")} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0f6fa840b0..c39d498249 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -238,6 +238,7 @@ import { StoreDelta, type ApplyToOptions, positionElementsOnGrid, + isFlowchartType, } from "@excalidraw/element"; import type { LocalPoint, Radians } from "@excalidraw/math"; @@ -8118,6 +8119,9 @@ class App extends React.Component { roundness: this.getCurrentItemRoundness(elementType), locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, + ...(isFlowchartType(elementType) && { + containerBehavior: this.state.currentItemContainerBehavior, + }), } as const; let element; diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 33e59380c7..557920a6ee 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -2338,3 +2338,38 @@ export const strokeIcon = createIcon( , tablerIconProps, ); + +export const stickyNoteIcon = createIcon( + + + + , + tablerIconProps, +); + +export const growingContainerIcon = createIcon( + + + + + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 34bdc8f57f..2a71dd7c26 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -18,7 +18,11 @@ import { normalizeLink, getLineHeight, } from "@excalidraw/common"; -import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element"; +import { + getNonDeletedElements, + isFlowchartType, + isValidPolygon, +} from "@excalidraw/element"; import { normalizeFixedPoint } from "@excalidraw/element"; import { updateElbowArrowPoints, @@ -155,29 +159,41 @@ const repairBinding = ( : PointBinding | FixedPointBinding | null; }; -const restoreElementWithProperties = < - T extends Required> & { +type _ElementForRestoreBase = Required< + Omit +> & + Pick & { customData?: ExcalidrawElement["customData"]; /** @deprecated */ boundElementIds?: readonly ExcalidrawElement["id"][]; /** @deprecated */ strokeSharpness?: StrokeRoundness; - }, + }; + +const restoreElementWithProperties = < + T extends _ElementForRestoreBase & + Omit< + any, + | keyof ExcalidrawElement + | "boundElementIds" + | "strokeSharpness" + | "customData" + | "containerBehavior" + >, K extends Pick, keyof ExcalidrawElement>>, >( element: T, extra: Pick< T, - // This extra Pick ensure no excess properties are passed. - // @ts-ignore TS complains here but type checks the call sites fine. + // @ts-ignore keyof K > & Partial>, ): T => { + const nextType = (extra.type || element.type) as ExcalidrawElementType; + const base: Pick = { - type: extra.type || element.type, - // all elements must have version > 0 so getSceneVersion() will pick up - // newly added elements + type: nextType, version: element.version || 1, versionNonce: element.versionNonce ?? 0, index: element.index ?? null, @@ -206,7 +222,7 @@ const restoreElementWithProperties = < ? { // for old elements that would now use adaptive radius algo, // use legacy algo instead - type: isUsingAdaptiveRadius(element.type) + type: isUsingAdaptiveRadius(nextType) ? ROUNDNESS.LEGACY : ROUNDNESS.PROPORTIONAL_RADIUS, } @@ -217,26 +233,29 @@ const restoreElementWithProperties = < updated: element.updated ?? getUpdatedTimestamp(), link: element.link ? normalizeLink(element.link) : null, locked: element.locked ?? false, + ...(isFlowchartType(nextType) + ? { + containerBehavior: + element.containerBehavior ?? + DEFAULT_ELEMENT_PROPS.containerBehavior, + } + : {}), }; if ("customData" in element || "customData" in extra) { - base.customData = + (base as any).customData = "customData" in extra ? extra.customData : element.customData; } const ret = { - // spread the original element properties to not lose unknown ones - // for forward-compatibility ...element, - // normalized properties ...base, ...getNormalizedDimensions(base), ...extra, } as unknown as T; - // strip legacy props (migrated in previous steps) - delete ret.strokeSharpness; - delete ret.boundElementIds; + delete (ret as any).strokeSharpness; + delete (ret as any).boundElementIds; return ret; }; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index b89e8ae5b8..54dbd6c8bd 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -31,6 +31,9 @@ "strokeStyle_dashed": "Dashed", "strokeStyle_dotted": "Dotted", "sloppiness": "Sloppiness", + "container": "Container", + "container_sticky": "Sticky note", + "container_growing": "Fit to text", "opacity": "Opacity", "textAlign": "Text align", "edges": "Edges", diff --git a/packages/excalidraw/tests/fixtures/elementFixture.ts b/packages/excalidraw/tests/fixtures/elementFixture.ts index 35aabd55f4..2e335b60c8 100644 --- a/packages/excalidraw/tests/fixtures/elementFixture.ts +++ b/packages/excalidraw/tests/fixtures/elementFixture.ts @@ -34,25 +34,29 @@ const elementBase: Omit = { export const rectangleFixture: ExcalidrawElement = { ...elementBase, + containerBehavior: "growing", type: "rectangle", -}; +} as unknown as ExcalidrawElement; export const embeddableFixture: ExcalidrawElement = { ...elementBase, type: "embeddable", }; export const ellipseFixture: ExcalidrawElement = { ...elementBase, + containerBehavior: "growing", type: "ellipse", -}; +} as unknown as ExcalidrawElement; export const diamondFixture: ExcalidrawElement = { ...elementBase, + containerBehavior: "growing", type: "diamond", -}; +} as unknown as ExcalidrawElement; export const rectangleWithLinkFixture: ExcalidrawElement = { ...elementBase, + containerBehavior: "growing", type: "rectangle", link: "excalidraw.com", -}; +} as unknown as ExcalidrawElement; export const textFixture: ExcalidrawElement = { ...elementBase, diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index 68b0813160..f839661f4e 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -217,6 +217,10 @@ export class API { : never; elbowed?: boolean; fixedSegments?: FixedSegment[] | null; + containerBehavior?: T extends "rectangle" | "diamond" | "ellipse" + ? ExcalidrawGenericElement["containerBehavior"] + : never; + } = { }): T extends "arrow" | "line" ? ExcalidrawLinearElement : T extends "freedraw" @@ -282,6 +286,7 @@ export class API { element = newElement({ type: type as "rectangle" | "diamond" | "ellipse", ...base, + containerBehavior: rest.containerBehavior ?? "growing", }); break; case "embeddable": diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 65f330ae24..e3165ca029 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -337,6 +337,7 @@ export interface AppState { currentHoveredFontFamily: FontFamilyValues | null; currentItemRoundness: StrokeRoundness; currentItemArrowType: "sharp" | "round" | "elbow"; + currentItemContainerBehavior: ExcalidrawElement["containerBehavior"]; viewBackgroundColor: string; scrollX: number; scrollY: number; diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index adede07f87..a7325b1877 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -36,6 +36,7 @@ import { isBoundToContainer, isTextElement, } from "@excalidraw/element"; +import { computeStickyNoteFontSize } from "@excalidraw/element"; import type { ExcalidrawElement, @@ -130,6 +131,9 @@ export const textWysiwyg = ({ return false; }; + // Sticky note: remember initial font size for this edit session + let stickyNoteInitialFontSize: number | null = null; + const updateWysiwygStyle = () => { const appState = app.state; const updatedTextElement = app.scene.getElement(id); @@ -157,65 +161,42 @@ export const textWysiwyg = ({ let maxHeight = updatedTextElement.height; if (container && updatedTextElement.containerId) { - if (isArrowElement(container)) { - const boundTextCoords = - LinearElementEditor.getBoundTextElementPosition( - container, - updatedTextElement as ExcalidrawTextElementWithContainer, - elementsMap, - ); - coordX = boundTextCoords.x; - coordY = boundTextCoords.y; - } - const propertiesUpdated = textPropertiesUpdated( - updatedTextElement, - editable, - ); - - let originalContainerData; - if (propertiesUpdated) { - originalContainerData = updateOriginalContainerCache( - container.id, - container.height, - ); - } else { - originalContainerData = originalContainerCache[container.id]; - if (!originalContainerData) { - originalContainerData = updateOriginalContainerCache( - container.id, - container.height, - ); + if ((container as any).containerBehavior === "stickyNote") { + if (stickyNoteInitialFontSize == null) { + stickyNoteInitialFontSize = updatedTextElement.fontSize; } - } - - maxWidth = getBoundTextMaxWidth(container, updatedTextElement); - maxHeight = getBoundTextMaxHeight( - container, - updatedTextElement as ExcalidrawTextElementWithContainer, - ); - - // autogrow container height if text exceeds - if (!isArrowElement(container) && height > maxHeight) { - const targetContainerHeight = computeContainerDimensionForBoundText( - height, - container.type, + const elementsMap = app.scene.getNonDeletedElementsMap(); + maxWidth = getBoundTextMaxWidth(container, updatedTextElement); + maxHeight = getBoundTextMaxHeight( + container, + updatedTextElement as ExcalidrawTextElementWithContainer, ); - - app.scene.mutateElement(container, { height: targetContainerHeight }); - return; - } else if ( - // autoshrink container height until original container height - // is reached when text is removed - !isArrowElement(container) && - container.height > originalContainerData.height && - height < maxHeight - ) { - const targetContainerHeight = computeContainerDimensionForBoundText( - height, - container.type, + const { + fontSize, + width: fittedW, + height: fittedH, + wrappedText, + } = computeStickyNoteFontSize( + editable.value, + updatedTextElement, + container, + stickyNoteInitialFontSize, ); - app.scene.mutateElement(container, { height: targetContainerHeight }); - } else { + const needsUpdate = + fontSize !== updatedTextElement.fontSize || + fittedW !== updatedTextElement.width || + fittedH !== updatedTextElement.height || + wrappedText !== updatedTextElement.text; + + if (needsUpdate) { + app.scene.mutateElement(updatedTextElement, { + fontSize, + width: fittedW, + height: fittedH, + text: wrappedText, + }); + } + const { x, y } = computeBoundTextPosition( container, updatedTextElement as ExcalidrawTextElementWithContainer, @@ -223,6 +204,79 @@ export const textWysiwyg = ({ ); coordX = x; coordY = y; + width = fittedW; + height = fittedH; + } else { + if (isArrowElement(container)) { + const boundTextCoords = + LinearElementEditor.getBoundTextElementPosition( + container, + updatedTextElement as ExcalidrawTextElementWithContainer, + elementsMap, + ); + coordX = boundTextCoords.x; + coordY = boundTextCoords.y; + } + const propertiesUpdated = textPropertiesUpdated( + updatedTextElement, + editable, + ); + + let originalContainerData; + if (propertiesUpdated) { + originalContainerData = updateOriginalContainerCache( + container.id, + container.height, + ); + } else { + originalContainerData = originalContainerCache[container.id]; + if (!originalContainerData) { + originalContainerData = updateOriginalContainerCache( + container.id, + container.height, + ); + } + } + + maxWidth = getBoundTextMaxWidth(container, updatedTextElement); + maxHeight = getBoundTextMaxHeight( + container, + updatedTextElement as ExcalidrawTextElementWithContainer, + ); + + // autogrow / autoshrink only for non-sticky behaviors + if ((container as any).containerBehavior !== "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);