bringing back scroll constraints debug

This commit is contained in:
Ryan Di 2025-05-08 12:21:42 +10:00
parent 4208c97b62
commit baa7b3293a
3 changed files with 210 additions and 110 deletions

View File

@ -51,7 +51,10 @@ import {
import { isElementLink } from "@excalidraw/element/elementLink"; import { isElementLink } from "@excalidraw/element/elementLink";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore"; import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks"; import {
isFrameLikeElement,
isInitializedImageElement,
} from "@excalidraw/element/typeChecks";
import clsx from "clsx"; import clsx from "clsx";
import { import {
parseLibraryTokensFromUrl, parseLibraryTokensFromUrl,
@ -142,6 +145,10 @@ import "./index.scss";
import type { CollabAPI } from "./collab/Collab"; import type { CollabAPI } from "./collab/Collab";
import { getSelectedElements } from "@excalidraw/element/selection"; import { getSelectedElements } from "@excalidraw/element/selection";
import {
decodeConstraints,
encodeConstraints,
} from "@excalidraw/excalidraw/scene/scrollConstraints";
polyfill(); polyfill();
@ -160,19 +167,37 @@ const ConstraintsSettings = ({
const [constraints, setConstraints] = const [constraints, setConstraints] =
useState<DebugScrollConstraints>(initialConstraints); useState<DebugScrollConstraints>(initialConstraints);
const frames = excalidrawAPI
.getSceneElements()
.filter((e) => isFrameLikeElement(e));
const [activeFrameId, setActiveFrameId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
// add JSON-stringified constraints into url hash for easy sharing
const hash = new URLSearchParams(window.location.hash.slice(1)); const hash = new URLSearchParams(window.location.hash.slice(1));
hash.set( hash.set("constraints", encodeConstraints(constraints));
"constraints",
encodeURIComponent(
window.btoa(JSON.stringify(constraints)).replace(/=+/, ""),
),
);
window.location.hash = decodeURIComponent(hash.toString()); window.location.hash = decodeURIComponent(hash.toString());
excalidrawAPI.setScrollConstraints(constraints);
constraints.enabled
? excalidrawAPI.setScrollConstraints(constraints)
: excalidrawAPI.setScrollConstraints(null);
}, [constraints]); }, [constraints]);
useEffect(() => {
const frame = frames.find((frame) => frame.id === activeFrameId);
if (frame) {
const { x, y, width, height } = frame;
setConstraints((s) => ({
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
enabled: s.enabled,
viewportZoomFactor: s.viewportZoomFactor,
lockZoom: s.lockZoom,
}));
}
}, [activeFrameId]);
const [selection, setSelection] = useState<ExcalidrawElement[]>([]); const [selection, setSelection] = useState<ExcalidrawElement[]>([]);
useEffect(() => { useEffect(() => {
return excalidrawAPI.onChange((elements, appState) => { return excalidrawAPI.onChange((elements, appState) => {
@ -183,109 +208,179 @@ const ConstraintsSettings = ({
return ( return (
<div <div
style={{ style={{
display: "flex",
position: "fixed", position: "fixed",
bottom: 10, bottom: 10,
left: "calc(50%)", left: "calc(50%)",
transform: "translateX(-50%)", transform: "translateX(-50%)",
gap: "0.6rem",
zIndex: 999999, zIndex: 999999,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
gap: "0.5rem",
}} }}
> >
enabled:{" "} <div
<input style={{
type="checkbox" display: "flex",
defaultChecked={!!constraints.enabled} gap: "0.6rem",
onChange={(e) => }}
setConstraints((s) => ({ ...s, enabled: e.target.checked })) >
} enabled:{" "}
/> <input
x:{" "} type="checkbox"
<input defaultChecked={!!constraints.enabled}
placeholder="x" onChange={(e) =>
size={4} setConstraints((s) => ({ ...s, enabled: e.target.checked }))
value={constraints.x.toString()} }
onChange={(e) => />
setConstraints((s) => ({ x:{" "}
...s, <input
x: parseInt(e.target.value) ?? 0, placeholder="x"
})) size={4}
} value={constraints.x.toString()}
/> onChange={(e) =>
y:{" "}
<input
placeholder="y"
size={4}
value={constraints.y.toString()}
onChange={(e) =>
setConstraints((s) => ({
...s,
y: parseInt(e.target.value) ?? 0,
}))
}
/>
w:{" "}
<input
placeholder="width"
size={4}
value={constraints.width.toString()}
onChange={(e) =>
setConstraints((s) => ({
...s,
width: parseInt(e.target.value) ?? 200,
}))
}
/>
h:{" "}
<input
placeholder="height"
size={4}
value={constraints.height.toString()}
onChange={(e) =>
setConstraints((s) => ({
...s,
height: parseInt(e.target.value) ?? 200,
}))
}
/>
zoomFactor:
<input
placeholder="height"
type="number"
min="0.1"
max="1"
step="0.1"
value={constraints.viewportZoomFactor.toString()}
onChange={(e) =>
setConstraints((s) => ({
...s,
viewportZoomFactor: parseFloat(e.target.value.toString()) ?? 0.7,
}))
}
/>
lockZoom:{" "}
<input
type="checkbox"
defaultChecked={!!constraints.lockZoom}
onChange={(e) =>
setConstraints((s) => ({ ...s, lockZoom: e.target.checked }))
}
/>
{selection.length > 0 && (
<button
onClick={() => {
const bbox = getCommonBounds(selection);
setConstraints((s) => ({ setConstraints((s) => ({
...s, ...s,
x: Math.round(bbox[0]), x: parseInt(e.target.value) ?? 0,
y: Math.round(bbox[1]), }))
width: Math.round(bbox[2] - bbox[0]), }
height: Math.round(bbox[3] - bbox[1]), />
})); y:{" "}
<input
placeholder="y"
size={4}
value={constraints.y.toString()}
onChange={(e) =>
setConstraints((s) => ({
...s,
y: parseInt(e.target.value) ?? 0,
}))
}
/>
w:{" "}
<input
placeholder="width"
size={4}
value={constraints.width.toString()}
onChange={(e) =>
setConstraints((s) => ({
...s,
width: parseInt(e.target.value) ?? 200,
}))
}
/>
h:{" "}
<input
placeholder="height"
size={4}
value={constraints.height.toString()}
onChange={(e) =>
setConstraints((s) => ({
...s,
height: parseInt(e.target.value) ?? 200,
}))
}
/>
zoomFactor:
<input
placeholder="height"
type="number"
min="0.1"
max="1"
step="0.1"
value={constraints.viewportZoomFactor.toString()}
onChange={(e) =>
setConstraints((s) => ({
...s,
viewportZoomFactor: parseFloat(e.target.value.toString()) ?? 0.7,
}))
}
/>
overscrollAllowance:
<input
placeholder="height"
type="number"
min="0"
max="1"
step="0.1"
value={constraints.overscrollAllowance?.toString()}
onChange={(e) =>
setConstraints((s) => ({
...s,
overscrollAllowance: parseFloat(e.target.value.toString()) ?? 0.5,
}))
}
/>
lockZoom:{" "}
<input
type="checkbox"
defaultChecked={!!constraints.lockZoom}
onChange={(e) =>
setConstraints((s) => ({ ...s, lockZoom: e.target.checked }))
}
value={constraints.lockZoom?.toString()}
/>
{selection.length > 0 && (
<button
onClick={() => {
const bbox = getCommonBounds(selection);
setConstraints((s) => ({
...s,
x: Math.round(bbox[0]),
y: Math.round(bbox[1]),
width: Math.round(bbox[2] - bbox[0]),
height: Math.round(bbox[3] - bbox[1]),
}));
}}
>
use selection
</button>
)}
</div>
{frames.length > 0 && (
<div
style={{
display: "flex",
gap: "0.6rem",
flexDirection: "row",
}} }}
> >
use selection <button
</button> onClick={() => {
const currentIndex = frames.findIndex(
(frame) => frame.id === activeFrameId,
);
if (currentIndex === -1) {
setActiveFrameId(frames[frames.length - 1].id);
} else {
const nextIndex =
(currentIndex - 1 + frames.length) % frames.length;
setActiveFrameId(frames[nextIndex].id);
}
}}
>
Prev
</button>
<button
onClick={() => {
const currentIndex = frames.findIndex(
(frame) => frame.id === activeFrameId,
);
if (currentIndex === -1) {
setActiveFrameId(frames[0].id);
} else {
const nextIndex = (currentIndex + 1) % frames.length;
setActiveFrameId(frames[nextIndex].id);
}
}}
>
Next
</button>
</div>
)} )}
</div> </div>
); );
@ -909,8 +1004,10 @@ const ExcalidrawWrapper = () => {
let storedConstraints = {}; let storedConstraints = {};
if (stored) { if (stored) {
try { try {
storedConstraints = JSON.parse(window.atob(stored)); storedConstraints = decodeConstraints(stored);
} catch {} } catch {
console.error("Invalid scroll constraints in URL");
}
} }
return { return {
@ -920,13 +1017,12 @@ const ExcalidrawWrapper = () => {
height: document.body.clientHeight, height: document.body.clientHeight,
lockZoom: false, lockZoom: false,
viewportZoomFactor: 0.7, viewportZoomFactor: 0.7,
overscrollAllowance: 0.5,
enabled: true, enabled: true,
...storedConstraints, ...storedConstraints,
}; };
}); });
console.log(constraints);
// browsers generally prevent infinite self-embedding, there are // browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding // cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard // by not whitelisting our own origin, this serves as an additional guard

View File

@ -21,7 +21,6 @@ import { getNormalizedZoom } from "./normalize";
export const calculateConstrainedScrollCenter = ( export const calculateConstrainedScrollCenter = (
state: AppState, state: AppState,
{ scrollX, scrollY }: Pick<AppState, "scrollX" | "scrollY">, { scrollX, scrollY }: Pick<AppState, "scrollX" | "scrollY">,
overscrollAllowance?: number,
): { ): {
scrollX: AppState["scrollX"]; scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"]; scrollY: AppState["scrollY"];
@ -59,7 +58,6 @@ export const calculateConstrainedScrollCenter = (
height, height,
zoom: _zoom, zoom: _zoom,
allowOverscroll: false, allowOverscroll: false,
overscrollAllowance,
}); });
return { return {
@ -69,6 +67,8 @@ export const calculateConstrainedScrollCenter = (
}; };
}; };
const DEFAULT_OVERSCROLL_ALLOWANCE = 0.2;
interface EncodedConstraints { interface EncodedConstraints {
x: number; x: number;
y: number; y: number;
@ -76,6 +76,8 @@ interface EncodedConstraints {
h: number; h: number;
l: boolean; l: boolean;
v: number; v: number;
// overscrollAllowance
oa: number;
} }
/** /**
@ -91,6 +93,7 @@ export const encodeConstraints = (constraints: ScrollConstraints): string => {
h: constraints.height, h: constraints.height,
l: !!constraints.lockZoom, l: !!constraints.lockZoom,
v: constraints.viewportZoomFactor ?? 1, v: constraints.viewportZoomFactor ?? 1,
oa: constraints.overscrollAllowance ?? DEFAULT_OVERSCROLL_ALLOWANCE,
}; };
const serialized = JSON.stringify(payload); const serialized = JSON.stringify(payload);
@ -115,6 +118,7 @@ export const decodeConstraints = (encoded: string): ScrollConstraints => {
lockZoom: parsed.l ?? false, lockZoom: parsed.l ?? false,
viewportZoomFactor: parsed.v ?? 1, viewportZoomFactor: parsed.v ?? 1,
animateOnNextUpdate: false, animateOnNextUpdate: false,
overscrollAllowance: parsed.oa ?? DEFAULT_OVERSCROLL_ALLOWANCE,
}; };
} catch (error) { } catch (error) {
// return safe defaults if decoding fails // return safe defaults if decoding fails
@ -170,22 +174,21 @@ const calculateConstraints = ({
height, height,
zoom, zoom,
allowOverscroll, allowOverscroll,
overscrollAllowance,
}: { }: {
scrollConstraints: ScrollConstraints; scrollConstraints: ScrollConstraints;
width: AppState["width"]; width: AppState["width"];
height: AppState["height"]; height: AppState["height"];
zoom: AppState["zoom"]; zoom: AppState["zoom"];
allowOverscroll: boolean; allowOverscroll: boolean;
overscrollAllowance?: number;
}) => { }) => {
// Validate the overscroll allowance percentage // Validate the overscroll allowance percentage
const overscrollAllowance = scrollConstraints.overscrollAllowance;
const validatedOverscroll = const validatedOverscroll =
overscrollAllowance != null && overscrollAllowance != null &&
overscrollAllowance >= 0 && overscrollAllowance >= 0 &&
overscrollAllowance <= 1 overscrollAllowance <= 1
? overscrollAllowance ? overscrollAllowance
: 0.2; : DEFAULT_OVERSCROLL_ALLOWANCE;
/** /**
* Calculates the center position of the constrained scroll area. * Calculates the center position of the constrained scroll area.

View File

@ -911,6 +911,7 @@ export type ScrollConstraints = {
animateOnNextUpdate?: boolean; animateOnNextUpdate?: boolean;
viewportZoomFactor?: number; viewportZoomFactor?: number;
lockZoom?: boolean; lockZoom?: boolean;
overscrollAllowance?: number;
}; };
export type PendingExcalidrawElements = ExcalidrawElement[]; export type PendingExcalidrawElements = ExcalidrawElement[];