mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: switch between basic shapes
This commit is contained in:
parent
70c3e921bb
commit
407d8ababb
9 changed files with 545 additions and 5 deletions
|
@ -120,6 +120,7 @@ export const getDefaultAppState = (): Omit<
|
||||||
isCropping: false,
|
isCropping: false,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
searchMatches: [],
|
searchMatches: [],
|
||||||
|
showShapeSwitchPanel: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -244,6 +245,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||||
isCropping: { browser: false, export: false, server: false },
|
isCropping: { browser: false, export: false, server: false },
|
||||||
croppingElementId: { browser: false, export: false, server: false },
|
croppingElementId: { browser: false, export: false, server: false },
|
||||||
searchMatches: { browser: false, export: false, server: false },
|
searchMatches: { browser: false, export: false, server: false },
|
||||||
|
showShapeSwitchPanel: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|
|
@ -166,6 +166,10 @@ import {
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFlowchartNodeElement,
|
isFlowchartNodeElement,
|
||||||
isBindableElement,
|
isBindableElement,
|
||||||
|
isGenericSwitchableElement,
|
||||||
|
isGenericSwitchableToolType,
|
||||||
|
isLinearSwitchableElement,
|
||||||
|
isLinearSwitchableToolType,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
|
@ -467,6 +471,7 @@ import {
|
||||||
getApproxMinLineHeight,
|
getApproxMinLineHeight,
|
||||||
getMinTextElementWidth,
|
getMinTextElementWidth,
|
||||||
} from "../element/textMeasurements";
|
} from "../element/textMeasurements";
|
||||||
|
import ShapeSwitch from "./ShapeSwitch";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
|
@ -559,6 +564,7 @@ const gesture: Gesture = {
|
||||||
initialDistance: null,
|
initialDistance: null,
|
||||||
initialScale: null,
|
initialScale: null,
|
||||||
};
|
};
|
||||||
|
let textWysiwygSubmitHandler: (() => void) | null = null;
|
||||||
|
|
||||||
class App extends React.Component<AppProps, AppState> {
|
class App extends React.Component<AppProps, AppState> {
|
||||||
canvas: AppClassProperties["canvas"];
|
canvas: AppClassProperties["canvas"];
|
||||||
|
@ -1805,6 +1811,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{this.renderFrameNames()}
|
{this.renderFrameNames()}
|
||||||
|
<ShapeSwitch app={this} />
|
||||||
</ExcalidrawActionManagerContext.Provider>
|
</ExcalidrawActionManagerContext.Provider>
|
||||||
{this.renderEmbeddables()}
|
{this.renderEmbeddables()}
|
||||||
</ExcalidrawElementsContext.Provider>
|
</ExcalidrawElementsContext.Provider>
|
||||||
|
@ -4092,6 +4099,56 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
(selectedElements[0].type === "rectangle" ||
|
||||||
|
selectedElements[0].type === "diamond" ||
|
||||||
|
selectedElements[0].type === "ellipse" ||
|
||||||
|
selectedElements[0].type === "arrow" ||
|
||||||
|
selectedElements[0].type === "line")
|
||||||
|
) {
|
||||||
|
if (this.state.showShapeSwitchPanel && event.key === KEYS.ESCAPE) {
|
||||||
|
this.setState({
|
||||||
|
showShapeSwitchPanel: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.SLASH) {
|
||||||
|
if (!this.state.showShapeSwitchPanel) {
|
||||||
|
this.setState({
|
||||||
|
showShapeSwitchPanel: true,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
selectedElements[0].type === "rectangle" ||
|
||||||
|
selectedElements[0].type === "diamond" ||
|
||||||
|
selectedElements[0].type === "ellipse"
|
||||||
|
) {
|
||||||
|
const index = ["rectangle", "diamond", "ellipse"].indexOf(
|
||||||
|
selectedElements[0].type,
|
||||||
|
);
|
||||||
|
const nextType = ["rectangle", "diamond", "ellipse"][
|
||||||
|
(index + 1) % 3
|
||||||
|
] as ToolType;
|
||||||
|
this.setActiveTool({ type: nextType });
|
||||||
|
} else if (
|
||||||
|
selectedElements[0].type === "arrow" ||
|
||||||
|
selectedElements[0].type === "line"
|
||||||
|
) {
|
||||||
|
const index = ["arrow", "line"].indexOf(selectedElements[0].type);
|
||||||
|
const nextType = ["arrow", "line"][(index + 1) % 2] as ToolType;
|
||||||
|
this.setActiveTool({ type: nextType });
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
showShapeSwitchPanel: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.key === KEYS.ESCAPE &&
|
event.key === KEYS.ESCAPE &&
|
||||||
this.flowChartCreator.isCreatingChart
|
this.flowChartCreator.isCreatingChart
|
||||||
|
@ -4742,6 +4799,95 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
...commonResets,
|
...commonResets,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
this.state,
|
||||||
|
);
|
||||||
|
const firstElement = selectedElements[0];
|
||||||
|
|
||||||
|
if (
|
||||||
|
firstElement &&
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
isGenericSwitchableElement(firstElement) &&
|
||||||
|
isGenericSwitchableToolType(tool.type)
|
||||||
|
) {
|
||||||
|
ShapeCache.delete(firstElement);
|
||||||
|
|
||||||
|
mutateElement(firstElement, {
|
||||||
|
type: tool.type,
|
||||||
|
roundness:
|
||||||
|
tool.type === "diamond" && firstElement.roundness
|
||||||
|
? {
|
||||||
|
type: isUsingAdaptiveRadius(tool.type)
|
||||||
|
? ROUNDNESS.ADAPTIVE_RADIUS
|
||||||
|
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
|
value: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
|
}
|
||||||
|
: firstElement.roundness,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setActiveTool({ type: "selection" });
|
||||||
|
|
||||||
|
if (firstElement.boundElements?.some((e) => e.type === "text")) {
|
||||||
|
this.startTextEditing({
|
||||||
|
sceneX: firstElement.x + firstElement.width / 2,
|
||||||
|
sceneY: firstElement.y + firstElement.height / 2,
|
||||||
|
container: firstElement as ExcalidrawTextContainer,
|
||||||
|
keepContainerDimensions: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
textWysiwygSubmitHandler?.();
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
selectedElementIds: {
|
||||||
|
[firstElement.id]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.store.shouldCaptureIncrement();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
firstElement &&
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
isLinearSwitchableElement(firstElement) &&
|
||||||
|
isLinearSwitchableToolType(tool.type)
|
||||||
|
) {
|
||||||
|
ShapeCache.delete(firstElement);
|
||||||
|
|
||||||
|
mutateElement(firstElement as ExcalidrawLinearElement, {
|
||||||
|
type: tool.type,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: tool.type === "arrow" ? "arrow" : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setActiveTool({ type: "selection" });
|
||||||
|
|
||||||
|
if (
|
||||||
|
firstElement.boundElements?.some((e) => e.type === "text") &&
|
||||||
|
isArrowElement(firstElement)
|
||||||
|
) {
|
||||||
|
this.startTextEditing({
|
||||||
|
sceneX: firstElement.x + firstElement.width / 2,
|
||||||
|
sceneY: firstElement.y + firstElement.height / 2,
|
||||||
|
container: firstElement,
|
||||||
|
keepContainerDimensions: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
textWysiwygSubmitHandler?.();
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
selectedElementIds: {
|
||||||
|
[firstElement.id]: true,
|
||||||
|
},
|
||||||
|
selectedLinearElement: new LinearElementEditor(
|
||||||
|
firstElement as ExcalidrawLinearElement,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setOpenDialog = (dialogType: AppState["openDialog"]) => {
|
setOpenDialog = (dialogType: AppState["openDialog"]) => {
|
||||||
|
@ -4843,8 +4989,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
element: ExcalidrawTextElement,
|
element: ExcalidrawTextElement,
|
||||||
{
|
{
|
||||||
isExistingElement = false,
|
isExistingElement = false,
|
||||||
|
keepContainerDimensions = false,
|
||||||
}: {
|
}: {
|
||||||
isExistingElement?: boolean;
|
isExistingElement?: boolean;
|
||||||
|
keepContainerDimensions?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
||||||
|
@ -4871,7 +5019,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
textWysiwyg({
|
textWysiwygSubmitHandler = textWysiwyg({
|
||||||
id: element.id,
|
id: element.id,
|
||||||
canvas: this.canvas,
|
canvas: this.canvas,
|
||||||
getViewportCoords: (x, y) => {
|
getViewportCoords: (x, y) => {
|
||||||
|
@ -4894,6 +5042,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
|
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
|
||||||
|
textWysiwygSubmitHandler = null;
|
||||||
const isDeleted = !nextOriginalText.trim();
|
const isDeleted = !nextOriginalText.trim();
|
||||||
updateElement(nextOriginalText, isDeleted);
|
updateElement(nextOriginalText, isDeleted);
|
||||||
// select the created text element only if submitting via keyboard
|
// select the created text element only if submitting via keyboard
|
||||||
|
@ -4949,6 +5098,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
// the text on edit anyway (and users can select-all from contextmenu
|
// the text on edit anyway (and users can select-all from contextmenu
|
||||||
// if needed)
|
// if needed)
|
||||||
autoSelect: !this.device.isTouchScreen,
|
autoSelect: !this.device.isTouchScreen,
|
||||||
|
keepContainerDimensions,
|
||||||
});
|
});
|
||||||
// deselect all other elements when inserting text
|
// deselect all other elements when inserting text
|
||||||
this.deselectElements();
|
this.deselectElements();
|
||||||
|
@ -5181,6 +5331,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
insertAtParentCenter = true,
|
insertAtParentCenter = true,
|
||||||
container,
|
container,
|
||||||
autoEdit = true,
|
autoEdit = true,
|
||||||
|
keepContainerDimensions = false,
|
||||||
}: {
|
}: {
|
||||||
/** X position to insert text at */
|
/** X position to insert text at */
|
||||||
sceneX: number;
|
sceneX: number;
|
||||||
|
@ -5190,6 +5341,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
insertAtParentCenter?: boolean;
|
insertAtParentCenter?: boolean;
|
||||||
container?: ExcalidrawTextContainer | null;
|
container?: ExcalidrawTextContainer | null;
|
||||||
autoEdit?: boolean;
|
autoEdit?: boolean;
|
||||||
|
keepContainerDimensions?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
let shouldBindToContainer = false;
|
let shouldBindToContainer = false;
|
||||||
|
|
||||||
|
@ -5325,6 +5477,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
if (autoEdit || existingTextElement || container) {
|
if (autoEdit || existingTextElement || container) {
|
||||||
this.handleTextWysiwyg(element, {
|
this.handleTextWysiwyg(element, {
|
||||||
isExistingElement: !!existingTextElement,
|
isExistingElement: !!existingTextElement,
|
||||||
|
keepContainerDimensions,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -8768,6 +8921,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
snapLines: updateStable(prevState.snapLines, []),
|
snapLines: updateStable(prevState.snapLines, []),
|
||||||
originSnapOffset: null,
|
originSnapOffset: null,
|
||||||
|
showShapeSwitchPanel: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.lastPointerMoveCoords = null;
|
this.lastPointerMoveCoords = null;
|
||||||
|
|
47
packages/excalidraw/components/ShapeSwitch.scss
Normal file
47
packages/excalidraw/components/ShapeSwitch.scss
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
@import "../css//variables.module.scss";
|
||||||
|
|
||||||
|
@keyframes disappear {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.ShapeSwitch__Hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--default-bg-color);
|
||||||
|
|
||||||
|
.key {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation {
|
||||||
|
opacity: 1;
|
||||||
|
animation: disappear 2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ShapeSwitch__Panel {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--default-bg-color);
|
||||||
|
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
199
packages/excalidraw/components/ShapeSwitch.tsx
Normal file
199
packages/excalidraw/components/ShapeSwitch.tsx
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import type App from "./App";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import {
|
||||||
|
ArrowIcon,
|
||||||
|
DiamondIcon,
|
||||||
|
EllipseIcon,
|
||||||
|
LineIcon,
|
||||||
|
RectangleIcon,
|
||||||
|
} from "./icons";
|
||||||
|
import { type ReactNode, useEffect, useRef } from "react";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import type { ToolType } from "../types";
|
||||||
|
import { sceneCoordsToViewportCoords } from "../utils";
|
||||||
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
|
import "./ShapeSwitch.scss";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { getElementAbsoluteCoords } from "../element";
|
||||||
|
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||||
|
import {
|
||||||
|
isArrowElement,
|
||||||
|
isGenericSwitchableElement,
|
||||||
|
isLinearElement,
|
||||||
|
isLinearSwitchableElement,
|
||||||
|
} from "../element/typeChecks";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
const GAP_HORIZONTAL = 8;
|
||||||
|
const GAP_VERTICAL = 10;
|
||||||
|
|
||||||
|
const ShapeSwitch = ({ app }: { app: App }) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
app.state,
|
||||||
|
);
|
||||||
|
const firstElement = selectedElements[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return app.setState({ showShapeSwitchPanel: false });
|
||||||
|
}, [app]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
firstElement &&
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
(isGenericSwitchableElement(firstElement) ||
|
||||||
|
isLinearSwitchableElement(firstElement))
|
||||||
|
) {
|
||||||
|
return app.state.showShapeSwitchPanel ? (
|
||||||
|
<Panel app={app} element={firstElement} />
|
||||||
|
) : (
|
||||||
|
<Hint app={app} element={firstElement} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Hint = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
|
||||||
|
const [x1, y1, , , cx, cy] = getElementAbsoluteCoords(
|
||||||
|
element,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rotatedTopLeft = pointRotateRads(
|
||||||
|
pointFrom(x1, y1),
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { x, y } = sceneCoordsToViewportCoords(
|
||||||
|
{
|
||||||
|
sceneX: rotatedTopLeft[0],
|
||||||
|
sceneY: rotatedTopLeft[1],
|
||||||
|
},
|
||||||
|
app.state,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hintRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = () => {
|
||||||
|
hintRef.current?.classList.remove("animation");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hintRef.current) {
|
||||||
|
hintRef.current.addEventListener("animationend", listener);
|
||||||
|
}
|
||||||
|
}, [element.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={hintRef}
|
||||||
|
key={element.id}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: `${
|
||||||
|
app.state.height +
|
||||||
|
GAP_VERTICAL * app.state.zoom.value -
|
||||||
|
y -
|
||||||
|
app.state.offsetTop
|
||||||
|
}px`,
|
||||||
|
left: `${x - app.state.offsetLeft - GAP_HORIZONTAL}px`,
|
||||||
|
zIndex: 2,
|
||||||
|
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
}}
|
||||||
|
className={clsx("ShapeSwitch__Hint", "animation")}
|
||||||
|
>
|
||||||
|
<div className="key">/</div>
|
||||||
|
<div className="text">{t("labels.slash")}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Panel = ({ app, element }: { app: App; element: ExcalidrawElement }) => {
|
||||||
|
const [x1, , , y2, cx, cy] = getElementAbsoluteCoords(
|
||||||
|
element,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rotatedBottomLeft = pointRotateRads(
|
||||||
|
pointFrom(x1, y2),
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { x, y } = sceneCoordsToViewportCoords(
|
||||||
|
{
|
||||||
|
sceneX: rotatedBottomLeft[0],
|
||||||
|
sceneY: rotatedBottomLeft[1],
|
||||||
|
},
|
||||||
|
app.state,
|
||||||
|
);
|
||||||
|
|
||||||
|
const SHAPES: [string, string, ReactNode][] = isLinearElement(element)
|
||||||
|
? [
|
||||||
|
["arrow", "5", ArrowIcon],
|
||||||
|
["line", "6", LineIcon],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
["rectangle", "2", RectangleIcon],
|
||||||
|
["diamond", "3", DiamondIcon],
|
||||||
|
["ellipse", "4", EllipseIcon],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: `${
|
||||||
|
y + GAP_VERTICAL * app.state.zoom.value - app.state.offsetTop
|
||||||
|
}px`,
|
||||||
|
left: `${x - app.state.offsetLeft - GAP_HORIZONTAL}px`,
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
className="ShapeSwitch__Panel"
|
||||||
|
>
|
||||||
|
{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 === "straight");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolButton
|
||||||
|
className="Shape"
|
||||||
|
key={type}
|
||||||
|
type="radio"
|
||||||
|
icon={icon}
|
||||||
|
checked={isSelected}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={type}
|
||||||
|
keyBindingLabel={""}
|
||||||
|
aria-label={type}
|
||||||
|
data-testid={`toolbar-${type}`}
|
||||||
|
onPointerDown={({ pointerType }) => {
|
||||||
|
if (!app.state.penDetected && pointerType === "pen") {
|
||||||
|
app.togglePenMode(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={() => {
|
||||||
|
if (app.state.activeTool.type !== type) {
|
||||||
|
trackEvent("shape-switch", type, "ui");
|
||||||
|
}
|
||||||
|
app.setActiveTool({ type: type as ToolType });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShapeSwitch;
|
39
packages/excalidraw/components/ShapeSwitcher.tsx
Normal file
39
packages/excalidraw/components/ShapeSwitcher.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
// render the shape switcher div on top of the canvas at the selected element's position
|
||||||
|
|
||||||
|
import { THEME } from "../constants";
|
||||||
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
|
import type { AppState } from "../types";
|
||||||
|
import { sceneCoordsToViewportCoords } from "../utils";
|
||||||
|
|
||||||
|
const ShapeSwitcher = ({
|
||||||
|
appState,
|
||||||
|
element,
|
||||||
|
}: {
|
||||||
|
appState: AppState;
|
||||||
|
element: ExcalidrawElement;
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
}) => {
|
||||||
|
const isDarkTheme = appState.theme === THEME.DARK;
|
||||||
|
const { x, y } = sceneCoordsToViewportCoords(
|
||||||
|
{
|
||||||
|
sceneX: element.x,
|
||||||
|
sceneY: element.y,
|
||||||
|
},
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: `${appState.height - y + appState.offsetTop}px`,
|
||||||
|
left: `${x - appState.offsetLeft}px`,
|
||||||
|
backgroundColor: isDarkTheme
|
||||||
|
? "rgba(0, 0, 0, 0.9)"
|
||||||
|
: "rgba(255, 255, 255, 0.9)",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShapeSwitcher;
|
|
@ -48,7 +48,7 @@ import {
|
||||||
originalContainerCache,
|
originalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
import { getTextWidth } from "./textMeasurements";
|
import { getTextWidth, measureText } from "./textMeasurements";
|
||||||
import { normalizeText } from "./textMeasurements";
|
import { normalizeText } from "./textMeasurements";
|
||||||
|
|
||||||
const getTransform = (
|
const getTransform = (
|
||||||
|
@ -72,6 +72,8 @@ const getTransform = (
|
||||||
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SubmitHandler = () => void;
|
||||||
|
|
||||||
export const textWysiwyg = ({
|
export const textWysiwyg = ({
|
||||||
id,
|
id,
|
||||||
onChange,
|
onChange,
|
||||||
|
@ -82,6 +84,7 @@ export const textWysiwyg = ({
|
||||||
excalidrawContainer,
|
excalidrawContainer,
|
||||||
app,
|
app,
|
||||||
autoSelect = true,
|
autoSelect = true,
|
||||||
|
keepContainerDimensions = false,
|
||||||
}: {
|
}: {
|
||||||
id: ExcalidrawElement["id"];
|
id: ExcalidrawElement["id"];
|
||||||
/**
|
/**
|
||||||
|
@ -98,7 +101,8 @@ export const textWysiwyg = ({
|
||||||
excalidrawContainer: HTMLDivElement | null;
|
excalidrawContainer: HTMLDivElement | null;
|
||||||
app: App;
|
app: App;
|
||||||
autoSelect?: boolean;
|
autoSelect?: boolean;
|
||||||
}) => {
|
keepContainerDimensions?: boolean;
|
||||||
|
}): SubmitHandler => {
|
||||||
const textPropertiesUpdated = (
|
const textPropertiesUpdated = (
|
||||||
updatedTextElement: ExcalidrawTextElement,
|
updatedTextElement: ExcalidrawTextElement,
|
||||||
editable: HTMLTextAreaElement,
|
editable: HTMLTextAreaElement,
|
||||||
|
@ -179,12 +183,53 @@ export const textWysiwyg = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
|
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
|
||||||
|
|
||||||
maxHeight = getBoundTextMaxHeight(
|
maxHeight = getBoundTextMaxHeight(
|
||||||
container,
|
container,
|
||||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
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
|
// autogrow container height if text exceeds
|
||||||
if (!isArrowElement(container) && height > maxHeight) {
|
if (!isArrowElement(container) && height > maxHeight) {
|
||||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||||
|
@ -727,4 +772,6 @@ export const textWysiwyg = ({
|
||||||
excalidrawContainer
|
excalidrawContainer
|
||||||
?.querySelector(".excalidraw-textEditorContainer")!
|
?.querySelector(".excalidraw-textEditorContainer")!
|
||||||
.appendChild(editable);
|
.appendChild(editable);
|
||||||
|
|
||||||
|
return handleSubmit;
|
||||||
};
|
};
|
||||||
|
|
|
@ -26,6 +26,9 @@ import type {
|
||||||
PointBinding,
|
PointBinding,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
ExcalidrawFlowchartNodeElement,
|
ExcalidrawFlowchartNodeElement,
|
||||||
|
ExcalidrawRectangleElement,
|
||||||
|
ExcalidrawEllipseElement,
|
||||||
|
ExcalidrawDiamondElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const isInitializedImageElement = (
|
export const isInitializedImageElement = (
|
||||||
|
@ -336,3 +339,49 @@ export const isBounds = (box: unknown): box is Bounds =>
|
||||||
typeof box[1] === "number" &&
|
typeof box[1] === "number" &&
|
||||||
typeof box[2] === "number" &&
|
typeof box[2] === "number" &&
|
||||||
typeof box[3] === "number";
|
typeof box[3] === "number";
|
||||||
|
|
||||||
|
type ExcalidrawGenericSwitchableElement =
|
||||||
|
| ExcalidrawRectangleElement
|
||||||
|
| ExcalidrawEllipseElement
|
||||||
|
| ExcalidrawDiamondElement;
|
||||||
|
|
||||||
|
type GenericSwitchableToolType = "rectangle" | "ellipse" | "diamond";
|
||||||
|
|
||||||
|
type LinearSwitchableToolType = "arrow" | "line";
|
||||||
|
|
||||||
|
export const isGenericSwitchableElement = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
): element is ExcalidrawGenericSwitchableElement => {
|
||||||
|
return (
|
||||||
|
element.type === "rectangle" ||
|
||||||
|
element.type === "ellipse" ||
|
||||||
|
element.type === "diamond"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isGenericSwitchableToolType = (
|
||||||
|
type: string,
|
||||||
|
): type is GenericSwitchableToolType => {
|
||||||
|
return type === "rectangle" || type === "ellipse" || type === "diamond";
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isLinearSwitchableToolType = (
|
||||||
|
type: string,
|
||||||
|
): type is LinearSwitchableToolType => {
|
||||||
|
return type === "arrow" || type === "line";
|
||||||
|
};
|
||||||
|
|
|
@ -165,7 +165,8 @@
|
||||||
"unCroppedDimension": "Uncropped dimension",
|
"unCroppedDimension": "Uncropped dimension",
|
||||||
"copyElementLink": "Copy link to object",
|
"copyElementLink": "Copy link to object",
|
||||||
"linkToElement": "Link to object",
|
"linkToElement": "Link to object",
|
||||||
"wrapSelectionInFrame": "Wrap selection in frame"
|
"wrapSelectionInFrame": "Wrap selection in frame",
|
||||||
|
"slash": "Slash"
|
||||||
},
|
},
|
||||||
"elementLink": {
|
"elementLink": {
|
||||||
"title": "Link to object",
|
"title": "Link to object",
|
||||||
|
|
|
@ -410,6 +410,8 @@ export interface AppState {
|
||||||
croppingElementId: ExcalidrawElement["id"] | null;
|
croppingElementId: ExcalidrawElement["id"] | null;
|
||||||
|
|
||||||
searchMatches: readonly SearchMatch[];
|
searchMatches: readonly SearchMatch[];
|
||||||
|
|
||||||
|
showShapeSwitchPanel: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchMatch = {
|
type SearchMatch = {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue