wip: drag input

editable-element-stats
Ryan Di 2 years ago
parent 80b9fd18b9
commit 6e577d1308

@ -0,0 +1,235 @@
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 { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { degreeToRadian, radianToDegree, rotatePoint } from "../math";
import Scene from "../scene/Scene";
import { AppState, Point } from "../types";
import { arrayToMap } from "../utils";
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;
zoom: AppState["zoom"];
}
const DragInput = ({ label, property, element, zoom }: DragInputProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const originalElementsMap = useMemo(
() => arrayToMap(Scene.getScene(element)?.getNonDeletedElements() ?? []),
[element],
);
const handleChange = useMemo(
() =>
(
initialValue: number,
delta: number,
source: "pointerMove" | "keyDown",
pointerOffset?: number,
) => {
if (inputRef.current) {
const keepAspectRatio = shouldKeepAspectRatio(element);
if (
(property === "width" || property === "height") &&
source === "pointerMove" &&
pointerOffset
) {
const handles = getTransformHandles(element, zoom, "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) {
const pointerRotated = rotatePoint(
[
referencePoint[0] +
(property === "width" ? pointerOffset : 0),
referencePoint[1] +
(property === "height" ? pointerOffset : 0),
],
referencePoint,
element.angle,
);
resizeSingleElement(
originalElementsMap,
keepAspectRatio,
element,
handleDirection,
false,
pointerRotated[0],
pointerRotated[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,
});
}
}
},
[element, property, zoom, originalElementsMap],
);
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();
});
return (
<label className="color-input-container">
<div
className="color-picker-hash"
ref={labelRef}
style={{
width: "20px",
}}
onPointerDown={(event) => {
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,
);
}
}}
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 value = Number(event.target.value);
if (isNaN(value)) {
return;
}
if (event.key === KEYS.ENTER) {
handleChange(value, 0, "keyDown");
}
}}
ref={inputRef}
></input>
</label>
);
};
export default DragInput;

@ -2,7 +2,7 @@
.excalidraw { .excalidraw {
.Stats { .Stats {
width: 202px; width: 204px;
position: absolute; position: absolute;
top: 64px; top: 64px;
right: 12px; right: 12px;

@ -1,14 +1,7 @@
import { nanoid } from "nanoid";
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 { mutateElement } from "../element/mutateElement"; import { NonDeletedExcalidrawElement } from "../element/types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys";
import { degreeToRadian, radianToDegree } from "../math";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, ExcalidrawProps } from "../types";
@ -16,6 +9,7 @@ import { CloseIcon } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import "./Stats.scss"; import "./Stats.scss";
import { throttle } from "lodash"; import { throttle } from "lodash";
import DragInput from "./DragInput";
const STATS_TIMEOUT = 50; const STATS_TIMEOUT = 50;
interface StatsProps { interface StatsProps {
@ -28,9 +22,6 @@ interface StatsProps {
type ElementStatItem = { type ElementStatItem = {
label: string; label: string;
value: number;
element: NonDeletedExcalidrawElement;
version: string;
property: "x" | "y" | "width" | "height" | "angle"; property: "x" | "y" | "width" | "height" | "angle";
}; };
@ -70,70 +61,6 @@ export const Stats = (props: StatsProps) => {
[throttledSetSceneDimension], [throttledSetSceneDimension],
); );
const [elementStats, setElementStats] = useState<ElementStatItem[]>([]);
const throttledSetElementStats = useMemo(
() =>
throttle((element: NonDeletedExcalidrawElement | null) => {
const stats: ElementStatItem[] = element
? [
{
label: "X",
value: Math.round(element.x),
element,
property: "x",
version: nanoid(),
},
{
label: "Y",
value: Math.round(element.y),
element,
property: "y",
version: nanoid(),
},
{
label: "W",
value: Math.round(element.width),
element,
property: "width",
version: nanoid(),
},
{
label: "H",
value: Math.round(element.height),
element,
property: "height",
version: nanoid(),
},
{
label: "A",
value: Math.round(radianToDegree(element.angle) * 100) / 100,
element,
property: "angle",
version: nanoid(),
},
]
: [];
setElementStats(stats);
}, STATS_TIMEOUT),
[],
);
useEffect(() => {
throttledSetElementStats(singleElement);
}, [
singleElement,
singleElement?.version,
singleElement?.versionNonce,
throttledSetElementStats,
]);
useEffect(
() => () => throttledSetElementStats.cancel(),
[throttledSetElementStats],
);
return ( return (
<div className="Stats"> <div className="Stats">
<Island padding={3}> <Island padding={3}>
@ -185,78 +112,38 @@ export const Stats = (props: StatsProps) => {
gap: "4px 8px", gap: "4px 8px",
}} }}
> >
{elementStats.map((statsItem) => { {(
return ( [
<label {
className="color-input-container" label: "X",
key={statsItem.property} property: "x",
> },
<div {
className="color-picker-hash" label: "Y",
style={{ property: "y",
width: "30px", },
}} {
> label: "W",
{statsItem.label} property: "width",
</div> },
<input {
id={statsItem.label} label: "H",
key={statsItem.version} property: "height",
defaultValue={statsItem.value} },
className="color-picker-input" {
style={{ label: "A",
width: "55px", property: "angle",
}} },
autoComplete="off" ] as ElementStatItem[]
spellCheck="false" ).map((statsItem) => (
onKeyDown={(event) => { <DragInput
let value = Number(event.target.value); key={statsItem.label}
label={statsItem.label}
if (isNaN(value)) { property={statsItem.property}
return; element={singleElement}
} zoom={props.appState.zoom}
/>
value = ))}
statsItem.property === "angle"
? degreeToRadian(value)
: value;
if (event.key === KEYS.ENTER) {
mutateElement(statsItem.element, {
[statsItem.property]: value,
});
event.target.value = statsItem.element[
statsItem.property as keyof ExcalidrawElement
] as string;
}
}}
onBlur={(event) => {
let value = Number(event.target.value);
if (isNaN(value)) {
return;
}
value =
statsItem.property === "angle"
? degreeToRadian(value)
: value;
if (!isNaN(value)) {
mutateElement(statsItem.element, {
[statsItem.property]: value,
});
}
event.target.value = statsItem.element[
statsItem.property as keyof ExcalidrawElement
] as string;
}}
></input>
</label>
);
})}
</div> </div>
</div> </div>
</div> </div>

@ -7,6 +7,12 @@
--zIndex-layerUI: 3; --zIndex-layerUI: 3;
} }
body.dragResize,
body.dragResize a:hover,
body.dragResize * {
cursor: ew-resize;
}
.excalidraw { .excalidraw {
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI, --ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif; Roboto, Helvetica, Arial, sans-serif;

Loading…
Cancel
Save