diff --git a/src/components/Dashboard/Management/Files/FileInfo.jsx b/src/components/Dashboard/Management/Files/FileInfo.jsx index 7274948e..e6f3c0be 100644 --- a/src/components/Dashboard/Management/Files/FileInfo.jsx +++ b/src/components/Dashboard/Management/Files/FileInfo.jsx @@ -206,12 +206,20 @@ const FileInfo = () => { collapseKey='preview' > {objectFormState?.objectData?._id ? ( - - - + } + > + + + + ) : ( )} diff --git a/src/components/Dashboard/common/FilePreview.jsx b/src/components/Dashboard/common/FilePreview.jsx index 55df8cf5..78c2352f 100644 --- a/src/components/Dashboard/common/FilePreview.jsx +++ b/src/components/Dashboard/common/FilePreview.jsx @@ -89,8 +89,6 @@ const FilePreview = ({ file, style = {} }) => { return ( 0 ? distance : null +} + +function getCameraScale(preview, baseDistance) { + const distance = getCameraDistance(preview) + return distance == null ? null : clampPreviewScale(baseDistance / distance) +} + +function applyCameraScale(preview, baseDistance, scale) { + const { camera, controls } = preview + const distance = getCameraDistance(preview) + if (distance == null) return + + const nextDistance = baseDistance / scale + if (Math.abs(nextDistance - distance) <= distance * 1e-4) return + + const offset = camera.position.clone().sub(controls.target) + camera.position + .copy(controls.target) + .addScaledVector(offset, nextDistance / distance) + controls.update() +} function GCodePreviewUI(props) { const { @@ -13,40 +73,107 @@ function GCodePreviewUI(props) { lineWidth, style = {} } = props + const { isDarkMode, themeConfig } = useThemeContext() + const { colorPrimary, colorWarning, colorPink } = themeConfig.token const canvasRef = useRef(null) + const previewPaneRef = useRef(null) const previewRef = useRef(null) + const baseDistanceRef = useRef(1) + const applyingScaleRef = useRef(false) + const [preview, setPreview] = useState(null) + const [previewScale, setPreviewScale] = useState(1) + const [panMode, setPanMode] = useState(false) + const [isDragging, setIsDragging] = useState(false) const resizePreview = useCallback(() => { previewRef.current?.resize() }, []) - // Ex-ref methods removed; this component is now a regular functional component - useEffect(() => { if (!canvasRef.current) return + const themeColors = getPreviewThemeColors({ + isDarkMode, + token: { colorPrimary, colorWarning, colorPink }, + topLayerColor, + lastSegmentColor + }) + previewRef.current?.dispose?.() - previewRef.current = GCodePreview.init({ + const instance = GCodePreview.init({ canvas: canvasRef.current, startLayer, endLayer, lineWidth, - topLayerColor: new THREE.Color(topLayerColor).getHex(), - lastSegmentColor: new THREE.Color(lastSegmentColor).getHex(), + backgroundColor: toThreeColorHex(themeColors.backgroundColor, '#ffffff'), + extrusionColor: toThreeColorHex(themeColors.extrusionColor, '#0091FF'), + travelColor: toThreeColorHex(themeColors.travelColor, '#8c8c8c'), + topLayerColor: toThreeColorHex(themeColors.topLayerColor, '#FF9230'), + lastSegmentColor: toThreeColorHex( + themeColors.lastSegmentColor, + '#FF69B4' + ), buildVolume: { x: 250, y: 220, z: 150 }, initialCameraPosition: [0, 400, 450], allowDragNDrop: false }) + previewRef.current = instance + const { camera, controls } = instance + baseDistanceRef.current = getCameraDistance(instance) || 1 + // The camera dollies instead of scaling pixels, so the frustum has to + // cover the full zoom range. + camera.far = Math.max( + camera.far, + (baseDistanceRef.current / MIN_PREVIEW_SCALE) * 2 + ) + camera.updateProjectionMatrix() + controls.minDistance = baseDistanceRef.current / MAX_PREVIEW_SCALE + controls.maxDistance = baseDistanceRef.current / MIN_PREVIEW_SCALE + + const handleControlsChange = () => { + if (applyingScaleRef.current) return + const scale = getCameraScale(instance, baseDistanceRef.current) + if (scale == null) return + setPreviewScale((prev) => (Math.abs(prev - scale) < 0.001 ? prev : scale)) + } + const handleControlsStart = () => setIsDragging(true) + const handleControlsEnd = () => setIsDragging(false) + + controls.addEventListener('change', handleControlsChange) + controls.addEventListener('start', handleControlsStart) + controls.addEventListener('end', handleControlsEnd) + + instance.resize() + setPreview(instance) + + const pane = previewPaneRef.current + const resizeObserver = + pane && typeof ResizeObserver === 'function' + ? new ResizeObserver(resizePreview) + : null + resizeObserver?.observe(pane) window.addEventListener('resize', resizePreview) return () => { + resizeObserver?.disconnect() window.removeEventListener('resize', resizePreview) - previewRef.current?.dispose?.() - previewRef.current = null + controls.removeEventListener('change', handleControlsChange) + controls.removeEventListener('start', handleControlsStart) + controls.removeEventListener('end', handleControlsEnd) + setPreview(null) + setIsDragging(false) + instance.dispose?.() + if (previewRef.current === instance) { + previewRef.current = null + } } }, [ + colorPink, + colorPrimary, + colorWarning, endLayer, + isDarkMode, lastSegmentColor, lineWidth, startLayer, @@ -54,17 +181,49 @@ function GCodePreviewUI(props) { resizePreview ]) + useEffect(() => { + if (!preview) return + applyingScaleRef.current = true + applyCameraScale(preview, baseDistanceRef.current, previewScale) + applyingScaleRef.current = false + }, [preview, previewScale]) + + useEffect(() => { + if (!preview) return + preview.controls.mouseButtons = { + LEFT: panMode ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE, + MIDDLE: THREE.MOUSE.DOLLY, + RIGHT: panMode ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN + } + preview.controls.touches = { + ONE: panMode ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE, + TWO: THREE.TOUCH.DOLLY_PAN + } + }, [preview, panMode]) + + usePinchZoom({ + containerRef: previewPaneRef, + scale: previewScale, + onScaleChange: setPreviewScale, + enabled: preview != null + }) + + const resetView = useCallback(() => { + preview?.controls.reset() + setPreviewScale(1) + }, [preview]) + useEffect(() => { let cancelled = false const loadFromSrc = async () => { - const preview = previewRef.current - if (!src || !preview) return + const instance = previewRef.current + if (!src || !instance) return try { const response = await fetch(src) const text = await response.text() - if (cancelled || previewRef.current !== preview) return - preview.processGCode(text) + if (cancelled || previewRef.current !== instance) return + instance.processGCode(text) } catch (e) { if (cancelled) return console.error('Failed to load G-code from src', e) @@ -75,9 +234,124 @@ function GCodePreviewUI(props) { return () => { cancelled = true } - }, [endLayer, lastSegmentColor, lineWidth, src, startLayer, topLayerColor]) + }, [ + colorPink, + colorPrimary, + colorWarning, + endLayer, + isDarkMode, + lastSegmentColor, + lineWidth, + src, + startLayer, + topLayerColor + ]) - return + return ( + + + { + setPanMode((prev) => !prev) + }} + > + + ) : ( + + ) + } + onClick={() => { + setPanMode((prev) => !prev) + }} + /> + + } + onClick={() => { + setPreviewScale((prev) => clampPreviewScale(prev + 0.05)) + }} + /> + + {previewScale.toFixed(2)}x + + } + onClick={() => { + setPreviewScale((prev) => clampPreviewScale(prev - 0.05)) + }} + /> + + + + + + + + + setPreviewScale(value)} + classNames={{ + handle: 'previewZoomSliderHandle' + }} + styles={{ + track: { + background: 'var(--color-primary)' + } + }} + /> + + + + + ) } GCodePreviewUI.propTypes = { diff --git a/src/components/Dashboard/common/ThreeDPreview.jsx b/src/components/Dashboard/common/ThreeDPreview.jsx index d80ab9e4..050651b4 100644 --- a/src/components/Dashboard/common/ThreeDPreview.jsx +++ b/src/components/Dashboard/common/ThreeDPreview.jsx @@ -1,7 +1,190 @@ import PropTypes from 'prop-types' +import { Button, Card, Flex, Slider } from 'antd' import { useCallback, useEffect, useRef, useState } from 'react' import * as OV from 'online-3d-viewer' import LoadingPlaceholder from './LoadingPlaceholder' +import KeyboardShortcut from './KeyboardShortcut' +import PlusIcon from '../../Icons/PlusIcon.jsx' +import MinusIcon from '../../Icons/MinusIcon.jsx' +import PanIcon from '../../Icons/PanIcon.jsx' +import PanFilledIcon from '../../Icons/PanFilledIcon.jsx' +import usePinchZoom, { + clampPreviewScale, + nextScaleFromWheel, + MAX_PREVIEW_SCALE, + MIN_PREVIEW_SCALE +} from '../hooks/usePinchZoom.js' +import { useThemeContext } from '../context/ThemeContext' + +const DEG_TO_RAD = Math.PI / 180 + +function parseHexColor(color) { + if (!color) return null + const match = String(color) + .trim() + .match(/^#([0-9a-f]{3,8})$/i) + if (!match) return null + + let hex = match[1] + if (hex.length === 3 || hex.length === 4) { + hex = hex + .split('') + .map((char) => char + char) + .join('') + } + if (hex.length !== 6 && hex.length !== 8) return null + + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255 + } +} + +function parseCssColor(color, fallbackHex) { + const fallback = parseHexColor(fallbackHex) || { + r: 255, + g: 255, + b: 255, + a: 255 + } + if (!color) return fallback + + const hex = parseHexColor(color) + if (hex) return hex + + const rgbMatch = String(color) + .trim() + .match( + /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i + ) + if (!rgbMatch) return fallback + + return { + r: Number(rgbMatch[1]), + g: Number(rgbMatch[2]), + b: Number(rgbMatch[3]), + a: + rgbMatch[4] === undefined + ? 255 + : Math.round(Math.min(1, Number(rgbMatch[4])) * 255) + } +} + +function getCssVarColor(name, fallbackHex) { + if (typeof document === 'undefined') { + return parseCssColor(fallbackHex, fallbackHex) + } + const value = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim() + return parseCssColor(value, fallbackHex) +} + +function getViewerThemeColors(backgroundColorOverride) { + return { + background: backgroundColorOverride + ? parseCssColor(backgroundColorOverride, '#ffffff') + : getCssVarColor('--layout-modal-bg', '#ffffff'), + defaultColor: getCssVarColor('--color-primary', '#0091FF'), + edgeColor: getCssVarColor('--color-text', '#000000') + } +} + +function toRgbaColor(color) { + return new OV.RGBAColor(color.r, color.g, color.b, color.a ?? 255) +} + +function toRgbColor(color) { + return new OV.RGBColor(color.r, color.g, color.b) +} + +function applyViewerTheme(embeddedViewer, colors) { + if (!embeddedViewer?.GetViewer) return + const viewer3d = embeddedViewer.GetViewer() + viewer3d.SetBackgroundColor(toRgbaColor(colors.background)) + viewer3d.SetEdgeSettings( + new OV.EdgeSettings(false, toRgbColor(colors.edgeColor), 1) + ) +} + +function fitModelToViewport(embeddedViewer) { + const viewer3d = embeddedViewer?.GetViewer?.() + if (!viewer3d) return + embeddedViewer.Resize?.() + const boundingSphere = viewer3d.GetBoundingSphere?.(() => true) + if (!boundingSphere) return + viewer3d.AdjustClippingPlanesToSphere?.(boundingSphere) + viewer3d.FitSphereToWindow?.(boundingSphere, false) +} + +function getViewer3d(embeddedViewer) { + return embeddedViewer?.GetViewer?.() || null +} + +function getCameraDistance(camera) { + return OV.CoordDistance3D(camera.eye, camera.center) +} + +// The viewer derives its clipping planes from the model size alone, which leaves +// the model behind the far plane once the eye is far enough away, so widen them +// based on the eye distance as well. +function adjustClippingToDistance(viewer3d, distance) { + const boundingSphere = viewer3d.GetBoundingSphere?.(() => true) + if (!boundingSphere) return + const radius = Math.max( + boundingSphere.radius, + (distance + boundingSphere.radius) / 10 + ) + viewer3d.AdjustClippingPlanesToSphere?.({ + center: boundingSphere.center, + radius + }) +} + +// Zooming moves the eye along the view axis, keeping the look-at point fixed. +function setCameraDistance(viewer3d, distance) { + const camera = viewer3d.GetCamera().Clone() + const eyeDirection = OV.SubCoord3D(camera.eye, camera.center).Normalize() + camera.eye = camera.center.Clone().Offset(eyeDirection, distance) + adjustClippingToDistance(viewer3d, distance) + viewer3d.SetCamera(camera) +} + +function getWorldUnitsPerPixel(viewer3d, camera) { + const canvas = viewer3d.GetCanvas?.() + const canvasHeight = canvas?.clientHeight || canvas?.height || 1 + const viewportHeight = + 2 * getCameraDistance(camera) * Math.tan((camera.fov / 2) * DEG_TO_RAD) + return viewportHeight / canvasHeight +} + +// Panning moves the eye and the look-at point together, so the model tracks the +// pointer one to one at the current zoom level. +function panCameraByPixels(viewer3d, pixelsX, pixelsY) { + const camera = viewer3d.GetCamera().Clone() + const viewDirection = OV.SubCoord3D(camera.center, camera.eye).Normalize() + const horizontalDirection = OV.CrossVector3D( + viewDirection, + camera.up + ).Normalize() + const verticalDirection = OV.CrossVector3D( + horizontalDirection, + viewDirection + ).Normalize() + + const unitsPerPixel = getWorldUnitsPerPixel(viewer3d, camera) + const moveX = pixelsX * unitsPerPixel + const moveY = pixelsY * unitsPerPixel + + camera.eye.Offset(horizontalDirection, -moveX) + camera.center.Offset(horizontalDirection, -moveX) + camera.eye.Offset(verticalDirection, moveY) + camera.center.Offset(verticalDirection, moveY) + + viewer3d.SetCamera(camera) +} function ThreeDPreview(props) { const { @@ -10,25 +193,59 @@ function ThreeDPreview(props) { width = 500, height = 500, style = {}, - backgroundColor = '#ffffff' + backgroundColor, + enableControls = true } = props + const { isDarkMode } = useThemeContext() const containerRef = useRef(null) const viewer = useRef(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + const [zoomScale, setZoomScale] = useState(1) + const [panMode, setPanMode] = useState(false) + const [isPanning, setIsPanning] = useState(false) + // Eye to center distance of the fitted view, which is what 1.00x means here. + const fitDistanceRef = useRef(null) + const zoomScaleRef = useRef(zoomScale) + zoomScaleRef.current = zoomScale + const panStartRef = useRef({ x: 0, y: 0 }) const resizeViewer = useCallback(() => { - if (viewer.current && containerRef.current) { - // Resize the viewer container - const container = containerRef.current - if (container.style.width !== width + 'px') { - container.style.width = width + 'px' - } - if (container.style.height !== height + 'px') { - container.style.height = height + 'px' - } - } - }, [viewer, width, height]) + viewer.current?.Resize?.() + }, []) + + const applyZoomScale = useCallback((nextScale) => { + const viewer3d = getViewer3d(viewer.current) + const fitDistance = fitDistanceRef.current + if (!viewer3d || !fitDistance) return + const scale = clampPreviewScale(nextScale) + setCameraDistance(viewer3d, fitDistance / scale) + setZoomScale(scale) + }, []) + + const captureFittedZoom = useCallback(() => { + const viewer3d = getViewer3d(viewer.current) + if (!viewer3d) return + fitDistanceRef.current = getCameraDistance(viewer3d.GetCamera()) + setZoomScale(1) + }, []) + + // The viewer handles orbit and ctrl/two finger zoom itself, so read the camera + // back once a gesture ends to keep the readout and the zoom limits in step. + const syncZoomScaleFromCamera = useCallback(() => { + const viewer3d = getViewer3d(viewer.current) + const fitDistance = fitDistanceRef.current + if (!viewer3d || !fitDistance) return + const distance = getCameraDistance(viewer3d.GetCamera()) + if (distance <= 0) return + applyZoomScale(fitDistance / distance) + }, [applyZoomScale]) + + const resetView = useCallback(() => { + if (!viewer.current) return + fitModelToViewport(viewer.current) + captureFittedZoom() + }, [captureFittedZoom]) useEffect(() => { // Variable to track the viewer instance created in this effect @@ -40,6 +257,8 @@ function ThreeDPreview(props) { try { setIsLoading(true) setError(null) + fitDistanceRef.current = null + setZoomScale(1) // Clear any existing viewer if (viewer.current) { @@ -60,6 +279,8 @@ function ThreeDPreview(props) { return } + const themeColors = getViewerThemeColors(backgroundColor) + // Initialize the online-3d-viewer using OV.EmbeddedViewer const newViewer = new OV.EmbeddedViewer(containerRef.current, { camera: new OV.Camera( @@ -68,15 +289,34 @@ function ThreeDPreview(props) { new OV.Coord3D(0.0, 1.0, 0.0), 45.0 ), - backgroundColor: new OV.RGBAColor(255, 255, 255, 255), - defaultColor: new OV.RGBColor(200, 200, 200), - edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), - environmentSettings: new OV.EnvironmentSettings([], false) + backgroundColor: toRgbaColor(themeColors.background), + defaultColor: toRgbColor(themeColors.defaultColor), + edgeSettings: new OV.EdgeSettings( + false, + toRgbColor(themeColors.edgeColor), + 1 + ), + environmentSettings: new OV.EnvironmentSettings([], false), + onModelLoaded: () => { + if (currentViewer !== newViewer) return + fitModelToViewport(newViewer) + requestAnimationFrame(() => { + if (currentViewer !== newViewer) return + fitModelToViewport(newViewer) + requestAnimationFrame(() => { + if (currentViewer !== newViewer) return + fitModelToViewport(newViewer) + captureFittedZoom() + }) + }) + setIsLoading(false) + } }) // Store the viewer instance in the ref and local variable viewer.current = newViewer currentViewer = newViewer + newViewer.Resize?.() try { setIsLoading(true) @@ -92,8 +332,7 @@ function ThreeDPreview(props) { }) // Load model from file using LoadModelFromFileList - await newViewer.LoadModelFromFileList([file]) - setIsLoading(false) + newViewer.LoadModelFromFileList([file]) } catch (err) { console.error('Failed to load 3D model from src', err) setError('Failed to load 3D model') @@ -108,54 +347,342 @@ function ThreeDPreview(props) { initializeViewer() + const container = containerRef.current + const resizeObserver = + container && typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => { + resizeViewer() + }) + : null + resizeObserver?.observe(container) window.addEventListener('resize', resizeViewer) return () => { + resizeObserver?.disconnect() window.removeEventListener('resize', resizeViewer) if (currentViewer && currentViewer.dispose) { currentViewer.dispose() } } - }, [width, height, src, extension, resizeViewer]) + }, [src, extension, resizeViewer, backgroundColor, captureFittedZoom]) - const containerStyle = { - width: width + 'px', - height: height + 'px', - backgroundColor, - position: 'relative', - ...style + useEffect(() => { + let cancelled = false + + const applyTheme = () => { + if (cancelled || !viewer.current) return + applyViewerTheme(viewer.current, getViewerThemeColors(backgroundColor)) + } + + applyTheme() + // ThemeContext writes CSS vars in a parent effect; re-read after it runs. + queueMicrotask(applyTheme) + + return () => { + cancelled = true + } + }, [isDarkMode, backgroundColor]) + + // Trackpad pinch and ctrl+wheel, shared with the PDF and template previews. + usePinchZoom({ + containerRef, + scale: zoomScale, + onScaleChange: applyZoomScale, + enabled: enableControls + }) + + // Plain wheel zooms too, but through our clamped scale instead of the + // viewer's own unbounded wheel handler. + useEffect(() => { + const container = containerRef.current + if (!enableControls || !container) { + return undefined + } + + const options = { capture: true, passive: false } + const handleWheel = (event) => { + if (event.ctrlKey || event.metaKey) { + return + } + if (event.target?.closest?.('.previewZoomOverlay')) { + return + } + event.preventDefault() + event.stopPropagation() + applyZoomScale(nextScaleFromWheel(zoomScaleRef.current, event)) + } + + container.addEventListener('wheel', handleWheel, options) + return () => { + container.removeEventListener('wheel', handleWheel, options) + } + }, [applyZoomScale, enableControls]) + + useEffect(() => { + const container = containerRef.current + if (!enableControls || !container) { + return undefined + } + + container.addEventListener('pointerup', syncZoomScaleFromCamera) + container.addEventListener('pointercancel', syncZoomScaleFromCamera) + return () => { + container.removeEventListener('pointerup', syncZoomScaleFromCamera) + container.removeEventListener('pointercancel', syncZoomScaleFromCamera) + } + }, [enableControls, syncZoomScaleFromCamera]) + + useEffect(() => { + if (!panMode) { + setIsPanning(false) + } + }, [panMode]) + + // In pan mode a left drag pans instead of orbiting, so the viewer's own + // navigation must not see those events. + useEffect(() => { + const container = containerRef.current + if (!panMode || !container) { + return undefined + } + + const options = { capture: true, passive: false } + const blockViewerNavigation = (event) => { + if (event.type === 'mousedown' && event.button !== 0) { + return + } + event.preventDefault() + event.stopPropagation() + } + + const handlePointerDown = (event) => { + if (event.pointerType === 'mouse' && event.button !== 0) { + return + } + panStartRef.current = { x: event.clientX, y: event.clientY } + setIsPanning(true) + } + + container.addEventListener('mousedown', blockViewerNavigation, options) + container.addEventListener('touchstart', blockViewerNavigation, options) + container.addEventListener('touchmove', blockViewerNavigation, options) + container.addEventListener('pointerdown', handlePointerDown) + return () => { + container.removeEventListener('mousedown', blockViewerNavigation, options) + container.removeEventListener( + 'touchstart', + blockViewerNavigation, + options + ) + container.removeEventListener('touchmove', blockViewerNavigation, options) + container.removeEventListener('pointerdown', handlePointerDown) + } + }, [panMode]) + + useEffect(() => { + if (!isPanning) { + return undefined + } + + const previousCursor = document.body.style.cursor + const previousUserSelect = document.body.style.userSelect + document.body.style.cursor = 'grabbing' + document.body.style.userSelect = 'none' + + const handlePointerMove = (event) => { + const viewer3d = getViewer3d(viewer.current) + if (!viewer3d) { + return + } + const { x, y } = panStartRef.current + panStartRef.current = { x: event.clientX, y: event.clientY } + panCameraByPixels(viewer3d, event.clientX - x, event.clientY - y) + } + + const handlePointerUp = () => { + setIsPanning(false) + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + window.addEventListener('pointercancel', handlePointerUp) + return () => { + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + window.removeEventListener('pointercancel', handlePointerUp) + } + }, [isPanning]) + + // The wrapper owns the size the caller asked for; the preview pane takes + // whatever is left once the toolbar has been laid out. + const { width: styleWidth, height: styleHeight, ...paneStyle } = style + + const wrapperStyle = { + width: styleWidth ?? width + 'px', + height: styleHeight ?? height + 'px', + minHeight: 0 } + const containerStyle = { + flex: '1 1 auto', + minHeight: 0, + backgroundColor: backgroundColor || 'var(--layout-modal-bg)', + position: 'relative', + ...paneStyle + } + + const controlsDisabled = isLoading || error != null + const panCursor = panMode ? (isPanning ? 'grabbing' : 'grab') : undefined + return ( - - - {isLoading && } - {error && ( + + {enableControls ? ( + + { + setPanMode((prev) => !prev) + }} + > + + ) : ( + + ) + } + disabled={controlsDisabled} + onClick={() => { + setPanMode((prev) => !prev) + }} + /> + + } + disabled={controlsDisabled} + onClick={() => { + applyZoomScale(zoomScale + 0.05) + }} + /> + + {zoomScale.toFixed(2)}x + + } + disabled={controlsDisabled} + onClick={() => { + applyZoomScale(zoomScale - 0.05) + }} + /> + + ) : null} + + {enableControls ? ( + + + + + + ) : null} + + + + - {error} + - )} - + + {error && ( + + {error} + + )} + + ) } diff --git a/src/components/Dashboard/hooks/usePinchZoom.js b/src/components/Dashboard/hooks/usePinchZoom.js index ad2f06a5..72f8c8aa 100644 --- a/src/components/Dashboard/hooks/usePinchZoom.js +++ b/src/components/Dashboard/hooks/usePinchZoom.js @@ -13,7 +13,7 @@ export const clampPreviewScale = (value) => { return Math.round(clamped * 10000) / 10000 } -const nextScaleFromWheel = (scale, event) => { +export const nextScaleFromWheel = (scale, event) => { const intensity = event.deltaMode === 1 ? 0.12 : 0.0045 return clampPreviewScale(scale * Math.exp(-event.deltaY * intensity)) } diff --git a/src/database/models/File.js b/src/database/models/File.js index cf2b0890..5dc5e9fb 100644 --- a/src/database/models/File.js +++ b/src/database/models/File.js @@ -111,6 +111,7 @@ export const File = { columns: [ '_reference', 'name', + 'extension', 'type', 'size', 'temp', @@ -125,9 +126,18 @@ export const File = { 'extension', 'createdAt', 'updatedAt', - '_reference' + '_reference', + 'extension' + ], + sorters: [ + 'name', + 'type', + 'size', + 'createdAt', + 'temp', + 'updatedAt', + 'extension' ], - sorters: ['name', 'type', 'size', 'createdAt', 'temp', 'updatedAt'], group: ['type'], properties: [ { @@ -173,13 +183,14 @@ export const File = { }, { - name: 'type', - label: 'Type', + name: 'extension', + label: 'Extension', type: 'text', readOnly: true, required: true, - columnWidth: 190 + columnWidth: 140 }, + { name: 'size', label: 'Size', @@ -222,6 +233,14 @@ export const File = { readOnly: true, required: false, columnWidth: 100 + }, + { + name: 'type', + label: 'Type', + type: 'text', + readOnly: true, + required: true, + columnWidth: 190 } ] }