@pallasoft/ps-file-components 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/components/ps-document-scanner-loader.d.ts +1 -1
  2. package/dist/components/ps-document-scanner-sheet.d.ts +1 -1
  3. package/dist/components/ps-document-scanner-sheet.js +80 -9
  4. package/dist/components/ps-document-scanner.d.ts +1 -1
  5. package/dist/components/ps-document-scanner.js +2 -2
  6. package/dist/components/ps-image-viewer.js +35 -16
  7. package/dist/components/ps-pdf-viewer.js +18 -1
  8. package/dist/components/ps-scan-camera-view.js +2 -2
  9. package/dist/components/ps-scan-page-strip.d.ts +8 -0
  10. package/dist/components/ps-scan-page-strip.js +8 -0
  11. package/dist/components/ps-scan-preview.d.ts +8 -2
  12. package/dist/components/ps-scan-preview.js +6 -1
  13. package/dist/components/ps-scan-viewfinder-overlay.d.ts +4 -2
  14. package/dist/components/ps-scan-viewfinder-overlay.js +6 -6
  15. package/dist/constants/ps-document-scanner-labels.d.ts +2 -1
  16. package/dist/constants/ps-document-scanner-labels.js +2 -1
  17. package/dist/constants/ps-file-viewer-config.d.ts +1 -0
  18. package/dist/constants/ps-file-viewer-config.js +1 -0
  19. package/dist/constants/ps-scan-config.d.ts +10 -3
  20. package/dist/constants/ps-scan-config.js +11 -3
  21. package/dist/constants/ps-scan-detect-profiles.d.ts +10 -0
  22. package/dist/constants/ps-scan-detect-profiles.js +8 -0
  23. package/dist/constants/ps-scan-format.d.ts +5 -0
  24. package/dist/constants/ps-scan-format.js +7 -0
  25. package/dist/esm/components/ps-document-scanner-sheet.js +80 -9
  26. package/dist/esm/components/ps-document-scanner.js +2 -2
  27. package/dist/esm/components/ps-image-viewer.js +35 -16
  28. package/dist/esm/components/ps-pdf-viewer.js +18 -1
  29. package/dist/esm/components/ps-scan-camera-view.js +2 -2
  30. package/dist/esm/components/ps-scan-page-strip.js +4 -0
  31. package/dist/esm/components/ps-scan-preview.js +6 -1
  32. package/dist/esm/components/ps-scan-viewfinder-overlay.js +6 -6
  33. package/dist/esm/constants/ps-document-scanner-labels.js +2 -1
  34. package/dist/esm/constants/ps-file-viewer-config.js +1 -0
  35. package/dist/esm/constants/ps-scan-config.js +11 -3
  36. package/dist/esm/constants/ps-scan-detect-profiles.js +5 -0
  37. package/dist/esm/constants/ps-scan-format.js +4 -0
  38. package/dist/esm/hooks/use-ps-drag-pan.js +66 -0
  39. package/dist/esm/hooks/use-ps-scan-live-detection.js +47 -22
  40. package/dist/esm/hooks/use-ps-scan-quad-tween.js +34 -0
  41. package/dist/esm/index.js +5 -0
  42. package/dist/esm/models/ps-document-scanner-page-labels.js +1 -0
  43. package/dist/esm/models/ps-drag-pan-handlers.js +1 -0
  44. package/dist/esm/models/ps-drag-pan-options.js +1 -0
  45. package/dist/esm/models/ps-scan-detect-profile.js +1 -0
  46. package/dist/esm/models/ps-scan-multi-page.js +1 -0
  47. package/dist/esm/models/ps-scan-page.js +1 -0
  48. package/dist/esm/models/ps-scan-pdf-page.js +1 -0
  49. package/dist/esm/services/ps-pan-service.js +13 -0
  50. package/dist/esm/services/ps-scan-canvas-service.js +10 -0
  51. package/dist/esm/services/ps-scan-edge-service.js +7 -4
  52. package/dist/esm/services/ps-scan-file-name-service.js +8 -1
  53. package/dist/esm/services/ps-scan-frame-service.js +10 -0
  54. package/dist/esm/services/ps-scan-output-service.js +23 -0
  55. package/dist/esm/services/ps-scan-pdf-service.js +20 -0
  56. package/dist/esm/services/ps-scan-quad-tween-service.js +10 -0
  57. package/dist/hooks/use-ps-drag-pan.d.ts +3 -0
  58. package/dist/hooks/use-ps-drag-pan.js +70 -0
  59. package/dist/hooks/use-ps-scan-live-detection.js +46 -21
  60. package/dist/hooks/use-ps-scan-quad-tween.d.ts +3 -0
  61. package/dist/hooks/use-ps-scan-quad-tween.js +38 -0
  62. package/dist/index.d.ts +5 -0
  63. package/dist/index.js +5 -0
  64. package/dist/models/ps-document-scanner-page-labels.d.ts +6 -0
  65. package/dist/models/ps-document-scanner-page-labels.js +2 -0
  66. package/dist/models/ps-document-scanner-props.d.ts +5 -0
  67. package/dist/models/ps-document-scanner-sheet-props.d.ts +4 -0
  68. package/dist/models/ps-drag-pan-handlers.d.ts +8 -0
  69. package/dist/models/ps-drag-pan-handlers.js +2 -0
  70. package/dist/models/ps-drag-pan-options.d.ts +9 -0
  71. package/dist/models/ps-drag-pan-options.js +2 -0
  72. package/dist/models/ps-scan-detect-profile.d.ts +4 -0
  73. package/dist/models/ps-scan-detect-profile.js +2 -0
  74. package/dist/models/ps-scan-live-detection.d.ts +3 -1
  75. package/dist/models/ps-scan-multi-page.d.ts +9 -0
  76. package/dist/models/ps-scan-multi-page.js +2 -0
  77. package/dist/models/ps-scan-page.d.ts +4 -0
  78. package/dist/models/ps-scan-page.js +2 -0
  79. package/dist/models/ps-scan-pdf-page.d.ts +5 -0
  80. package/dist/models/ps-scan-pdf-page.js +2 -0
  81. package/dist/services/ps-pan-service.d.ts +6 -0
  82. package/dist/services/ps-pan-service.js +17 -0
  83. package/dist/services/ps-scan-canvas-service.d.ts +1 -0
  84. package/dist/services/ps-scan-canvas-service.js +10 -0
  85. package/dist/services/ps-scan-edge-service.d.ts +2 -1
  86. package/dist/services/ps-scan-edge-service.js +7 -4
  87. package/dist/services/ps-scan-file-name-service.d.ts +2 -0
  88. package/dist/services/ps-scan-file-name-service.js +8 -1
  89. package/dist/services/ps-scan-frame-service.d.ts +3 -0
  90. package/dist/services/ps-scan-frame-service.js +14 -0
  91. package/dist/services/ps-scan-output-service.d.ts +6 -0
  92. package/dist/services/ps-scan-output-service.js +27 -0
  93. package/dist/services/ps-scan-pdf-service.d.ts +8 -0
  94. package/dist/services/ps-scan-pdf-service.js +24 -0
  95. package/dist/services/ps-scan-quad-tween-service.d.ts +4 -0
  96. package/dist/services/ps-scan-quad-tween-service.js +14 -0
  97. package/package.json +2 -2
@@ -5,25 +5,31 @@ import { PallasoftViewportTier, PallasoftZIndex } from '@pallasoft/ps-design-sys
5
5
  import { PsIconKey } from '@pallasoft/ps-icons';
6
6
  import { createPortal } from 'react-dom';
7
7
  import { PsScanCameraStatus } from '../constants/ps-scan-camera-status';
8
+ import { PsScanConfig } from '../constants/ps-scan-config';
8
9
  import { PsScanStep } from '../constants/ps-scan-step';
9
10
  import { usePsFocusTrap } from '../hooks/use-ps-focus-trap';
10
11
  import { usePsScanCamera } from '../hooks/use-ps-scan-camera';
11
12
  import { usePsScanLiveDetection } from '../hooks/use-ps-scan-live-detection';
12
13
  import { usePsScanPhoto } from '../hooks/use-ps-scan-photo';
13
- import { PsScanFileNameService } from '../services/ps-scan-file-name-service';
14
+ import { PsScanOutputService } from '../services/ps-scan-output-service';
14
15
  import { PsScanPipelineService } from '../services/ps-scan-pipeline-service';
15
16
  import { PsScanCameraView } from './ps-scan-camera-view';
16
17
  import { PsScanCropEditor } from './ps-scan-crop-editor';
17
18
  import { PsScanIconButton } from './ps-scan-icon-button';
19
+ import { PsScanPageStrip } from './ps-scan-page-strip';
18
20
  import { PsScanPermissionView } from './ps-scan-permission-view';
19
21
  import { PsScanPreview } from './ps-scan-preview';
20
- export const PsDocumentScannerSheet = ({ fileName, jpegQuality, labels, maxDimension, onClose, onComplete, }) => {
22
+ export const PsDocumentScannerSheet = ({ fileName, formats, jpegQuality, labels, maxDimension, onClose, onComplete, pageLabels, }) => {
21
23
  const tier = usePsViewport();
22
24
  const zIndex = usePsLayerZIndex(PallasoftZIndex.Modal);
23
25
  const content = useRef(null);
24
26
  const { clear, detectAgain, load, photo, setCorners } = usePsScanPhoto();
25
27
  const [step, setStep] = useState(PsScanStep.Camera);
26
28
  const [result, setResult] = useState(null);
29
+ const [pages, setPages] = useState([]);
30
+ const [isAddingPage, setIsAddingPage] = useState(false);
31
+ const pagesRef = useRef([]);
32
+ const nextPageId = useRef(1);
27
33
  const [announcement, setAnnouncement] = useState('');
28
34
  const [error, setError] = useState('');
29
35
  const [isCapturing, setIsCapturing] = useState(false);
@@ -41,6 +47,12 @@ export const PsDocumentScannerSheet = ({ fileName, jpegQuality, labels, maxDimen
41
47
  if (result !== null)
42
48
  URL.revokeObjectURL(result.url);
43
49
  }, [result]);
50
+ useEffect(() => {
51
+ pagesRef.current = pages;
52
+ }, [pages]);
53
+ useEffect(() => () => {
54
+ pagesRef.current.forEach((page) => URL.revokeObjectURL(page.url));
55
+ }, []);
44
56
  const edgeMessage = (isDetected) => isDetected ? labels.edgesFound : labels.edgesNotFound;
45
57
  const accept = async (source) => {
46
58
  setError('');
@@ -94,19 +106,78 @@ export const PsDocumentScannerSheet = ({ fileName, jpegQuality, labels, maxDimen
94
106
  setError(labels.processFailed);
95
107
  }
96
108
  };
97
- const use = () => {
98
- if (result === null)
109
+ const multiPageDimension = Math.min(maxDimension, PsScanConfig.multiPageMaxDimension);
110
+ const renderPage = async () => {
111
+ if (photo === null)
112
+ return null;
113
+ const rendered = await PsScanPipelineService.render(photo.source, photo.corners, multiPageDimension, jpegQuality);
114
+ const id = nextPageId.current;
115
+ nextPageId.current += 1;
116
+ return { ...rendered, id, url: URL.createObjectURL(rendered.blob) };
117
+ };
118
+ const addPage = async () => {
119
+ if (result === null || isAddingPage)
120
+ return;
121
+ setIsAddingPage(true);
122
+ setError('');
123
+ try {
124
+ const page = await renderPage();
125
+ if (page === null)
126
+ return;
127
+ setPages((current) => [...current, page]);
128
+ clear();
129
+ setResult(null);
130
+ setStep(PsScanStep.Camera);
131
+ }
132
+ catch {
133
+ setError(labels.processFailed);
134
+ }
135
+ finally {
136
+ setIsAddingPage(false);
137
+ }
138
+ };
139
+ const removePage = (id) => {
140
+ const removed = pages.find((page) => page.id === id);
141
+ if (removed !== undefined)
142
+ URL.revokeObjectURL(removed.url);
143
+ setPages((current) => current.filter((page) => page.id !== id));
144
+ };
145
+ const save = async (format) => {
146
+ if (result === null || isAddingPage)
99
147
  return;
100
- onComplete([
101
- new File([result.blob], PsScanFileNameService.build(fileName), { type: 'image/jpeg' }),
102
- ]);
103
- onClose();
148
+ setIsAddingPage(true);
149
+ setError('');
150
+ try {
151
+ const last = pages.length === 0 ? { ...result, id: 0 } : await renderPage();
152
+ if (last === null)
153
+ return;
154
+ const files = await PsScanOutputService.build([...pages, last], format, fileName);
155
+ if (pages.length > 0)
156
+ URL.revokeObjectURL(last.url);
157
+ onComplete(files);
158
+ onClose();
159
+ }
160
+ catch {
161
+ setError(labels.processFailed);
162
+ }
163
+ finally {
164
+ setIsAddingPage(false);
165
+ }
104
166
  };
167
+ const multiPage = pageLabels === null
168
+ ? undefined
169
+ : {
170
+ isBusy: isAddingPage,
171
+ labels: pageLabels,
172
+ onAddPage: addPage,
173
+ onRemovePage: removePage,
174
+ pages,
175
+ };
105
176
  if (typeof document === 'undefined')
106
177
  return null;
107
178
  return createPortal(_jsx("div", { "aria-label": labels.title, "aria-modal": 'true', className: isDesktop
108
179
  ? 'fixed inset-0 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm animate-ps-fade-in'
109
180
  : 'fixed inset-0 bg-black animate-ps-fade-in', role: 'dialog', style: { zIndex }, children: _jsx(PsLayer, { zIndex: zIndex, children: _jsxs("div", { className: isDesktop
110
181
  ? 'relative h-[min(52rem,calc(100dvh-3rem))] w-[min(76rem,calc(100vw-3rem))] overflow-hidden rounded-2xl bg-black text-white shadow-2xl ring-1 ring-white/10 animate-ps-scale-in'
111
- : 'relative h-full overflow-hidden bg-black text-white', ref: content, children: [_jsx("p", { "aria-live": 'polite', className: 'sr-only', role: 'status', children: announcement }), isCameraStep && showsCamera ? (_jsx(PsScanCameraView, { canTorch: camera.canTorch, detection: detection, flashKey: flashKey, isBusy: isCapturing, isReady: isReady, isTorchOn: camera.isTorchOn, labels: labels, onCapture: capture, onClose: onClose, onFile: accept, onToggleTorch: camera.toggleTorch, videoRef: camera.videoRef, videoTarget: camera.attachVideo })) : null, isCameraStep && !showsCamera ? (_jsxs(_Fragment, { children: [_jsx(PsScanPermissionView, { labels: labels, onAllow: camera.allow, onFile: accept, status: camera.status }), _jsx("div", { className: 'absolute left-4 top-[max(1rem,env(safe-area-inset-top))] flex gap-2', children: _jsx(PsScanIconButton, { iconKey: PsIconKey.Close, label: labels.close, onClick: onClose }) })] })) : null, step === PsScanStep.Crop && photo !== null ? (_jsx(PsScanCropEditor, { labels: labels, onAutoDetect: autoDetect, onChange: setCorners, onContinue: flatten, onRetake: retake, photo: photo })) : null, step === PsScanStep.Preview ? (_jsx(PsScanPreview, { error: error, labels: labels, onAdjust: adjust, onRetake: retake, onUse: use, result: result })) : null, isCameraStep && error !== '' ? (_jsx("p", { className: 'absolute inset-x-4 bottom-36 mx-auto max-w-sm rounded-ps-control bg-danger px-3 py-2 text-center text-sm text-white', role: 'alert', children: error })) : null] }) }) }), document.body);
182
+ : 'relative h-full overflow-hidden bg-black text-white', ref: content, children: [_jsx("p", { "aria-live": 'polite', className: 'sr-only', role: 'status', children: announcement }), isCameraStep && showsCamera ? (_jsx(PsScanCameraView, { canTorch: camera.canTorch, detection: detection, flashKey: flashKey, isBusy: isCapturing, isReady: isReady, isTorchOn: camera.isTorchOn, labels: labels, onCapture: capture, onClose: onClose, onFile: accept, onToggleTorch: camera.toggleTorch, videoRef: camera.videoRef, videoTarget: camera.attachVideo })) : null, isCameraStep && !showsCamera ? (_jsxs(_Fragment, { children: [_jsx(PsScanPermissionView, { labels: labels, onAllow: camera.allow, onFile: accept, status: camera.status }), _jsx("div", { className: 'absolute left-4 top-[max(1rem,env(safe-area-inset-top))] flex gap-2', children: _jsx(PsScanIconButton, { iconKey: PsIconKey.Close, label: labels.close, onClick: onClose }) })] })) : null, step === PsScanStep.Crop && photo !== null ? (_jsx(PsScanCropEditor, { labels: labels, onAutoDetect: autoDetect, onChange: setCorners, onContinue: flatten, onRetake: retake, photo: photo })) : null, step === PsScanStep.Preview ? (_jsx(PsScanPreview, { error: error, isSaving: isAddingPage, labels: labels, multiPage: multiPage, onAdjust: adjust, onRetake: retake, onSave: save, formats: formats, preferredFormat: PsScanOutputService.preferred(pages.length + 1, formats), result: result })) : null, isCameraStep && pageLabels !== null && pages.length > 0 ? (_jsx("div", { className: 'absolute inset-x-0 bottom-32 flex justify-center', children: _jsx(PsScanPageStrip, { labels: pageLabels, onRemove: removePage, pages: pages }) })) : null, isCameraStep && error !== '' ? (_jsx("p", { className: 'absolute inset-x-4 bottom-36 mx-auto max-w-sm rounded-ps-control bg-danger px-3 py-2 text-center text-sm text-white', role: 'alert', children: error })) : null] }) }) }), document.body);
112
183
  };
@@ -4,9 +4,9 @@ import { usePsHydrated } from '@pallasoft/ps-core-components';
4
4
  import { PsDocumentScannerLabels } from '../constants/ps-document-scanner-labels';
5
5
  import { PsScanConfig } from '../constants/ps-scan-config';
6
6
  import { PsDocumentScannerLoader } from './ps-document-scanner-loader';
7
- export const PsDocumentScanner = ({ fileName = PsScanConfig.defaultFileName, isOpen, jpegQuality = PsScanConfig.defaultJpegQuality, labels, maxDimension = PsScanConfig.defaultMaxDimension, onClose, onComplete, }) => {
7
+ export const PsDocumentScanner = ({ fileName = PsScanConfig.defaultFileName, formats = PsScanConfig.formats, isMultiPage = false, isOpen, jpegQuality = PsScanConfig.defaultJpegQuality, labels, maxDimension = PsScanConfig.defaultMaxDimension, onClose, onComplete, pageLabels, }) => {
8
8
  const isHydrated = usePsHydrated();
9
9
  if (!isOpen || !isHydrated)
10
10
  return null;
11
- return (_jsx(Suspense, { fallback: null, children: _jsx(PsDocumentScannerLoader, { fileName: fileName, jpegQuality: jpegQuality, labels: { ...PsDocumentScannerLabels, ...labels }, maxDimension: maxDimension, onClose: onClose, onComplete: onComplete }) }));
11
+ return (_jsx(Suspense, { fallback: null, children: _jsx(PsDocumentScannerLoader, { fileName: fileName, formats: formats, jpegQuality: jpegQuality, labels: { ...PsDocumentScannerLabels, ...labels }, maxDimension: maxDimension, onClose: onClose, onComplete: onComplete, pageLabels: isMultiPage && pageLabels !== undefined ? pageLabels : null }) }));
12
12
  };
@@ -1,29 +1,48 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
+ import { usePsDragPan } from '../hooks/use-ps-drag-pan';
4
+ import { PsPanService } from '../services/ps-pan-service';
3
5
  export const PsImageViewer = ({ file, labels, rotation, zoom }) => {
4
6
  const [offset, setOffset] = useState({ x: 0, y: 0 });
5
7
  const [hasFailed, setHasFailed] = useState(false);
6
- const drag = useRef(null);
8
+ const pan = useRef(null);
9
+ const base = useRef({ x: 0, y: 0 });
10
+ const frame = useRef(0);
11
+ const pending = useRef({ x: 0, y: 0 });
7
12
  useEffect(() => {
8
13
  setOffset({ x: 0, y: 0 });
9
14
  setHasFailed(false);
10
15
  }, [file.id, zoom === 1]);
11
- const onPointerDown = (event) => {
12
- if (zoom <= 1)
13
- return;
14
- drag.current = { x: event.clientX - offset.x, y: event.clientY - offset.y };
15
- event.currentTarget.setPointerCapture(event.pointerId);
16
- };
17
- const onPointerMove = (event) => {
18
- if (drag.current === null)
19
- return;
20
- setOffset({ x: event.clientX - drag.current.x, y: event.clientY - drag.current.y });
16
+ useEffect(() => () => cancelAnimationFrame(frame.current), []);
17
+ const write = (next) => {
18
+ if (pan.current !== null)
19
+ pan.current.style.transform = PsPanService.translate(next);
21
20
  };
21
+ const handlers = usePsDragPan({
22
+ activationDistance: 0,
23
+ isEnabled: zoom > 1,
24
+ isMouseOnly: false,
25
+ onEnd: (delta) => {
26
+ cancelAnimationFrame(frame.current);
27
+ frame.current = 0;
28
+ const next = PsPanService.add(base.current, delta);
29
+ write(next);
30
+ setOffset(next);
31
+ },
32
+ onMove: (delta) => {
33
+ pending.current = PsPanService.add(base.current, delta);
34
+ if (frame.current !== 0)
35
+ return;
36
+ frame.current = requestAnimationFrame(() => {
37
+ frame.current = 0;
38
+ write(pending.current);
39
+ });
40
+ },
41
+ onStart: () => {
42
+ base.current = offset;
43
+ },
44
+ });
22
45
  if (hasFailed)
23
46
  return _jsx("p", { className: 'm-auto text-sm text-white/70', children: labels.loadFailed });
24
- return (_jsx("div", { className: `flex h-full w-full touch-none items-center justify-center overflow-hidden ${zoom > 1 ? 'cursor-grab active:cursor-grabbing' : ''}`, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: () => {
25
- drag.current = null;
26
- }, children: _jsx("img", { alt: file.name, className: 'max-h-full max-w-full select-none object-contain shadow-2xl transition-transform duration-150', draggable: false, onError: () => setHasFailed(true), src: file.url, style: {
27
- transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom}) rotate(${rotation}deg)`,
28
- } }) }));
47
+ return (_jsx("div", { className: `h-full w-full touch-none overflow-hidden ${zoom > 1 ? 'cursor-grab active:cursor-grabbing' : ''}`, ...handlers, children: _jsx("div", { className: 'flex h-full w-full items-center justify-center', ref: pan, style: { transform: PsPanService.translate(offset) }, children: _jsx("img", { alt: file.name, className: 'max-h-full max-w-full select-none object-contain shadow-2xl transition-transform duration-150', draggable: false, onError: () => setHasFailed(true), src: file.url, style: { transform: `scale(${zoom}) rotate(${rotation}deg)` } }) }) }));
29
48
  };
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { PsSpinner, usePsElementSize } from '@pallasoft/ps-core-components';
4
4
  import { PsFileViewerConfig } from '../constants/ps-file-viewer-config';
5
+ import { usePsDragPan } from '../hooks/use-ps-drag-pan';
5
6
  import { PsPdfService } from '../services/ps-pdf-service';
6
7
  import { PsPdfPage } from './ps-pdf-page';
7
8
  import { PsPdfThumbnail } from './ps-pdf-thumbnail';
@@ -13,6 +14,22 @@ export const PsPdfViewer = ({ activeMatch, file, labels, onMatchCount, pdfWorker
13
14
  const [currentPage, setCurrentPage] = useState(1);
14
15
  const [frame, frameSize] = usePsElementSize();
15
16
  const scroller = useRef(null);
17
+ const scrollStart = useRef({ x: 0, y: 0 });
18
+ const handlers = usePsDragPan({
19
+ activationDistance: PsFileViewerConfig.dragActivationDistance,
20
+ isEnabled: true,
21
+ isMouseOnly: true,
22
+ onEnd: () => undefined,
23
+ onMove: (delta) => {
24
+ if (scroller.current === null)
25
+ return;
26
+ scroller.current.scrollLeft = scrollStart.current.x - delta.x;
27
+ scroller.current.scrollTop = scrollStart.current.y - delta.y;
28
+ },
29
+ onStart: (element) => {
30
+ scrollStart.current = { x: element.scrollLeft, y: element.scrollTop };
31
+ },
32
+ });
16
33
  useEffect(() => {
17
34
  let isCurrent = true;
18
35
  let opened = null;
@@ -83,7 +100,7 @@ export const PsPdfViewer = ({ activeMatch, file, labels, onMatchCount, pdfWorker
83
100
  return Math.max(0.1, fit * zoom);
84
101
  }, [frameSize.width, pageWidth, zoom]);
85
102
  const pages = document === null ? [] : Array.from({ length: document.numPages }, (_, index) => index + 1);
86
- return (_jsxs("div", { className: 'flex h-full w-full min-w-0', children: [document !== null && document.numPages > 1 ? (_jsx("nav", { className: 'ps-scrollbar-hidden hidden w-24 shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-white/10 py-3 md:flex', children: pages.map((pageNumber) => (_jsx(PsPdfThumbnail, { document: document, hasMatches: matches.some((match) => match.pageNumber === pageNumber), isCurrent: pageNumber === currentPage, label: labels.page, onSelect: () => scrollToPage(pageNumber), pageNumber: pageNumber, rotation: rotation }, pageNumber))) })) : null, _jsx("div", { className: 'min-w-0 flex-1', ref: frame, children: hasFailed ? (_jsx("p", { className: 'flex h-full items-center justify-center text-sm text-white/70', children: labels.loadFailed })) : document === null ? (_jsx("div", { className: 'flex h-full items-center justify-center text-white', children: _jsx(PsSpinner, { color: 'current', size: 'large' }) })) : (_jsx("div", { className: 'flex h-full flex-col items-center gap-4 overflow-auto px-6 py-6', onScroll: (event) => {
103
+ return (_jsxs("div", { className: 'flex h-full w-full min-w-0', children: [document !== null && document.numPages > 1 ? (_jsx("nav", { className: 'ps-scrollbar-hidden hidden w-24 shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-white/10 py-3 md:flex', children: pages.map((pageNumber) => (_jsx(PsPdfThumbnail, { document: document, hasMatches: matches.some((match) => match.pageNumber === pageNumber), isCurrent: pageNumber === currentPage, label: labels.page, onSelect: () => scrollToPage(pageNumber), pageNumber: pageNumber, rotation: rotation }, pageNumber))) })) : null, _jsx("div", { className: 'min-w-0 flex-1', ref: frame, children: hasFailed ? (_jsx("p", { className: 'flex h-full items-center justify-center text-sm text-white/70', children: labels.loadFailed })) : document === null ? (_jsx("div", { className: 'flex h-full items-center justify-center text-white', children: _jsx(PsSpinner, { color: 'current', size: 'large' }) })) : (_jsx("div", { className: 'flex h-full cursor-grab flex-col items-center gap-4 overflow-auto px-6 py-6 active:cursor-grabbing', ...handlers, onScroll: (event) => {
87
104
  const top = event.currentTarget.scrollTop + event.currentTarget.clientHeight / 3;
88
105
  const visible = Array.from(event.currentTarget.querySelectorAll('[data-ps-pdf-page]')).find((node) => node.offsetTop + node.offsetHeight > top);
89
106
  if (visible)
@@ -11,11 +11,11 @@ import { PsScanViewfinderOverlay } from './ps-scan-viewfinder-overlay';
11
11
  export const PsScanCameraView = ({ canTorch, detection, flashKey, isBusy, isReady, isTorchOn, labels, onCapture, onClose, onFile, onToggleTorch, videoRef, videoTarget, }) => {
12
12
  const [areaRef, area] = usePsElementSize();
13
13
  const [video, setVideo] = useState({ height: 0, width: 0 });
14
- const hint = detection.quad === null ? labels.hintFind : labels.hintFound;
14
+ const hint = detection.hasQuad ? labels.hintFound : labels.hintFind;
15
15
  const measure = () => {
16
16
  const element = videoRef.current;
17
17
  if (element !== null)
18
18
  setVideo({ height: element.videoHeight, width: element.videoWidth });
19
19
  };
20
- return (_jsxs("div", { className: 'relative h-full min-h-0 overflow-hidden bg-black text-white', children: [_jsxs("div", { className: 'absolute inset-0', ref: areaRef, children: [_jsx("video", { "aria-hidden": 'true', autoPlay: true, className: PallasoftClassNameService.merge('size-full object-cover transition-opacity duration-300', isReady ? 'opacity-100' : 'opacity-0'), muted: true, onLoadedMetadata: measure, onResize: measure, playsInline: true, ref: videoTarget }), isReady ? (_jsx(PsScanViewfinderOverlay, { area: area, quad: detection.quad, video: video })) : null] }), isReady ? null : (_jsxs("div", { className: 'absolute inset-0 flex flex-col items-center justify-center gap-3 text-sm text-white/75', role: 'status', children: [_jsx(PsSpinner, { color: 'current', label: labels.cameraStarting, size: 'small' }), _jsx("span", { children: labels.cameraStarting })] })), flashKey > 0 ? (_jsx("div", { "aria-hidden": 'true', className: 'pointer-events-none absolute inset-0 bg-white animate-ps-fade-out' }, flashKey)) : null, _jsxs("div", { className: 'absolute inset-x-0 top-0 flex items-start justify-between gap-3 bg-gradient-to-b from-black/60 to-transparent px-4 pb-10 pt-[max(1rem,env(safe-area-inset-top))]', children: [_jsx(PsScanIconButton, { iconKey: PsIconKey.Close, label: labels.close, onClick: onClose }), _jsx("div", { className: 'flex items-center gap-2', children: canTorch && isReady ? (_jsx(PsScanIconButton, { iconKey: PsIconKey.Flash, isPressed: isTorchOn, label: isTorchOn ? labels.torchOff : labels.torchOn, onClick: onToggleTorch })) : null })] }), isReady ? (_jsx("div", { className: 'absolute inset-x-0 top-[calc(max(1rem,env(safe-area-inset-top))+3.75rem)] flex justify-center px-4', children: _jsx("p", { "aria-live": 'polite', className: PsScanChromeClasses.pill, children: hint }) })) : null, _jsxs("div", { className: 'absolute inset-x-0 bottom-0 grid grid-cols-[1fr_auto_1fr] items-center gap-4 bg-gradient-to-t from-black/80 to-transparent px-5 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-12', children: [_jsx("div", { className: 'flex justify-start', children: _jsx(PsScanPhotoButton, { isIconOnly: true, label: labels.choosePhoto, onFile: onFile }) }), _jsx(PsScanShutter, { disabled: !isReady || isBusy, label: labels.capture, onCapture: onCapture }), _jsx("span", { "aria-hidden": 'true' })] })] }));
20
+ return (_jsxs("div", { className: 'relative h-full min-h-0 overflow-hidden bg-black text-white', children: [_jsxs("div", { className: 'absolute inset-0', ref: areaRef, children: [_jsx("video", { "aria-hidden": 'true', autoPlay: true, className: PallasoftClassNameService.merge('size-full object-cover transition-opacity duration-300', isReady ? 'opacity-100' : 'opacity-0'), muted: true, onLoadedMetadata: measure, onResize: measure, playsInline: true, ref: videoTarget }), isReady ? (_jsx(PsScanViewfinderOverlay, { area: area, hasQuad: detection.hasQuad, quadRef: detection.quadRef, video: video })) : null] }), isReady ? null : (_jsxs("div", { className: 'absolute inset-0 flex flex-col items-center justify-center gap-3 text-sm text-white/75', role: 'status', children: [_jsx(PsSpinner, { color: 'current', label: labels.cameraStarting, size: 'small' }), _jsx("span", { children: labels.cameraStarting })] })), flashKey > 0 ? (_jsx("div", { "aria-hidden": 'true', className: 'pointer-events-none absolute inset-0 bg-white animate-ps-fade-out' }, flashKey)) : null, _jsxs("div", { className: 'absolute inset-x-0 top-0 flex items-start justify-between gap-3 bg-gradient-to-b from-black/60 to-transparent px-4 pb-10 pt-[max(1rem,env(safe-area-inset-top))]', children: [_jsx(PsScanIconButton, { iconKey: PsIconKey.Close, label: labels.close, onClick: onClose }), _jsx("div", { className: 'flex items-center gap-2', children: canTorch && isReady ? (_jsx(PsScanIconButton, { iconKey: PsIconKey.Flash, isPressed: isTorchOn, label: isTorchOn ? labels.torchOff : labels.torchOn, onClick: onToggleTorch })) : null })] }), isReady ? (_jsx("div", { className: 'absolute inset-x-0 top-[calc(max(1rem,env(safe-area-inset-top))+3.75rem)] flex justify-center px-4', children: _jsx("p", { "aria-live": 'polite', className: PsScanChromeClasses.pill, children: hint }) })) : null, _jsxs("div", { className: 'absolute inset-x-0 bottom-0 grid grid-cols-[1fr_auto_1fr] items-center gap-4 bg-gradient-to-t from-black/80 to-transparent px-5 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-12', children: [_jsx("div", { className: 'flex justify-start', children: _jsx(PsScanPhotoButton, { isIconOnly: true, label: labels.choosePhoto, onFile: onFile }) }), _jsx(PsScanShutter, { disabled: !isReady || isBusy, label: labels.capture, onCapture: onCapture }), _jsx("span", { "aria-hidden": 'true' })] })] }));
21
21
  };
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { PsIcon, PsIconKey } from '@pallasoft/ps-icons';
3
+ import { PsScanTemplateService } from '../services/ps-scan-template-service';
4
+ export const PsScanPageStrip = ({ labels, onRemove, pages }) => (_jsx("ul", { className: 'ps-scrollbar-hidden flex min-w-0 items-center justify-center gap-3 overflow-x-auto px-4 py-2', children: pages.map((page, index) => (_jsxs("li", { className: 'relative shrink-0', children: [_jsx("img", { alt: PsScanTemplateService.fill(labels.pageLabel, { n: index + 1 }), className: 'h-16 w-auto rounded-sm bg-white object-contain ring-1 ring-white/25', src: page.url }), _jsx("button", { "aria-label": PsScanTemplateService.fill(labels.removePage, { n: index + 1 }), className: 'ps-focus-ring ps-control-square ps-control-compact absolute -right-2 -top-2 inline-flex items-center justify-center rounded-ps-control bg-black/80 text-white transition hover:bg-black', onClick: () => onRemove(page.id), type: 'button', children: _jsx(PsIcon, { "aria-hidden": 'true', className: 'size-4', iconKey: PsIconKey.Close }) })] }, page.id))) }));
@@ -1,6 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { PsSpinner } from '@pallasoft/ps-core-components';
3
3
  import { PsIcon, PsIconKey } from '@pallasoft/ps-icons';
4
+ import { PsScanFormat } from '../constants/ps-scan-format';
5
+ import { PsScanTemplateService } from '../services/ps-scan-template-service';
4
6
  import { PsScanButton } from './ps-scan-button';
5
7
  import { PsScanIconButton } from './ps-scan-icon-button';
6
- export const PsScanPreview = ({ error, labels, onAdjust, onRetake, onUse, result, }) => (_jsxs("div", { className: 'flex h-full min-h-0 flex-col bg-black text-white animate-ps-fade-in', children: [_jsxs("header", { className: 'grid shrink-0 grid-cols-[2.75rem_1fr_2.75rem] items-center gap-3 px-4 pb-1 pt-[max(1rem,env(safe-area-inset-top))]', children: [_jsx(PsScanIconButton, { iconKey: PsIconKey.Crop, label: labels.cropTitle, onClick: onAdjust }), _jsx("h2", { className: 'truncate text-center text-base font-semibold', children: labels.previewTitle }), _jsx("span", { "aria-hidden": 'true' })] }), _jsx("div", { className: 'flex min-h-0 flex-1 items-center justify-center px-6 py-4', children: result === null ? (_jsxs("div", { className: 'flex flex-col items-center gap-3 text-sm text-white/75', role: 'status', children: [_jsx(PsSpinner, { color: 'current', label: labels.preparing, size: 'small' }), _jsx("span", { children: labels.preparing })] })) : (_jsx("img", { alt: labels.previewAlt, className: 'max-h-full max-w-full rounded-sm bg-white object-contain shadow-[0_24px_60px_-20px_rgb(0_0_0/0.9)] animate-ps-fade-in', src: result.url })) }), error === '' ? null : (_jsx("p", { className: 'mx-auto mb-2 max-w-sm rounded-ps-control bg-danger px-3 py-2 text-center text-sm text-white', role: 'alert', children: error })), _jsxs("footer", { className: 'mx-auto grid w-full max-w-md shrink-0 grid-cols-2 gap-3 px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-2', children: [_jsx(PsScanButton, { isLarge: true, onClick: onRetake, children: labels.retake }), _jsx(PsScanButton, { disabled: result === null, isLarge: true, isPrimary: true, leadingIcon: _jsx(PsIcon, { "aria-hidden": 'true', className: 'size-4', iconKey: PsIconKey.Check }), onClick: onUse, children: labels.useScan })] })] }));
8
+ import { PsScanPageStrip } from './ps-scan-page-strip';
9
+ export const PsScanPreview = ({ error, formats, isSaving, labels, multiPage, onAdjust, onRetake, onSave, preferredFormat, result, }) => (_jsxs("div", { className: 'flex h-full min-h-0 flex-col bg-black text-white animate-ps-fade-in', children: [_jsxs("header", { className: 'grid shrink-0 grid-cols-[2.75rem_1fr_2.75rem] items-center gap-3 px-4 pb-1 pt-[max(1rem,env(safe-area-inset-top))]', children: [_jsx(PsScanIconButton, { iconKey: PsIconKey.Crop, label: labels.cropTitle, onClick: onAdjust }), _jsx("h2", { className: 'truncate text-center text-base font-semibold', children: labels.previewTitle }), _jsx("span", { "aria-hidden": 'true' })] }), _jsx("div", { className: 'flex min-h-0 flex-1 items-center justify-center px-6 py-4', children: result === null ? (_jsxs("div", { className: 'flex flex-col items-center gap-3 text-sm text-white/75', role: 'status', children: [_jsx(PsSpinner, { color: 'current', label: labels.preparing, size: 'small' }), _jsx("span", { children: labels.preparing })] })) : (_jsx("img", { alt: labels.previewAlt, className: 'max-h-full max-w-full rounded-sm bg-white object-contain shadow-[0_24px_60px_-20px_rgb(0_0_0/0.9)] animate-ps-fade-in', src: result.url })) }), error === '' ? null : (_jsx("p", { className: 'mx-auto mb-2 max-w-sm rounded-ps-control bg-danger px-3 py-2 text-center text-sm text-white', role: 'alert', children: error })), multiPage === undefined || multiPage.pages.length === 0 ? null : (_jsxs("div", { className: 'shrink-0', children: [_jsx(PsScanPageStrip, { labels: multiPage.labels, onRemove: multiPage.onRemovePage, pages: multiPage.pages }), _jsx("p", { className: 'text-center text-sm text-white/75', children: PsScanTemplateService.fill(multiPage.labels.pageCount, {
10
+ count: multiPage.pages.length + 1,
11
+ }) })] })), _jsxs("footer", { className: 'mx-auto grid w-full max-w-md shrink-0 grid-cols-2 gap-3 px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-2', children: [multiPage === undefined ? null : (_jsx(PsScanButton, { className: 'col-span-2', disabled: result === null, isLoading: multiPage.isBusy, isLarge: true, leadingIcon: _jsx(PsIcon, { "aria-hidden": 'true', className: 'size-4', iconKey: PsIconKey.Plus }), onClick: multiPage.onAddPage, children: multiPage.labels.addPage })), formats.includes(PsScanFormat.Pdf) ? (_jsx(PsScanButton, { className: formats.length === 1 ? 'col-span-2' : undefined, disabled: result === null, isLarge: true, isLoading: isSaving, isPrimary: preferredFormat === PsScanFormat.Pdf, leadingIcon: _jsx(PsIcon, { "aria-hidden": 'true', className: 'size-4', iconKey: PsIconKey.File }), onClick: () => onSave(PsScanFormat.Pdf), children: labels.saveAsPdf })) : null, formats.includes(PsScanFormat.Png) ? (_jsx(PsScanButton, { className: formats.length === 1 ? 'col-span-2' : undefined, disabled: result === null, isLarge: true, isLoading: isSaving, isPrimary: preferredFormat === PsScanFormat.Png, leadingIcon: _jsx(PsIcon, { "aria-hidden": 'true', className: 'size-4', iconKey: PsIconKey.Image }), onClick: () => onSave(PsScanFormat.Png), children: labels.saveAsImages })) : null, _jsx(PsScanButton, { className: 'col-span-2', disabled: isSaving, isLarge: true, onClick: onRetake, children: labels.retake })] })] }));
@@ -1,12 +1,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { PsScanConfig } from '../constants/ps-scan-config';
3
+ import { usePsScanQuadTween } from '../hooks/use-ps-scan-quad-tween';
3
4
  import { PsScanGeometryService } from '../services/ps-scan-geometry-service';
4
- export const PsScanViewfinderOverlay = ({ area, quad, video }) => {
5
- if (area.width <= 0 || area.height <= 0)
6
- return null;
5
+ export const PsScanViewfinderOverlay = ({ area, hasQuad, quadRef, video, }) => {
7
6
  const box = PsScanGeometryService.cover(area, video);
8
7
  const left = (area.width - box.width) / 2;
9
8
  const top = (area.height - box.height) / 2;
9
+ const polygon = usePsScanQuadTween(quadRef, (quad) => quad.map((point) => `${left + point.x * box.width},${top + point.y * box.height}`).join(' '));
10
+ if (area.width <= 0 || area.height <= 0)
11
+ return null;
10
12
  const frameHeight = Math.min(area.height * PsScanConfig.guideHeightRatio, (area.width * PsScanConfig.guideWidthRatio) / PsScanConfig.guideAspect);
11
13
  const frameWidth = frameHeight * PsScanConfig.guideAspect;
12
14
  const frameLeft = (area.width - frameWidth) / 2;
@@ -18,7 +20,5 @@ export const PsScanViewfinderOverlay = ({ area, quad, video }) => {
18
20
  `M${frameLeft + frameWidth} ${frameTop + frameHeight - arm}V${frameTop + frameHeight}H${frameLeft + frameWidth - arm}`,
19
21
  `M${frameLeft + arm} ${frameTop + frameHeight}H${frameLeft}V${frameTop + frameHeight - arm}`,
20
22
  ].join('');
21
- return (_jsx("svg", { "aria-hidden": 'true', className: 'pointer-events-none absolute inset-0 size-full', viewBox: `0 0 ${area.width} ${area.height}`, children: quad === null || box.width === 0 ? (_jsx("path", { className: 'stroke-white/75 animate-ps-fade-in', d: corners, fill: 'none', strokeLinecap: 'round', strokeLinejoin: 'round', strokeWidth: '4' })) : (_jsx("polygon", { className: 'fill-primary/10 stroke-primary', points: quad
22
- .map((point) => `${left + point.x * box.width},${top + point.y * box.height}`)
23
- .join(' '), strokeLinejoin: 'round', strokeWidth: '3' })) }));
23
+ return (_jsx("svg", { "aria-hidden": 'true', className: 'pointer-events-none absolute inset-0 size-full', viewBox: `0 0 ${area.width} ${area.height}`, children: !hasQuad || box.width === 0 ? (_jsx("path", { className: 'stroke-white/75 animate-ps-fade-in', d: corners, fill: 'none', strokeLinecap: 'round', strokeLinejoin: 'round', strokeWidth: '4' })) : (_jsx("polygon", { className: 'fill-primary/10 stroke-primary', ref: polygon, strokeLinejoin: 'round', strokeWidth: '3' })) }));
24
24
  };
@@ -41,11 +41,12 @@ export const PsDocumentScannerLabels = {
41
41
  primerTitle: 'Allow camera access to scan your documents',
42
42
  processFailed: 'The scan could not be prepared. Try again.',
43
43
  retake: 'Retake',
44
+ saveAsImages: 'Save as images',
45
+ saveAsPdf: 'Save as PDF',
44
46
  title: 'Scan document',
45
47
  torchOff: 'Turn flash off',
46
48
  torchOn: 'Turn flash on',
47
49
  tryAgain: 'Try again',
48
50
  unavailableBody: 'This device has no camera the browser can use. Choose a photo of the page instead.',
49
51
  unavailableTitle: 'No camera found',
50
- useScan: 'Use scan',
51
52
  };
@@ -1,4 +1,5 @@
1
1
  export const PsFileViewerConfig = {
2
+ dragActivationDistance: 4,
2
3
  maxZoom: 4,
3
4
  minZoom: 0.25,
4
5
  pdfBaseScale: 1.4,
@@ -1,3 +1,4 @@
1
+ import { PsScanFormat } from './ps-scan-format';
1
2
  export const PsScanConfig = {
2
3
  cameraPermission: 'camera',
3
4
  cannyHighPercentile: 0.9,
@@ -20,6 +21,7 @@ export const PsScanConfig = {
20
21
  detectMinAreaRatio: 0.1,
21
22
  detectMinQuadFill: 0.88,
22
23
  detectMinSideRatio: 0.06,
24
+ formats: [PsScanFormat.Pdf, PsScanFormat.Png],
23
25
  guideArm: 36,
24
26
  guideArmRatio: 0.18,
25
27
  guideAspect: 0.72,
@@ -27,14 +29,20 @@ export const PsScanConfig = {
27
29
  guideLift: 0.03,
28
30
  guideWidthRatio: 0.76,
29
31
  liveDeadzone: 0.006,
30
- liveDetectIntervalMs: 200,
32
+ liveCloseRadii: [2],
33
+ liveDetectMinIntervalMs: 70,
31
34
  liveDetectMaxDimension: 280,
32
- liveMissLimit: 3,
33
- liveSmoothing: 0.45,
35
+ liveMissLimit: 1,
36
+ liveSmoothing: 0.68,
37
+ liveTweenMs: 55,
34
38
  loupeOffset: 72,
35
39
  loupeSize: 112,
36
40
  loupeZoom: 2.5,
37
41
  minCornerGap: 0.02,
42
+ multiPageMaxDimension: 2400,
43
+ jpegType: 'image/jpeg',
44
+ pdfType: 'application/pdf',
45
+ pngType: 'image/png',
38
46
  polygonEpsilons: [0.008, 0.012, 0.018, 0.026, 0.036, 0.05, 0.07],
39
47
  torchConstraint: 'torch',
40
48
  warpChunkPixels: 400000,
@@ -0,0 +1,5 @@
1
+ import { PsScanConfig } from './ps-scan-config';
2
+ export const PsScanDetectProfiles = {
3
+ full: { closeRadii: PsScanConfig.closeRadii, isYielding: true },
4
+ live: { closeRadii: PsScanConfig.liveCloseRadii, isYielding: false },
5
+ };
@@ -0,0 +1,4 @@
1
+ export const PsScanFormat = {
2
+ Pdf: 'pdf',
3
+ Png: 'png',
4
+ };
@@ -0,0 +1,66 @@
1
+ import { useRef } from 'react';
2
+ import { PsPanService } from '../services/ps-pan-service';
3
+ export const usePsDragPan = ({ activationDistance, isEnabled, isMouseOnly, onEnd, onMove, onStart, }) => {
4
+ const origin = useRef(null);
5
+ const pointerId = useRef(-1);
6
+ const isActive = useRef(false);
7
+ const last = useRef({ x: 0, y: 0 });
8
+ const activate = (element) => {
9
+ isActive.current = true;
10
+ last.current = { x: 0, y: 0 };
11
+ if (typeof element.setPointerCapture === 'function')
12
+ element.setPointerCapture(pointerId.current);
13
+ element.style.userSelect = 'none';
14
+ window.getSelection()?.removeAllRanges();
15
+ onStart(element);
16
+ };
17
+ const finish = (element) => {
18
+ if (origin.current === null)
19
+ return;
20
+ const wasActive = isActive.current;
21
+ origin.current = null;
22
+ isActive.current = false;
23
+ element.style.userSelect = '';
24
+ if (wasActive)
25
+ onEnd(last.current);
26
+ };
27
+ const onPointerDown = (event) => {
28
+ if (!isEnabled || origin.current !== null)
29
+ return;
30
+ if (isMouseOnly && event.pointerType === 'touch')
31
+ return;
32
+ if (event.pointerType === 'mouse' && event.button !== 0)
33
+ return;
34
+ if (PsPanService.isOnScrollbar(event.currentTarget, event.clientX, event.clientY))
35
+ return;
36
+ origin.current = { x: event.clientX, y: event.clientY };
37
+ pointerId.current = event.pointerId;
38
+ isActive.current = false;
39
+ if (activationDistance <= 0)
40
+ activate(event.currentTarget);
41
+ };
42
+ const onPointerMove = (event) => {
43
+ if (origin.current === null || event.pointerId !== pointerId.current)
44
+ return;
45
+ const delta = { x: event.clientX - origin.current.x, y: event.clientY - origin.current.y };
46
+ if (!isActive.current) {
47
+ if (Math.hypot(delta.x, delta.y) < activationDistance)
48
+ return;
49
+ activate(event.currentTarget);
50
+ }
51
+ last.current = delta;
52
+ onMove(delta);
53
+ };
54
+ const onPointerEnd = (event) => {
55
+ if (event.pointerId !== pointerId.current)
56
+ return;
57
+ finish(event.currentTarget);
58
+ };
59
+ return {
60
+ onLostPointerCapture: onPointerEnd,
61
+ onPointerCancel: onPointerEnd,
62
+ onPointerDown,
63
+ onPointerMove,
64
+ onPointerUp: onPointerEnd,
65
+ };
66
+ };
@@ -1,41 +1,66 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useRef, useState } from 'react';
2
2
  import { PsScanConfig } from '../constants/ps-scan-config';
3
+ import { PsScanDetectProfiles } from '../constants/ps-scan-detect-profiles';
3
4
  import { PsScanCanvasService } from '../services/ps-scan-canvas-service';
4
5
  import { PsScanEdgeService } from '../services/ps-scan-edge-service';
6
+ import { PsScanFrameService } from '../services/ps-scan-frame-service';
5
7
  import { PsScanStabilityService } from '../services/ps-scan-stability-service';
6
8
  export const usePsScanLiveDetection = (videoRef, isActive) => {
7
- const [detection, setDetection] = useState({ quad: null });
9
+ const [hasQuad, setHasQuad] = useState(false);
10
+ const quadRef = useRef(null);
8
11
  useEffect(() => {
12
+ quadRef.current = null;
9
13
  if (!isActive) {
10
- setDetection({ quad: null });
14
+ setHasQuad(false);
11
15
  return;
12
16
  }
13
17
  let isCancelled = false;
18
+ let isRunning = false;
19
+ let lastStart = 0;
14
20
  let state = PsScanStabilityService.initial();
15
- let timer;
16
- const tick = async () => {
21
+ let cancelNext = () => undefined;
22
+ const schedule = () => {
23
+ if (!isCancelled)
24
+ cancelNext = PsScanFrameService.schedule(videoRef.current, run);
25
+ };
26
+ const run = async () => {
17
27
  const video = videoRef.current;
18
- if (video !== null && video.videoWidth > 0 && document.visibilityState === 'visible') {
19
- try {
20
- const raster = PsScanCanvasService.sample(video, PsScanConfig.liveDetectMaxDimension);
21
- const quad = await PsScanEdgeService.detect(raster, PsScanConfig.liveDetectMaxDimension);
22
- if (isCancelled)
23
- return;
24
- state = PsScanStabilityService.next(state, quad);
25
- }
26
- catch {
27
- state = PsScanStabilityService.initial();
28
- }
29
- setDetection((current) => (current.quad === state.quad ? current : { quad: state.quad }));
28
+ const now = performance.now();
29
+ if (isRunning ||
30
+ video === null ||
31
+ video.videoWidth === 0 ||
32
+ document.visibilityState !== 'visible' ||
33
+ now - lastStart < PsScanConfig.liveDetectMinIntervalMs) {
34
+ schedule();
35
+ return;
30
36
  }
31
- if (!isCancelled)
32
- timer = setTimeout(tick, PsScanConfig.liveDetectIntervalMs);
37
+ isRunning = true;
38
+ lastStart = now;
39
+ try {
40
+ const raster = PsScanCanvasService.sample(video, PsScanConfig.liveDetectMaxDimension);
41
+ const quad = await PsScanEdgeService.detect(raster, PsScanConfig.liveDetectMaxDimension, PsScanDetectProfiles.live);
42
+ if (isCancelled)
43
+ return;
44
+ state = PsScanStabilityService.next(state, quad);
45
+ }
46
+ catch {
47
+ state = PsScanStabilityService.initial();
48
+ }
49
+ finally {
50
+ isRunning = false;
51
+ }
52
+ if (isCancelled)
53
+ return;
54
+ quadRef.current = state.quad;
55
+ setHasQuad(state.quad !== null);
56
+ schedule();
33
57
  };
34
- timer = setTimeout(tick, PsScanConfig.liveDetectIntervalMs);
58
+ schedule();
35
59
  return () => {
36
60
  isCancelled = true;
37
- clearTimeout(timer);
61
+ cancelNext();
62
+ quadRef.current = null;
38
63
  };
39
64
  }, [isActive]);
40
- return detection;
65
+ return { hasQuad, quadRef };
41
66
  };
@@ -0,0 +1,34 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { PsScanQuadTweenService } from '../services/ps-scan-quad-tween-service';
3
+ export const usePsScanQuadTween = (quadRef, format) => {
4
+ const polygon = useRef(null);
5
+ const formatRef = useRef(format);
6
+ formatRef.current = format;
7
+ useEffect(() => {
8
+ let displayed = null;
9
+ let lastTime = performance.now();
10
+ let lastPoints = '';
11
+ let handle = 0;
12
+ const frame = (time) => {
13
+ const target = quadRef.current;
14
+ const elapsed = time - lastTime;
15
+ lastTime = time;
16
+ if (target === null)
17
+ displayed = null;
18
+ else
19
+ displayed =
20
+ displayed === null ? target : PsScanQuadTweenService.step(displayed, target, elapsed);
21
+ if (displayed !== null && polygon.current !== null) {
22
+ const points = formatRef.current(displayed);
23
+ if (points !== lastPoints) {
24
+ lastPoints = points;
25
+ polygon.current.setAttribute('points', points);
26
+ }
27
+ }
28
+ handle = requestAnimationFrame(frame);
29
+ };
30
+ handle = requestAnimationFrame(frame);
31
+ return () => cancelAnimationFrame(handle);
32
+ }, [quadRef]);
33
+ return polygon;
34
+ };
package/dist/esm/index.js CHANGED
@@ -33,6 +33,8 @@ export * from './constants/ps-upload-messages';
33
33
  export * from './constants/ps-upload-queue-config';
34
34
  export * from './constants/ps-upload-status-tones';
35
35
  export * from './constants/ps-upload-status';
36
+ export * from './models/ps-drag-pan-handlers';
37
+ export * from './models/ps-drag-pan-options';
36
38
  export * from './models/ps-file-icon-button-tone';
37
39
  export * from './models/ps-file-rejection';
38
40
  export * from './models/ps-file-selection-rules';
@@ -66,10 +68,13 @@ export * from './services/ps-text-search-service';
66
68
  export * from './services/ps-upload-queue-service';
67
69
  export * from './services/ps-xhr-upload-service';
68
70
  export * from './hooks/use-ps-active-match-scroll';
71
+ export * from './hooks/use-ps-drag-pan';
69
72
  export * from './hooks/use-ps-file-text';
70
73
  export * from './hooks/use-ps-in-view';
71
74
  export * from './hooks/use-ps-upload-queue';
72
75
  export * from './components/ps-document-scanner';
73
76
  export * from './constants/ps-document-scanner-labels';
77
+ export * from './constants/ps-scan-format';
78
+ export * from './models/ps-document-scanner-page-labels';
74
79
  export * from './models/ps-document-scanner-props';
75
80
  export * from './models/ps-file-picker-scan';
@@ -0,0 +1 @@
1
+ export {};