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.
|
* Used by /p/[projectSlug] and /p/[projectSlug]/stage routes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, {
|
import React, {
|
||||||
ReactElement,
|
ReactElement,
|
||||||
@ -17,9 +16,8 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import BaseButton from './BaseButton';
|
|
||||||
import CardBox from './CardBox';
|
import CardBox from './CardBox';
|
||||||
import { OfflineToggle } from './Offline/OfflineToggle';
|
import RuntimeControls from './Runtime/RuntimeControls';
|
||||||
import RuntimeElement from './RuntimeElement';
|
import RuntimeElement from './RuntimeElement';
|
||||||
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
|
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
|
||||||
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||||
@ -693,24 +691,6 @@ export default function RuntimePresentation({
|
|||||||
</div>
|
</div>
|
||||||
{/* End page elements wrapper */}
|
{/* 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 */}
|
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
|
||||||
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
|
{/* 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) */}
|
{/* NO fade-out: video itself IS the transition (last frame = new page) */}
|
||||||
@ -785,6 +765,17 @@ export default function RuntimePresentation({
|
|||||||
</div>
|
</div>
|
||||||
{/* End inner canvas container */}
|
{/* 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 */}
|
{/* Toast notifications for offline download status */}
|
||||||
<ToastContainer
|
<ToastContainer
|
||||||
position='bottom-center'
|
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