fixed offline and full screen buttons scale issue

This commit is contained in:
Dmitri 2026-04-30 10:12:18 +02:00
parent 917223ad43
commit 99f126396f
3 changed files with 422 additions and 21 deletions

View File

@ -0,0 +1,401 @@
/**
* RuntimeControls Component
*
* Control buttons for runtime presentations (offline toggle, fullscreen).
* Uses fixed pixel values (not rem or canvas units) to ensure:
* 1. Buttons maintain usable size regardless of canvas scaling
* 2. Consistent behavior during iOS pinch-zoom (avoiding WebKit rem/zoom bugs)
*
* Note: These are UI chrome controls, not design elements, so they should
* not scale with the canvas like navigation buttons do.
*/
import React, { CSSProperties, useEffect, useRef, useState } from 'react';
import {
mdiCloudDownload,
mdiCloudCheck,
mdiCloudOff,
mdiFullscreen,
mdiFullscreenExit,
mdiDelete,
} from '@mdi/js';
import { toast } from 'react-toastify';
import { useOfflineMode } from '../../hooks/useOfflineMode';
import { useStorageQuota } from '../../hooks/useStorageQuota';
import type { ProjectOfflineStatus } from '../../types/offline';
import type { PreloadPage } from '../../types/preload';
import { logger } from '../../lib/logger';
interface RuntimeControlsProps {
projectId: string | null;
projectSlug: string;
projectName?: string;
pages?: PreloadPage[];
isFullscreen: boolean;
toggleFullscreen: () => void;
}
/**
* Button colors matching BaseButton 'info', 'success', 'danger', 'warning' colors
*/
const buttonColors = {
info: {
background: '#2563EB', // bg-blue-600
backgroundHover: '#1D4ED8', // bg-blue-700
border: '#2563EB',
},
success: {
background: '#059669', // bg-emerald-600
backgroundHover: '#047857', // bg-emerald-700
border: '#059669',
},
danger: {
background: '#DC2626', // bg-red-600
backgroundHover: '#B91C1C', // bg-red-700
border: '#DC2626',
},
warning: {
background: '#CA8A04', // bg-yellow-600
backgroundHover: '#A16207', // bg-yellow-700
border: '#CA8A04',
},
};
/**
* Icon component using fixed pixel sizes
*/
function ControlIcon({
path,
size = 20,
fill = 'currentColor',
}: {
path: string;
size?: number;
fill?: string;
}) {
return (
<svg
viewBox='0 0 24 24'
width={size}
height={size}
style={{
display: 'inline-block',
flexShrink: 0,
}}
>
<path fill={fill} d={path} />
</svg>
);
}
/**
* Button component using fixed pixel sizes
*/
function ControlButton({
icon,
color = 'info',
onClick,
disabled = false,
title,
}: {
icon: string;
color?: 'info' | 'success' | 'danger' | 'warning';
onClick: () => void;
disabled?: boolean;
title?: string;
}) {
const colors = buttonColors[color];
const buttonStyle: CSSProperties = {
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
padding: 8,
borderRadius: 6,
backgroundColor: colors.background,
borderWidth: 1,
borderStyle: 'solid',
borderColor: colors.border,
color: 'white',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.7 : 1,
transition: 'background-color 150ms, border-color 150ms',
};
return (
<button
type='button'
style={buttonStyle}
onClick={onClick}
disabled={disabled}
title={title}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.backgroundColor = colors.backgroundHover;
e.currentTarget.style.borderColor = colors.backgroundHover;
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = colors.background;
e.currentTarget.style.borderColor = colors.border;
}}
>
<ControlIcon path={icon} size={20} />
</button>
);
}
/**
* Delete button for removing offline data (smaller, subtle styling)
*/
function DeleteButton({ onClick }: { onClick: () => void }) {
const buttonStyle: CSSProperties = {
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
padding: 4,
borderRadius: 4,
backgroundColor: 'transparent',
border: 'none',
color: '#9CA3AF', // text-gray-400
cursor: 'pointer',
transition: 'color 150ms',
};
return (
<button
type='button'
style={buttonStyle}
onClick={onClick}
title='Remove offline data'
onMouseEnter={(e) => {
e.currentTarget.style.color = '#EF4444'; // text-red-500
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#9CA3AF'; // text-gray-400
}}
>
<ControlIcon path={mdiDelete} size={16} />
</button>
);
}
/**
* Offline toggle component using fixed pixel sizes
*/
function OfflineControl({
projectId,
projectSlug,
projectName,
pages,
}: {
projectId: string | null;
projectSlug?: string;
projectName?: string;
pages?: PreloadPage[];
}) {
const {
isOfflineCapable,
isDownloaded,
isDownloading,
status,
progress,
startDownload,
pauseDownload,
deleteOfflineData,
estimatedSize,
formatSize,
} = useOfflineMode({
projectId,
projectSlug,
projectName,
pages,
});
const { canStore, isWarning, isCritical } = useStorageQuota();
// Track previous status to detect completion transition
const prevStatusRef = useRef<ProjectOfflineStatus>(status);
// Show toast notification when download completes
useEffect(() => {
logger.debug('[RuntimeControls] Status changed:', {
prev: prevStatusRef.current,
current: status,
});
if (prevStatusRef.current === 'downloading' && status === 'downloaded') {
logger.debug('[RuntimeControls] Showing toast - download complete!');
toast.success('Presentation ready for offline mode!', {
position: 'bottom-center',
autoClose: 5000,
});
}
prevStatusRef.current = status;
}, [status]);
// Don't render if offline not supported
if (!isOfflineCapable) {
return null;
}
const handleClick = () => {
if (isDownloaded) {
if (confirm('Remove offline data for this project?')) {
deleteOfflineData();
}
} else if (isDownloading) {
pauseDownload();
} else if (status === 'error') {
startDownload();
} else {
if (isCritical) {
alert(
'Storage space is critically low. Please free up some space first.',
);
return;
}
if (isWarning && estimatedSize > 0) {
if (
!confirm(
`Storage space is running low. Download ${formatSize(estimatedSize)} anyway?`,
)
) {
return;
}
}
startDownload();
}
};
// Determine icon and color
let icon = mdiCloudDownload;
let color: 'info' | 'success' | 'danger' | 'warning' = 'info';
let title = 'Download for offline';
if (isDownloaded) {
icon = mdiCloudCheck;
color = 'success';
title = 'Available offline';
} else if (isDownloading) {
icon = mdiCloudDownload;
color = 'info';
title = `Downloading ${progress}%`;
} else if (status === 'error') {
icon = mdiCloudOff;
color = 'danger';
title = 'Retry download';
} else if (status === 'outdated') {
icon = mdiCloudDownload;
color = 'warning';
title = 'Update available';
}
const containerStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 4,
};
return (
<div style={containerStyle}>
<ControlButton
icon={icon}
color={color}
onClick={handleClick}
disabled={!canStore(estimatedSize) && !isDownloaded}
title={title}
/>
{isDownloaded && (
<DeleteButton
onClick={() => {
if (confirm('Remove offline data for this project?')) {
deleteOfflineData();
}
}}
/>
)}
</div>
);
}
/**
* Hook to counter pinch-zoom scaling using visualViewport API
* Returns a scale factor to apply as inverse transform
*/
function useCounterZoom() {
const [scale, setScale] = useState(1);
useEffect(() => {
// visualViewport API is supported in all modern browsers
const vv = window.visualViewport;
if (!vv) return;
const handleResize = () => {
// visualViewport.scale gives the pinch-zoom level
// We apply 1/scale to counter it
setScale(1 / vv.scale);
};
// Set initial value
handleResize();
vv.addEventListener('resize', handleResize);
vv.addEventListener('scroll', handleResize);
return () => {
vv.removeEventListener('resize', handleResize);
vv.removeEventListener('scroll', handleResize);
};
}, []);
return scale;
}
/**
* RuntimeControls - Main component for presentation controls
*
* Renders offline toggle and fullscreen button using fixed pixel values
* to maintain usable size regardless of canvas scaling.
* Uses visualViewport API to counter pinch-zoom scaling on mobile.
*/
export default function RuntimeControls({
projectId,
projectSlug,
projectName,
pages,
isFullscreen,
toggleFullscreen,
}: RuntimeControlsProps) {
// Counter-scale to resist pinch-zoom
const counterScale = useCounterZoom();
const containerStyle: CSSProperties = {
position: 'fixed',
top: 16,
right: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
zIndex: 9999, // Above everything including modals
// Counter pinch-zoom scaling
transform: `scale(${counterScale})`,
transformOrigin: 'top right',
};
return (
<div style={containerStyle}>
<OfflineControl
projectId={projectId}
projectSlug={projectSlug}
projectName={projectName}
pages={pages}
/>
<ControlButton
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
color='info'
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
/>
</div>
);
}

View File

@ -5,7 +5,6 @@
* Used by /p/[projectSlug] and /p/[projectSlug]/stage routes.
*/
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
import Head from 'next/head';
import React, {
ReactElement,
@ -17,9 +16,8 @@ import React, {
} from 'react';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import BaseButton from './BaseButton';
import CardBox from './CardBox';
import { OfflineToggle } from './Offline/OfflineToggle';
import RuntimeControls from './Runtime/RuntimeControls';
import RuntimeElement from './RuntimeElement';
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
@ -693,24 +691,6 @@ export default function RuntimePresentation({
</div>
{/* End page elements wrapper */}
{/* Controls: Offline toggle and Fullscreen button */}
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
<OfflineToggle
projectId={project?.id || null}
projectSlug={projectSlug}
projectName={project?.name}
pages={pages}
showLabel={false}
size='small'
/>
<BaseButton
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
color='info'
small
onClick={toggleFullscreen}
/>
</div>
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* NO fade-out: video itself IS the transition (last frame = new page) */}
@ -785,6 +765,17 @@ export default function RuntimePresentation({
</div>
{/* End inner canvas container */}
{/* Controls: Offline toggle and Fullscreen button */}
{/* Positioned outside canvas to avoid scaling with canvas transform */}
<RuntimeControls
projectId={project?.id || null}
projectSlug={projectSlug}
projectName={project?.name}
pages={pages}
isFullscreen={isFullscreen}
toggleFullscreen={toggleFullscreen}
/>
{/* Toast notifications for offline download status */}
<ToastContainer
position='bottom-center'

9
frontend/src/types/css.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/**
* Type declarations for CSS module imports
*/
declare module '*.css' {
const content: { [className: string]: string };
export default content;
}
declare module 'react-toastify/dist/ReactToastify.css';