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 {
|
.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…
x
Reference in New Issue
Block a user