@pallasoft/ps-file-components 2.0.0 → 2.1.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 (89) 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 +87 -2
  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-file-delete-confirm.js +1 -1
  7. package/dist/components/ps-image-viewer.js +35 -16
  8. package/dist/components/ps-pdf-viewer.js +18 -1
  9. package/dist/components/ps-scan-camera-view.js +2 -2
  10. package/dist/components/ps-scan-page-strip.d.ts +8 -0
  11. package/dist/components/ps-scan-page-strip.js +8 -0
  12. package/dist/components/ps-scan-preview.d.ts +3 -1
  13. package/dist/components/ps-scan-preview.js +5 -1
  14. package/dist/components/ps-scan-viewfinder-overlay.d.ts +4 -2
  15. package/dist/components/ps-scan-viewfinder-overlay.js +6 -6
  16. package/dist/components/ps-upload-modal.js +1 -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 +7 -3
  20. package/dist/constants/ps-scan-config.js +7 -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/esm/components/ps-document-scanner-sheet.js +87 -2
  24. package/dist/esm/components/ps-document-scanner.js +2 -2
  25. package/dist/esm/components/ps-file-delete-confirm.js +2 -2
  26. package/dist/esm/components/ps-image-viewer.js +35 -16
  27. package/dist/esm/components/ps-pdf-viewer.js +18 -1
  28. package/dist/esm/components/ps-scan-camera-view.js +2 -2
  29. package/dist/esm/components/ps-scan-page-strip.js +4 -0
  30. package/dist/esm/components/ps-scan-preview.js +5 -1
  31. package/dist/esm/components/ps-scan-viewfinder-overlay.js +6 -6
  32. package/dist/esm/components/ps-upload-modal.js +2 -2
  33. package/dist/esm/constants/ps-file-viewer-config.js +1 -0
  34. package/dist/esm/constants/ps-scan-config.js +7 -3
  35. package/dist/esm/constants/ps-scan-detect-profiles.js +5 -0
  36. package/dist/esm/hooks/use-ps-drag-pan.js +66 -0
  37. package/dist/esm/hooks/use-ps-scan-live-detection.js +47 -22
  38. package/dist/esm/hooks/use-ps-scan-quad-tween.js +34 -0
  39. package/dist/esm/index.js +4 -0
  40. package/dist/esm/models/ps-document-scanner-page-labels.js +1 -0
  41. package/dist/esm/models/ps-drag-pan-handlers.js +1 -0
  42. package/dist/esm/models/ps-drag-pan-options.js +1 -0
  43. package/dist/esm/models/ps-scan-detect-profile.js +1 -0
  44. package/dist/esm/models/ps-scan-multi-page.js +1 -0
  45. package/dist/esm/models/ps-scan-page.js +1 -0
  46. package/dist/esm/models/ps-scan-pdf-page.js +1 -0
  47. package/dist/esm/services/ps-pan-service.js +13 -0
  48. package/dist/esm/services/ps-scan-edge-service.js +7 -4
  49. package/dist/esm/services/ps-scan-file-name-service.js +3 -0
  50. package/dist/esm/services/ps-scan-frame-service.js +10 -0
  51. package/dist/esm/services/ps-scan-pdf-service.js +69 -0
  52. package/dist/esm/services/ps-scan-quad-tween-service.js +10 -0
  53. package/dist/hooks/use-ps-drag-pan.d.ts +3 -0
  54. package/dist/hooks/use-ps-drag-pan.js +70 -0
  55. package/dist/hooks/use-ps-scan-live-detection.js +46 -21
  56. package/dist/hooks/use-ps-scan-quad-tween.d.ts +3 -0
  57. package/dist/hooks/use-ps-scan-quad-tween.js +38 -0
  58. package/dist/index.d.ts +4 -0
  59. package/dist/index.js +4 -0
  60. package/dist/models/ps-document-scanner-page-labels.d.ts +6 -0
  61. package/dist/models/ps-document-scanner-page-labels.js +2 -0
  62. package/dist/models/ps-document-scanner-props.d.ts +3 -0
  63. package/dist/models/ps-document-scanner-sheet-props.d.ts +2 -0
  64. package/dist/models/ps-drag-pan-handlers.d.ts +8 -0
  65. package/dist/models/ps-drag-pan-handlers.js +2 -0
  66. package/dist/models/ps-drag-pan-options.d.ts +9 -0
  67. package/dist/models/ps-drag-pan-options.js +2 -0
  68. package/dist/models/ps-scan-detect-profile.d.ts +4 -0
  69. package/dist/models/ps-scan-detect-profile.js +2 -0
  70. package/dist/models/ps-scan-live-detection.d.ts +3 -1
  71. package/dist/models/ps-scan-multi-page.d.ts +9 -0
  72. package/dist/models/ps-scan-multi-page.js +2 -0
  73. package/dist/models/ps-scan-page.d.ts +4 -0
  74. package/dist/models/ps-scan-page.js +2 -0
  75. package/dist/models/ps-scan-pdf-page.d.ts +5 -0
  76. package/dist/models/ps-scan-pdf-page.js +2 -0
  77. package/dist/services/ps-pan-service.d.ts +6 -0
  78. package/dist/services/ps-pan-service.js +17 -0
  79. package/dist/services/ps-scan-edge-service.d.ts +2 -1
  80. package/dist/services/ps-scan-edge-service.js +7 -4
  81. package/dist/services/ps-scan-file-name-service.d.ts +1 -0
  82. package/dist/services/ps-scan-file-name-service.js +3 -0
  83. package/dist/services/ps-scan-frame-service.d.ts +3 -0
  84. package/dist/services/ps-scan-frame-service.js +14 -0
  85. package/dist/services/ps-scan-pdf-service.d.ts +10 -0
  86. package/dist/services/ps-scan-pdf-service.js +73 -0
  87. package/dist/services/ps-scan-quad-tween-service.d.ts +4 -0
  88. package/dist/services/ps-scan-quad-tween-service.js +14 -0
  89. package/package.json +3 -5
@@ -5,25 +5,32 @@ 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
14
  import { PsScanFileNameService } from '../services/ps-scan-file-name-service';
15
+ import { PsScanPdfService } from '../services/ps-scan-pdf-service';
14
16
  import { PsScanPipelineService } from '../services/ps-scan-pipeline-service';
15
17
  import { PsScanCameraView } from './ps-scan-camera-view';
16
18
  import { PsScanCropEditor } from './ps-scan-crop-editor';
17
19
  import { PsScanIconButton } from './ps-scan-icon-button';
20
+ import { PsScanPageStrip } from './ps-scan-page-strip';
18
21
  import { PsScanPermissionView } from './ps-scan-permission-view';
19
22
  import { PsScanPreview } from './ps-scan-preview';
20
- export const PsDocumentScannerSheet = ({ fileName, jpegQuality, labels, maxDimension, onClose, onComplete, }) => {
23
+ export const PsDocumentScannerSheet = ({ fileName, jpegQuality, labels, maxDimension, onClose, onComplete, pageLabels, }) => {
21
24
  const tier = usePsViewport();
22
25
  const zIndex = usePsLayerZIndex(PallasoftZIndex.Modal);
23
26
  const content = useRef(null);
24
27
  const { clear, detectAgain, load, photo, setCorners } = usePsScanPhoto();
25
28
  const [step, setStep] = useState(PsScanStep.Camera);
26
29
  const [result, setResult] = useState(null);
30
+ const [pages, setPages] = useState([]);
31
+ const [isAddingPage, setIsAddingPage] = useState(false);
32
+ const pagesRef = useRef([]);
33
+ const nextPageId = useRef(1);
27
34
  const [announcement, setAnnouncement] = useState('');
28
35
  const [error, setError] = useState('');
29
36
  const [isCapturing, setIsCapturing] = useState(false);
@@ -41,6 +48,12 @@ export const PsDocumentScannerSheet = ({ fileName, jpegQuality, labels, maxDimen
41
48
  if (result !== null)
42
49
  URL.revokeObjectURL(result.url);
43
50
  }, [result]);
51
+ useEffect(() => {
52
+ pagesRef.current = pages;
53
+ }, [pages]);
54
+ useEffect(() => () => {
55
+ pagesRef.current.forEach((page) => URL.revokeObjectURL(page.url));
56
+ }, []);
44
57
  const edgeMessage = (isDetected) => isDetected ? labels.edgesFound : labels.edgesNotFound;
45
58
  const accept = async (source) => {
46
59
  setError('');
@@ -94,19 +107,91 @@ export const PsDocumentScannerSheet = ({ fileName, jpegQuality, labels, maxDimen
94
107
  setError(labels.processFailed);
95
108
  }
96
109
  };
110
+ const multiPageDimension = Math.min(maxDimension, PsScanConfig.multiPageMaxDimension);
111
+ const renderPage = async () => {
112
+ if (photo === null)
113
+ return null;
114
+ const rendered = await PsScanPipelineService.render(photo.source, photo.corners, multiPageDimension, jpegQuality);
115
+ const id = nextPageId.current;
116
+ nextPageId.current += 1;
117
+ return { ...rendered, id, url: URL.createObjectURL(rendered.blob) };
118
+ };
119
+ const addPage = async () => {
120
+ if (result === null || isAddingPage)
121
+ return;
122
+ setIsAddingPage(true);
123
+ setError('');
124
+ try {
125
+ const page = await renderPage();
126
+ if (page === null)
127
+ return;
128
+ setPages((current) => [...current, page]);
129
+ clear();
130
+ setResult(null);
131
+ setStep(PsScanStep.Camera);
132
+ }
133
+ catch {
134
+ setError(labels.processFailed);
135
+ }
136
+ finally {
137
+ setIsAddingPage(false);
138
+ }
139
+ };
140
+ const removePage = (id) => {
141
+ const removed = pages.find((page) => page.id === id);
142
+ if (removed !== undefined)
143
+ URL.revokeObjectURL(removed.url);
144
+ setPages((current) => current.filter((page) => page.id !== id));
145
+ };
146
+ const useAsPdf = async () => {
147
+ if (isAddingPage)
148
+ return;
149
+ setIsAddingPage(true);
150
+ setError('');
151
+ try {
152
+ const last = await renderPage();
153
+ if (last === null)
154
+ return;
155
+ const pdf = await PsScanPdfService.build([...pages, last]);
156
+ URL.revokeObjectURL(last.url);
157
+ onComplete([
158
+ new File([pdf], PsScanFileNameService.buildPdf(fileName), { type: 'application/pdf' }),
159
+ ]);
160
+ onClose();
161
+ }
162
+ catch {
163
+ setError(labels.processFailed);
164
+ }
165
+ finally {
166
+ setIsAddingPage(false);
167
+ }
168
+ };
97
169
  const use = () => {
98
170
  if (result === null)
99
171
  return;
172
+ if (pages.length > 0) {
173
+ void useAsPdf();
174
+ return;
175
+ }
100
176
  onComplete([
101
177
  new File([result.blob], PsScanFileNameService.build(fileName), { type: 'image/jpeg' }),
102
178
  ]);
103
179
  onClose();
104
180
  };
181
+ const multiPage = pageLabels === null
182
+ ? undefined
183
+ : {
184
+ isBusy: isAddingPage,
185
+ labels: pageLabels,
186
+ onAddPage: addPage,
187
+ onRemovePage: removePage,
188
+ pages,
189
+ };
105
190
  if (typeof document === 'undefined')
106
191
  return null;
107
192
  return createPortal(_jsx("div", { "aria-label": labels.title, "aria-modal": 'true', className: isDesktop
108
193
  ? 'fixed inset-0 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm animate-ps-fade-in'
109
194
  : 'fixed inset-0 bg-black animate-ps-fade-in', role: 'dialog', style: { zIndex }, children: _jsx(PsLayer, { zIndex: zIndex, children: _jsxs("div", { className: isDesktop
110
195
  ? '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);
196
+ : '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, multiPage: multiPage, onAdjust: adjust, onRetake: retake, onUse: use, 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
197
  };
@@ -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, 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, jpegQuality: jpegQuality, labels: { ...PsDocumentScannerLabels, ...labels }, maxDimension: maxDimension, onClose: onClose, onComplete: onComplete, pageLabels: isMultiPage && pageLabels !== undefined ? pageLabels : null }) }));
12
12
  };
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { PsButton } from '@pallasoft/ps-core-components';
3
3
  import { PsModal } from '@pallasoft/ps-popovers';
4
- export const PsFileDeleteConfirm = ({ fileName, isDeleting, isOpen, labels, onCancel, onConfirm, }) => (_jsx(PsModal, { eyebrow: fileName, isOpen: isOpen, onClose: onCancel, size: 'sm', title: labels.deleteConfirm, children: _jsxs("div", { className: 'flex flex-col gap-5', children: [_jsxs("p", { className: 'text-sm text-ink/70', children: [_jsx("span", { className: 'font-medium text-ink', children: fileName }), " ", labels.deleteConfirmBody] }), _jsxs("div", { className: 'flex justify-end gap-2 border-t border-line pt-4', children: [_jsx(PsButton, { disabled: isDeleting, onClick: onCancel, type: 'button', variant: 'ghost', children: labels.deleteCancel }), _jsx(PsButton, { isLoading: isDeleting, onClick: onConfirm, type: 'button', variant: 'danger', children: labels.delete })] })] }) }));
4
+ export const PsFileDeleteConfirm = ({ fileName, isDeleting, isOpen, labels, onCancel, onConfirm, }) => (_jsx(PsModal, { eyebrow: fileName, footer: _jsxs(_Fragment, { children: [_jsx(PsButton, { disabled: isDeleting, onClick: onCancel, type: 'button', variant: 'surface', children: labels.deleteCancel }), _jsx(PsButton, { isLoading: isDeleting, onClick: onConfirm, type: 'button', variant: 'danger', children: labels.delete })] }), isOpen: isOpen, onClose: onCancel, size: 'sm', title: labels.deleteConfirm, children: _jsxs("p", { className: 'text-sm text-ink/70', children: [_jsx("span", { className: 'font-medium text-ink', children: fileName }), " ", labels.deleteConfirmBody] }) }));
@@ -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,10 @@
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 { PsScanTemplateService } from '../services/ps-scan-template-service';
4
5
  import { PsScanButton } from './ps-scan-button';
5
6
  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 })] })] }));
7
+ import { PsScanPageStrip } from './ps-scan-page-strip';
8
+ export const PsScanPreview = ({ error, labels, multiPage, 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 })), 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, {
9
+ count: multiPage.pages.length + 1,
10
+ }) })] })), _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 })), _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 })] })] }));
@@ -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
  };
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { PsButton } from '@pallasoft/ps-core-components';
3
3
  import { PsModal } from '@pallasoft/ps-popovers';
4
4
  import { PsUploadLabels } from '../constants/ps-upload-labels';
@@ -8,5 +8,5 @@ export const PsUploadModal = ({ isOpen, labels, onClose, queue }) => {
8
8
  const text = { ...PsUploadLabels, ...labels };
9
9
  const summary = PsUploadQueueService.summary(queue.items);
10
10
  const title = queue.isBusy ? text.title : summary.failed > 0 ? text.titleFailed : text.titleDone;
11
- return (_jsx(PsModal, { isOpen: isOpen, onClose: onClose, size: 'md', title: title, children: _jsxs("div", { className: 'flex flex-col gap-5', children: [_jsx("ul", { className: 'flex max-h-[50vh] flex-col gap-2 overflow-y-auto', children: queue.items.map((item) => (_jsx(PsUploadRow, { item: item, labels: text, onCancel: () => queue.cancel(item.id), onRemove: () => queue.remove(item.id), onRetry: () => queue.retry(item.id) }, item.id))) }), _jsxs("div", { className: 'flex flex-wrap items-center justify-end gap-2 border-t border-line pt-4', children: [queue.isBusy ? (_jsx(PsButton, { onClick: () => queue.items.forEach((item) => queue.cancel(item.id)), type: 'button', variant: 'ghost', children: text.cancelAll })) : null, _jsx(PsButton, { onClick: onClose, type: 'button', variant: 'primary', children: text.finish })] })] }) }));
11
+ return (_jsx(PsModal, { footer: _jsxs(_Fragment, { children: [queue.isBusy ? (_jsx(PsButton, { onClick: () => queue.items.forEach((item) => queue.cancel(item.id)), type: 'button', variant: 'surface', children: text.cancelAll })) : null, _jsx(PsButton, { onClick: onClose, type: 'button', variant: 'primary', children: text.finish })] }), isOpen: isOpen, onClose: onClose, size: 'md', title: title, children: _jsx("ul", { className: 'flex flex-col gap-2', children: queue.items.map((item) => (_jsx(PsUploadRow, { item: item, labels: text, onCancel: () => queue.cancel(item.id), onRemove: () => queue.remove(item.id), onRetry: () => queue.retry(item.id) }, item.id))) }) }));
12
12
  };
@@ -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,
@@ -27,14 +27,18 @@ export const PsScanConfig = {
27
27
  guideLift: 0.03,
28
28
  guideWidthRatio: 0.76,
29
29
  liveDeadzone: 0.006,
30
- liveDetectIntervalMs: 200,
30
+ liveCloseRadii: [2],
31
+ liveDetectMinIntervalMs: 70,
31
32
  liveDetectMaxDimension: 280,
32
- liveMissLimit: 3,
33
- liveSmoothing: 0.45,
33
+ liveMissLimit: 1,
34
+ liveSmoothing: 0.68,
35
+ liveTweenMs: 55,
34
36
  loupeOffset: 72,
35
37
  loupeSize: 112,
36
38
  loupeZoom: 2.5,
37
39
  minCornerGap: 0.02,
40
+ multiPageMaxDimension: 2400,
41
+ pdfLongEdgePoints: 842,
38
42
  polygonEpsilons: [0.008, 0.012, 0.018, 0.026, 0.036, 0.05, 0.07],
39
43
  torchConstraint: 'torch',
40
44
  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,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,12 @@ 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 './models/ps-document-scanner-page-labels';
74
78
  export * from './models/ps-document-scanner-props';
75
79
  export * from './models/ps-file-picker-scan';
@@ -0,0 +1 @@
1
+ export {};