wip: drag input
This commit is contained in:
parent
80b9fd18b9
commit
6e577d1308
235
src/components/DragInput.tsx
Normal file
235
src/components/DragInput.tsx
Normal file
@ -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 {
|
||||
.Stats {
|
||||
width: 202px;
|
||||
width: 204px;
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 12px;
|
||||
|
@ -1,14 +1,7 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { degreeToRadian, radianToDegree } from "../math";
|
||||
import { getTargetElements } from "../scene";
|
||||
import Scene from "../scene/Scene";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
@ -16,6 +9,7 @@ import { CloseIcon } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./Stats.scss";
|
||||
import { throttle } from "lodash";
|
||||
import DragInput from "./DragInput";
|
||||
|
||||
const STATS_TIMEOUT = 50;
|
||||
interface StatsProps {
|
||||
@ -28,9 +22,6 @@ interface StatsProps {
|
||||
|
||||
type ElementStatItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
element: NonDeletedExcalidrawElement;
|
||||
version: string;
|
||||
property: "x" | "y" | "width" | "height" | "angle";
|
||||
};
|
||||
|
||||
@ -70,70 +61,6 @@ export const Stats = (props: StatsProps) => {
|
||||
[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 (
|
||||
<div className="Stats">
|
||||
<Island padding={3}>
|
||||
@ -185,78 +112,38 @@ export const Stats = (props: StatsProps) => {
|
||||
gap: "4px 8px",
|
||||
}}
|
||||
>
|
||||
{elementStats.map((statsItem) => {
|
||||
return (
|
||||
<label
|
||||
className="color-input-container"
|
||||
key={statsItem.property}
|
||||
>
|
||||
<div
|
||||
className="color-picker-hash"
|
||||
style={{
|
||||
width: "30px",
|
||||
}}
|
||||
>
|
||||
{statsItem.label}
|
||||
</div>
|
||||
<input
|
||||
id={statsItem.label}
|
||||
key={statsItem.version}
|
||||
defaultValue={statsItem.value}
|
||||
className="color-picker-input"
|
||||
style={{
|
||||
width: "55px",
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
onKeyDown={(event) => {
|
||||
let value = Number(event.target.value);
|
||||
|
||||
if (isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{(
|
||||
[
|
||||
{
|
||||
label: "X",
|
||||
property: "x",
|
||||
},
|
||||
{
|
||||
label: "Y",
|
||||
property: "y",
|
||||
},
|
||||
{
|
||||
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}
|
||||
zoom={props.appState.zoom}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -7,6 +7,12 @@
|
||||
--zIndex-layerUI: 3;
|
||||
}
|
||||
|
||||
body.dragResize,
|
||||
body.dragResize a:hover,
|
||||
body.dragResize * {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
|
Loading…
x
Reference in New Issue
Block a user