@@ -1429,8 +1468,7 @@ export const actionChangeVerticalAlign = register({
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
- appState.stylesPanelMode === "compact" ||
- appState.stylesPanelMode === "mobile",
+ isCompact,
!!appState.editingTextElement,
data?.onPreventClose,
);
@@ -1442,7 +1480,7 @@ export const actionChangeVerticalAlign = register({
},
});
-export const actionChangeRoundness = register({
+export const actionChangeRoundness = register<"sharp" | "round">({
name: "changeRoundness",
label: "Change edge roundness",
trackEvent: false,
@@ -1551,11 +1589,18 @@ const getMarginValue = (margin: number | null) => {
}
};
-export const actionChangeContainerBehavior = register({
+export const actionChangeContainerBehavior = register<
+ | { textFlow: ContainerBehavior["textFlow"] }
+ | { margin: NonNullable
> }
+>({
name: "changeContainerBehavior",
label: "labels.container",
trackEvent: false,
perform: (elements, appState, value, app) => {
+ invariant(
+ value,
+ "actionChangeContainerBehavior: value must be defined",
+ );
const elementsMap = app.scene.getNonDeletedElementsMap();
let selected = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
@@ -1591,7 +1636,8 @@ export const actionChangeContainerBehavior = register({
}
}
- if (value.hasOwnProperty("margin")) {
+ if ("margin" in value) {
+ const marginSize = getMargin(value.margin);
if (containerIdsToUpdate.size === 0) {
return {
appState: {
@@ -1599,24 +1645,22 @@ export const actionChangeContainerBehavior = register({
currentItemContainerBehavior: {
textFlow:
appState.currentItemContainerBehavior?.textFlow ?? "growing",
- margin: getMargin(value.margin as "small" | "medium" | "large"),
+ margin: marginSize,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
-
const nextElements = changeProperty(elements, appState, (el) =>
containerIdsToUpdate.has(el.id)
? newElementWith(el, {
containerBehavior: {
textFlow: el.containerBehavior?.textFlow ?? "growing",
- margin: getMargin(value.margin as "small" | "medium" | "large"),
+ margin: marginSize,
},
})
: el,
);
-
// Invalidate containers to trigger re-render
containerIdsToUpdate.forEach((id) => {
const container = nextElements.find((el) => el.id === id);
@@ -1638,30 +1682,29 @@ export const actionChangeContainerBehavior = register({
currentItemContainerBehavior: {
textFlow:
appState.currentItemContainerBehavior?.textFlow ?? "growing",
- margin: getMargin(value.margin as "small" | "medium" | "large"),
+ margin: marginSize,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
-
+ const textFlow = value.textFlow;
const nextElements = changeProperty(elements, appState, (el) =>
containerIdsToUpdate.has(el.id)
? newElementWith(el, {
containerBehavior: {
- textFlow: value,
+ textFlow,
margin: el.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
})
: el,
);
-
return {
elements: nextElements,
appState: {
...appState,
currentItemContainerBehavior: {
- textFlow: value,
+ textFlow,
margin:
appState.currentItemContainerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
@@ -1866,15 +1909,16 @@ const getArrowheadOptions = (flip: boolean) => {
] as const;
};
-export const actionChangeArrowhead = register({
+export const actionChangeArrowhead = register<{
+ position: "start" | "end";
+ type: Arrowhead;
+}>({
name: "changeArrowhead",
label: "Change arrowheads",
trackEvent: false,
- perform: (
- elements,
- appState,
- value: { position: "start" | "end"; type: Arrowhead },
- ) => {
+ perform: (elements, appState, value) => {
+ invariant(value, "actionChangeArrowhead: value must be defined");
+
return {
elements: changeProperty(elements, appState, (el) => {
if (isLinearElement(el)) {
@@ -1969,7 +2013,7 @@ export const actionChangeArrowProperties = register({
},
});
-export const actionChangeArrowType = register({
+export const actionChangeArrowType = register({
name: "changeArrowType",
label: "Change arrow types",
trackEvent: false,
@@ -1978,7 +2022,20 @@ export const actionChangeArrowType = register({
if (!isArrowElement(el)) {
return el;
}
+ const elementsMap = app.scene.getNonDeletedElementsMap();
+ const startPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ el,
+ 0,
+ elementsMap,
+ );
+ const endPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ el,
+ -1,
+ elementsMap,
+ );
let newElement = newElementWith(el, {
+ x: value === ARROW_TYPE.elbow ? startPoint[0] : el.x,
+ y: value === ARROW_TYPE.elbow ? startPoint[1] : el.y,
roundness:
value === ARROW_TYPE.round
? {
@@ -1986,9 +2043,31 @@ export const actionChangeArrowType = register({
}
: null,
elbowed: value === ARROW_TYPE.elbow,
+ angle: value === ARROW_TYPE.elbow ? (0 as Radians) : el.angle,
points:
value === ARROW_TYPE.elbow || el.elbowed
- ? [el.points[0], el.points[el.points.length - 1]]
+ ? [
+ LinearElementEditor.pointFromAbsoluteCoords(
+ {
+ ...el,
+ x: startPoint[0],
+ y: startPoint[1],
+ angle: 0 as Radians,
+ },
+ startPoint,
+ elementsMap,
+ ),
+ LinearElementEditor.pointFromAbsoluteCoords(
+ {
+ ...el,
+ x: startPoint[0],
+ y: startPoint[1],
+ angle: 0 as Radians,
+ },
+ endPoint,
+ elementsMap,
+ ),
+ ]
: el.points,
});
@@ -2070,7 +2149,13 @@ export const actionChangeArrowType = register({
newElement.startBinding.elementId,
) as ExcalidrawBindableElement;
if (startElement) {
- bindLinearElement(newElement, startElement, "start", app.scene);
+ bindBindingElement(
+ newElement,
+ startElement,
+ appState.bindMode === "inside" ? "inside" : "orbit",
+ "start",
+ app.scene,
+ );
}
}
if (newElement.endBinding) {
@@ -2078,7 +2163,13 @@ export const actionChangeArrowType = register({
newElement.endBinding.elementId,
) as ExcalidrawBindableElement;
if (endElement) {
- bindLinearElement(newElement, endElement, "end", app.scene);
+ bindBindingElement(
+ newElement,
+ endElement,
+ appState.bindMode === "inside" ? "inside" : "orbit",
+ "end",
+ app.scene,
+ );
}
}
}
diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx
index 98175b384a..ad445f47dd 100644
--- a/packages/excalidraw/actions/actionToggleZenMode.tsx
+++ b/packages/excalidraw/actions/actionToggleZenMode.tsx
@@ -25,8 +25,11 @@ export const actionToggleZenMode = register({
};
},
checked: (appState) => appState.zenModeEnabled,
- predicate: (elements, appState, appProps) => {
- return typeof appProps.zenModeEnabled === "undefined";
+ predicate: (elements, appState, appProps, app) => {
+ return (
+ app.editorInterface.formFactor !== "phone" &&
+ typeof appProps.zenModeEnabled === "undefined"
+ );
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx
index f3314bf35e..adac253e22 100644
--- a/packages/excalidraw/actions/manager.tsx
+++ b/packages/excalidraw/actions/manager.tsx
@@ -37,7 +37,9 @@ const trackAction = (
trackEvent(
action.trackEvent.category,
action.trackEvent.action || action.name,
- `${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
+ `${source} (${
+ app.editorInterface.formFactor === "phone" ? "mobile" : "desktop"
+ })`,
);
}
}
diff --git a/packages/excalidraw/actions/register.ts b/packages/excalidraw/actions/register.ts
index 7c841e3aee..8f22810393 100644
--- a/packages/excalidraw/actions/register.ts
+++ b/packages/excalidraw/actions/register.ts
@@ -2,7 +2,12 @@ import type { Action } from "./types";
export let actions: readonly Action[] = [];
-export const register = (action: T) => {
+export const register = <
+ TData extends any,
+ T extends Action = Action,
+>(
+ action: T,
+) => {
actions = actions.concat(action);
return action as T & {
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts
index 430a04c71d..f078d21fbd 100644
--- a/packages/excalidraw/actions/types.ts
+++ b/packages/excalidraw/actions/types.ts
@@ -32,10 +32,10 @@ export type ActionResult =
}
| false;
-type ActionFn = (
+type ActionFn = (
elements: readonly OrderedExcalidrawElement[],
appState: Readonly,
- formData: any,
+ formData: TData | undefined,
app: AppClassProperties,
) => ActionResult | Promise;
@@ -158,7 +158,7 @@ export type PanelComponentProps = {
) => React.JSX.Element | null;
};
-export interface Action {
+export interface Action {
name: ActionName;
label:
| string
@@ -175,7 +175,7 @@ export interface Action {
elements: readonly ExcalidrawElement[],
) => React.ReactNode);
PanelComponent?: React.FC;
- perform: ActionFn;
+ perform: ActionFn;
keyPriority?: number;
keyTest?: (
event: React.KeyboardEvent | KeyboardEvent,
diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts
index c5b5d27a7f..e94f27f29e 100644
--- a/packages/excalidraw/appState.ts
+++ b/packages/excalidraw/appState.ts
@@ -101,7 +101,7 @@ export const getDefaultAppState = (): Omit<
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
startBoundElement: null,
- suggestedBindings: [],
+ suggestedBinding: null,
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
editingFrame: null,
@@ -128,7 +128,7 @@ export const getDefaultAppState = (): Omit<
searchMatches: null,
lockedMultiSelections: {},
activeLockedId: null,
- stylesPanelMode: "full",
+ bindMode: "orbit",
};
};
@@ -232,7 +232,7 @@ const APP_STATE_STORAGE_CONF = (<
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
- suggestedBindings: { browser: false, export: false, server: false },
+ suggestedBinding: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false },
@@ -255,7 +255,7 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false },
- stylesPanelMode: { browser: false, export: false, server: false },
+ bindMode: { browser: true, export: false, server: false },
});
const _clearAppStateForStorage = <
diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx
index cf0c1501d8..e90923c5de 100644
--- a/packages/excalidraw/components/Actions.tsx
+++ b/packages/excalidraw/components/Actions.tsx
@@ -52,11 +52,17 @@ import { getFormValue } from "../actions/actionProperties";
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
+import { actionToggleViewMode } from "../actions/actionToggleViewMode";
+
import { getToolbarTools } from "./shapes";
import "./Actions.scss";
-import { useDevice, useExcalidrawContainer } from "./App";
+import {
+ useEditorInterface,
+ useStylesPanelMode,
+ useExcalidrawContainer,
+} from "./App";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { ToolPopover } from "./ToolPopover";
@@ -78,6 +84,7 @@ import {
adjustmentsIcon,
DotsHorizontalIcon,
SelectionIcon,
+ pencilIcon,
} from "./icons";
import { Island } from "./Island";
@@ -163,7 +170,7 @@ export const SelectedShapeActions = ({
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
- const device = useDevice();
+ const editorInterface = useEditorInterface();
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons =
@@ -311,8 +318,10 @@ export const SelectedShapeActions = ({
{t("labels.actions")}
- {!device.editor.isMobile && renderAction("duplicateSelection")}
- {!device.editor.isMobile && renderAction("deleteSelectedElements")}
+ {editorInterface.formFactor !== "phone" &&
+ renderAction("duplicateSelection")}
+ {editorInterface.formFactor !== "phone" &&
+ renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
@@ -1075,6 +1084,9 @@ export const ShapesSwitcher = ({
UIOptions: AppProps["UIOptions"];
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
+ const stylesPanelMode = useStylesPanelMode();
+ const isFullStylesPanel = stylesPanelMode === "full";
+ const isCompactStylesPanel = stylesPanelMode === "compact";
const SELECTION_TOOLS = [
{
@@ -1092,7 +1104,7 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected =
- app.state.stylesPanelMode === "full" &&
+ isFullStylesPanel &&
activeTool.type === "lasso" &&
app.state.preferredSelectionTool.type !== "lasso";
@@ -1125,7 +1137,7 @@ export const ShapesSwitcher = ({
// use a ToolPopover for selection/lasso toggle as well
if (
(value === "selection" || value === "lasso") &&
- app.state.stylesPanelMode === "compact"
+ isCompactStylesPanel
) {
return (
{t("toolBar.laser")}
- {app.state.stylesPanelMode === "full" && (
+ {isFullStylesPanel && (
app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
@@ -1329,7 +1341,7 @@ export const UndoRedoActions = ({
);
-export const ExitZenModeAction = ({
+export const ExitZenModeButton = ({
actionManager,
showExitZenModeBtn,
}: {
@@ -1346,3 +1358,17 @@ export const ExitZenModeAction = ({
{t("buttons.exitZenMode")}
);
+
+export const ExitViewModeButton = ({
+ actionManager,
+}: {
+ actionManager: ActionManager;
+}) => (
+ actionManager.executeAction(actionToggleViewMode)}
+ >
+ {pencilIcon}
+
+);
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx
index c600b35399..1b698c98c6 100644
--- a/packages/excalidraw/components/App.tsx
+++ b/packages/excalidraw/components/App.tsx
@@ -37,7 +37,6 @@ import {
FRAME_STYLE,
IMAGE_MIME_TYPES,
IMAGE_RENDER_TIMEOUT,
- isBrave,
LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES,
MIME_TYPES,
@@ -55,13 +54,11 @@ import {
ZOOM_STEP,
POINTER_EVENTS,
TOOL_TYPE,
- isIOS,
supportsResizeObserver,
DEFAULT_COLLISION_THRESHOLD,
DEFAULT_TEXT_ALIGN,
ARROW_TYPE,
DEFAULT_REDUCED_GLOBAL_ALPHA,
- isSafari,
isLocalLink,
normalizeLink,
toValidURL,
@@ -98,27 +95,33 @@ import {
Emitter,
MINIMUM_ARROW_SIZE,
DOUBLE_TAP_POSITION_THRESHOLD,
- isMobileOrTablet,
- MQ_MAX_MOBILE,
- MQ_MIN_TABLET,
- MQ_MAX_TABLET,
- MQ_MAX_HEIGHT_LANDSCAPE,
- MQ_MAX_WIDTH_LANDSCAPE,
+ BIND_MODE_TIMEOUT,
BOUND_TEXT_PADDING,
+ invariant,
+ getFeatureFlag,
+ createUserAgentDescriptor,
+ getFormFactor,
+ deriveStylesPanelMode,
+ isIOS,
+ isBrave,
+ isSafari,
+ type EditorInterface,
+ type StylesPanelMode,
+ loadDesktopUIModePreference,
+ setDesktopUIMode,
+ isSelectionLikeTool,
} from "@excalidraw/common";
import {
getObservedAppState,
getCommonBounds,
- maybeSuggestBindingsForLinearElementAtCoords,
getElementAbsoluteCoords,
- bindOrUnbindLinearElements,
+ bindOrUnbindBindingElements,
fixBindingsAfterDeletion,
getHoveredElementForBinding,
isBindingEnabled,
shouldEnableBindingForPointerEvent,
updateBoundElements,
- getSuggestedBindingsForArrows,
LinearElementEditor,
newElementWith,
newFrameElement,
@@ -154,7 +157,6 @@ import {
isFlowchartNodeElement,
isBindableElement,
isTextElement,
- getLockedLinearCursorAlignSize,
getNormalizedDimensions,
isElementCompletelyInViewport,
isElementInViewport,
@@ -240,10 +242,17 @@ import {
StoreDelta,
type ApplyToOptions,
positionElementsOnGrid,
+ calculateFixedPointForNonElbowArrowBinding,
+ bindOrUnbindBindingElement,
+ mutateElement,
+ getElementBounds,
+ doBoundsIntersect,
isFlowchartType,
+ isPointInElement,
+ maxBindingDistance_simple,
} from "@excalidraw/element";
-import type { LocalPoint, Radians } from "@excalidraw/math";
+import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
import type {
ExcalidrawElement,
@@ -268,6 +277,7 @@ import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
SceneElementsMap,
+ ExcalidrawBindableElement,
} from "@excalidraw/element/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -462,7 +472,6 @@ import type {
LibraryItems,
PointerDownState,
SceneData,
- Device,
FrameNameBoundsCache,
SidebarName,
SidebarTabName,
@@ -483,19 +492,20 @@ import type { Action, ActionResult } from "../actions/types";
const AppContext = React.createContext(null!);
const AppPropsContext = React.createContext(null!);
-const deviceContextInitialValue = {
- viewport: {
- isMobile: false,
- isLandscape: false,
- },
- editor: {
- isMobile: false,
- canFitSidebar: false,
- },
+const editorInterfaceContextInitialValue: EditorInterface = {
+ formFactor: "desktop",
+ desktopUIMode: "full",
+ userAgent: createUserAgentDescriptor(
+ typeof navigator !== "undefined" ? navigator.userAgent : "",
+ ),
isTouchScreen: false,
+ canFitSidebar: false,
+ isLandscape: true,
};
-const DeviceContext = React.createContext(deviceContextInitialValue);
-DeviceContext.displayName = "DeviceContext";
+const EditorInterfaceContext = React.createContext(
+ editorInterfaceContextInitialValue,
+);
+EditorInterfaceContext.displayName = "EditorInterfaceContext";
export const ExcalidrawContainerContext = React.createContext<{
container: HTMLDivElement | null;
@@ -531,7 +541,10 @@ ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
export const useApp = () => useContext(AppContext);
export const useAppProps = () => useContext(AppPropsContext);
-export const useDevice = () => useContext(DeviceContext);
+export const useEditorInterface = () =>
+ useContext(EditorInterfaceContext);
+export const useStylesPanelMode = () =>
+ deriveStylesPanelMode(useEditorInterface());
export const useExcalidrawContainer = () =>
useContext(ExcalidrawContainerContext);
export const useExcalidrawElements = () =>
@@ -579,7 +592,10 @@ class App extends React.Component