switch multi

This commit is contained in:
Ryan Di 2025-03-24 11:45:02 +11:00
parent 230a339c7b
commit 41a4dadaaf
5 changed files with 279 additions and 190 deletions

View file

@ -392,6 +392,7 @@ import {
} from "../element/textMeasurements";
import ShapeSwitch, {
adjustBoundTextSize,
GENERIC_SWITCHABLE_SHAPES,
LINEAR_SWITCHABLE_SHAPES,
shapeSwitchAtom,
@ -446,6 +447,8 @@ import type {
MagicGenerationData,
ExcalidrawNonSelectionElement,
ExcalidrawArrowElement,
GenericSwitchableToolType,
LinearSwitchableToolType,
} from "../element/types";
import type {
RenderInteractiveSceneCallback,
@ -573,7 +576,6 @@ const gesture: Gesture = {
initialDistance: null,
initialScale: null,
};
let textWysiwygSubmitHandler: (() => void) | null = null;
class App extends React.Component<AppProps, AppState> {
canvas: AppClassProperties["canvas"];
@ -4108,28 +4110,24 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const firstElement = selectedElements[0];
const isGenericSwitchable =
firstElement && isGenericSwitchableElement(firstElement);
const isLinearSwitchable =
firstElement && isLinearSwitchableElement(firstElement);
const genericSwitchable = isGenericSwitchableElement(selectedElements);
const linearSwitchable = isLinearSwitchableElement(selectedElements);
if (genericSwitchable || linearSwitchable) {
const firstElement = selectedElements[0];
if (
selectedElements.length === 1 &&
(isGenericSwitchable || isLinearSwitchable)
) {
if (event.key === KEYS.ESCAPE) {
editorJotaiStore.set(shapeSwitchAtom, null);
} else if (event.key === KEYS.SLASH || event.key === KEYS.TAB) {
event.preventDefault();
if (editorJotaiStore.get(shapeSwitchAtom)?.type === "panel") {
const index = isGenericSwitchable
const index = genericSwitchable
? GENERIC_SWITCHABLE_SHAPES.indexOf(selectedElements[0].type)
: LINEAR_SWITCHABLE_SHAPES.indexOf(selectedElements[0].type);
const nextType = (
isGenericSwitchable
genericSwitchable
? GENERIC_SWITCHABLE_SHAPES[
(index + 1) % GENERIC_SWITCHABLE_SHAPES.length
]
@ -4145,16 +4143,20 @@ class App extends React.Component<AppProps, AppState> {
type: "panel",
});
if (!editorJotaiStore.get(shapeSwitchFontSizeAtom)) {
selectedElements.forEach((element) => {
const boundText = getBoundTextElement(
firstElement,
element,
this.scene.getNonDeletedElementsMap(),
);
if (boundText && isGenericSwitchable) {
if (boundText && genericSwitchable && firstElement) {
editorJotaiStore.set(shapeSwitchFontSizeAtom, {
[element.id]: {
fontSize: boundText.fontSize,
elementType: firstElement.type,
elementType: element.type as GenericSwitchableToolType,
},
});
}
});
}
}
}
@ -4819,90 +4821,98 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
this.state,
);
const firstElement = selectedElements[0];
const selectedElementIds = selectedElements.reduce(
(acc, element) => ({ ...acc, [element.id]: true }),
{},
);
if (
firstElement &&
selectedElements.length === 1 &&
isGenericSwitchableElement(firstElement) &&
isGenericSwitchableElement(selectedElements) &&
isGenericSwitchableToolType(tool.type)
) {
ShapeCache.delete(firstElement);
selectedElements.forEach((element) => {
ShapeCache.delete(element);
mutateElement(firstElement, {
type: tool.type,
mutateElement(
element,
{
type: tool.type as GenericSwitchableToolType,
roundness:
tool.type === "diamond" && firstElement.roundness
tool.type === "diamond" && element.roundness
? {
type: isUsingAdaptiveRadius(tool.type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
value: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: firstElement.roundness,
});
: element.roundness,
},
false,
);
const boundText = getBoundTextElement(
firstElement,
element,
this.scene.getNonDeletedElementsMap(),
);
if (boundText) {
if (
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.elementType ===
tool.type
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id]
?.elementType === tool.type
) {
mutateElement(
boundText,
{
fontSize:
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.fontSize ??
boundText.fontSize,
editorJotaiStore.get(shapeSwitchFontSizeAtom)?.[element.id]
?.fontSize ?? boundText.fontSize,
},
false,
);
}
this.startTextEditing({
sceneX: firstElement.x + firstElement.width / 2,
sceneY: firstElement.y + firstElement.height / 2,
container: firstElement as ExcalidrawTextContainer,
keepContainerDimensions: true,
});
adjustBoundTextSize(
element,
boundText,
this.scene.getNonDeletedElementsMap(),
);
}
});
this.setState((prevState) => ({
selectedElementIds: {
[firstElement.id]: true,
},
this.setState((prevState) => {
return {
selectedElementIds,
activeTool: updateActiveTool(prevState, { type: "selection" }),
}));
textWysiwygSubmitHandler?.();
};
});
this.store.shouldCaptureIncrement();
}
if (
firstElement &&
selectedElements.length === 1 &&
isLinearSwitchableElement(firstElement) &&
isLinearSwitchableElement(selectedElements) &&
isLinearSwitchableToolType(tool.type)
) {
ShapeCache.delete(firstElement);
selectedElements.forEach((element) => {
ShapeCache.delete(element);
mutateElement(firstElement as ExcalidrawLinearElement, {
type: tool.type,
mutateElement(
element as ExcalidrawLinearElement,
{
type: tool.type as LinearSwitchableToolType,
startArrowhead: null,
endArrowhead: tool.type === "arrow" ? "arrow" : null,
},
false,
);
});
const firstElement = selectedElements[0];
this.setState((prevState) => ({
selectedElementIds: {
[firstElement.id]: true,
},
selectedLinearElement: new LinearElementEditor(
firstElement as ExcalidrawLinearElement,
),
selectedElementIds,
selectedLinearElement:
selectedElements.length === 1
? new LinearElementEditor(firstElement as ExcalidrawLinearElement)
: null,
activeTool: updateActiveTool(prevState, { type: "selection" }),
}));
}
@ -5037,7 +5047,7 @@ class App extends React.Component<AppProps, AppState> {
]);
};
textWysiwygSubmitHandler = textWysiwyg({
textWysiwyg({
id: element.id,
canvas: this.canvas,
getViewportCoords: (x, y) => {
@ -5060,7 +5070,6 @@ class App extends React.Component<AppProps, AppState> {
}
}),
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
textWysiwygSubmitHandler = null;
const isDeleted = !nextOriginalText.trim();
updateElement(nextOriginalText, isDeleted);
// select the created text element only if submitting via keyboard

View file

@ -6,15 +6,24 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math";
import { atom, useAtom } from "../editor-jotai";
import { getElementAbsoluteCoords } from "../element";
import { sceneCoordsToViewportCoords } from "../utils";
import { getElementAbsoluteCoords, refreshTextDimensions } from "../element";
import { getFontString, sceneCoordsToViewportCoords } from "../utils";
import { getSelectedElements } from "../scene";
import { trackEvent } from "../analytics";
import { isArrowElement, isLinearElement } from "../element/typeChecks";
import { t } from "../i18n";
import "./ShapeSwitch.scss";
import {
computeBoundTextPosition,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
} from "../element/textElement";
import { wrapText } from "../element/textWrapping";
import { measureText } from "../element/textMeasurements";
import { mutateElement } from "../element/mutateElement";
import { getCommonBoundingBox } from "../element/bounds";
import { ToolButton } from "./ToolButton";
import {
ArrowIcon,
DiamondIcon,
@ -22,10 +31,17 @@ import {
LineIcon,
RectangleIcon,
} from "./icons";
import { ToolButton } from "./ToolButton";
import "./ShapeSwitch.scss";
import type App from "./App";
import type { ExcalidrawElement } from "../element/types";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer,
GenericSwitchableToolType,
} from "../element/types";
import type { ToolType } from "../types";
const GAP_HORIZONTAL = 8;
@ -46,8 +62,10 @@ export const shapeSwitchAtom = atom<
| null
>(null);
export const shapeSwitchFontSizeAtom = atom<{
[id: string]: {
fontSize: number;
elementType: "rectangle" | "diamond" | "ellipse";
elementType: GenericSwitchableToolType;
};
} | null>(null);
const ShapeSwitch = ({ app }: { app: App }) => {
@ -64,22 +82,22 @@ const ShapeSwitch = ({ app }: { app: App }) => {
app.scene.getNonDeletedElementsMap(),
app.state,
);
const firstElement = selectedElements[0];
const isSingleSelected = firstElement && selectedElements.length === 1;
// clear if hint target no longer matches
if (shapeSwitch.type === "hint" && firstElement?.id !== shapeSwitch.id) {
if (
shapeSwitch.type === "hint" &&
selectedElements?.[0]?.id !== shapeSwitch.id
) {
setShapeSwitch(null);
return null;
}
if (!isSingleSelected) {
if (selectedElements.length === 0) {
setShapeSwitch(null);
return null;
}
const props = { app, element: firstElement };
const props = { app, elements: selectedElements };
switch (shapeSwitch.type) {
case "hint":
@ -91,7 +109,13 @@ const ShapeSwitch = ({ app }: { app: App }) => {
}
};
const Hint = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
const Hint = ({
app,
elements,
}: {
app: App;
elements: ExcalidrawElement[];
}) => {
const [, setShapeSwitch] = useAtom(shapeSwitchAtom);
const hintRef = useRef<HTMLDivElement>(null);
@ -114,14 +138,14 @@ const Hint = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
}, [setShapeSwitch]);
const [x1, y1, , , cx, cy] = getElementAbsoluteCoords(
element,
elements[0],
app.scene.getNonDeletedElementsMap(),
);
const rotatedTopLeft = pointRotateRads(
pointFrom(x1, y1),
pointFrom(cx, cy),
element.angle,
elements[0].angle,
);
const { x, y } = sceneCoordsToViewportCoords(
@ -135,7 +159,7 @@ const Hint = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
return (
<div
ref={hintRef}
key={element.id}
// key={element.id}
style={{
position: "absolute",
bottom: `${
@ -158,17 +182,35 @@ const Hint = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
);
};
const Panel = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
const [x1, , , y2, cx, cy] = getElementAbsoluteCoords(
element,
const Panel = ({
app,
elements,
}: {
app: App;
elements: ExcalidrawElement[];
}) => {
let [x1, y2, cx, cy] = [0, 0, 0, 0];
let rotatedBottomLeft = [0, 0];
if (elements.length === 1) {
[x1, , , y2, cx, cy] = getElementAbsoluteCoords(
elements[0],
app.scene.getNonDeletedElementsMap(),
);
const rotatedBottomLeft = pointRotateRads(
rotatedBottomLeft = pointRotateRads(
pointFrom(x1, y2),
pointFrom(cx, cy),
element.angle,
elements[0].angle,
);
} else {
const { minX, maxY, midX, midY } = getCommonBoundingBox(elements);
x1 = minX;
y2 = maxY;
cx = midX;
cy = midY;
rotatedBottomLeft = pointFrom(x1, y2);
}
const { x, y } = sceneCoordsToViewportCoords(
{
@ -178,7 +220,7 @@ const Panel = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
app.state,
);
const SHAPES: [string, string, ReactNode][] = isLinearElement(element)
const SHAPES: [string, string, ReactNode][] = isLinearElement(elements[0])
? [
["arrow", "5", ArrowIcon],
["line", "6", LineIcon],
@ -203,18 +245,22 @@ const Panel = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
>
{SHAPES.map(([type, shortcut, icon]) => {
const isSelected =
type === element.type ||
(isArrowElement(element) && element.elbowed && type === "elbow") ||
(isArrowElement(element) && element.roundness && type === "curve") ||
(isArrowElement(element) &&
!element.elbowed &&
!element.roundness &&
type === elements[0].type ||
(isArrowElement(elements[0]) &&
elements[0].elbowed &&
type === "elbow") ||
(isArrowElement(elements[0]) &&
elements[0].roundness &&
type === "curve") ||
(isArrowElement(elements[0]) &&
!elements[0].elbowed &&
!elements[0].roundness &&
type === "straight");
return (
<ToolButton
className="Shape"
key={`${element.version}_${type}`}
key={`${elements[0].version}_${type}`}
type="radio"
icon={icon}
checked={isSelected}
@ -241,4 +287,77 @@ const Panel = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
);
};
export const adjustBoundTextSize = (
container: ExcalidrawTextContainer,
boundText: ExcalidrawTextElementWithContainer,
elementsMap: ElementsMap,
) => {
const maxWidth = getBoundTextMaxWidth(container, boundText);
const maxHeight = getBoundTextMaxHeight(container, boundText);
const wrappedText = wrapText(
boundText.text,
getFontString(boundText),
maxWidth,
);
let metrics = measureText(
wrappedText,
getFontString(boundText),
boundText.lineHeight,
);
let nextFontSize = boundText.fontSize;
while (
(metrics.width > maxWidth || metrics.height > maxHeight) &&
nextFontSize > 0
) {
nextFontSize -= 1;
const _updatedTextElement = {
...boundText,
fontSize: nextFontSize,
};
metrics = measureText(
boundText.text,
getFontString(_updatedTextElement),
boundText.lineHeight,
);
}
mutateElement(
boundText,
{
fontSize: nextFontSize,
width: metrics.width,
height: metrics.height,
},
false,
);
const { x, y } = computeBoundTextPosition(container, boundText, elementsMap);
mutateElement(
boundText,
{
x,
y,
},
false,
);
mutateElement(
boundText,
{
...refreshTextDimensions(
boundText,
container,
elementsMap,
boundText.originalText,
),
containerId: container.id,
},
false,
);
};
export default ShapeSwitch;

View file

@ -10,7 +10,6 @@ import {
import { parseClipboard } from "../clipboard";
import { CLASSES, POINTER_BUTTON } from "../constants";
import { CODES, KEYS } from "../keys";
import Scene from "../scene/Scene";
import {
isWritableElement,
getFontString,
@ -35,7 +34,7 @@ import {
computeBoundTextPosition,
getBoundTextElement,
} from "./textElement";
import { getTextWidth, measureText } from "./textMeasurements";
import { getTextWidth } from "./textMeasurements";
import { normalizeText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import {
@ -86,7 +85,6 @@ export const textWysiwyg = ({
excalidrawContainer,
app,
autoSelect = true,
keepContainerDimensions = false,
}: {
id: ExcalidrawElement["id"];
/**
@ -127,8 +125,7 @@ export const textWysiwyg = ({
const updateWysiwygStyle = () => {
const appState = app.state;
const updatedTextElement =
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
const updatedTextElement = app.scene.getElement<ExcalidrawTextElement>(id);
if (!updatedTextElement) {
return;
@ -190,48 +187,6 @@ export const textWysiwyg = ({
updatedTextElement as ExcalidrawTextElementWithContainer,
);
if (keepContainerDimensions) {
const wrappedText = wrapText(
updatedTextElement.text,
getFontString(updatedTextElement),
maxWidth,
);
let metrics = measureText(
wrappedText,
getFontString(updatedTextElement),
updatedTextElement.lineHeight,
);
if (width > maxWidth || height > maxHeight) {
let nextFontSize = updatedTextElement.fontSize;
while (
(metrics.width > maxWidth || metrics.height > maxHeight) &&
nextFontSize > 0
) {
nextFontSize -= 1;
const _updatedTextElement = {
...updatedTextElement,
fontSize: nextFontSize,
};
metrics = measureText(
updatedTextElement.text,
getFontString(_updatedTextElement),
updatedTextElement.lineHeight,
);
}
mutateElement(
updatedTextElement,
{ fontSize: nextFontSize },
false,
);
}
width = metrics.width;
height = metrics.height;
}
// autogrow container height if text exceeds
if (!isArrowElement(container) && height > maxHeight) {
const targetContainerHeight = computeContainerDimensionForBoundText(
@ -578,7 +533,7 @@ export const textWysiwyg = ({
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
// wysiwyg on update
cleanup();
const updateElement = Scene.getScene(element)?.getElement(
const updateElement = app.scene.getElement(
element.id,
) as ExcalidrawTextElement;
if (!updateElement) {

View file

@ -30,6 +30,8 @@ import type {
ExcalidrawRectangleElement,
ExcalidrawEllipseElement,
ExcalidrawDiamondElement,
GenericSwitchableToolType,
LinearSwitchableToolType,
} from "./types";
export const isInitializedImageElement = (
@ -341,22 +343,25 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[2] === "number" &&
typeof box[3] === "number";
type NonEmptyArray<T> = [T, ...T[]];
type ExcalidrawGenericSwitchableElement =
| ExcalidrawRectangleElement
| ExcalidrawEllipseElement
| ExcalidrawDiamondElement;
type GenericSwitchableToolType = "rectangle" | "ellipse" | "diamond";
type LinearSwitchableToolType = "arrow" | "line";
export const isGenericSwitchableElement = (
element: ExcalidrawElement,
): element is ExcalidrawGenericSwitchableElement => {
elements: ExcalidrawElement[],
): elements is NonEmptyArray<ExcalidrawGenericSwitchableElement> => {
if (elements.length === 0) {
return false;
}
const firstType = elements[0].type;
return (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond"
(firstType === "rectangle" ||
firstType === "ellipse" ||
firstType === "diamond") &&
elements.every((element) => element.type === firstType)
);
};
@ -367,18 +372,16 @@ export const isGenericSwitchableToolType = (
};
export const isLinearSwitchableElement = (
element: ExcalidrawElement,
): element is ExcalidrawLinearElement => {
if (element.type === "arrow" || element.type === "line") {
if (
(!element.boundElements || element.boundElements.length === 0) &&
!element.startBinding &&
!element.endBinding
) {
return true;
}
}
elements: ExcalidrawElement[],
): elements is NonEmptyArray<ExcalidrawLinearElement> => {
if (elements.length === 0) {
return false;
}
const firstType = elements[0].type;
return (
(firstType === "arrow" || firstType === "line") &&
elements.every((element) => element.type === firstType)
);
};
export const isLinearSwitchableToolType = (

View file

@ -411,3 +411,6 @@ export type NonDeletedSceneElementsMap = Map<
export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| Readonly<ElementsMap>;
export type GenericSwitchableToolType = "rectangle" | "ellipse" | "diamond";
export type LinearSwitchableToolType = "line" | "arrow";