Compare commits
32 Commits
master
...
arnost/scr
Author | SHA1 | Date | |
---|---|---|---|
|
b0cdd00c2a | ||
|
6711735b27 | ||
|
803e14ada1 | ||
|
4469c02191 | ||
|
04e23e1d29 | ||
|
d24a032dbb | ||
|
76d3930983 | ||
|
af6e64ffc2 | ||
|
4e9039e850 | ||
|
132750f753 | ||
|
71eb3023b2 | ||
|
6d165971fc | ||
|
9562e4309f | ||
|
e8e391e465 | ||
|
92be92071a | ||
|
71918e57a8 | ||
|
c0bd9027cb | ||
|
7336b1c276 | ||
|
7fb6c23715 | ||
|
82014fe670 | ||
|
bc44c3f947 | ||
|
19ba107041 | ||
|
381ef93956 | ||
|
f82363aae9 | ||
|
485c57fd59 | ||
|
35b43c14d8 | ||
|
f7e8056abe | ||
|
71f7960606 | ||
|
2998573e79 | ||
|
209934c90a | ||
|
a8158691b7 | ||
|
75f8e904cc |
@ -69,10 +69,6 @@ It's also a good idea to consider if your change should include additional tests
|
||||
|
||||
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
|
||||
|
||||
:::note
|
||||
Some checks, such as the `lint` and `test`, require approval from the maintainers to run.
|
||||
They will appear as `Expected — Waiting for status to be reported` in the PR checks when they are waiting for approval.
|
||||
:::
|
||||
|
||||
## Translating
|
||||
|
||||
|
@ -16,7 +16,7 @@ export type ActionResult =
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: MarkOptional<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
"offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints"
|
||||
> | null;
|
||||
files?: BinaryFiles | null;
|
||||
commitToHistory: boolean;
|
||||
|
@ -17,7 +17,7 @@ const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
|
||||
export const getDefaultAppState = (): Omit<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
"offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints"
|
||||
> => {
|
||||
return {
|
||||
showWelcomeScreen: false,
|
||||
@ -206,6 +206,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
pendingImageElementId: { browser: false, export: false, server: false },
|
||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||
selectedLinearElement: { browser: true, export: false, server: false },
|
||||
scrollConstraints: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
@ -24,7 +24,6 @@ export interface ClipboardData {
|
||||
files?: BinaryFiles;
|
||||
text?: string;
|
||||
errorMessage?: string;
|
||||
programmaticAPI?: boolean;
|
||||
}
|
||||
|
||||
let CLIPBOARD = "";
|
||||
@ -49,7 +48,6 @@ const clipboardContainsElements = (
|
||||
[
|
||||
EXPORT_DATA_TYPES.excalidraw,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
|
||||
].includes(contents?.type) &&
|
||||
Array.isArray(contents.elements)
|
||||
) {
|
||||
@ -193,8 +191,6 @@ export const parseClipboard = async (
|
||||
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(systemClipboard);
|
||||
const programmaticAPI =
|
||||
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
return {
|
||||
elements: systemClipboardData.elements,
|
||||
@ -202,7 +198,6 @@ export const parseClipboard = async (
|
||||
text: isPlainPaste
|
||||
? JSON.stringify(systemClipboardData.elements, null, 2)
|
||||
: undefined,
|
||||
programmaticAPI,
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
|
@ -209,7 +209,11 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import Scene from "../scene/Scene";
|
||||
import { RenderConfig, ScrollBars } from "../scene/types";
|
||||
import {
|
||||
RenderConfig,
|
||||
ScrollBars,
|
||||
ConstrainedScrollValues,
|
||||
} from "../scene/types";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { findShapeByKey, SHAPES } from "../shapes";
|
||||
import {
|
||||
@ -229,6 +233,7 @@ import {
|
||||
FrameNameBoundsCache,
|
||||
SidebarName,
|
||||
SidebarTabName,
|
||||
ScrollConstraints,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
@ -257,6 +262,7 @@ import {
|
||||
muteFSAbortError,
|
||||
isTestEnv,
|
||||
easeOut,
|
||||
isShallowEqual,
|
||||
} from "../utils";
|
||||
import {
|
||||
embeddableURLValidator,
|
||||
@ -298,6 +304,7 @@ import {
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
@ -346,10 +353,6 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
import { activeEyeDropperAtom } from "./EyeDropper";
|
||||
import {
|
||||
ExcalidrawElementSkeleton,
|
||||
convertToExcalidrawElements,
|
||||
} from "../data/transform";
|
||||
import { ValueOf } from "../utility-types";
|
||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
|
||||
@ -383,6 +386,7 @@ const ExcalidrawAppStateContext = React.createContext<AppState>({
|
||||
height: 0,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
scrollConstraints: null,
|
||||
});
|
||||
ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";
|
||||
|
||||
@ -481,6 +485,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
lastPointerDown: React.PointerEvent<HTMLElement> | null = null;
|
||||
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
|
||||
lastViewportPosition = { x: 0, y: 0 };
|
||||
private memoizedScrollConstraints: ReturnType<
|
||||
App["calculateConstraints"]
|
||||
> | null = null;
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
@ -492,7 +499,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
gridModeEnabled = false,
|
||||
theme = defaultAppState.theme,
|
||||
name = defaultAppState.name,
|
||||
scrollConstraints,
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
...defaultAppState,
|
||||
theme,
|
||||
@ -504,6 +513,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
name,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
scrollConstraints: scrollConstraints ?? null,
|
||||
};
|
||||
this.id = nanoid();
|
||||
this.library = new Library(this);
|
||||
@ -535,6 +545,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
resetCursor: this.resetCursor,
|
||||
updateFrameRendering: this.updateFrameRendering,
|
||||
toggleSidebar: this.toggleSidebar,
|
||||
setScrollConstraints: this.setScrollConstraints,
|
||||
} as const;
|
||||
if (typeof excalidrawRef === "function") {
|
||||
excalidrawRef(api);
|
||||
@ -1549,7 +1560,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isLoading: false,
|
||||
toast: this.state.toast,
|
||||
};
|
||||
if (initialData?.scrollToContent) {
|
||||
if (this.props.scrollConstraints) {
|
||||
scene.appState = {
|
||||
...scene.appState,
|
||||
...this.calculateConstrainedScrollCenter(
|
||||
this.props.scrollConstraints,
|
||||
scene.appState,
|
||||
),
|
||||
};
|
||||
} else if (initialData?.scrollToContent) {
|
||||
scene.appState = {
|
||||
...scene.appState,
|
||||
...calculateScrollCenter(
|
||||
@ -1560,6 +1579,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
height: this.state.height,
|
||||
offsetTop: this.state.offsetTop,
|
||||
offsetLeft: this.state.offsetLeft,
|
||||
scrollConstraints: this.state.scrollConstraints,
|
||||
},
|
||||
null,
|
||||
),
|
||||
@ -1846,10 +1866,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
|
||||
this.updateEmbeddables();
|
||||
if (
|
||||
!this.state.showWelcomeScreen &&
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
) {
|
||||
const elementsIncludingDeleted = this.scene.getElementsIncludingDeleted();
|
||||
if (!this.state.showWelcomeScreen && !elementsIncludingDeleted.length) {
|
||||
this.setState({ showWelcomeScreen: true });
|
||||
}
|
||||
|
||||
@ -1976,7 +1994,73 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
);
|
||||
}
|
||||
this.renderScene();
|
||||
|
||||
let constraintedScrollState;
|
||||
if (
|
||||
this.state.scrollConstraints &&
|
||||
!this.state.scrollConstraints.isAnimating
|
||||
) {
|
||||
const {
|
||||
scrollX,
|
||||
scrollY,
|
||||
width,
|
||||
height,
|
||||
scrollConstraints,
|
||||
zoom,
|
||||
cursorButton,
|
||||
} = this.state;
|
||||
|
||||
const canUseMemoizedConstraints =
|
||||
isShallowEqual(scrollConstraints, prevState.scrollConstraints ?? {}) &&
|
||||
isShallowEqual(
|
||||
{ width, height, zoom: zoom.value, cursorButton },
|
||||
{
|
||||
width: prevState.width,
|
||||
height: prevState.height,
|
||||
zoom: prevState.zoom.value,
|
||||
cursorButton: prevState.cursorButton,
|
||||
} ?? {},
|
||||
);
|
||||
|
||||
const calculatedConstraints =
|
||||
canUseMemoizedConstraints && !!this.memoizedScrollConstraints
|
||||
? this.memoizedScrollConstraints
|
||||
: this.calculateConstraints({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
cursorButton,
|
||||
});
|
||||
|
||||
this.memoizedScrollConstraints = calculatedConstraints;
|
||||
|
||||
const constrainedScrollValues = this.constrainScrollValues({
|
||||
...calculatedConstraints,
|
||||
scrollX,
|
||||
scrollY,
|
||||
});
|
||||
|
||||
const isViewportOutsideOfConstrainedArea =
|
||||
this.isViewportOutsideOfConstrainedArea({
|
||||
scrollX,
|
||||
scrollY,
|
||||
width,
|
||||
height,
|
||||
scrollConstraints,
|
||||
});
|
||||
|
||||
constraintedScrollState = this.handleConstrainedScrollStateChange({
|
||||
...constrainedScrollValues,
|
||||
shouldAnimate:
|
||||
isViewportOutsideOfConstrainedArea &&
|
||||
this.state.cursorButton !== "down" &&
|
||||
prevState.zoom.value === this.state.zoom.value &&
|
||||
elementsIncludingDeleted.length > 0, // Do not animate when app is initialized but scene is empty - this would cause flickering
|
||||
});
|
||||
}
|
||||
|
||||
this.renderScene(constraintedScrollState);
|
||||
this.history.record(this.state, this.scene.getElementsIncludingDeleted());
|
||||
|
||||
// Do not notify consumers if we're still loading the scene. Among other
|
||||
@ -1992,7 +2076,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
private renderScene = () => {
|
||||
private renderScene = (constrainedScroll?: ConstrainedScrollValues) => {
|
||||
const cursorButton: {
|
||||
[id: string]: string | undefined;
|
||||
} = {};
|
||||
@ -2063,10 +2147,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
canvas: this.canvas!,
|
||||
renderConfig: {
|
||||
selectionColor,
|
||||
scrollX: this.state.scrollX,
|
||||
scrollY: this.state.scrollY,
|
||||
scrollX: constrainedScroll?.scrollX ?? this.state.scrollX,
|
||||
scrollY: constrainedScroll?.scrollY ?? this.state.scrollY,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||
zoom: this.state.zoom,
|
||||
zoom: constrainedScroll?.zoom ?? this.state.zoom,
|
||||
remotePointerViewportCoords: pointerViewportCoords,
|
||||
remotePointerButton: cursorButton,
|
||||
remoteSelectedElementIds,
|
||||
@ -2084,7 +2168,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
const scrolledOutside =
|
||||
// hide when editing text
|
||||
isTextElement(this.state.editingElement)
|
||||
isTextElement(this.state.editingElement) ||
|
||||
this.state.scrollConstraints
|
||||
? false
|
||||
: !atLeastOneVisibleElement && renderingElements.length > 0;
|
||||
if (this.state.scrolledOutside !== scrolledOutside) {
|
||||
@ -2235,6 +2320,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
let file = event?.clipboardData?.files[0];
|
||||
|
||||
const data = await parseClipboard(event, isPlainPaste);
|
||||
|
||||
if (!file && data.text && !isPlainPaste) {
|
||||
const string = data.text.trim();
|
||||
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
||||
@ -2289,16 +2375,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
});
|
||||
} else if (data.elements) {
|
||||
const elements = (
|
||||
data.programmaticAPI
|
||||
? convertToExcalidrawElements(
|
||||
data.elements as ExcalidrawElementSkeleton[],
|
||||
)
|
||||
: data.elements
|
||||
) as readonly ExcalidrawElement[];
|
||||
// TODO remove formatting from elements if isPlainPaste
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
elements: data.elements,
|
||||
files: data.files || null,
|
||||
position: "cursor",
|
||||
retainSeed: isPlainPaste,
|
||||
@ -2628,8 +2707,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
/** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */
|
||||
value: number,
|
||||
) => {
|
||||
this.setState({
|
||||
...getStateForZoom(
|
||||
this.setState(
|
||||
getStateForZoom(
|
||||
{
|
||||
viewportX: this.state.width / 2 + this.state.offsetLeft,
|
||||
viewportY: this.state.height / 2 + this.state.offsetTop,
|
||||
@ -2637,7 +2716,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
this.state,
|
||||
),
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
private cancelInProgresAnimation: (() => void) | null = null;
|
||||
@ -3557,8 +3636,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
|
||||
const newHeight = Math.max(container.height, minHeight);
|
||||
const newWidth = Math.max(container.width, minWidth);
|
||||
const containerDims = getContainerDims(container);
|
||||
const newHeight = Math.max(containerDims.height, minHeight);
|
||||
const newWidth = Math.max(containerDims.width, minWidth);
|
||||
mutateElement(container, { height: newHeight, width: newWidth });
|
||||
sceneX = container.x + newWidth / 2;
|
||||
sceneY = container.y + newHeight / 2;
|
||||
@ -5405,7 +5485,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
width: embedLink.aspectRatio.w,
|
||||
height: embedLink.aspectRatio.h,
|
||||
link,
|
||||
validated: null,
|
||||
validated: undefined,
|
||||
});
|
||||
|
||||
this.scene.replaceAllElements([
|
||||
@ -5593,7 +5673,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
private createGenericElementOnPointerDown = (
|
||||
elementType: ExcalidrawGenericElement["type"] | "embeddable",
|
||||
elementType: ExcalidrawGenericElement["type"],
|
||||
pointerDownState: PointerDownState,
|
||||
): void => {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
@ -5607,7 +5687,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
y: gridY,
|
||||
});
|
||||
|
||||
const baseElementAttributes = {
|
||||
const element = newElement({
|
||||
type: elementType,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
@ -5620,21 +5701,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
roundness: this.getCurrentItemRoundness(elementType),
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
} as const;
|
||||
|
||||
let element;
|
||||
if (elementType === "embeddable") {
|
||||
element = newEmbeddableElement({
|
||||
type: "embeddable",
|
||||
validated: null,
|
||||
...baseElementAttributes,
|
||||
...(elementType === "embeddable" ? { validated: false } : {}),
|
||||
});
|
||||
} else {
|
||||
element = newElement({
|
||||
type: elementType,
|
||||
...baseElementAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "selection") {
|
||||
this.setState({
|
||||
@ -8173,6 +8241,424 @@ class App extends React.Component<AppProps, AppState> {
|
||||
await setLanguage(currentLang);
|
||||
this.setAppState({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the scroll constraints of the application state.
|
||||
*
|
||||
* @param scrollConstraints - The new scroll constraints.
|
||||
*/
|
||||
public setScrollConstraints = (
|
||||
scrollConstraints: ScrollConstraints | null,
|
||||
) => {
|
||||
if (scrollConstraints) {
|
||||
const { scrollX, scrollY, width, height, zoom, cursorButton } =
|
||||
this.state;
|
||||
const constrainedScrollValues = this.constrainScrollValues({
|
||||
...this.calculateConstraints({
|
||||
scrollConstraints,
|
||||
zoom,
|
||||
cursorButton,
|
||||
width,
|
||||
height,
|
||||
}),
|
||||
scrollX,
|
||||
scrollY,
|
||||
});
|
||||
this.animateConstrainedScroll({
|
||||
...constrainedScrollValues,
|
||||
opts: {
|
||||
onEndCallback: () => {
|
||||
this.setState({
|
||||
scrollConstraints,
|
||||
viewModeEnabled: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
scrollConstraints: null,
|
||||
viewModeEnabled: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the scroll center coordinates and the optimal zoom level to fit the constrained scrollable area within the viewport.
|
||||
*
|
||||
* This method first calculates the necessary zoom level to fit the entire constrained scrollable area within the viewport.
|
||||
* Then it calculates the constraints for the viewport given the new zoom level and the current scrollable area dimensions.
|
||||
* The function returns an object containing the optimal scroll positions and zoom level.
|
||||
*
|
||||
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
|
||||
* @param appState - An object containing the current horizontal and vertical scroll positions.
|
||||
* @returns An object containing the calculated optimal horizontal and vertical scroll positions and zoom level.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const { scrollX, scrollY, zoom } = this.calculateConstrainedScrollCenter(scrollConstraints, { scrollX, scrollY });
|
||||
*/
|
||||
public calculateConstrainedScrollCenter = (
|
||||
scrollConstraints: AppState["scrollConstraints"],
|
||||
{ scrollX, scrollY }: Pick<AppState, "scrollX" | "scrollY">,
|
||||
): {
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
zoom: AppState["zoom"];
|
||||
} => {
|
||||
const { width, height, zoom } = this.state;
|
||||
|
||||
if (!scrollConstraints) {
|
||||
return { scrollX, scrollY, zoom };
|
||||
}
|
||||
|
||||
const { zoomLevelX, zoomLevelY, maxZoomLevel } = this.calculateZoomLevel(
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
// The zoom level to contain the whole constrained area in view
|
||||
const _zoom = {
|
||||
value: getNormalizedZoom(
|
||||
maxZoomLevel ?? Math.min(zoomLevelX, zoomLevelY),
|
||||
),
|
||||
};
|
||||
|
||||
const constraints = this.calculateConstraints({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom: _zoom,
|
||||
cursorButton: "up",
|
||||
});
|
||||
|
||||
return {
|
||||
scrollX: constraints.minScrollX,
|
||||
scrollY: constraints.minScrollY,
|
||||
zoom: constraints.constrainedZoom,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the zoom levels necessary to fit the constrained scrollable area within the viewport on the X and Y axes.
|
||||
*
|
||||
* The function considers the dimensions of the scrollable area, the dimensions of the viewport, the viewport zoom factor,
|
||||
* and whether the zoom should be locked. It then calculates the necessary zoom levels for the X and Y axes separately.
|
||||
* If the zoom should be locked, it calculates the maximum zoom level that fits the scrollable area within the viewport,
|
||||
* factoring in the viewport zoom factor. If the zoom should not be locked, the maximum zoom level is set to null.
|
||||
*
|
||||
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
|
||||
* @param width - The width of the viewport.
|
||||
* @param height - The height of the viewport.
|
||||
* @returns An object containing the calculated zoom levels for the X and Y axes, and the maximum zoom level if applicable.
|
||||
*/
|
||||
private calculateZoomLevel = (
|
||||
scrollConstraints: ScrollConstraints,
|
||||
width: AppState["width"],
|
||||
height: AppState["height"],
|
||||
) => {
|
||||
const DEFAULT_VIEWPORT_ZOOM_FACTOR = 0.7;
|
||||
|
||||
const lockZoom = scrollConstraints.lockZoom ?? false;
|
||||
const viewportZoomFactor = scrollConstraints.viewportZoomFactor
|
||||
? Math.min(1, Math.max(scrollConstraints.viewportZoomFactor, 0.1))
|
||||
: DEFAULT_VIEWPORT_ZOOM_FACTOR;
|
||||
|
||||
const scrollableWidth = scrollConstraints.width;
|
||||
const scrollableHeight = scrollConstraints.height;
|
||||
const zoomLevelX = width / scrollableWidth;
|
||||
const zoomLevelY = height / scrollableHeight;
|
||||
const maxZoomLevel = lockZoom
|
||||
? getNormalizedZoom(Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor)
|
||||
: null;
|
||||
return { zoomLevelX, zoomLevelY, maxZoomLevel };
|
||||
};
|
||||
|
||||
private calculateConstraints = ({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
cursorButton,
|
||||
}: {
|
||||
scrollConstraints: ScrollConstraints;
|
||||
width: AppState["width"];
|
||||
height: AppState["height"];
|
||||
zoom: AppState["zoom"];
|
||||
cursorButton: AppState["cursorButton"];
|
||||
}) => {
|
||||
// Set the overscroll allowance percentage
|
||||
const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2;
|
||||
|
||||
/**
|
||||
* Calculates the center position of the constrained scroll area.
|
||||
* @returns The X and Y coordinates of the center position.
|
||||
*/
|
||||
const calculateConstrainedScrollCenter = (zoom: number) => {
|
||||
const constrainedScrollCenterX =
|
||||
scrollConstraints.x + (scrollConstraints.width - width / zoom) / -2;
|
||||
const constrainedScrollCenterY =
|
||||
scrollConstraints.y + (scrollConstraints.height - height / zoom) / -2;
|
||||
return { constrainedScrollCenterX, constrainedScrollCenterY };
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the overscroll allowance values for the constrained area.
|
||||
* @returns The overscroll allowance values for the X and Y axes.
|
||||
*/
|
||||
const calculateOverscrollAllowance = () => {
|
||||
const overscrollAllowanceX =
|
||||
OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollConstraints.width;
|
||||
const overscrollAllowanceY =
|
||||
OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollConstraints.height;
|
||||
return { overscrollAllowanceX, overscrollAllowanceY };
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the minimum and maximum scroll values based on the current state.
|
||||
* @param shouldAdjustForCenteredView - Whether the view should be adjusted for centered view - when constrained area fits the viewport.
|
||||
* @param overscrollAllowanceX - The overscroll allowance value for the X axis.
|
||||
* @param overscrollAllowanceY - The overscroll allowance value for the Y axis.
|
||||
* @param constrainedScrollCenterX - The X coordinate of the constrained scroll area center.
|
||||
* @param constrainedScrollCenterY - The Y coordinate of the constrained scroll area center.
|
||||
* @returns The minimum and maximum scroll values for the X and Y axes.
|
||||
*/
|
||||
const calculateMinMaxScrollValues = (
|
||||
shouldAdjustForCenteredView: boolean,
|
||||
overscrollAllowanceX: number,
|
||||
overscrollAllowanceY: number,
|
||||
constrainedScrollCenterX: number,
|
||||
constrainedScrollCenterY: number,
|
||||
zoom: number,
|
||||
) => {
|
||||
let maxScrollX;
|
||||
let minScrollX;
|
||||
let maxScrollY;
|
||||
let minScrollY;
|
||||
|
||||
if (cursorButton === "down" && shouldAdjustForCenteredView) {
|
||||
maxScrollX = constrainedScrollCenterX + overscrollAllowanceX;
|
||||
minScrollX = constrainedScrollCenterX - overscrollAllowanceX;
|
||||
maxScrollY = constrainedScrollCenterY + overscrollAllowanceY;
|
||||
minScrollY = constrainedScrollCenterY - overscrollAllowanceY;
|
||||
} else if (cursorButton === "down" && !shouldAdjustForCenteredView) {
|
||||
maxScrollX = scrollConstraints.x + overscrollAllowanceX;
|
||||
minScrollX =
|
||||
scrollConstraints.x -
|
||||
scrollConstraints.width +
|
||||
width / zoom -
|
||||
overscrollAllowanceX;
|
||||
maxScrollY = scrollConstraints.y + overscrollAllowanceY;
|
||||
minScrollY =
|
||||
scrollConstraints.y -
|
||||
scrollConstraints.height +
|
||||
height / zoom -
|
||||
overscrollAllowanceY;
|
||||
} else if (cursorButton !== "down" && shouldAdjustForCenteredView) {
|
||||
maxScrollX = constrainedScrollCenterX;
|
||||
minScrollX = constrainedScrollCenterX;
|
||||
maxScrollY = constrainedScrollCenterY;
|
||||
minScrollY = constrainedScrollCenterY;
|
||||
} else {
|
||||
maxScrollX = scrollConstraints.x;
|
||||
minScrollX =
|
||||
scrollConstraints.x - scrollConstraints.width + width / zoom;
|
||||
maxScrollY = scrollConstraints.y;
|
||||
minScrollY =
|
||||
scrollConstraints.y - scrollConstraints.height + height / zoom;
|
||||
}
|
||||
|
||||
return { maxScrollX, minScrollX, maxScrollY, minScrollY };
|
||||
};
|
||||
|
||||
const { zoomLevelX, zoomLevelY, maxZoomLevel } = this.calculateZoomLevel(
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
const constrainedZoom = getNormalizedZoom(
|
||||
maxZoomLevel ? Math.max(maxZoomLevel, zoom.value) : zoom.value,
|
||||
);
|
||||
const { constrainedScrollCenterX, constrainedScrollCenterY } =
|
||||
calculateConstrainedScrollCenter(constrainedZoom);
|
||||
const { overscrollAllowanceX, overscrollAllowanceY } =
|
||||
calculateOverscrollAllowance();
|
||||
const shouldAdjustForCenteredView =
|
||||
constrainedZoom <= zoomLevelX || constrainedZoom <= zoomLevelY;
|
||||
const { maxScrollX, minScrollX, maxScrollY, minScrollY } =
|
||||
calculateMinMaxScrollValues(
|
||||
shouldAdjustForCenteredView,
|
||||
overscrollAllowanceX,
|
||||
overscrollAllowanceY,
|
||||
constrainedScrollCenterX,
|
||||
constrainedScrollCenterY,
|
||||
constrainedZoom,
|
||||
);
|
||||
|
||||
return {
|
||||
maxScrollX,
|
||||
minScrollX,
|
||||
maxScrollY,
|
||||
minScrollY,
|
||||
constrainedZoom: {
|
||||
value: constrainedZoom,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Constrains the scroll values within the constrained area.
|
||||
* @param maxScrollX - The maximum scroll value for the X axis.
|
||||
* @param minScrollX - The minimum scroll value for the X axis.
|
||||
* @param maxScrollY - The maximum scroll value for the Y axis.
|
||||
* @param minScrollY - The minimum scroll value for the Y axis.
|
||||
* @returns The constrained scroll values for the X and Y axes.
|
||||
*/
|
||||
private constrainScrollValues = ({
|
||||
scrollX,
|
||||
scrollY,
|
||||
maxScrollX,
|
||||
minScrollX,
|
||||
maxScrollY,
|
||||
minScrollY,
|
||||
constrainedZoom,
|
||||
}: {
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
maxScrollX: number;
|
||||
minScrollX: number;
|
||||
maxScrollY: number;
|
||||
minScrollY: number;
|
||||
constrainedZoom: AppState["zoom"];
|
||||
}) => {
|
||||
const constrainedScrollX = Math.min(
|
||||
maxScrollX,
|
||||
Math.max(scrollX, minScrollX),
|
||||
);
|
||||
const constrainedScrollY = Math.min(
|
||||
maxScrollY,
|
||||
Math.max(scrollY, minScrollY),
|
||||
);
|
||||
return { constrainedScrollX, constrainedScrollY, constrainedZoom };
|
||||
};
|
||||
|
||||
/**
|
||||
* Animate the scroll values to the constrained area
|
||||
*/
|
||||
private animateConstrainedScroll = ({
|
||||
constrainedScrollX,
|
||||
constrainedScrollY,
|
||||
opts,
|
||||
}: {
|
||||
constrainedScrollX: number;
|
||||
constrainedScrollY: number;
|
||||
opts?: {
|
||||
onStartCallback?: () => void;
|
||||
onEndCallback?: () => void;
|
||||
};
|
||||
}) => {
|
||||
const { scrollX, scrollY, scrollConstraints } = this.state;
|
||||
|
||||
const { onStartCallback, onEndCallback } = opts || {};
|
||||
|
||||
if (!scrollConstraints) {
|
||||
return null;
|
||||
}
|
||||
|
||||
easeToValuesRAF({
|
||||
fromValues: { scrollX, scrollY },
|
||||
toValues: {
|
||||
scrollX: constrainedScrollX,
|
||||
scrollY: constrainedScrollY,
|
||||
},
|
||||
onStep: ({ scrollX, scrollY }) => {
|
||||
this.setState({ scrollX, scrollY });
|
||||
},
|
||||
onStart: () => {
|
||||
this.setState({
|
||||
scrollConstraints: { ...scrollConstraints, isAnimating: true },
|
||||
});
|
||||
onStartCallback && onStartCallback();
|
||||
},
|
||||
onEnd: () => {
|
||||
this.setState({
|
||||
scrollConstraints: { ...scrollConstraints, isAnimating: false },
|
||||
});
|
||||
onEndCallback && onEndCallback();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private isViewportOutsideOfConstrainedArea = ({
|
||||
scrollX,
|
||||
scrollY,
|
||||
width,
|
||||
height,
|
||||
scrollConstraints,
|
||||
}: {
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
width: AppState["width"];
|
||||
height: AppState["height"];
|
||||
scrollConstraints: AppState["scrollConstraints"];
|
||||
}) => {
|
||||
if (!scrollConstraints) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
scrollX < scrollConstraints.x ||
|
||||
scrollX + width > scrollConstraints.x + scrollConstraints.width ||
|
||||
scrollY < scrollConstraints.y ||
|
||||
scrollY + height > scrollConstraints.y + scrollConstraints.height
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the state change based on the constrained scroll values.
|
||||
* Also handles the animation to the constrained area when the viewport is outside of constrained area.
|
||||
* @param constrainedScrollX - The constrained scroll value for the X axis.
|
||||
* @param constrainedScrollY - The constrained scroll value for the Y axis.
|
||||
* @returns The constrained state if the state has changed, when needs to be passed into render function, otherwise null.
|
||||
*/
|
||||
private handleConstrainedScrollStateChange = ({
|
||||
constrainedScrollX,
|
||||
constrainedScrollY,
|
||||
constrainedZoom,
|
||||
shouldAnimate,
|
||||
}: {
|
||||
constrainedScrollX: number;
|
||||
constrainedScrollY: number;
|
||||
constrainedZoom: AppState["zoom"];
|
||||
shouldAnimate?: boolean;
|
||||
}) => {
|
||||
const { scrollX, scrollY } = this.state;
|
||||
const isStateChanged =
|
||||
constrainedScrollX !== scrollX || constrainedScrollY !== scrollY;
|
||||
|
||||
if (isStateChanged) {
|
||||
if (shouldAnimate) {
|
||||
this.animateConstrainedScroll({
|
||||
constrainedScrollX,
|
||||
constrainedScrollY,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
const constrainedState = {
|
||||
scrollX: constrainedScrollX,
|
||||
scrollY: constrainedScrollY,
|
||||
zoom: constrainedZoom,
|
||||
};
|
||||
|
||||
this.setState(constrainedState);
|
||||
return constrainedState;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
@ -117,7 +117,6 @@ export const FRAME_STYLE = {
|
||||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
@ -164,7 +163,6 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidraw: "excalidraw",
|
||||
excalidrawClipboard: "excalidraw/clipboard",
|
||||
excalidrawLibrary: "excalidrawlib",
|
||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE =
|
||||
@ -241,8 +239,6 @@ export const VERSIONS = {
|
||||
} as const;
|
||||
|
||||
export const BOUND_TEXT_PADDING = 5;
|
||||
export const ARROW_LABEL_WIDTH_FRACTION = 0.7;
|
||||
export const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11;
|
||||
|
||||
export const VERTICAL_ALIGN = {
|
||||
TOP: "top",
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -29,7 +29,6 @@ import {
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
DEFAULT_SIDEBAR,
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
@ -42,11 +41,12 @@ import {
|
||||
getDefaultLineHeight,
|
||||
measureBaseline,
|
||||
} from "../element/textElement";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import { normalizeLink } from "./url";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
"offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints"
|
||||
>;
|
||||
|
||||
export const AllowedExcalidrawActiveTools: Record<
|
||||
@ -122,18 +122,16 @@ const restoreElementWithProperties = <
|
||||
versionNonce: element.versionNonce ?? 0,
|
||||
isDeleted: element.isDeleted ?? false,
|
||||
id: element.id || randomId(),
|
||||
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
strokeWidth: element.strokeWidth || DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
strokeStyle: element.strokeStyle ?? DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
|
||||
opacity:
|
||||
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
|
||||
fillStyle: element.fillStyle || "hachure",
|
||||
strokeWidth: element.strokeWidth || 1,
|
||||
strokeStyle: element.strokeStyle ?? "solid",
|
||||
roughness: element.roughness ?? 1,
|
||||
opacity: element.opacity == null ? 100 : element.opacity,
|
||||
angle: element.angle || 0,
|
||||
x: extra.x ?? element.x ?? 0,
|
||||
y: extra.y ?? element.y ?? 0,
|
||||
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
backgroundColor:
|
||||
element.backgroundColor || DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||
strokeColor: element.strokeColor || COLOR_PALETTE.black,
|
||||
backgroundColor: element.backgroundColor || COLOR_PALETTE.transparent,
|
||||
width: element.width || 0,
|
||||
height: element.height || 0,
|
||||
seed: element.seed ?? 1,
|
||||
@ -248,6 +246,7 @@ const restoreElement = (
|
||||
startArrowhead = null,
|
||||
endArrowhead = element.type === "arrow" ? "arrow" : null,
|
||||
} = element;
|
||||
|
||||
let x = element.x;
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
@ -287,7 +286,7 @@ const restoreElement = (
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "embeddable":
|
||||
return restoreElementWithProperties(element, {
|
||||
validated: null,
|
||||
validated: undefined,
|
||||
});
|
||||
case "frame":
|
||||
return restoreElementWithProperties(element, {
|
||||
@ -411,6 +410,7 @@ export const restoreElements = (
|
||||
): ExcalidrawElement[] => {
|
||||
// used to detect duplicate top-level element ids
|
||||
const existingIds = new Set<string>();
|
||||
|
||||
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||
const restoredElements = (elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
@ -429,7 +429,6 @@ export const restoreElements = (
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
}
|
||||
|
@ -1,706 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
ExcalidrawElementSkeleton,
|
||||
convertToExcalidrawElements,
|
||||
} from "./transform";
|
||||
import { ExcalidrawArrowElement } from "../element/types";
|
||||
|
||||
describe("Test Transform", () => {
|
||||
it("should transform regular shapes", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 100,
|
||||
y: 250,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#c0eb75",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 300,
|
||||
y: 250,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#ffc9c9",
|
||||
strokeStyle: "dotted",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 300,
|
||||
y: 400,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#a5d8ff",
|
||||
strokeColor: "#1971c2",
|
||||
strokeStyle: "dashed",
|
||||
fillStyle: "cross-hatch",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
];
|
||||
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform text element", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "text",
|
||||
x: 100,
|
||||
y: 100,
|
||||
text: "HELLO WORLD!",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
x: 100,
|
||||
y: 150,
|
||||
text: "STYLED HELLO WORLD!",
|
||||
fontSize: 20,
|
||||
strokeColor: "#5f3dc4",
|
||||
},
|
||||
];
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform linear elements", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 20,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 450,
|
||||
y: 20,
|
||||
startArrowhead: "dot",
|
||||
endArrowhead: "triangle",
|
||||
strokeColor: "#1971c2",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
x: 100,
|
||||
y: 60,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
x: 450,
|
||||
y: 60,
|
||||
strokeColor: "#2f9e44",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "dotted",
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform to text containers when label provided", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "RECTANGLE TEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 500,
|
||||
y: 100,
|
||||
width: 200,
|
||||
label: {
|
||||
text: "ELLIPSE TEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 150,
|
||||
width: 280,
|
||||
label: {
|
||||
text: "DIAMOND\nTEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 400,
|
||||
width: 300,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "STYLED DIAMOND TEXT CONTAINER",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 500,
|
||||
y: 300,
|
||||
width: 200,
|
||||
strokeColor: "#c2255c",
|
||||
label: {
|
||||
text: "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 500,
|
||||
y: 500,
|
||||
strokeColor: "#f08c00",
|
||||
backgroundColor: "#ffec99",
|
||||
width: 200,
|
||||
label: {
|
||||
text: "STYLED ELLIPSE TEXT CONTAINER",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(12);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform to labelled arrows when label provided for arrows", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "LABELED ARROW",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 200,
|
||||
label: {
|
||||
text: "STYLED LABELED ARROW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 300,
|
||||
strokeColor: "#1098ad",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "ANOTHER STYLED LABELLED ARROW",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 400,
|
||||
strokeColor: "#1098ad",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "ANOTHER STYLED LABELLED ARROW",
|
||||
strokeColor: "#099268",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(8);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test arrow bindings", () => {
|
||||
it("should bind arrows to shapes when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
type: "rectangle",
|
||||
},
|
||||
end: {
|
||||
type: "ellipse",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
const [arrow, text, rectangle, ellipse] = excaldrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: rectangle.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: ellipse.id,
|
||||
focus: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).toMatchObject({
|
||||
x: 340,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
containerId: arrow.id,
|
||||
});
|
||||
|
||||
expect(rectangle).toMatchObject({
|
||||
x: 155,
|
||||
y: 189,
|
||||
type: "rectangle",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ellipse).toMatchObject({
|
||||
x: 555,
|
||||
y: 189,
|
||||
type: "ellipse",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to text when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
},
|
||||
end: {
|
||||
type: "text",
|
||||
text: "WHATS UP ?",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
const [arrow, text1, text2, text3] = excaldrawElements;
|
||||
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [{ id: text1.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: text2.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: text3.id,
|
||||
focus: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(text1).toMatchObject({
|
||||
x: 340,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
containerId: arrow.id,
|
||||
});
|
||||
|
||||
expect(text2).toMatchObject({
|
||||
x: 185,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(text3).toMatchObject({
|
||||
x: 555,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing shapes when start / end provided with ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "ellipse",
|
||||
id: "ellipse-1",
|
||||
strokeColor: "#66a80f",
|
||||
x: 630,
|
||||
y: 316,
|
||||
width: 300,
|
||||
height: 300,
|
||||
backgroundColor: "#d8f5a2",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
id: "diamond-1",
|
||||
strokeColor: "#9c36b5",
|
||||
width: 140,
|
||||
x: 96,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 247,
|
||||
y: 420,
|
||||
width: 395,
|
||||
height: 35,
|
||||
strokeColor: "#1864ab",
|
||||
start: {
|
||||
type: "rectangle",
|
||||
width: 300,
|
||||
height: 300,
|
||||
},
|
||||
end: {
|
||||
id: "ellipse-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 227,
|
||||
y: 450,
|
||||
width: 400,
|
||||
strokeColor: "#e67700",
|
||||
start: {
|
||||
id: "diamond-1",
|
||||
},
|
||||
end: {
|
||||
id: "ellipse-1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(5);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing text elements when start / end provided with ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
x: 100,
|
||||
y: 239,
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
id: "text-1",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
id: "text-2",
|
||||
x: 560,
|
||||
y: 239,
|
||||
text: "Whats up ?",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
id: "text-1",
|
||||
},
|
||||
end: {
|
||||
id: "text-2",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
|
||||
excaldrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing elements if ids are correct", () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
const elements = [
|
||||
{
|
||||
x: 100,
|
||||
y: 239,
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
id: "text-1",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 560,
|
||||
y: 139,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
backgroundColor: "#bac8ff",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
id: "text-13",
|
||||
},
|
||||
end: {
|
||||
id: "rect-11",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(4);
|
||||
const [, , arrow] = excaldrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
boundElements: [
|
||||
{
|
||||
id: "id46",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"No element for start binding with id text-13 found",
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"No element for end binding with id rect-11 found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should bind when ids referenced before the element data", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
end: {
|
||||
id: "rect-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 560,
|
||||
y: 139,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
backgroundColor: "#bac8ff",
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
expect(excaldrawElements.length).toBe(2);
|
||||
const [arrow, rect] = excaldrawElements;
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
focus: 0,
|
||||
gap: 5,
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
id: "id47",
|
||||
type: "arrow",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow duplicate ids", () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 100,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
},
|
||||
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 200,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
},
|
||||
];
|
||||
const excaldrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
|
||||
expect(excaldrawElements.length).toBe(1);
|
||||
expect(excaldrawElements[0]).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Duplicate id found for rect-1",
|
||||
);
|
||||
});
|
||||
});
|
@ -1,561 +0,0 @@
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
newElement,
|
||||
newLinearElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { bindLinearElement } from "../element/binding";
|
||||
import {
|
||||
ElementConstructorOpts,
|
||||
newImageElement,
|
||||
newTextElement,
|
||||
} from "../element/newElement";
|
||||
import {
|
||||
getDefaultLineHeight,
|
||||
measureText,
|
||||
normalizeText,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FileId,
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
} from "../element/types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
import { assertNever, getFontString } from "../utils";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
x: number;
|
||||
y: number;
|
||||
label?: {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
end?:
|
||||
| (
|
||||
| (
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
| {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
}
|
||||
)
|
||||
| ((
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type?: "text";
|
||||
id: ExcalidrawTextElement["id"];
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
start?:
|
||||
| (
|
||||
| (
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
| {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
>;
|
||||
}
|
||||
)
|
||||
| ((
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type?: "text";
|
||||
id: ExcalidrawTextElement["id"];
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & Partial<ExcalidrawLinearElement>;
|
||||
|
||||
export type ValidContainer =
|
||||
| {
|
||||
type: Exclude<ExcalidrawGenericElement["type"], "selection">;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
label?: {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & ElementConstructorOpts;
|
||||
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawFrameElement
|
||||
>
|
||||
| ({
|
||||
type: Extract<ExcalidrawLinearElement["type"], "line">;
|
||||
x: number;
|
||||
y: number;
|
||||
} & Partial<ExcalidrawLinearElement>)
|
||||
| ValidContainer
|
||||
| ValidLinearElement
|
||||
| ({
|
||||
type: "text";
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
id?: ExcalidrawTextElement["id"];
|
||||
} & Partial<ExcalidrawTextElement>)
|
||||
| ({
|
||||
type: Extract<ExcalidrawImageElement["type"], "image">;
|
||||
x: number;
|
||||
y: number;
|
||||
fileId: FileId;
|
||||
} & Partial<ExcalidrawImageElement>);
|
||||
|
||||
const DEFAULT_LINEAR_ELEMENT_PROPS = {
|
||||
width: 300,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_DIMENSION = 100;
|
||||
|
||||
const bindTextToContainer = (
|
||||
container: ExcalidrawElement,
|
||||
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
|
||||
) => {
|
||||
const textElement: ExcalidrawTextElement = newTextElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
...textProps,
|
||||
containerId: container.id,
|
||||
strokeColor: textProps.strokeColor || container.strokeColor,
|
||||
});
|
||||
|
||||
Object.assign(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
return [container, textElement] as const;
|
||||
};
|
||||
|
||||
const bindLinearElementToElement = (
|
||||
linearElement: ExcalidrawArrowElement,
|
||||
start: ValidLinearElement["start"],
|
||||
end: ValidLinearElement["end"],
|
||||
elementStore: ElementStore,
|
||||
): {
|
||||
linearElement: ExcalidrawLinearElement;
|
||||
startBoundElement?: ExcalidrawElement;
|
||||
endBoundElement?: ExcalidrawElement;
|
||||
} => {
|
||||
let startBoundElement;
|
||||
let endBoundElement;
|
||||
|
||||
Object.assign(linearElement, {
|
||||
startBinding: linearElement?.startBinding || null,
|
||||
endBinding: linearElement.endBinding || null,
|
||||
});
|
||||
|
||||
if (start) {
|
||||
const width = start?.width ?? DEFAULT_DIMENSION;
|
||||
const height = start?.height ?? DEFAULT_DIMENSION;
|
||||
|
||||
let existingElement;
|
||||
if (start.id) {
|
||||
existingElement = elementStore.getElement(start.id);
|
||||
if (!existingElement) {
|
||||
console.error(`No element for start binding with id ${start.id} found`);
|
||||
}
|
||||
}
|
||||
|
||||
const startX = start.x || linearElement.x - width;
|
||||
const startY = start.y || linearElement.y - height / 2;
|
||||
const startType = existingElement ? existingElement.type : start.type;
|
||||
|
||||
if (startType) {
|
||||
if (startType === "text") {
|
||||
let text = "";
|
||||
if (existingElement && existingElement.type === "text") {
|
||||
text = existingElement.text;
|
||||
} else if (start.type === "text") {
|
||||
text = start.text;
|
||||
}
|
||||
if (!text) {
|
||||
console.error(
|
||||
`No text found for start binding text element for ${linearElement.id}`,
|
||||
);
|
||||
}
|
||||
startBoundElement = newTextElement({
|
||||
x: startX,
|
||||
y: startY,
|
||||
type: "text",
|
||||
...existingElement,
|
||||
...start,
|
||||
text,
|
||||
});
|
||||
// to position the text correctly when coordinates not provided
|
||||
Object.assign(startBoundElement, {
|
||||
x: start.x || linearElement.x - startBoundElement.width,
|
||||
y: start.y || linearElement.y - startBoundElement.height / 2,
|
||||
});
|
||||
} else {
|
||||
switch (startType) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
startBoundElement = newElement({
|
||||
x: startX,
|
||||
y: startY,
|
||||
width,
|
||||
height,
|
||||
...existingElement,
|
||||
...start,
|
||||
type: startType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
`Unhandled element start type "${start.type}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
startBoundElement as ExcalidrawBindableElement,
|
||||
"start",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (end) {
|
||||
const height = end?.height ?? DEFAULT_DIMENSION;
|
||||
const width = end?.width ?? DEFAULT_DIMENSION;
|
||||
|
||||
let existingElement;
|
||||
if (end.id) {
|
||||
existingElement = elementStore.getElement(end.id);
|
||||
if (!existingElement) {
|
||||
console.error(`No element for end binding with id ${end.id} found`);
|
||||
}
|
||||
}
|
||||
const endX = end.x || linearElement.x + linearElement.width;
|
||||
const endY = end.y || linearElement.y - height / 2;
|
||||
const endType = existingElement ? existingElement.type : end.type;
|
||||
|
||||
if (endType) {
|
||||
if (endType === "text") {
|
||||
let text = "";
|
||||
if (existingElement && existingElement.type === "text") {
|
||||
text = existingElement.text;
|
||||
} else if (end.type === "text") {
|
||||
text = end.text;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
console.error(
|
||||
`No text found for end binding text element for ${linearElement.id}`,
|
||||
);
|
||||
}
|
||||
endBoundElement = newTextElement({
|
||||
x: endX,
|
||||
y: endY,
|
||||
type: "text",
|
||||
...existingElement,
|
||||
...end,
|
||||
text,
|
||||
});
|
||||
// to position the text correctly when coordinates not provided
|
||||
Object.assign(endBoundElement, {
|
||||
y: end.y || linearElement.y - endBoundElement.height / 2,
|
||||
});
|
||||
} else {
|
||||
switch (endType) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
endBoundElement = newElement({
|
||||
x: endX,
|
||||
y: endY,
|
||||
width,
|
||||
height,
|
||||
...existingElement,
|
||||
...end,
|
||||
type: endType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
`Unhandled element end type "${endType}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
endBoundElement as ExcalidrawBindableElement,
|
||||
"end",
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
linearElement,
|
||||
startBoundElement,
|
||||
endBoundElement,
|
||||
};
|
||||
};
|
||||
|
||||
class ElementStore {
|
||||
excalidrawElements = new Map<string, ExcalidrawElement>();
|
||||
|
||||
add = (ele?: ExcalidrawElement) => {
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.excalidrawElements.set(ele.id, ele);
|
||||
};
|
||||
getElements = () => {
|
||||
return Array.from(this.excalidrawElements.values());
|
||||
};
|
||||
|
||||
getElement = (id: string) => {
|
||||
return this.excalidrawElements.get(id);
|
||||
};
|
||||
}
|
||||
|
||||
export const convertToExcalidrawElements = (
|
||||
elements: ExcalidrawElementSkeleton[] | null,
|
||||
) => {
|
||||
if (!elements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const elementStore = new ElementStore();
|
||||
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
|
||||
|
||||
// Create individual elements
|
||||
for (const element of elements) {
|
||||
let excalidrawElement: ExcalidrawElement;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
const width =
|
||||
element?.label?.text && element.width === undefined
|
||||
? 0
|
||||
: element?.width || DEFAULT_DIMENSION;
|
||||
const height =
|
||||
element?.label?.text && element.height === undefined
|
||||
? 0
|
||||
: element?.height || DEFAULT_DIMENSION;
|
||||
excalidrawElement = newElement({
|
||||
...element,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "line": {
|
||||
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
|
||||
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
points: [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
],
|
||||
...element,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "arrow": {
|
||||
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
|
||||
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
endArrowhead: "arrow",
|
||||
points: [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
],
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "text": {
|
||||
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight =
|
||||
element?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const text = element.text ?? "";
|
||||
const normalizedText = normalizeText(text);
|
||||
const metrics = measureText(
|
||||
normalizedText,
|
||||
getFontString({ fontFamily, fontSize }),
|
||||
lineHeight,
|
||||
);
|
||||
|
||||
excalidrawElement = newTextElement({
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
excalidrawElement = newImageElement({
|
||||
width: element?.width || DEFAULT_DIMENSION,
|
||||
height: element?.height || DEFAULT_DIMENSION,
|
||||
...element,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "frame":
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
excalidrawElement = element;
|
||||
assertNever(
|
||||
element,
|
||||
`Unhandled element type "${(element as any).type}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
const existingElement = elementStore.getElement(excalidrawElement.id);
|
||||
if (existingElement) {
|
||||
console.error(`Duplicate id found for ${excalidrawElement.id}`);
|
||||
} else {
|
||||
elementStore.add(excalidrawElement);
|
||||
elementsWithIds.set(excalidrawElement.id, element);
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels and arrow bindings
|
||||
for (const [id, element] of elementsWithIds) {
|
||||
const excalidrawElement = elementStore.getElement(id)!;
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond":
|
||||
case "arrow": {
|
||||
if (element.label?.text) {
|
||||
let [container, text] = bindTextToContainer(
|
||||
excalidrawElement,
|
||||
element?.label,
|
||||
);
|
||||
elementStore.add(container);
|
||||
elementStore.add(text);
|
||||
|
||||
if (container.type === "arrow") {
|
||||
const originalStart =
|
||||
element.type === "arrow" ? element?.start : undefined;
|
||||
const originalEnd =
|
||||
element.type === "arrow" ? element?.end : undefined;
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
container as ExcalidrawArrowElement,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
elementStore,
|
||||
);
|
||||
container = linearElement;
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
}
|
||||
} else {
|
||||
switch (element.type) {
|
||||
case "arrow": {
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
excalidrawElement as ExcalidrawArrowElement,
|
||||
element.start,
|
||||
element.end,
|
||||
elementStore,
|
||||
);
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return elementStore.getElements();
|
||||
};
|
@ -190,7 +190,7 @@ export const maybeBindLinearElement = (
|
||||
}
|
||||
};
|
||||
|
||||
export const bindLinearElement = (
|
||||
const bindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
|
@ -264,12 +264,12 @@ export class LinearElementEditor {
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
if (isBindingElement(element, false)) {
|
||||
|
@ -46,7 +46,7 @@ import {
|
||||
} from "../constants";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
| "width"
|
||||
| "height"
|
||||
@ -134,7 +134,7 @@ export const newElement = (
|
||||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
validated: ExcalidrawEmbeddableElement["validated"];
|
||||
validated: boolean | undefined;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
||||
return {
|
||||
@ -187,7 +187,7 @@ export const newTextElement = (
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
} & ElementConstructorOpts,
|
||||
@ -361,8 +361,8 @@ export const newFreeDrawElement = (
|
||||
export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
startArrowhead?: Arrowhead | null;
|
||||
endArrowhead?: Arrowhead | null;
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
@ -372,8 +372,8 @@ export const newLinearElement = (
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
endArrowhead: opts.endArrowhead || null,
|
||||
startArrowhead: opts.startArrowhead,
|
||||
endArrowhead: opts.endArrowhead,
|
||||
};
|
||||
};
|
||||
|
||||
@ -477,7 +477,7 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
|
||||
* utility wrapper to generate new id. In test env it reuses the old + postfix
|
||||
* for test assertions.
|
||||
*/
|
||||
export const regenerateId = (
|
||||
const regenerateId = (
|
||||
/** supply null if no previous id exists */
|
||||
previousId: string | null,
|
||||
) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
import {
|
||||
@ -204,6 +204,8 @@ const rescalePointsInElement = (
|
||||
}
|
||||
: {};
|
||||
|
||||
const MIN_FONT_SIZE = 1;
|
||||
|
||||
const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
nextWidth: number,
|
||||
@ -587,42 +589,24 @@ export const resizeSingleElement = (
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isArrowElement(element) &&
|
||||
boundTextElement &&
|
||||
shouldMaintainAspectRatio
|
||||
) {
|
||||
const fontSize =
|
||||
(resizedElement.width / element.width) * boundTextElement.fontSize;
|
||||
if (fontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
boundTextFont.fontSize = fontSize;
|
||||
}
|
||||
|
||||
if (
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.height !== 0 &&
|
||||
Number.isFinite(resizedElement.x) &&
|
||||
Number.isFinite(resizedElement.y)
|
||||
) {
|
||||
mutateElement(element, resizedElement);
|
||||
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
});
|
||||
|
||||
mutateElement(element, resizedElement);
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
baseline: boundTextFont.baseline,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(
|
||||
element,
|
||||
transformHandleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
handleBindTextResize(element, transformHandleDirection);
|
||||
}
|
||||
};
|
||||
|
||||
@ -738,8 +722,12 @@ export const resizeMultipleElements = (
|
||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
baseline?: ExcalidrawTextElement["baseline"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
};
|
||||
boundText: {
|
||||
element: ExcalidrawTextElementWithContainer;
|
||||
fontSize: ExcalidrawTextElement["fontSize"];
|
||||
baseline: ExcalidrawTextElement["baseline"];
|
||||
} | null;
|
||||
}[] = [];
|
||||
|
||||
for (const { orig, latest } of targetElements) {
|
||||
@ -810,39 +798,50 @@ export const resizeMultipleElements = (
|
||||
}
|
||||
}
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
const metrics = measureFontSizeFromWidth(orig, width, height);
|
||||
let boundText: typeof elementsAndUpdates[0]["boundText"] = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(latest);
|
||||
|
||||
if (boundTextElement || isTextElement(orig)) {
|
||||
const updatedElement = {
|
||||
...latest,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const metrics = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (orig as ExcalidrawTextElement),
|
||||
boundTextElement
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
boundTextElement
|
||||
? getBoundTextMaxHeight(updatedElement, boundTextElement)
|
||||
: updatedElement.height,
|
||||
);
|
||||
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
const boundTextElement = pointerDownState.originalElements.get(
|
||||
getBoundTextElementId(orig) ?? "",
|
||||
) as ExcalidrawTextElementWithContainer | undefined;
|
||||
|
||||
if (boundTextElement) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
if (newFontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
boundText = {
|
||||
element: boundTextElement,
|
||||
fontSize: metrics.size,
|
||||
baseline: metrics.baseline,
|
||||
};
|
||||
}
|
||||
update.boundTextFontSize = newFontSize;
|
||||
}
|
||||
|
||||
elementsAndUpdates.push({
|
||||
element: latest,
|
||||
update,
|
||||
});
|
||||
elementsAndUpdates.push({ element: latest, update, boundText });
|
||||
}
|
||||
|
||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||
|
||||
for (const {
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
for (const { element, update, boundText } of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
|
||||
mutateElement(element, update, false);
|
||||
@ -852,17 +851,17 @@ export const resizeMultipleElements = (
|
||||
newSize: { width, height },
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
if (boundText) {
|
||||
const { element: boundTextElement, ...boundTextUpdates } = boundText;
|
||||
mutateElement(
|
||||
boundTextElement,
|
||||
{
|
||||
fontSize: boundTextFontSize,
|
||||
...boundTextUpdates,
|
||||
angle: isLinearElement(element) ? undefined : angle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
handleBindTextResize(element, transformHandleType, true);
|
||||
handleBindTextResize(element, transformHandleType);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -10,8 +10,6 @@ import {
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
@ -67,7 +65,7 @@ export const redrawTextBoundingBox = (
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getBoundTextMaxWidth(container, textElement);
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
boundTextUpdates.text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
@ -85,27 +83,21 @@ export const redrawTextBoundingBox = (
|
||||
boundTextUpdates.baseline = metrics.baseline;
|
||||
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
|
||||
let nextHeight = containerDims.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
const nextHeight = computeContainerDimensionForBoundText(
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
if (metrics.width > maxContainerWidth) {
|
||||
const nextWidth = computeContainerDimensionForBoundText(
|
||||
metrics.width,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { width: nextWidth });
|
||||
}
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
@ -163,7 +155,6 @@ export const bindTextToShapeAfterDuplication = (
|
||||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
) => {
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
@ -184,17 +175,15 @@ export const handleBindTextResize = (
|
||||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxWidth = getBoundTextMaxWidth(container);
|
||||
const maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let containerHeight = container.height;
|
||||
let containerHeight = containerDims.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (
|
||||
shouldMaintainAspectRatio ||
|
||||
(transformHandleType !== "n" && transformHandleType !== "s")
|
||||
) {
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
@ -218,7 +207,7 @@ export const handleBindTextResize = (
|
||||
container.type,
|
||||
);
|
||||
|
||||
const diff = containerHeight - container.height;
|
||||
const diff = containerHeight - containerDims.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
!isArrowElement(container) &&
|
||||
@ -698,6 +687,16 @@ export const getContainerElement = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getContainerDims = (element: ExcalidrawElement) => {
|
||||
const MIN_WIDTH = 300;
|
||||
if (isArrowElement(element)) {
|
||||
const width = Math.max(element.width, MIN_WIDTH);
|
||||
const height = element.height;
|
||||
return { width, height };
|
||||
}
|
||||
return { width: element.width, height: element.height };
|
||||
};
|
||||
|
||||
export const getContainerCenter = (
|
||||
container: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
@ -866,9 +865,8 @@ const VALID_CONTAINER_TYPES = new Set([
|
||||
"arrow",
|
||||
]);
|
||||
|
||||
export const isValidTextContainer = (element: {
|
||||
type: ExcalidrawElement["type"];
|
||||
}) => VALID_CONTAINER_TYPES.has(element.type);
|
||||
export const isValidTextContainer = (element: ExcalidrawElement) =>
|
||||
VALID_CONTAINER_TYPES.has(element.type);
|
||||
|
||||
export const computeContainerDimensionForBoundText = (
|
||||
dimension: number,
|
||||
@ -889,19 +887,12 @@ export const computeContainerDimensionForBoundText = (
|
||||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxWidth = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElement | null = getBoundTextElement(
|
||||
container,
|
||||
),
|
||||
) => {
|
||||
const { width } = container;
|
||||
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
if (isArrowElement(container)) {
|
||||
const minWidth =
|
||||
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
|
||||
return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
|
||||
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The width of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
||||
@ -920,7 +911,7 @@ export const getBoundTextMaxHeight = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const { height } = container;
|
||||
const height = getContainerDims(container).height;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
|
@ -23,6 +23,7 @@ import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
@ -176,19 +177,20 @@ export const textWysiwyg = ({
|
||||
updatedTextElement,
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
container.height,
|
||||
containerDims.height,
|
||||
);
|
||||
} else {
|
||||
originalContainerData = originalContainerCache[container.id];
|
||||
if (!originalContainerData) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
container.height,
|
||||
containerDims.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -212,7 +214,7 @@ export const textWysiwyg = ({
|
||||
// autoshrink container height until original container height
|
||||
// is reached when text is removed
|
||||
!isArrowElement(container) &&
|
||||
container.height > originalContainerData.height &&
|
||||
containerDims.height > originalContainerData.height &&
|
||||
textElementHeight < maxHeight
|
||||
) {
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
|
@ -86,15 +86,15 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
||||
|
||||
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "embeddable";
|
||||
/**
|
||||
* indicates whether the embeddable src (url) has been validated for rendering.
|
||||
* null value indicates that the validation is pending. We reset the
|
||||
* nullish value indicates that the validation is pending. We reset the
|
||||
* value on each restore (or url change) so that we can guarantee
|
||||
* the validation came from a trusted source (the editor). Also because we
|
||||
* may not have access to host-app supplied url validator during restore.
|
||||
*/
|
||||
validated: boolean | null;
|
||||
validated?: boolean;
|
||||
type: "embeddable";
|
||||
}>;
|
||||
|
||||
export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
@ -123,6 +123,7 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
||||
export type ExcalidrawGenericElement =
|
||||
| ExcalidrawSelectionElement
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
@ -137,8 +138,7 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
| ExcalidrawFrameElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
|
@ -731,6 +731,13 @@ const ExcalidrawWrapper = () => {
|
||||
/>
|
||||
);
|
||||
}}
|
||||
scrollConstraints={{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 2560,
|
||||
height: 1300,
|
||||
lockZoom: true,
|
||||
}}
|
||||
>
|
||||
<AppMainMenu
|
||||
setCollabDialogShown={setCollabDialogShown}
|
||||
|
@ -65,6 +65,7 @@ const canvas = exportToCanvas(
|
||||
offsetLeft: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
scrollConstraints: null,
|
||||
},
|
||||
{}, // files
|
||||
{
|
||||
|
@ -75,7 +75,6 @@ const {
|
||||
WelcomeScreen,
|
||||
MainMenu,
|
||||
LiveCollaborationTrigger,
|
||||
convertToExcalidrawElements,
|
||||
} = window.ExcalidrawLib;
|
||||
|
||||
const COMMENT_ICON_DIMENSION = 32;
|
||||
@ -141,10 +140,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
||||
];
|
||||
|
||||
//@ts-ignore
|
||||
initialStatePromiseRef.current.promise.resolve({
|
||||
...initialData,
|
||||
elements: convertToExcalidrawElements(initialData.elements),
|
||||
});
|
||||
initialStatePromiseRef.current.promise.resolve(initialData);
|
||||
excalidrawAPI.addFiles(imagesArray);
|
||||
};
|
||||
};
|
||||
@ -188,40 +184,38 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
||||
const updateScene = () => {
|
||||
const sceneData = {
|
||||
elements: restoreElements(
|
||||
convertToExcalidrawElements([
|
||||
[
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "rect-1",
|
||||
version: 141,
|
||||
versionNonce: 361174001,
|
||||
isDeleted: false,
|
||||
id: "oDVXy8D6rom3H1-LLH2-f",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
angle: 0,
|
||||
x: 100.50390625,
|
||||
y: 93.67578125,
|
||||
strokeColor: "#c92a2a",
|
||||
backgroundColor: "transparent",
|
||||
width: 186.47265625,
|
||||
height: 141.9765625,
|
||||
seed: 1968410350,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
boundElements: null,
|
||||
locked: false,
|
||||
link: null,
|
||||
updated: 1,
|
||||
roundness: {
|
||||
type: ROUNDNESS.ADAPTIVE_RADIUS,
|
||||
value: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 300,
|
||||
y: 150,
|
||||
start: { id: "rect-1" },
|
||||
end: { type: "ellipse" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
x: 300,
|
||||
y: 100,
|
||||
text: "HELLO WORLD!",
|
||||
},
|
||||
]),
|
||||
],
|
||||
null,
|
||||
),
|
||||
appState: {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -42,6 +42,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
onPointerDown,
|
||||
onScrollChange,
|
||||
children,
|
||||
scrollConstraints,
|
||||
validateEmbeddable,
|
||||
renderEmbeddable,
|
||||
} = props;
|
||||
@ -100,7 +101,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
onPointerUpdate={onPointerUpdate}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
langCode={langCode}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
viewModeEnabled={viewModeEnabled /* || !!scrollConstraints */}
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
gridModeEnabled={gridModeEnabled}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
@ -117,6 +118,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
onLinkOpen={onLinkOpen}
|
||||
onPointerDown={onPointerDown}
|
||||
onScrollChange={onScrollChange}
|
||||
scrollConstraints={scrollConstraints}
|
||||
validateEmbeddable={validateEmbeddable}
|
||||
renderEmbeddable={renderEmbeddable}
|
||||
>
|
||||
@ -253,4 +255,3 @@ export { LiveCollaborationTrigger };
|
||||
export { DefaultSidebar } from "../../components/DefaultSidebar";
|
||||
|
||||
export { normalizeLink } from "../../data/url";
|
||||
export { convertToExcalidrawElements } from "../../data/transform";
|
||||
|
@ -2,8 +2,8 @@ const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const { parseEnvVariables } = require("./env");
|
||||
const outputDir = process.env.EXAMPLE === "true" ? "example/public" : "dist";
|
||||
|
||||
const outputDir = process.env.EXAMPLE === "true" ? "example/public" : "dist";
|
||||
module.exports = {
|
||||
mode: "development",
|
||||
devtool: false,
|
||||
@ -17,6 +17,7 @@ module.exports = {
|
||||
filename: "[name].js",
|
||||
chunkFilename: "excalidraw-assets-dev/[name]-[contenthash].js",
|
||||
assetModuleFilename: "excalidraw-assets-dev/[name][ext]",
|
||||
|
||||
publicPath: "",
|
||||
},
|
||||
resolve: {
|
||||
@ -44,7 +45,7 @@ module.exports = {
|
||||
{
|
||||
test: /\.(ts|tsx|js|jsx|mjs)$/,
|
||||
exclude:
|
||||
/node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
use: [
|
||||
{
|
||||
loader: "import-meta-loader",
|
||||
|
@ -1,10 +1,10 @@
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const { parseEnvVariables } = require("./env");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const BundleAnalyzerPlugin =
|
||||
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const webpack = require("webpack");
|
||||
const { parseEnvVariables } = require("./env");
|
||||
|
||||
module.exports = {
|
||||
mode: "production",
|
||||
@ -47,7 +47,8 @@ module.exports = {
|
||||
{
|
||||
test: /\.(ts|tsx|js|jsx|mjs)$/,
|
||||
exclude:
|
||||
/node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
|
||||
use: [
|
||||
{
|
||||
loader: "import-meta-loader",
|
||||
|
@ -64,7 +64,14 @@ export const exportToCanvas = ({
|
||||
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
||||
return _exportToCanvas(
|
||||
passElementsSafely(restoredElements),
|
||||
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
|
||||
{
|
||||
...restoredAppState,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
scrollConstraints: null,
|
||||
},
|
||||
files || {},
|
||||
{ exportBackground, exportPadding, viewBackgroundColor },
|
||||
(width: number, height: number) => {
|
||||
|
@ -60,3 +60,8 @@ export type ScrollBars = {
|
||||
height: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ConstrainedScrollValues = Pick<
|
||||
AppState,
|
||||
"scrollX" | "scrollY" | "zoom"
|
||||
> | null;
|
||||
|
@ -346,6 +346,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -539,6 +540,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -738,6 +740,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -1111,6 +1114,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -1484,6 +1488,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -1683,6 +1688,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -1919,6 +1925,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2222,6 +2229,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"id1": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2609,6 +2617,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3488,6 +3497,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3861,6 +3871,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -4236,6 +4247,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"id1": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -4968,6 +4980,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -5548,6 +5561,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -6050,6 +6064,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -6446,6 +6461,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -6820,6 +6836,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
|
@ -74,6 +74,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"id2": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -527,6 +528,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"id2": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -978,6 +980,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -1808,6 +1811,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2018,6 +2022,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2469,6 +2474,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2706,6 +2712,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -2873,6 +2880,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"id2": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3312,6 +3320,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3608,6 +3617,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -3850,6 +3860,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -4103,6 +4114,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"id1": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -4342,6 +4354,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"id2": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -4712,6 +4725,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"id1": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -5006,6 +5020,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"id1": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -5271,6 +5286,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -5464,6 +5480,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -5627,6 +5644,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -6079,6 +6097,7 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"id1": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -6390,6 +6409,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -8457,6 +8477,7 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"id2": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -8797,6 +8818,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9037,6 +9059,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9233,6 +9256,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9499,6 +9523,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9664,6 +9689,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9829,6 +9855,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -9994,6 +10021,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -10197,6 +10225,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -10400,6 +10429,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -10583,6 +10613,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -10786,6 +10817,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -10951,6 +10983,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -11154,6 +11187,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -11319,6 +11353,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -11502,6 +11537,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -11671,6 +11707,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"id2": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -12330,6 +12367,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -12567,6 +12605,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": -2.916666666666668,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -12687,6 +12726,7 @@ exports[`regression tests > rerenders UI on language change > [end of test] appS
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -12809,6 +12849,7 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -12975,6 +13016,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"id1": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -13290,6 +13332,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"id2": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -13844,6 +13887,7 @@ exports[`regression tests > should show fill icons when element has non transpar
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -14057,6 +14101,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"id6": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -14905,6 +14950,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 60,
|
||||
"scrollY": 60,
|
||||
"scrolledOutside": false,
|
||||
@ -15027,6 +15073,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"id0": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -15840,6 +15887,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"id2": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -16236,6 +16284,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"id1": true,
|
||||
},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -16501,6 +16550,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 10,
|
||||
"scrollY": -10,
|
||||
"scrolledOutside": false,
|
||||
@ -16621,6 +16671,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -17101,6 +17152,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
@ -17221,6 +17273,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollConstraints": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
|
@ -140,8 +140,9 @@ describe("restoreElements", () => {
|
||||
expect(restoredArrow).toMatchSnapshot({ seed: expect.any(Number) });
|
||||
});
|
||||
|
||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
|
||||
it("when arrow element has defined endArrowHead", () => {
|
||||
const arrowElement = API.createElement({ type: "arrow" });
|
||||
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
|
||||
const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
|
||||
@ -149,7 +150,7 @@ describe("restoreElements", () => {
|
||||
expect(arrowElement.endArrowhead).toBe(restoredArrow.endArrowhead);
|
||||
});
|
||||
|
||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is undefined', () => {
|
||||
it("when arrow element has undefined endArrowHead", () => {
|
||||
const arrowElement = API.createElement({ type: "arrow" });
|
||||
Object.defineProperty(arrowElement, "endArrowhead", {
|
||||
get: vi.fn(() => undefined),
|
||||
|
1
src/tests/fixtures/elementFixture.ts
vendored
1
src/tests/fixtures/elementFixture.ts
vendored
@ -34,7 +34,6 @@ export const rectangleFixture: ExcalidrawElement = {
|
||||
export const embeddableFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "embeddable",
|
||||
validated: null,
|
||||
};
|
||||
export const ellipseFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
|
@ -15,11 +15,7 @@ import fs from "fs";
|
||||
import util from "util";
|
||||
import path from "path";
|
||||
import { getMimeType } from "../../data/blob";
|
||||
import {
|
||||
newEmbeddableElement,
|
||||
newFreeDrawElement,
|
||||
newImageElement,
|
||||
} from "../../element/newElement";
|
||||
import { newFreeDrawElement, newImageElement } from "../../element/newElement";
|
||||
import { Point } from "../../types";
|
||||
import { getSelectedElements } from "../../scene/selection";
|
||||
import { isLinearElementType } from "../../element/typeChecks";
|
||||
@ -182,20 +178,14 @@ export class API {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "embeddable":
|
||||
element = newElement({
|
||||
type: type as "rectangle" | "diamond" | "ellipse",
|
||||
type: type as "rectangle" | "diamond" | "ellipse" | "embeddable",
|
||||
width,
|
||||
height,
|
||||
...base,
|
||||
});
|
||||
break;
|
||||
case "embeddable":
|
||||
element = newEmbeddableElement({
|
||||
type: "embeddable",
|
||||
...base,
|
||||
validated: null,
|
||||
});
|
||||
break;
|
||||
case "text":
|
||||
const fontSize = rest.fontSize ?? appState.currentItemFontSize;
|
||||
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
|
||||
|
@ -1128,7 +1128,7 @@ describe("Test Linear Elements", () => {
|
||||
height: 500,
|
||||
});
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: -10,
|
||||
x: 210,
|
||||
y: 250,
|
||||
width: 400,
|
||||
height: 1,
|
||||
@ -1152,8 +1152,8 @@ describe("Test Linear Elements", () => {
|
||||
expect(
|
||||
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made easy"
|
||||
"Online whiteboard collaboration
|
||||
made easy"
|
||||
`);
|
||||
const handleBindTextResizeSpy = vi.spyOn(
|
||||
textElementUtils,
|
||||
@ -1165,7 +1165,7 @@ describe("Test Linear Elements", () => {
|
||||
mouse.moveTo(200, 0);
|
||||
mouse.upAt(200, 0);
|
||||
|
||||
expect(arrow.width).toBe(200);
|
||||
expect(arrow.width).toBe(170);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
|
13
src/types.ts
13
src/types.ts
@ -238,6 +238,7 @@ export type AppState = {
|
||||
pendingImageElementId: ExcalidrawImageElement["id"] | null;
|
||||
showHyperlinkPopup: false | "info" | "editor";
|
||||
selectedLinearElement: LinearElementEditor | null;
|
||||
scrollConstraints: ScrollConstraints | null;
|
||||
};
|
||||
|
||||
export type UIAppState = Omit<
|
||||
@ -376,6 +377,7 @@ export interface ExcalidrawProps {
|
||||
) => void;
|
||||
onScrollChange?: (scrollX: number, scrollY: number) => void;
|
||||
children?: React.ReactNode;
|
||||
scrollConstraints?: AppState["scrollConstraints"];
|
||||
validateEmbeddable?:
|
||||
| boolean
|
||||
| string[]
|
||||
@ -574,6 +576,7 @@ export type ExcalidrawImperativeAPI = {
|
||||
* used in conjunction with view mode (props.viewModeEnabled).
|
||||
*/
|
||||
updateFrameRendering: InstanceType<typeof App>["updateFrameRendering"];
|
||||
setScrollConstraints: InstanceType<typeof App>["setScrollConstraints"];
|
||||
};
|
||||
|
||||
export type Device = Readonly<{
|
||||
@ -602,3 +605,13 @@ export type FrameNameBoundsCache = {
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type ScrollConstraints = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isAnimating?: boolean;
|
||||
viewportZoomFactor?: number;
|
||||
lockZoom?: boolean;
|
||||
};
|
||||
|
13
src/utils.ts
13
src/utils.ts
@ -914,16 +914,3 @@ export const isOnlyExportingSingleFrame = (
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const assertNever = (
|
||||
value: never,
|
||||
message: string,
|
||||
softAssert?: boolean,
|
||||
): never => {
|
||||
if (softAssert) {
|
||||
console.error(message);
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user