refactor to include dimension and step size

editable-element-stats
Ryan Di 9 months ago
parent 0a529bd2ed
commit 0987c5b770

@ -1,270 +0,0 @@
import throttle from "lodash.throttle";
import { useEffect, useMemo, useRef } from "react";
import { EVENT } from "../constants";
import { getTransformHandles } from "../element";
import { mutateElement } from "../element/mutateElement";
import { resizeSingleElement } from "../element/resizeElements";
import type { ElementsMap, ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { degreeToRadian, radianToDegree } from "../math";
import type { AppState, Point } from "../types";
import { deepCopyElement } from "../element/newElement";
const shouldKeepAspectRatio = (element: ExcalidrawElement) => {
return element.type === "image";
};
type AdjustableProperty = "width" | "height" | "angle" | "x" | "y";
interface DragInputProps {
label: string | React.ReactNode;
property: AdjustableProperty;
element: ExcalidrawElement;
elementsMap: ElementsMap;
zoom: AppState["zoom"];
}
const DragInput = ({
label,
property,
element,
elementsMap,
zoom,
}: DragInputProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const originalElement = useRef<ExcalidrawElement>();
const accumulatedDimensionChange = useRef(0);
const handleChange = useMemo(
() =>
(
initialValue: number,
delta: number,
source: "pointerMove" | "keyDown",
pointerOffset?: number,
) => {
if (inputRef.current && originalElement.current) {
const keepAspectRatio = shouldKeepAspectRatio(element);
if (
(property === "width" || property === "height") &&
source === "pointerMove" &&
pointerOffset
) {
const handles = getTransformHandles(
originalElement.current,
zoom,
elementsMap,
"mouse",
{},
);
let referencePoint: Point | undefined;
let handleDirection: "e" | "s" | "se" | undefined;
if (keepAspectRatio && handles.se) {
referencePoint = [handles.se[0], handles.se[1]];
handleDirection = "se";
} else if (property === "width" && handles.e) {
referencePoint = [handles.e[0], handles.e[1]];
handleDirection = "e";
} else if (property === "height" && handles.s) {
referencePoint = [handles.s[0], handles.s[1]];
handleDirection = "s";
}
if (referencePoint !== undefined && handleDirection !== undefined) {
accumulatedDimensionChange.current += pointerOffset;
const pointer: Point = [
referencePoint[0] +
(property === "width"
? accumulatedDimensionChange.current
: 0),
referencePoint[1] +
(property === "height"
? accumulatedDimensionChange.current
: 0),
];
resizeSingleElement(
elementsMap,
keepAspectRatio,
element,
elementsMap,
handleDirection,
false,
pointer[0],
pointer[1],
);
}
} else if (
source === "keyDown" ||
(source === "pointerMove" &&
property !== "width" &&
property !== "height")
) {
const incVal = Math.round(
Math.sign(delta) * Math.pow(Math.abs(delta) / 10, 1.6),
);
let newVal = initialValue + incVal;
newVal =
property === "angle"
? // so the degree converted from radian is an integer
degreeToRadian(
Math.round(
radianToDegree(
degreeToRadian(
Math.sign(newVal % 360) === -1
? (newVal % 360) + 360
: newVal % 360,
),
),
),
)
: Math.round(newVal);
mutateElement(element, {
[property]: newVal,
});
originalElement.current = deepCopyElement(element);
}
}
},
[element, property, zoom, elementsMap],
);
const hangleChangeThrottled = useMemo(() => {
return throttle(handleChange, 16);
}, [handleChange]);
useEffect(() => {
const value =
Math.round(
property === "angle"
? radianToDegree(element[property]) * 100
: element[property] * 100,
) / 100;
if (inputRef.current) {
inputRef.current.value = String(value);
}
}, [element, element.version, element.versionNonce, property]);
useEffect(() => {
hangleChangeThrottled.cancel();
});
useEffect(() => {
accumulatedDimensionChange.current = 0;
originalElement.current = undefined;
}, [element.id]);
return (
<label className="color-input-container">
<div
className="color-picker-hash"
ref={labelRef}
style={{
width: "20px",
}}
onPointerDown={(event) => {
if (!originalElement.current) {
originalElement.current = deepCopyElement(element);
}
if (!accumulatedDimensionChange.current) {
accumulatedDimensionChange.current = 0;
}
if (inputRef.current) {
const startPosition = event.clientX;
let startValue = Number(inputRef.current.value);
if (isNaN(startValue)) {
startValue = 0;
}
let lastPointerRef: {
x: number;
y: number;
} | null = null;
document.body.classList.add("dragResize");
const onPointerMove = (event: PointerEvent) => {
if (lastPointerRef) {
hangleChangeThrottled(
startValue,
Math.ceil(event.clientX - startPosition),
"pointerMove",
event.clientX - lastPointerRef.x,
);
}
lastPointerRef = {
x: event.clientX,
y: event.clientY,
};
};
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
window.addEventListener(
EVENT.POINTER_UP,
() => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
lastPointerRef = null;
document.body.classList.remove("dragResize");
},
false,
);
}
}}
onPointerUp={(event) => {
accumulatedDimensionChange.current = 0;
originalElement.current = undefined;
}}
onPointerEnter={() => {
if (labelRef.current) {
labelRef.current.style.cursor = "ew-resize";
}
}}
>
{label}
</div>
<input
className="color-picker-input"
style={{
width: "66px",
fontSize: "12px",
}}
autoComplete="off"
spellCheck="false"
onKeyDown={(event) => {
const eventTarget = event.target;
if (eventTarget instanceof HTMLInputElement) {
const value = Number(eventTarget.value);
if (isNaN(value)) {
return;
}
if (event.key === KEYS.ENTER) {
handleChange(value, 0, "keyDown");
}
}
}}
ref={inputRef}
></input>
</label>
);
};
export default DragInput;

@ -0,0 +1,61 @@
import { mutateElement } from "../../element/mutateElement";
import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
interface AngleProps {
element: ExcalidrawElement;
}
const STEP_SIZE = 15;
const Angle = ({ element }: AngleProps) => {
const handleDegreeChange: DragInputCallbackType = (
accumulatedChange,
instantChange,
stateAtStart,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
) => {
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
mutateElement(element, {
angle: nextAngle,
});
return;
}
if (stateAtStart) {
const originalAngleInDegrees =
Math.round(radianToDegree(stateAtStart.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
mutateElement(element, {
angle: degreeToRadian(
nextAngleInDegrees < 0
? nextAngleInDegrees + 360
: nextAngleInDegrees,
),
});
}
};
return (
<DragInput
label="A"
value={Math.round(radianToDegree(element.angle) * 100) / 100}
element={element}
dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")}
/>
);
};
export default Angle;

@ -0,0 +1,160 @@
import type { ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import { mutateElement } from "../../element/mutateElement";
interface DimensionDragInputProps {
property: "width" | "height";
element: ExcalidrawElement;
}
const STEP_SIZE = 10;
const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
return element.type === "image";
};
const newOrigin = (
x1: number,
y1: number,
w1: number,
h1: number,
w2: number,
h2: number,
angle: number,
) => {
/**
* The formula below is the result of solving
* rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle)
* where rotate is the function defined in math.ts
*
* This is so that the new origin (x2, y2),
* when rotated against the new center (cx2, cy2),
* coincides with (x1, y1) rotated against (cx1, cy1)
*
* The reason for doing this computation is so the element's top left corner
* on the canvas remains fixed after any changes in its dimension.
*/
return {
x:
x1 +
(w1 - w2) / 2 +
((w2 - w1) / 2) * Math.cos(angle) +
((h1 - h2) / 2) * Math.sin(angle),
y:
y1 +
(h1 - h2) / 2 +
((w2 - w1) / 2) * Math.sin(angle) +
((h2 - h1) / 2) * Math.cos(angle),
};
};
const DimensionDragInput = ({ property, element }: DimensionDragInputProps) => {
const handleDimensionChange: DragInputCallbackType = (
accumulatedChange,
instantChange,
stateAtStart,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
) => {
if (stateAtStart) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
const aspectRatio = stateAtStart.width / stateAtStart.height;
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
? nextValue
: keepAspectRatio
? nextValue * aspectRatio
: stateAtStart.width,
0,
);
const nextHeight = Math.max(
property === "height"
? nextValue
: keepAspectRatio
? nextValue / aspectRatio
: stateAtStart.height,
0,
);
mutateElement(element, {
...newOrigin(
element.x,
element.y,
element.width,
element.height,
nextWidth,
nextHeight,
element.angle,
),
width: nextWidth,
height: nextHeight,
});
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, stateAtStart.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, stateAtStart.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
mutateElement(element, {
width: nextWidth,
height: nextHeight,
...newOrigin(
stateAtStart.x,
stateAtStart.y,
stateAtStart.width,
stateAtStart.height,
nextWidth,
nextHeight,
stateAtStart.angle,
),
});
}
};
return (
<DragInput
label={property === "width" ? "W" : "H"}
element={element}
dragInputCallback={handleDimensionChange}
value={
Math.round(
(property === "width" ? element.width : element.height) * 100,
) / 100
}
editable={isPropertyEditable(element, property)}
/>
);
};
export default DimensionDragInput;

@ -0,0 +1,75 @@
.excalidraw {
.drag-input-container {
display: flex;
width: 100%;
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
}
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
.drag-input-label {
height: var(--default-button-size);
flex-shrink: 0;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border: 1px solid var(--default-border-color);
border-right: 0;
box-sizing: border-box;
:root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
}
:root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
}
color: var(--input-label-color);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.drag-input {
box-sizing: border-box;
width: 100%;
margin: 0;
font-size: 0.875rem;
font-family: inherit;
background-color: transparent;
color: var(--text-primary-color);
border: 0;
outline: none;
height: var(--default-button-size);
border: 1px solid var(--default-border-color);
border-left: 0;
letter-spacing: 0.4px;
:root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
}
:root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-left: 1px solid var(--default-border-color);
border-right: 0;
}
padding: 0.5rem;
padding-left: 0.25rem;
appearance: none;
&:focus-visible {
box-shadow: none;
}
}
}

@ -0,0 +1,176 @@
import { useEffect, useMemo, useRef, useState } from "react";
import throttle from "lodash.throttle";
import { EVENT } from "../../constants";
import { KEYS } from "../../keys";
import type { ExcalidrawElement } from "../../element/types";
import { deepCopyElement } from "../../element/newElement";
import "./DragInput.scss";
import clsx from "clsx";
export type DragInputCallbackType = (
accumulatedChange: number,
instantChange: number,
stateAtStart: ExcalidrawElement,
shouldKeepAspectRatio: boolean,
shouldChangeByStepSize: boolean,
nextValue?: number,
) => void;
interface StatsDragInputProps {
label: string | React.ReactNode;
value: number;
element: ExcalidrawElement;
editable?: boolean;
shouldKeepAspectRatio?: boolean;
dragInputCallback: DragInputCallbackType;
}
const StatsDragInput = ({
label,
dragInputCallback,
value,
element,
editable = true,
shouldKeepAspectRatio,
}: StatsDragInputProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const cbThrottled = useMemo(() => {
return throttle(dragInputCallback, 16);
}, [dragInputCallback]);
const [inputValue, setInputValue] = useState(value.toString());
useEffect(() => {
setInputValue(value.toString());
}, [value]);
return (
<div className={clsx("drag-input-container", !editable && "disabled")}>
<div
className="drag-input-label"
ref={labelRef}
onPointerDown={(event) => {
if (inputRef.current && editable) {
let startValue = Number(inputRef.current.value);
if (isNaN(startValue)) {
startValue = 0;
}
let lastPointer: {
x: number;
y: number;
} | null = null;
let stateAtStart: ExcalidrawElement | null = null;
let accumulatedChange: number | null = null;
document.body.classList.add("dragResize");
const onPointerMove = (event: PointerEvent) => {
if (!stateAtStart) {
stateAtStart = deepCopyElement(element);
}
if (!accumulatedChange) {
accumulatedChange = 0;
}
if (lastPointer && stateAtStart && accumulatedChange !== null) {
const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange;
cbThrottled(
accumulatedChange,
instantChange,
stateAtStart,
shouldKeepAspectRatio!!,
event.shiftKey,
);
}
lastPointer = {
x: event.clientX,
y: event.clientY,
};
};
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
window.addEventListener(
EVENT.POINTER_UP,
() => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
lastPointer = null;
accumulatedChange = null;
stateAtStart = null;
document.body.classList.remove("dragResize");
},
false,
);
}
}}
onPointerEnter={() => {
if (labelRef.current) {
labelRef.current.style.cursor = "ew-resize";
}
}}
>
{label}
</div>
<input
className="drag-input"
autoComplete="off"
spellCheck="false"
onKeyDown={(event) => {
if (editable) {
const eventTarget = event.target;
if (
eventTarget instanceof HTMLInputElement &&
event.key === KEYS.ENTER
) {
const v = Number(eventTarget.value);
if (isNaN(v)) {
setInputValue(value.toString());
return;
}
dragInputCallback(
0,
0,
element,
shouldKeepAspectRatio!!,
false,
v,
);
}
}
}}
ref={inputRef}
value={inputValue}
onChange={(event) => {
const eventTarget = event.target;
if (eventTarget instanceof HTMLInputElement) {
setInputValue(event.target.value);
}
}}
onBlur={() => {
if (!inputValue) {
setInputValue(value.toString());
}
}}
disabled={!editable}
></input>
</div>
);
};
export default StatsDragInput;

@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @import "../../css/variables.module.scss";
.excalidraw { .excalidraw {
.Stats { .Stats {
@ -24,18 +24,13 @@
} }
.statsItem { .statsItem {
width: 100%;
margin-bottom: 4px; margin-bottom: 4px;
display: flex; display: grid;
align-items: center; gap: 4px;
// margin-right: 8px;
.label { .label {
margin-right: 4px; margin-right: 4px;
width: 10px;
}
.input {
width: 55px;
} }
} }

@ -1,17 +1,19 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { getCommonBounds } from "../element/bounds"; import { getCommonBounds } from "../../element/bounds";
import type { NonDeletedExcalidrawElement } from "../element/types"; import type { NonDeletedExcalidrawElement } from "../../element/types";
import { t } from "../i18n"; import { t } from "../../i18n";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../../scene";
import type Scene from "../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState, ExcalidrawProps } from "../types"; import type { AppState, ExcalidrawProps } from "../../types";
import { CloseIcon } from "./icons"; import { CloseIcon } from "../icons";
import { Island } from "./Island"; import { Island } from "../Island";
import "./Stats.scss";
import { throttle } from "lodash"; import { throttle } from "lodash";
import DragInput from "./DragInput"; import Dimension from "./Dimension";
import Angle from "./Angle";
import "./index.scss";
import FontSize from "./FontSize";
const STATS_TIMEOUT = 50;
interface StatsProps { interface StatsProps {
appState: AppState; appState: AppState;
scene: Scene; scene: Scene;
@ -19,14 +21,11 @@ interface StatsProps {
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
} }
const STATS_TIMEOUT = 50;
type ElementStatItem = {
label: string;
property: "x" | "y" | "width" | "height" | "angle";
};
export const Stats = (props: StatsProps) => { export const Stats = (props: StatsProps) => {
const elements = props.scene.getNonDeletedElements(); const elements = props.scene.getNonDeletedElements();
const elementsMap = props.scene.getNonDeletedElementsMap();
const sceneNonce = props.scene.getSceneNonce(); const sceneNonce = props.scene.getSceneNonce();
const selectedElements = getTargetElements(elements, props.appState); const selectedElements = getTargetElements(elements, props.appState);
@ -106,39 +105,16 @@ export const Stats = (props: StatsProps) => {
{t(`element.${singleElement.type}`)} {t(`element.${singleElement.type}`)}
</div> </div>
<div <div className="statsItem">
style={{ <Dimension property="width" element={singleElement} />
display: "grid", <Dimension property="height" element={singleElement} />
gridTemplateColumns: "repeat(2, 1fr)", <Angle element={singleElement} />
gap: "4px 8px", {singleElement.type === "text" && (
}} <FontSize element={singleElement} elementsMap={elementsMap} />
> )}
{(
[
{
label: "W",
property: "width",
},
{
label: "H",
property: "height",
},
{
label: "A",
property: "angle",
},
] as ElementStatItem[]
).map((statsItem) => (
<DragInput
key={statsItem.label}
label={statsItem.label}
property={statsItem.property}
element={singleElement}
elementsMap={props.scene.getNonDeletedElementsMap()}
zoom={props.appState.zoom}
/>
))}
</div> </div>
{singleElement.type === "text" && <div></div>}
</div> </div>
</div> </div>
)} )}

@ -0,0 +1,23 @@
import { isFrameLikeElement, isTextElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
export const isPropertyEditable = (
element: ExcalidrawElement,
property: keyof ExcalidrawElement,
) => {
if (property === "height" && isTextElement(element)) {
return false;
}
if (property === "width" && isTextElement(element)) {
return false;
}
if (property === "angle" && isFrameLikeElement(element)) {
return false;
}
return true;
};
export const getStepSizedValue = (value: number, stepSize: number) => {
const v = value + stepSize / 2;
return v - (v % stepSize);
};
Loading…
Cancel
Save