fixed offline and full screen buttons scale issue
This commit is contained in:
parent
917223ad43
commit
99f126396f
401
frontend/src/components/Runtime/RuntimeControls.tsx
Normal file
401
frontend/src/components/Runtime/RuntimeControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
9
frontend/src/types/css.d.ts
vendored
Normal 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';
|
||||
Loading…
x
Reference in New Issue
Block a user