Merge branch 'master' into mtolmacs/fix/small-elbow-routing

This commit is contained in:
Márk Tolmács 2025-05-02 09:47:18 +02:00 committed by GitHub
commit 8d7ffa21d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1286 additions and 62 deletions

View file

@ -32,6 +32,12 @@
"name": "jotai",
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
}
],
"react/jsx-no-target-blank": [
"error",
{
"allowReferrer": true
}
]
}
}

View file

@ -73,7 +73,7 @@ export const AIComponents = ({
</br>
<div>You can also try <a href="${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
</div>
</body>
</html>`,

View file

@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
className="encrypted-icon tooltip"
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
target="_blank"
rel="noopener noreferrer"
rel="noopener"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} long={true}>

View file

@ -10,7 +10,7 @@ export const ExcalidrawPlusAppLink = () => {
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank"
rel="noreferrer"
rel="noopener"
className="plus-button"
>
Go to Excalidraw+

View file

@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<a
class="welcome-screen-menu-item "
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
rel="noreferrer"
rel="noopener"
target="_blank"
>
<div

View file

@ -119,6 +119,7 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";

View file

@ -87,7 +87,6 @@ import type {
NonDeletedSceneElementsMap,
ExcalidrawTextElement,
ExcalidrawArrowElement,
OrderedExcalidrawElement,
ExcalidrawElbowArrowElement,
FixedPoint,
FixedPointBinding,
@ -716,29 +715,32 @@ const calculateFocusAndGap = (
// Supports translating, rotating and scaling `changedElement` with bound
// linear elements.
// Because scaling involves moving the focus points as well, it is
// done before the `changedElement` is updated, and the `newSize` is passed
// in explicitly.
export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>;
changedElements?: Map<string, ExcalidrawElement>;
},
) => {
if (!isBindableElement(changedElement)) {
return;
}
const { newSize, simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
if (!isBindableElement(changedElement)) {
return;
let elementsMap: ElementsMap = scene.getNonDeletedElementsMap();
if (options?.changedElements) {
elementsMap = new Map(elementsMap) as typeof elementsMap;
options.changedElements.forEach((element) => {
elementsMap.set(element.id, element);
});
}
const elementsMap = scene.getNonDeletedElementsMap();
boundElementsVisitor(elementsMap, changedElement, (element) => {
if (!isLinearElement(element) || element.isDeleted) {
return;
@ -842,6 +844,25 @@ export const updateBoundElements = (
});
};
export const updateBindings = (
latestElement: ExcalidrawElement,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom);
} else {
updateBoundElements(latestElement, scene, {
...options,
changedElements: new Map([[latestElement.id, latestElement]]),
});
}
};
const doesNeedUpdate = (
boundElement: NonDeleted<ExcalidrawLinearElement>,
changedElement: ExcalidrawBindableElement,

View file

@ -44,7 +44,6 @@ import type {
ExcalidrawIframeElement,
ElementsMap,
ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types";
@ -478,7 +477,7 @@ export const newArrowElement = <T extends boolean>(
endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"];
elbowed?: T;
fixedSegments?: FixedSegment[] | null;
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
} & ElementConstructorOpts,
): T extends true
? NonDeleted<ExcalidrawElbowArrowElement>

View file

@ -119,6 +119,20 @@ export const isElbowArrow = (
return isArrowElement(element) && element.elbowed;
};
export const isSharpArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed && !element.roundness;
};
export const isCurvedArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return (
isArrowElement(element) && !element.elbowed && element.roundness !== null
);
};
export const isLinearElementType = (
elementType: ElementOrToolType,
): boolean => {
@ -271,6 +285,10 @@ export const isBoundToContainer = (
);
};
export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
return !!element.startBinding || !!element.endBinding;
};
export const isUsingAdaptiveRadius = (type: string) =>
type === "rectangle" ||
type === "embeddable" ||

View file

@ -412,3 +412,11 @@ export type NonDeletedSceneElementsMap = Map<
export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| Readonly<ElementsMap>;
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
export type ConvertibleLinearTypes =
| "line"
| "sharpArrow"
| "curvedArrow"
| "elbowArrow";
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;

View file

@ -0,0 +1,34 @@
import type { ExcalidrawElement } from "@excalidraw/element/types";
import {
getConversionTypeFromElements,
convertElementTypePopupAtom,
} from "../components/ConvertElementTypePopup";
import { editorJotaiStore } from "../editor-jotai";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
export const actionToggleShapeSwitch = register({
name: "toggleShapeSwitch",
label: "labels.shapeSwitch",
icon: () => null,
viewMode: true,
trackEvent: {
category: "shape_switch",
action: "toggle",
},
keywords: ["change", "switch", "swap"],
perform(elements, appState, _, app) {
editorJotaiStore.set(convertElementTypePopupAtom, {
type: "panel",
});
return {
captureUpdate: CaptureUpdateAction.NEVER,
};
},
checked: (appState) => appState.gridModeEnabled,
predicate: (elements, appState, props) =>
getConversionTypeFromElements(elements as ExcalidrawElement[]) !== null,
});

View file

@ -140,7 +140,8 @@ export type ActionName =
| "linkToElement"
| "cropEditor"
| "wrapSelectionInFrame"
| "toggleLassoTool";
| "toggleLassoTool"
| "toggleShapeSwitch";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@ -195,7 +196,8 @@ export interface Action {
| "menu"
| "collab"
| "hyperlink"
| "search_menu";
| "search_menu"
| "shape_switch";
action?: string;
predicate?: (
appState: Readonly<AppState>,

View file

@ -100,6 +100,7 @@ import {
arrayToMap,
type EXPORT_IMAGE_TYPES,
randomInteger,
CLASSES,
} from "@excalidraw/common";
import {
@ -431,7 +432,7 @@ import {
} from "../components/hyperlink/Hyperlink";
import { Fonts } from "../fonts";
import { editorJotaiStore } from "../editor-jotai";
import { editorJotaiStore, type WritableAtom } from "../editor-jotai";
import { ImageSceneDataError } from "../errors";
import {
getSnapLinesAtPointer,
@ -467,6 +468,12 @@ import { LassoTrail } from "../lasso";
import { EraserTrail } from "../eraser";
import ConvertElementTypePopup, {
getConversionTypeFromElements,
convertElementTypePopupAtom,
convertElementTypes,
} from "./ConvertElementTypePopup";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
@ -498,7 +505,6 @@ import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem";
import type { ExcalidrawElementSkeleton } from "../data/transform";
import type {
AppClassProperties,
AppProps,
@ -815,6 +821,15 @@ class App extends React.Component<AppProps, AppState> {
);
}
updateEditorAtom = <Value, Args extends unknown[], Result>(
atom: WritableAtom<Value, Args, Result>,
...args: Args
): Result => {
const result = editorJotaiStore.set(atom, ...args);
this.triggerRender();
return result;
};
private onWindowMessage(event: MessageEvent) {
if (
event.origin !== "https://player.vimeo.com" &&
@ -1583,6 +1598,9 @@ class App extends React.Component<AppProps, AppState> {
const firstSelectedElement = selectedElements[0];
const showShapeSwitchPanel =
editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel";
return (
<div
className={clsx("excalidraw excalidraw-container", {
@ -1857,6 +1875,9 @@ class App extends React.Component<AppProps, AppState> {
/>
)}
{this.renderFrameNames()}
{showShapeSwitchPanel && (
<ConvertElementTypePopup app={this} />
)}
</ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()}
</ExcalidrawElementsContext.Provider>
@ -2138,7 +2159,7 @@ class App extends React.Component<AppProps, AppState> {
};
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
editorJotaiStore.set(activeEyeDropperAtom, {
this.updateEditorAtom(activeEyeDropperAtom, {
swapPreviewOnAlt: true,
colorPickerType:
type === "stroke" ? "elementStroke" : "elementBackground",
@ -4157,6 +4178,40 @@ class App extends React.Component<AppProps, AppState> {
return;
}
// Shape switching
if (event.key === KEYS.ESCAPE) {
this.updateEditorAtom(convertElementTypePopupAtom, null);
} else if (
event.key === KEYS.TAB &&
(document.activeElement === this.excalidrawContainerRef?.current ||
document.activeElement?.classList.contains(
CLASSES.CONVERT_ELEMENT_TYPE_POPUP,
))
) {
event.preventDefault();
const conversionType =
getConversionTypeFromElements(selectedElements);
if (
editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel"
) {
if (
convertElementTypes(this, {
conversionType,
direction: event.shiftKey ? "left" : "right",
})
) {
this.store.shouldCaptureIncrement();
}
}
if (conversionType) {
this.updateEditorAtom(convertElementTypePopupAtom, {
type: "panel",
});
}
}
if (
event.key === KEYS.ESCAPE &&
this.flowChartCreator.isCreatingChart
@ -4615,7 +4670,7 @@ class App extends React.Component<AppProps, AppState> {
event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
) {
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
this.updateEditorAtom(activeConfirmDialogAtom, "clearCanvas");
}
// eye dropper
@ -6364,7 +6419,11 @@ class App extends React.Component<AppProps, AppState> {
focus: false,
})),
}));
editorJotaiStore.set(searchItemInFocusAtom, null);
this.updateEditorAtom(searchItemInFocusAtom, null);
}
if (editorJotaiStore.get(convertElementTypePopupAtom)) {
this.updateEditorAtom(convertElementTypePopupAtom, null);
}
// since contextMenu options are potentially evaluated on each render,

View file

@ -11,6 +11,8 @@ import {
isWritableElement,
} from "@excalidraw/common";
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
import type { MarkRequired } from "@excalidraw/common/utility-types";
import {
@ -410,6 +412,14 @@ function CommandPaletteInner({
actionManager.executeAction(actionToggleSearchMenu);
},
},
{
label: t("labels.shapeSwitch"),
category: DEFAULT_CATEGORIES.elements,
icon: boltIcon,
perform: () => {
actionManager.executeAction(actionToggleShapeSwitch);
},
},
{
label: t("labels.changeStroke"),
keywords: ["color", "outline"],

View file

@ -0,0 +1,18 @@
@import "../css//variables.module.scss";
.excalidraw {
.ConvertElementTypePopup {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.2rem;
border-radius: 0.5rem;
background: var(--island-bg-color);
box-shadow: var(--shadow-island);
padding: 0.5rem;
&:focus {
outline: none;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,7 @@ const Header = () => (
className="HelpDialog__btn"
href="https://docs.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
rel="noopener"
>
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
{t("helpDialog.documentation")}
@ -30,7 +30,7 @@ const Header = () => (
className="HelpDialog__btn"
href="https://plus.excalidraw.com/blog"
target="_blank"
rel="noopener noreferrer"
rel="noopener"
>
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
{t("helpDialog.blog")}
@ -247,6 +247,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("toolBar.link")}
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
/>
<Shortcut
label={t("toolBar.convertElementType")}
shortcuts={["Tab", "Shift+Tab"]}
isOr={true}
/>
</ShortcutIsland>
<ShortcutIsland
className="HelpDialog__island--view"

View file

@ -389,7 +389,7 @@ const PublishLibrary = ({
<a
href="https://libraries.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
rel="noopener"
>
{el}
</a>

View file

@ -11,8 +11,10 @@ import type Scene from "@excalidraw/element/Scene";
import { angleIcon } from "../icons";
import { updateBindings } from "../../../element/src/binding";
import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type { AppState } from "../../types";

View file

@ -1,13 +1,8 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math";
import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "@excalidraw/element/binding";
import { getBoundTextElement } from "@excalidraw/element/textElement";
import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "@excalidraw/element/typeChecks";
@ -27,6 +22,8 @@ import type {
import type Scene from "@excalidraw/element/Scene";
import { updateBindings } from "../../../element/src/binding";
import type { AppState } from "../../types";
export type StatsInputProperty =
@ -194,19 +191,3 @@ export const getAtomicUnits = (
});
return _atomicUnits;
};
export const updateBindings = (
latestElement: ExcalidrawElement,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom);
} else {
updateBoundElements(latestElement, scene, options);
}
};

View file

@ -16,7 +16,7 @@ const DropdownMenuItemLink = ({
onSelect,
className = "",
selected,
rel = "noreferrer",
rel = "noopener",
...rest
}: {
href: string;
@ -31,11 +31,12 @@ const DropdownMenuItemLink = ({
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
// eslint-disable-next-line react/jsx-no-target-blank
<a
{...rest}
href={href}
target="_blank"
rel="noreferrer"
rel={rel || "noopener"}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
onClick={handleClick}

View file

@ -78,7 +78,7 @@ const WelcomeScreenMenuItemLink = ({
className={`welcome-screen-menu-item ${className}`}
href={href}
target="_blank"
rel="noreferrer"
rel="noopener"
>
<WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}>
{children}

View file

@ -29,6 +29,7 @@ import { bumpVersion } from "@excalidraw/element/mutateElement";
import { getContainerElement } from "@excalidraw/element/textElement";
import { detectLineHeight } from "@excalidraw/element/textMeasurements";
import {
isArrowBoundToElement,
isArrowElement,
isElbowArrow,
isFixedPointBinding,
@ -594,8 +595,7 @@ export const restoreElements = (
return restoredElements.map((element) => {
if (
isElbowArrow(element) &&
element.startBinding == null &&
element.endBinding == null &&
!isArrowBoundToElement(element) &&
!validateElbowPoints(element.points)
) {
return {

View file

@ -1,10 +1,15 @@
// eslint-disable-next-line no-restricted-imports
import { atom, createStore, type PrimitiveAtom } from "jotai";
import {
atom,
createStore,
type PrimitiveAtom,
type WritableAtom,
} from "jotai";
import { createIsolation } from "jotai-scope";
const jotai = createIsolation();
export { atom, PrimitiveAtom };
export { atom, PrimitiveAtom, WritableAtom };
export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai;
export const EditorJotaiProvider: ReturnType<
typeof createIsolation

View file

@ -165,7 +165,9 @@
"unCroppedDimension": "Uncropped dimension",
"copyElementLink": "Copy link to object",
"linkToElement": "Link to object",
"wrapSelectionInFrame": "Wrap selection in frame"
"wrapSelectionInFrame": "Wrap selection in frame",
"tab": "Tab",
"shapeSwitch": "Switch shape"
},
"elementLink": {
"title": "Link to object",
@ -296,7 +298,8 @@
"laser": "Laser pointer",
"hand": "Hand (panning tool)",
"extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw"
"mermaidToExcalidraw": "Mermaid to Excalidraw",
"convertElementType": "Toggle shape type"
},
"element": {
"rectangle": "Rectangle",

View file

@ -21,7 +21,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<a
class="dropdown-menu-item dropdown-menu-item-base"
href="blog.excalidaw.com"
rel="noreferrer"
rel="noopener"
target="_blank"
>
<div
@ -392,7 +392,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-label="GitHub"
class="dropdown-menu-item dropdown-menu-item-base"
href="https://github.com/excalidraw/excalidraw"
rel="noreferrer"
rel="noopener"
target="_blank"
title="GitHub"
>
@ -426,7 +426,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-label="X"
class="dropdown-menu-item dropdown-menu-item-base"
href="https://x.com/excalidraw"
rel="noreferrer"
rel="noopener"
target="_blank"
title="X"
>
@ -472,7 +472,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-label="Discord"
class="dropdown-menu-item dropdown-menu-item-base"
href="https://discord.gg/UexuTaE"
rel="noreferrer"
rel="noopener"
target="_blank"
title="Discord"
>

View file

@ -714,6 +714,7 @@ export type AppClassProperties = {
excalidrawContainerValue: App["excalidrawContainerValue"];
onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"];
};
export type PointerDownState = Readonly<{

View file

@ -80,6 +80,8 @@ const getTransform = (
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
};
type SubmitHandler = () => void;
export const textWysiwyg = ({
id,
onChange,
@ -106,7 +108,7 @@ export const textWysiwyg = ({
excalidrawContainer: HTMLDivElement | null;
app: App;
autoSelect?: boolean;
}) => {
}): SubmitHandler => {
const textPropertiesUpdated = (
updatedTextElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement,
@ -186,7 +188,6 @@ export const textWysiwyg = ({
}
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
maxHeight = getBoundTextMaxHeight(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
@ -735,4 +736,6 @@ export const textWysiwyg = ({
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
return handleSubmit;
};