Implement responsive mobile support.
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
105
src/App.tsx
105
src/App.tsx
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user