mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-18 19:54:35 +01:00
initial implementation
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -2056,11 +2056,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
}
|
||||
|
||||
private static stripIrrelevantProps(
|
||||
partial: Partial<OrderedExcalidrawElement>,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = <T extends ExcalidrawElement>(
|
||||
export const newElement = (
|
||||
opts: {
|
||||
type: ExcalidrawGenericElement["type"];
|
||||
containerBehavior?: ContainerBehavior;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
): NonDeleted<ExcalidrawGenericElement> => {
|
||||
if (isFlowchartType(opts.type)) {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawFlowchartNodeElement>(
|
||||
opts.type as ExcalidrawFlowchartNodeElement["type"],
|
||||
opts,
|
||||
),
|
||||
containerBehavior: opts.containerBehavior ?? "growing",
|
||||
} as NonDeleted<ExcalidrawFlowchartNodeElement>;
|
||||
}
|
||||
return _newElementBase<ExcalidrawGenericElement>(
|
||||
opts.type,
|
||||
opts,
|
||||
) as NonDeleted<ExcalidrawGenericElement>;
|
||||
};
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ export type StrokeRoundness = "round" | "sharp";
|
||||
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
|
||||
export type StrokeStyle = "solid" | "dashed" | "dotted";
|
||||
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
||||
export type ContainerBehavior = "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<string, any>;
|
||||
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";
|
||||
};
|
||||
|
||||
|
||||
@@ -270,6 +270,7 @@ export const actionWrapTextInContainer = register({
|
||||
),
|
||||
groupIds: textElement.groupIds,
|
||||
frameId: textElement.frameId,
|
||||
containerBehavior: appState.currentItemContainerBehavior,
|
||||
});
|
||||
|
||||
// update bindings
|
||||
|
||||
@@ -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 }) => (
|
||||
<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) => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
actionChangeTextAlign,
|
||||
actionChangeVerticalAlign,
|
||||
actionChangeArrowProperties,
|
||||
actionChangeContainerBehavior,
|
||||
} from "./actionProperties";
|
||||
|
||||
export {
|
||||
|
||||
@@ -67,6 +67,7 @@ export type ActionName =
|
||||
| "changeStrokeShape"
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeContainerBehavior"
|
||||
| "changeArrowhead"
|
||||
| "changeArrowType"
|
||||
| "changeArrowProperties"
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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")}
|
||||
</div>
|
||||
</PropertiesPopover>
|
||||
|
||||
@@ -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<AppProps, AppState> {
|
||||
roundness: this.getCurrentItemRoundness(elementType),
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
...(isFlowchartType(elementType) && {
|
||||
containerBehavior: this.state.currentItemContainerBehavior,
|
||||
}),
|
||||
} as const;
|
||||
|
||||
let element;
|
||||
|
||||
@@ -2338,3 +2338,38 @@ export const strokeIcon = createIcon(
|
||||
</g>,
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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 = <T extends ExcalidrawLinearElement>(
|
||||
: PointBinding | FixedPointBinding | null;
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
||||
type _ElementForRestoreBase = Required<
|
||||
Omit<ExcalidrawElement, "customData" | "containerBehavior">
|
||||
> &
|
||||
Pick<ExcalidrawElement, "containerBehavior"> & {
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
/** @deprecated */
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
/** @deprecated */
|
||||
strokeSharpness?: StrokeRoundness;
|
||||
},
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends _ElementForRestoreBase &
|
||||
Omit<
|
||||
any,
|
||||
| keyof ExcalidrawElement
|
||||
| "boundElementIds"
|
||||
| "strokeSharpness"
|
||||
| "customData"
|
||||
| "containerBehavior"
|
||||
>,
|
||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||
>(
|
||||
element: T,
|
||||
extra: Pick<
|
||||
T,
|
||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||
// @ts-ignore TS complains here but type checks the call sites fine.
|
||||
// @ts-ignore
|
||||
keyof K
|
||||
> &
|
||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
|
||||
): T => {
|
||||
const nextType = (extra.type || element.type) as ExcalidrawElementType;
|
||||
|
||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
||||
type: extra.type || element.type,
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -34,25 +34,29 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
|
||||
|
||||
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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ExcalidrawTextElement>(id);
|
||||
@@ -157,65 +161,42 @@ export const textWysiwyg = ({
|
||||
let maxHeight = updatedTextElement.height;
|
||||
|
||||
if (container && updatedTextElement.containerId) {
|
||||
if (isArrowElement(container)) {
|
||||
const boundTextCoords =
|
||||
LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
elementsMap,
|
||||
);
|
||||
coordX = boundTextCoords.x;
|
||||
coordY = boundTextCoords.y;
|
||||
}
|
||||
const propertiesUpdated = textPropertiesUpdated(
|
||||
updatedTextElement,
|
||||
editable,
|
||||
);
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
container.height,
|
||||
);
|
||||
} else {
|
||||
originalContainerData = originalContainerCache[container.id];
|
||||
if (!originalContainerData) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
container.height,
|
||||
);
|
||||
if ((container as any).containerBehavior === "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);
|
||||
|
||||
Reference in New Issue
Block a user