FN-8049: preserve desktop geometry across sheet views
Prevent full-screen sheet opens from replacing saved desktop FloatingWindow geometry. - Suspend geometry restoration and writes for sheet modal surfaces without adding storage keys. - Preserve Artifact Gallery geometry at both narrow-width and short-height sheet breakpoints. - Cover sheet persistence behavior and document desktop geometry restoration. Files changed: docs/dashboard-guide.md | 3 + packages/dashboard/app/App.tsx | 4 + .../dashboard/app/components/ArtifactsGallery.tsx | 3 + .../dashboard/app/components/FileBrowserModal.tsx | 2 + .../dashboard/app/components/FloatingWindow.tsx | 25 ++++- .../dashboard/app/components/GitHubImportModal.tsx | 4 + .../app/components/MissionInterviewModal.tsx | 2 + .../dashboard/app/components/PrCreateModal.tsx | 2 + .../app/components/ScheduledTasksModal.tsx | 2 + .../app/components/WorkflowNodeEditor.tsx | 2 + .../components/__tests__/FloatingWindow.test.tsx | 118 ++++++++++++++++++++- .../app/hooks/__tests__/useViewportMode.test.ts | 13 ++- packages/dashboard/app/hooks/useViewportMode.ts | 15 +++ 13 files changed, 190 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8049 Fusion-Task-Lineage: 4c659046-694a-42fa-aca1-e702c1d3842a Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -60,6 +60,9 @@ Shortcut handling is intentionally guarded. Fusion ignores global shortcuts whil
|
||||
|
||||
Press `Escape` to close the current/topmost dashboard popup. Popped-out task windows and floating Quick Chat close before fixed app modals such as Terminal, Settings, Files, or Task Detail, and only one surface closes per key press. Nested editors and menus that already handle Escape keep first ownership by preventing the global handler.
|
||||
|
||||
<!-- FNXC:ModalGeometryPersistenceDocs 2026-07-16-00:40: Full-screen mobile FloatingWindow sheets must preserve, rather than overwrite, the movable desktop geometry record so a later desktop reopen restores the user's chosen location and size. -->
|
||||
Movable dashboard pop-outs remember their last desktop location and size, while centered resizable dialogs remember their size. When a pop-out becomes a full-screen sheet at mobile widths (or, for Artifact Gallery, its short-height sheet breakpoint), it leaves that desktop record untouched; reopening it on desktop restores the prior floating geometry.
|
||||
|
||||
## Mobile/PWA app icons
|
||||
|
||||
The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images.
|
||||
|
||||
@@ -1812,6 +1812,8 @@ function AppInner() {
|
||||
hideHeader
|
||||
dragHandleSelector=".chat-view--floating .view-header"
|
||||
className="floating-window--chat"
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Chat is a full-screen sheet at ≤768px, so preserve its desktop location and size instead of restoring or overwriting them there. */
|
||||
suspendGeometryPersistenceOnMobile
|
||||
persistGeometryKey="kb-dashboard-chat-floating-window"
|
||||
defaultSize={{ width: 980, height: 680 }}
|
||||
/*
|
||||
@@ -1865,6 +1867,8 @@ function AppInner() {
|
||||
hideHeader
|
||||
dragHandleSelector=".task-detail-content--embedded > .modal-header"
|
||||
className="floating-window--task-detail"
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Task pop-outs are full-screen sheets at ≤768px; preserve the shared desktop geometry record for their next movable reopen. */
|
||||
suspendGeometryPersistenceOnMobile
|
||||
persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY}
|
||||
layer="task-detail"
|
||||
>
|
||||
|
||||
@@ -464,6 +464,9 @@ function OverlayShell({ label, onClose, children, wide, closeRef, windowKey, per
|
||||
dragHandleSelector=".artifacts-gallery-viewer-header"
|
||||
className="artifacts-gallery-window"
|
||||
ariaLabel={label}
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-16-00:40: Artifact viewers are full-screen sheets at ≤768px or ≤480px tall, so neither mobile shape may overwrite desktop geometry. */
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
persistGeometryKey={persistKey}
|
||||
defaultSize={wide ? { width: 1024, height: 720 } : { width: 720, height: 640 }}
|
||||
minSize={{ width: 320, height: 280 }}
|
||||
|
||||
@@ -378,6 +378,8 @@ export function FileBrowserModal({
|
||||
className="floating-window--file-browser"
|
||||
defaultSize={{ width: 1120, height: 720 }}
|
||||
minSize={{ width: 360, height: 420 }}
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: File Browser is a ≤768px full-screen sheet; preserve its desktop position and size for movable reopen. */
|
||||
suspendGeometryPersistenceOnMobile
|
||||
persistGeometryKey="fusion:files-modal-window"
|
||||
>
|
||||
{/*
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { isFullScreenSheetViewport, isShortViewport } from "../hooks/useViewportMode";
|
||||
import { currentFloatingZ, currentTaskDetailFloatingZ, nextFloatingZ, nextTaskDetailFloatingZ } from "./floatingWindowStack";
|
||||
import "./FloatingWindow.css";
|
||||
|
||||
@@ -48,6 +49,10 @@ export interface FloatingWindowProps {
|
||||
className?: string;
|
||||
/** Optional localStorage key used to restore the last clamped position and size. */
|
||||
persistGeometryKey?: string;
|
||||
/** Skip desktop geometry restoration/writes while this caller renders as a full-screen mobile sheet. */
|
||||
suspendGeometryPersistenceOnMobile?: boolean;
|
||||
/** Include the CSS short-viewport sheet breakpoint when suspending geometry persistence. */
|
||||
suspendGeometryPersistenceOnShortViewport?: boolean;
|
||||
/**
|
||||
* Opt-in outside-pointer dismissal for transient windows like Quick Chat.
|
||||
* Persistent task/terminal pop-outs must omit this so page clicks do not close them.
|
||||
@@ -175,16 +180,30 @@ export function FloatingWindow({
|
||||
dragHandleSelector,
|
||||
className,
|
||||
persistGeometryKey,
|
||||
suspendGeometryPersistenceOnMobile = false,
|
||||
suspendGeometryPersistenceOnShortViewport = false,
|
||||
closeOnOutsidePointerDown = false,
|
||||
layer = "utility",
|
||||
ariaLabel,
|
||||
}: FloatingWindowProps) {
|
||||
const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT };
|
||||
const initialGeometry = useRef<{ size: FloatingWindowSize; position: FloatingWindowPosition } | null>(null);
|
||||
/*
|
||||
FNXC:ModalGeometryPersistence 2026-07-16-00:40:
|
||||
Opt-in sheet callers leave desktop geometry untouched at `max-width: 768px`. Most wide, short
|
||||
landscape phones remain movable FloatingWindows and must restore geometry; Artifact Gallery opts
|
||||
into its separate `max-height: 480px` full-screen-sheet CSS breakpoint as well.
|
||||
*/
|
||||
const geometryPersistenceSuspended = suspendGeometryPersistenceOnMobile && (
|
||||
isFullScreenSheetViewport() || (suspendGeometryPersistenceOnShortViewport && isShortViewport())
|
||||
);
|
||||
|
||||
if (!initialGeometry.current) {
|
||||
const fallbackSize = clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize);
|
||||
const fallbackPosition = defaultPosition ? clampPosition(defaultPosition, fallbackSize) : defaultPositionFor(windowKey, fallbackSize);
|
||||
initialGeometry.current = readPersistedGeometry(persistGeometryKey, fallbackSize, fallbackPosition, resolvedMinSize);
|
||||
initialGeometry.current = geometryPersistenceSuspended
|
||||
? { size: fallbackSize, position: fallbackPosition }
|
||||
: readPersistedGeometry(persistGeometryKey, fallbackSize, fallbackPosition, resolvedMinSize);
|
||||
}
|
||||
|
||||
const [size, setSize] = useState<FloatingWindowSize>(() =>
|
||||
@@ -406,13 +425,13 @@ export function FloatingWindow({
|
||||
Quick Chat reopens should restore the last desktop floating-window size and position while still clamping onto the current viewport. Keep persistence generic and opt-in with persistGeometryKey so each caller controls whether geometry is shared or isolated.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!persistGeometryKey || typeof window === "undefined") return;
|
||||
if (!persistGeometryKey || typeof window === "undefined" || geometryPersistenceSuspended) return;
|
||||
try {
|
||||
localStorage.setItem(persistGeometryKey, JSON.stringify({ size, position }));
|
||||
} catch {
|
||||
// Ignore storage failures; geometry persistence is a convenience only.
|
||||
}
|
||||
}, [persistGeometryKey, position, size]);
|
||||
}, [geometryPersistenceSuspended, persistGeometryKey, position, size]);
|
||||
|
||||
const panelStyle = {
|
||||
left: `${position.x}px`,
|
||||
|
||||
@@ -1641,6 +1641,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
onClose={() => { setSelectedIssueNumber(null); setSelectedPullNumber(null); }}
|
||||
defaultSize={{ width: 760, height: 680 }}
|
||||
minSize={{ width: 420, height: 360 }}
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Import detail is a ≤768px sheet, so preserve its desktop floating geometry instead of touching it on mobile. */
|
||||
suspendGeometryPersistenceOnMobile
|
||||
persistGeometryKey="floating-window:github-import-detail"
|
||||
className="floating-window--github-import-detail"
|
||||
>
|
||||
@@ -1938,6 +1940,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
onClose={() => setSelectedGitlabKey(null)}
|
||||
defaultSize={{ width: 760, height: 680 }}
|
||||
minSize={{ width: 420, height: 360 }}
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: GitLab detail shares the ≤768px import sheet behavior and must preserve the shared desktop geometry record. */
|
||||
suspendGeometryPersistenceOnMobile
|
||||
persistGeometryKey="floating-window:github-import-detail"
|
||||
className="floating-window--github-import-detail"
|
||||
>
|
||||
|
||||
@@ -780,6 +780,8 @@ export function MissionInterviewModal({
|
||||
className="floating-window--mission-interview"
|
||||
defaultSize={{ width: 760, height: 680 }}
|
||||
minSize={{ width: 560, height: 420 }}
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: This interview is a ≤768px full-screen sheet, so do not replace its stored desktop window geometry while mobile. */
|
||||
suspendGeometryPersistenceOnMobile
|
||||
persistGeometryKey="floating-window:mission-interview"
|
||||
>
|
||||
{/*
|
||||
|
||||
@@ -555,6 +555,8 @@ export function PrCreateModal({
|
||||
className="floating-window--pr-create"
|
||||
defaultSize={{ width: 720, height: 680 }}
|
||||
minSize={{ width: 480, height: 420 }}
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Create PR becomes a ≤768px sheet, so its desktop floating geometry remains intact across mobile opens. */
|
||||
suspendGeometryPersistenceOnMobile
|
||||
persistGeometryKey="floating-window:pr-create"
|
||||
>
|
||||
{/**
|
||||
|
||||
@@ -566,6 +566,8 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
|
||||
className="floating-window--automation"
|
||||
defaultSize={{ width: 720, height: 640 }}
|
||||
minSize={{ width: 420, height: 360 }}
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Automations is a full-screen sheet at ≤768px; its movable desktop geometry must survive mobile opens. */
|
||||
suspendGeometryPersistenceOnMobile
|
||||
persistGeometryKey="floating-window:automation"
|
||||
>
|
||||
{/**
|
||||
|
||||
@@ -5643,6 +5643,8 @@ function InnerEditor({
|
||||
dragHandleSelector=".wf-editor-header"
|
||||
defaultSize={{ width: 1200, height: 820 }}
|
||||
minSize={{ width: 640, height: 480 }}
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: The workflow editor becomes a ≤768px full-screen sheet, so retain desktop geometry without replaying or writing it on the sheet. */
|
||||
suspendGeometryPersistenceOnMobile
|
||||
persistGeometryKey="fusion:workflow-node-editor-floating-geometry"
|
||||
>
|
||||
{modalElement}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture";
|
||||
import { FloatingWindow } from "../FloatingWindow";
|
||||
|
||||
@@ -38,6 +38,16 @@ function cssRulesForClass(css: string, className: string): string[] {
|
||||
return [...css.matchAll(new RegExp(`\\.${escaped}[^{}]*\\{[^}]*\\}`, "g"))].map((match) => match[0]);
|
||||
}
|
||||
|
||||
function setSheetViewport(isSheetWidth: boolean): void {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: query === "(max-width: 768px)" ? isSheetWidth : query === "(max-height: 480px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:FloatingWindow 2026-06-22-20:45:
|
||||
Contract tests for the reusable non-blocking floating window:
|
||||
@@ -52,6 +62,10 @@ describe("FloatingWindow", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
it("renders a non-blocking, click-through transparent overlay with a pointer-events:auto panel", () => {
|
||||
render(
|
||||
<FloatingWindow windowKey="alpha" title="Alpha" onClose={() => {}}>
|
||||
@@ -628,6 +642,108 @@ describe("FloatingWindow", () => {
|
||||
expect(panel.style.top).toBe("90px");
|
||||
});
|
||||
|
||||
it("preserves desktop geometry during opt-in sheet opens and restores it on desktop", () => {
|
||||
const key = "floating-window:sheet-preserve";
|
||||
const desktopGeometry = { size: { width: 640, height: 460 }, position: { x: 120, y: 96 } };
|
||||
localStorage.setItem(key, JSON.stringify(desktopGeometry));
|
||||
setSheetViewport(true);
|
||||
|
||||
const { unmount } = render(
|
||||
<FloatingWindow
|
||||
windowKey="sheet-preserve-mobile"
|
||||
title="Sheet"
|
||||
onClose={() => {}}
|
||||
persistGeometryKey={key}
|
||||
suspendGeometryPersistenceOnMobile
|
||||
defaultSize={{ width: 500, height: 400 }}
|
||||
defaultPosition={{ x: 32, y: 48 }}
|
||||
>
|
||||
<div>sheet body</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
|
||||
const sheetPanel = screen.getByTestId("floating-window-sheet-preserve-mobile");
|
||||
expect(sheetPanel.style.width).toBe("500px");
|
||||
expect(sheetPanel.style.left).toBe("32px");
|
||||
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(desktopGeometry);
|
||||
unmount();
|
||||
|
||||
setSheetViewport(false);
|
||||
render(
|
||||
<FloatingWindow windowKey="sheet-preserve-desktop" title="Desktop" onClose={() => {}} persistGeometryKey={key} suspendGeometryPersistenceOnMobile>
|
||||
<div>desktop body</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
const desktopPanel = screen.getByTestId("floating-window-sheet-preserve-desktop");
|
||||
expect(desktopPanel.style.width).toBe("640px");
|
||||
expect(desktopPanel.style.height).toBe("460px");
|
||||
expect(desktopPanel.style.left).toBe("120px");
|
||||
expect(desktopPanel.style.top).toBe("96px");
|
||||
});
|
||||
|
||||
it("preserves opt-in geometry during a short-viewport full-screen sheet", () => {
|
||||
const key = "floating-window:short-sheet";
|
||||
const desktopGeometry = { size: { width: 640, height: 460 }, position: { x: 120, y: 96 } };
|
||||
localStorage.setItem(key, JSON.stringify(desktopGeometry));
|
||||
setSheetViewport(false);
|
||||
|
||||
render(
|
||||
<FloatingWindow
|
||||
windowKey="short-sheet"
|
||||
title="Short sheet"
|
||||
onClose={() => {}}
|
||||
persistGeometryKey={key}
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
defaultSize={{ width: 500, height: 400 }}
|
||||
defaultPosition={{ x: 32, y: 48 }}
|
||||
>
|
||||
<div>short sheet body</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
|
||||
const sheetPanel = screen.getByTestId("floating-window-short-sheet");
|
||||
expect(sheetPanel.style.width).toBe("500px");
|
||||
expect(sheetPanel.style.left).toBe("32px");
|
||||
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(desktopGeometry);
|
||||
});
|
||||
|
||||
it("keeps opt-in geometry persistence on wide short landscape phones that remain movable", () => {
|
||||
const key = "floating-window:landscape-phone";
|
||||
localStorage.setItem(key, JSON.stringify({ size: { width: 620, height: 450 }, position: { x: 100, y: 80 } }));
|
||||
// `isMobileViewport()` would be true for this max-height match, but sheets use only max-width.
|
||||
setSheetViewport(false);
|
||||
|
||||
render(
|
||||
<FloatingWindow windowKey="landscape-phone" title="Landscape" onClose={() => {}} persistGeometryKey={key} suspendGeometryPersistenceOnMobile>
|
||||
<div>landscape body</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
|
||||
const panel = screen.getByTestId("floating-window-landscape-phone");
|
||||
expect(panel.style.width).toBe("620px");
|
||||
expect(panel.style.left).toBe("100px");
|
||||
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual({ size: { width: 620, height: 450 }, position: { x: 100, y: 80 } });
|
||||
});
|
||||
|
||||
it("continues persistence at sheet width when suspension is not opted in", () => {
|
||||
const key = "floating-window:sheet-default";
|
||||
const geometry = { size: { width: 610, height: 440 }, position: { x: 90, y: 72 } };
|
||||
localStorage.setItem(key, JSON.stringify(geometry));
|
||||
setSheetViewport(true);
|
||||
|
||||
render(
|
||||
<FloatingWindow windowKey="sheet-default" title="Default" onClose={() => {}} persistGeometryKey={key}>
|
||||
<div>default body</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
|
||||
const panel = screen.getByTestId("floating-window-sheet-default");
|
||||
expect(panel.style.width).toBe("610px");
|
||||
expect(panel.style.left).toBe("90px");
|
||||
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(geometry);
|
||||
});
|
||||
|
||||
it("shares geometry only between windows that opt into the same persistence key", () => {
|
||||
localStorage.setItem(
|
||||
"floating-window:shared-task-detail",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getViewportMode, isMobileViewport, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
|
||||
import { getViewportMode, isFullScreenSheetViewport, isMobileViewport, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
|
||||
|
||||
const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)";
|
||||
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
||||
@@ -131,6 +131,17 @@ describe("useViewportMode", () => {
|
||||
expect(renderHook(() => useViewportMode()).result.current).toBe("mobile");
|
||||
});
|
||||
|
||||
it("matches full-screen sheets by width only, not the landscape-phone mobile clause", () => {
|
||||
stubScreen(844, 390);
|
||||
installViewportMedia({ width: false, height: true, tablet: false });
|
||||
|
||||
expect(isMobileViewport()).toBe(true);
|
||||
expect(isFullScreenSheetViewport()).toBe(false);
|
||||
|
||||
installViewportMedia({ width: true, height: false, tablet: false });
|
||||
expect(isFullScreenSheetViewport()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps tablet mode when only the short-height clause matches on a tablet-class screen", () => {
|
||||
stubScreen(1024, 768);
|
||||
installViewportMedia({ width: false, height: true, tablet: true });
|
||||
|
||||
@@ -13,6 +13,21 @@ export const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
|
||||
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
||||
const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)";
|
||||
|
||||
/*
|
||||
FNXC:ModalGeometryPersistence 2026-07-15-19:30:
|
||||
Full-screen FloatingWindow sheets use only the CSS width breakpoint. This deliberately diverges from
|
||||
`isMobileViewport()`: its short landscape-phone clause still renders movable windows, whose desktop
|
||||
geometry must continue to restore and persist.
|
||||
*/
|
||||
export function isFullScreenSheetViewport(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
/** Returns whether the CSS short-viewport breakpoint is active. */
|
||||
export function isShortViewport(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(MOBILE_HEIGHT_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
function hasTouchScreen(): boolean {
|
||||
if (typeof window === "undefined" || typeof navigator === "undefined") return false;
|
||||
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
Reference in New Issue
Block a user