Implement responsive mobile support.

This commit is contained in:
2026-06-14 18:42:14 +03:30
parent b0830f7f71
commit fc4ef29230
4 changed files with 146 additions and 13 deletions

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#11131a" /> <meta name="theme-color" content="#11131a" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>OpenXD</title> <title>OpenXD</title>

View File

@@ -53,9 +53,23 @@ export default function App() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [warningsOpen, setWarningsOpen] = useState(false); const [warningsOpen, setWarningsOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const dragRef = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null); const dragRef = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null);
const panRef = useRef(pan);
const zoomRef = useRef(zoom);
const touchRef = useRef<{
mode: "pan" | "pinch";
x: number;
y: number;
distance: number;
pan: { x: number; y: number };
zoom: number;
} | null>(null);
const canvasRef = useRef<HTMLElement>(null); const canvasRef = useRef<HTMLElement>(null);
useEffect(() => { panRef.current = pan; }, [pan]);
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
const fit = useCallback(() => { const fit = useCallback(() => {
if (!document) return; if (!document) return;
const canvas = canvasRef.current?.getBoundingClientRect(); const canvas = canvasRef.current?.getBoundingClientRect();
@@ -68,6 +82,71 @@ export default function App() {
}, [bounds, document]); }, [bounds, document]);
useEffect(() => { fit(); }, [fit]); useEffect(() => { fit(); }, [fit]);
useEffect(() => {
const refit = () => fit();
window.addEventListener("resize", refit);
return () => window.removeEventListener("resize", refit);
}, [fit]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !document) return;
const center = (touches: TouchList) => ({
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
});
const distance = (touches: TouchList) =>
Math.hypot(touches[1].clientX - touches[0].clientX, touches[1].clientY - touches[0].clientY);
const begin = (event: TouchEvent) => {
event.preventDefault();
if (event.touches.length >= 2) {
const point = center(event.touches);
touchRef.current = { mode: "pinch", ...point, distance: distance(event.touches), pan: panRef.current, zoom: zoomRef.current };
} else if (event.touches.length === 1) {
touchRef.current = {
mode: "pan",
x: event.touches[0].clientX,
y: event.touches[0].clientY,
distance: 0,
pan: panRef.current,
zoom: zoomRef.current,
};
}
};
const move = (event: TouchEvent) => {
event.preventDefault();
const gesture = touchRef.current;
if (!gesture) return;
if (event.touches.length >= 2) {
const point = center(event.touches);
if (gesture.mode !== "pinch") {
touchRef.current = { mode: "pinch", ...point, distance: distance(event.touches), pan: panRef.current, zoom: zoomRef.current };
return;
}
setZoom(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, gesture.zoom * distance(event.touches) / gesture.distance)));
setPan({ x: gesture.pan.x + point.x - gesture.x, y: gesture.pan.y + point.y - gesture.y });
} else if (event.touches.length === 1 && gesture.mode === "pan") {
setPan({
x: gesture.pan.x + event.touches[0].clientX - gesture.x,
y: gesture.pan.y + event.touches[0].clientY - gesture.y,
});
}
};
const end = (event: TouchEvent) => {
event.preventDefault();
if (event.touches.length) begin(event);
else touchRef.current = null;
};
canvas.addEventListener("touchstart", begin, { passive: false });
canvas.addEventListener("touchmove", move, { passive: false });
canvas.addEventListener("touchend", end, { passive: false });
canvas.addEventListener("touchcancel", end, { passive: false });
return () => {
canvas.removeEventListener("touchstart", begin);
canvas.removeEventListener("touchmove", move);
canvas.removeEventListener("touchend", end);
canvas.removeEventListener("touchcancel", end);
};
}, [document]);
const openFile = useCallback(async (file: File) => { const openFile = useCallback(async (file: File) => {
setLoading(true); setLoading(true);
@@ -116,21 +195,25 @@ export default function App() {
setActive(index); setActive(index);
setZoom(Math.max(MIN_ZOOM, transform.zoom)); setZoom(Math.max(MIN_ZOOM, transform.zoom));
setPan(transform.pan); setPan(transform.pan);
setSidebarOpen(false);
}; };
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="topbar"> <header className="topbar">
<div className="brand"><span>XD</span><strong>{document.name}</strong>{document.version && <small>v{document.version}</small>}</div> <div className="brand">
<button className="menu-button" onClick={() => setSidebarOpen((open) => !open)} aria-label="Toggle artboards" aria-expanded={sidebarOpen}></button>
<span>XD</span><strong>{document.name}</strong>{document.version && <small>v{document.version}</small>}
</div>
<div className="toolbar"> <div className="toolbar">
<button onClick={() => setZoom((value) => Math.max(MIN_ZOOM, value - 0.1))} aria-label="Zoom out"></button> <button onClick={() => setZoom((value) => Math.max(MIN_ZOOM, value / 1.2))} aria-label="Zoom out"></button>
<output>{Math.round(zoom * 100)}%</output> <output>{Math.round(zoom * 100)}%</output>
<button onClick={() => setZoom((value) => Math.min(MAX_ZOOM, value + 0.1))} aria-label="Zoom in">+</button> <button onClick={() => setZoom((value) => Math.min(MAX_ZOOM, value * 1.2))} aria-label="Zoom in">+</button>
<button onClick={fit}>Fit</button> <button onClick={fit}>Fit</button>
<button className="clear" onClick={() => { setDocument(null); setError(""); }}>Close file</button> <button className="clear" onClick={() => { setDocument(null); setError(""); }}>Close</button>
</div> </div>
</header> </header>
<aside className="sidebar"> <aside className={`sidebar ${sidebarOpen ? "open" : ""}`}>
<div className="sidebar-title"><strong>Artboards</strong><span>{document.artboards.length}</span></div> <div className="sidebar-title"><strong>Artboards</strong><span>{document.artboards.length}</span></div>
<div className="artboard-list"> <div className="artboard-list">
{document.artboards.map((item, index) => ( {document.artboards.map((item, index) => (
@@ -150,6 +233,7 @@ export default function App() {
</div> </div>
)} )}
</aside> </aside>
{sidebarOpen && <button className="sidebar-backdrop" onClick={() => setSidebarOpen(false)} aria-label="Close artboards" />}
<main <main
ref={canvasRef} ref={canvasRef}
className="canvas" className="canvas"
@@ -158,14 +242,21 @@ export default function App() {
setZoom((value) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value * (event.deltaY > 0 ? 0.9 : 1.1)))); setZoom((value) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value * (event.deltaY > 0 ? 0.9 : 1.1))));
}} }}
onPointerDown={(event) => { onPointerDown={(event) => {
if (event.pointerType === "touch") return;
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { x: event.clientX, y: event.clientY, originX: pan.x, originY: pan.y }; dragRef.current = { x: event.clientX, y: event.clientY, originX: pan.x, originY: pan.y };
}} }}
onPointerMove={(event) => { onPointerMove={(event) => {
if (!dragRef.current) return; if (event.pointerType !== "touch" && dragRef.current) {
setPan({ x: dragRef.current.originX + event.clientX - dragRef.current.x, y: dragRef.current.originY + event.clientY - dragRef.current.y }); setPan({ x: dragRef.current.originX + event.clientX - dragRef.current.x, y: dragRef.current.originY + event.clientY - dragRef.current.y });
}
}}
onPointerUp={(event) => {
if (event.pointerType !== "touch") dragRef.current = null;
}}
onPointerCancel={(event) => {
if (event.pointerType !== "touch") dragRef.current = null;
}} }}
onPointerUp={() => { dragRef.current = null; }}
> >
{document.artboards.length || document.pasteboardLayers.length ? ( {document.artboards.length || document.pasteboardLayers.length ? (
<div <div

View File

@@ -6,8 +6,8 @@
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; min-width: 720px; min-height: 100vh; overflow: hidden; } body { margin: 0; min-width: 0; min-height: 100vh; overflow: hidden; }
button { font: inherit; } button { font: inherit; touch-action: manipulation; }
.drop-screen { .drop-screen {
min-height: 100vh; min-height: 100vh;
@@ -36,6 +36,7 @@ h1 { margin: 0; font-size: 34px; letter-spacing: -1.3px; }
.brand span { width: 34px; height: 34px; border-radius: 9px; font-size: 13px; letter-spacing: -1px; } .brand span { width: 34px; height: 34px; border-radius: 9px; font-size: 13px; letter-spacing: -1px; }
.brand strong { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .brand strong { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.brand small { color: #777c8b; } .brand small { color: #777c8b; }
.menu-button { display: none; }
.toolbar { display: flex; align-items: center; gap: 4px; } .toolbar { display: flex; align-items: center; gap: 4px; }
.toolbar button, .toolbar output { height: 34px; min-width: 36px; padding: 0 10px; border: 1px solid #343744; border-radius: 7px; color: #daddE7; background: #222530; cursor: pointer; } .toolbar button, .toolbar output { height: 34px; min-width: 36px; padding: 0 10px; border: 1px solid #343744; border-radius: 7px; color: #daddE7; background: #222530; cursor: pointer; }
.toolbar output { display: grid; place-items: center; min-width: 60px; color: #aeb2c1; font-size: 12px; } .toolbar output { display: grid; place-items: center; min-width: 60px; color: #aeb2c1; font-size: 12px; }
@@ -60,11 +61,46 @@ h1 { margin: 0; font-size: 34px; letter-spacing: -1.3px; }
.warnings li { padding: 8px 0; border-bottom: 1px solid #292c36; color: #aeb2bf; font-size: 11px; line-height: 1.4; } .warnings li { padding: 8px 0; border-bottom: 1px solid #292c36; color: #aeb2bf; font-size: 11px; line-height: 1.4; }
.warnings li small { display: block; color: #777c8b; } .warnings li small { display: block; color: #777c8b; }
.canvas { position: relative; display: grid; place-items: center; overflow: hidden; touch-action: none; cursor: grab; background-color: #11131a; background-image: radial-gradient(#343743 1px, transparent 1px); background-size: 20px 20px; } .canvas { position: relative; display: grid; place-items: center; overflow: hidden; touch-action: none; overscroll-behavior: none; user-select: none; cursor: grab; background-color: #11131a; background-image: radial-gradient(#343743 1px, transparent 1px); background-size: 20px 20px; }
.canvas:active { cursor: grabbing; } .canvas:active { cursor: grabbing; }
.stage { position: relative; transform-origin: center; transition: transform .05s linear; } .stage { position: relative; transform-origin: center; transition: transform .05s linear; }
.document-stage { flex: 0 0 auto; } .document-stage { flex: 0 0 auto; }
.document-view { display: block; overflow: visible; } .document-view { display: block; overflow: visible; pointer-events: none; }
.document-artboard-label { fill: #969baa; font-size: 12px; } .document-artboard-label { fill: #969baa; font-size: 12px; }
.artboard { display: block; overflow: visible; background: white; } .artboard { display: block; overflow: visible; background: white; }
.artboard-label { position: absolute; left: 0; top: -28px; max-width: 100%; color: #969baa; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .artboard-label { position: absolute; left: 0; top: -28px; max-width: 100%; color: #969baa; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-backdrop { display: none; }
@media (max-width: 800px), (max-height: 600px) and (pointer: coarse) {
.drop-card { width: calc(100vw - 28px); padding: 34px 20px; border-radius: 18px; }
.drop-card h1 { font-size: 27px; }
.drop-hint { display: none; }
.app-shell { height: 100dvh; grid-template: calc(108px + env(safe-area-inset-top)) 1fr / 1fr; }
.topbar { grid-column: 1; flex-wrap: wrap; align-content: center; gap: 8px; padding: calc(8px + env(safe-area-inset-top)) 10px 8px; }
.brand { width: 100%; }
.brand strong { flex: 1; max-width: none; font-size: 14px; }
.brand small { display: none; }
.menu-button { display: grid; place-items: center; width: 38px; height: 38px; padding: 0; border: 1px solid #343744; border-radius: 8px; color: #daddE7; background: #222530; }
.toolbar { width: 100%; justify-content: stretch; }
.toolbar button, .toolbar output { height: 38px; flex: 1; min-width: 0; padding: 0 7px; }
.toolbar output { min-width: 48px; }
.toolbar .clear { margin-left: 4px; }
.sidebar { position: fixed; z-index: 5; top: 0; bottom: 0; left: 0; width: min(84vw, 320px); padding-top: env(safe-area-inset-top); transform: translateX(-105%); transition: transform .2s ease; box-shadow: 12px 0 40px #0009; }
.sidebar.open { transform: translateX(0); }
.sidebar-title { padding: 18px 20px; min-height: 58px; }
.artboard-list button { min-height: 62px; }
.sidebar-backdrop { display: block; position: fixed; z-index: 4; inset: 0; border: 0; background: #08090dbb; }
.canvas { grid-column: 1; }
}
@media (max-height: 600px) and (pointer: coarse) {
.app-shell { grid-template-rows: calc(58px + env(safe-area-inset-top)) 1fr; }
.topbar { flex-wrap: nowrap; padding-top: env(safe-area-inset-top); }
.brand { width: auto; flex: 1; }
.brand span { display: none; }
.toolbar { width: auto; flex: 1.5; }
}

View File

@@ -20,6 +20,12 @@ describe("viewport helpers", () => {
}); });
}); });
it("fits complete bounds inside a narrow mobile viewport", () => {
const transform = fitBounds({ x: -500, y: -100, width: 1100, height: 800 }, { width: 390, height: 700 }, 40);
expect(transform.zoom).toBeCloseTo(310 / 1100);
expect(transform.pan).toEqual({ x: 0, y: 0 });
});
it("focuses an artboard relative to the document center", () => { it("focuses an artboard relative to the document center", () => {
const transform = focusArtboard(artboards[0], artboardBounds(document), { width: 1000, height: 800 }, 100); const transform = focusArtboard(artboards[0], artboardBounds(document), { width: 1000, height: 800 }, 100);
expect(transform.zoom).toBe(1.5); expect(transform.zoom).toBe(1.5);