@pallasoft/ps-mobile-components 1.6.0 → 1.7.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 (52) hide show
  1. package/dist/components/ps-list/ps-list-select-row.js +8 -4
  2. package/dist/components/ps-menu-popup/ps-menu-popup-divider.js +8 -2
  3. package/dist/components/ps-menu-popup/ps-menu-popup-row.js +21 -9
  4. package/dist/components/ps-menu-popup/ps-menu-popup.js +28 -17
  5. package/dist/constants/ps-material-menu-config.d.ts +12 -0
  6. package/dist/constants/ps-material-menu-config.js +12 -0
  7. package/dist/constants/ps-scan-format.d.ts +4 -0
  8. package/dist/constants/ps-scan-format.js +5 -0
  9. package/dist/constants/ps-scan-output-config.d.ts +17 -0
  10. package/dist/constants/ps-scan-output-config.js +23 -0
  11. package/dist/hooks/use-ps-document-scanner.js +3 -2
  12. package/dist/hooks/use-ps-menu-popup-reveal.d.ts +3 -0
  13. package/dist/hooks/use-ps-menu-popup-reveal.js +28 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/interfaces/ps-capture-options.d.ts +2 -0
  17. package/dist/interfaces/ps-document-scanner-options.d.ts +2 -0
  18. package/dist/interfaces/ps-image-manipulator-native.d.ts +19 -0
  19. package/dist/interfaces/ps-image-manipulator-native.js +1 -0
  20. package/dist/interfaces/ps-menu-popup-container-style.d.ts +8 -0
  21. package/dist/interfaces/ps-menu-popup-container-style.js +1 -0
  22. package/dist/interfaces/ps-menu-popup-divider-style.d.ts +4 -0
  23. package/dist/interfaces/ps-menu-popup-divider-style.js +1 -0
  24. package/dist/interfaces/ps-menu-popup-layout.d.ts +1 -0
  25. package/dist/interfaces/ps-menu-popup-row-style.d.ts +6 -0
  26. package/dist/interfaces/ps-menu-popup-row-style.js +1 -0
  27. package/dist/interfaces/ps-menu-popup-size.d.ts +4 -0
  28. package/dist/interfaces/ps-menu-popup-size.js +1 -0
  29. package/dist/interfaces/ps-page-size.d.ts +4 -0
  30. package/dist/interfaces/ps-page-size.js +1 -0
  31. package/dist/interfaces/ps-pdf-page.d.ts +5 -0
  32. package/dist/interfaces/ps-pdf-page.js +1 -0
  33. package/dist/interfaces/ps-scan-format-labels.d.ts +7 -0
  34. package/dist/interfaces/ps-scan-format-labels.js +1 -0
  35. package/dist/services/ps-capture-picker-service.js +1 -0
  36. package/dist/services/ps-document-scanner-service.d.ts +2 -0
  37. package/dist/services/ps-document-scanner-service.js +21 -4
  38. package/dist/services/ps-jpeg-size-service.d.ts +4 -0
  39. package/dist/services/ps-jpeg-size-service.js +32 -0
  40. package/dist/services/ps-menu-popup-service.js +1 -0
  41. package/dist/services/ps-menu-popup-style-service.d.ts +13 -0
  42. package/dist/services/ps-menu-popup-style-service.js +55 -0
  43. package/dist/services/ps-scan-format-prompt-service.d.ts +6 -0
  44. package/dist/services/ps-scan-format-prompt-service.js +21 -0
  45. package/dist/services/ps-scan-image-converter-service.d.ts +5 -0
  46. package/dist/services/ps-scan-image-converter-service.js +22 -0
  47. package/dist/services/ps-scan-output-service.d.ts +10 -0
  48. package/dist/services/ps-scan-output-service.js +55 -0
  49. package/dist/services/ps-scan-pdf-service.d.ts +4 -0
  50. package/dist/services/ps-scan-pdf-service.js +6 -0
  51. package/jest-setup.js +1 -1
  52. package/package.json +6 -2
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { PsIconKey } from '@pallasoft/ps-icons';
3
- import { View } from 'react-native';
3
+ import { Pressable, View } from 'react-native';
4
4
  import { PsLayoutConfig } from '../../constants/ps-layout-config';
5
5
  import { PsMobileControlConfig } from '../../constants/ps-mobile-control-config';
6
+ import { PsRippleKind } from '../../constants/ps-ripple-kind';
6
7
  import { usePsMobileTheme } from '../../hooks/use-ps-mobile-theme';
7
8
  import { PsNativeMenuService } from '../../services/ps-native-menu-service';
9
+ import { PsRippleService } from '../../services/ps-ripple-service';
8
10
  import { PsSelectService } from '../../services/ps-select-service';
9
11
  import { PsStateService } from '../../services/ps-state-service';
10
12
  import { PsIcon } from '../ps-icon/ps-icon';
@@ -15,14 +17,16 @@ export const PsListSelectRow = ({ isDisabled = false, isLoading = false, onChang
15
17
  const theme = usePsMobileTheme();
16
18
  if (isLoading)
17
19
  return _jsx(PsListRowSkeleton, { subtitle: subtitle, value: value });
18
- return (_jsx(PsNativeMenu, { isDisabled: isDisabled, isFullWidth: true, items: PsNativeMenuService.items(options, value), label: title, onSelect: onChange, children: () => (_jsxs(View, { accessibilityRole: 'button', accessibilityValue: { text: PsSelectService.selectedLabel(options, value) }, style: {
20
+ return (_jsx(PsNativeMenu, { isDisabled: isDisabled, isFullWidth: true, items: PsNativeMenuService.items(options, value), label: title, onSelect: onChange, children: ({ open }) => (_jsxs(Pressable, { accessibilityLabel: title, accessibilityRole: 'button', accessibilityState: { disabled: isDisabled }, accessibilityValue: { text: PsSelectService.selectedLabel(options, value) }, android_ripple: PsRippleService.resolve(theme, PsRippleKind.Surface), disabled: isDisabled, onPress: open, style: ({ pressed }) => ({
19
21
  alignItems: 'center',
20
- backgroundColor: theme.surface,
22
+ backgroundColor: pressed
23
+ ? PsStateService.pressedFill(theme, theme.surface)
24
+ : theme.surface,
21
25
  flexDirection: 'row',
22
26
  gap: PsLayoutConfig.headerSlotGap,
23
27
  minHeight: PsLayoutConfig.rowMinHeight,
24
28
  opacity: isDisabled ? PsStateService.disabledOpacity() : 1,
25
29
  paddingHorizontal: PsLayoutConfig.gutter,
26
30
  paddingVertical: PsLayoutConfig.rowPaddingVertical,
27
- }, testID: 'ps-list-select-row', children: [_jsxs(View, { style: { flex: 1, gap: PsLayoutConfig.rowLineGap }, children: [_jsx(PsText, { numberOfLines: 1, weight: 'semibold', children: title }), subtitle === undefined ? null : (_jsx(PsText, { tone: 'muted', variant: 'small', children: subtitle }))] }), valueAccessory, _jsx(PsText, { numberOfLines: 1, style: { maxWidth: PsLayoutConfig.rowValueMaxWidth }, tone: 'muted', children: PsSelectService.selectedLabel(options, value) }), _jsx(PsIcon, { color: theme.muted, iconKey: PsIconKey.Sort, size: PsMobileControlConfig.iconSizeInline })] })) }));
31
+ }), testID: 'ps-list-select-row', children: [_jsxs(View, { style: { flex: 1, gap: PsLayoutConfig.rowLineGap }, children: [_jsx(PsText, { numberOfLines: 1, weight: 'semibold', children: title }), subtitle === undefined ? null : (_jsx(PsText, { tone: 'muted', variant: 'small', children: subtitle }))] }), valueAccessory, _jsx(PsText, { numberOfLines: 1, style: { maxWidth: PsLayoutConfig.rowValueMaxWidth }, tone: 'muted', children: PsSelectService.selectedLabel(options, value) }), _jsx(PsIcon, { color: theme.muted, iconKey: PsIconKey.Sort, size: PsMobileControlConfig.iconSizeInline })] })) }));
28
32
  };
@@ -1,7 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { View } from 'react-native';
3
+ import { usePsMaterialScheme } from '../../hooks/use-ps-material-scheme';
3
4
  import { usePsMobileTheme } from '../../hooks/use-ps-mobile-theme';
5
+ import { PsMenuPopupStyleService } from '../../services/ps-menu-popup-style-service';
4
6
  export const PsMenuPopupDivider = () => {
5
- const theme = usePsMobileTheme();
6
- return _jsx(View, { style: { backgroundColor: theme.line, height: 1 } });
7
+ const divider = PsMenuPopupStyleService.divider(usePsMobileTheme(), usePsMaterialScheme(), PsMenuPopupStyleService.isMaterial());
8
+ return (_jsx(View, { style: {
9
+ backgroundColor: divider.backgroundColor,
10
+ height: 1,
11
+ marginVertical: divider.marginVertical,
12
+ } }));
7
13
  };
@@ -3,20 +3,32 @@ import { PsIconKey } from '@pallasoft/ps-icons';
3
3
  import { Pressable, View } from 'react-native';
4
4
  import { PsMenuPopupConfig } from '../../constants/ps-menu-popup-config';
5
5
  import { PsMobileControlConfig } from '../../constants/ps-mobile-control-config';
6
+ import { PsRippleKind } from '../../constants/ps-ripple-kind';
7
+ import { usePsMaterialScheme } from '../../hooks/use-ps-material-scheme';
6
8
  import { usePsMobileTheme } from '../../hooks/use-ps-mobile-theme';
9
+ import { PsMenuPopupStyleService } from '../../services/ps-menu-popup-style-service';
10
+ import { PsRippleService } from '../../services/ps-ripple-service';
7
11
  import { PsStateService } from '../../services/ps-state-service';
8
12
  import { PsIcon } from '../ps-icon/ps-icon';
9
13
  import { PsText } from '../ps-text/ps-text';
10
14
  export const PsMenuPopupRow = ({ iconKey, isChecked = false, isDanger = false, isDisabled = false, isWarning = false, label, onPress, subtitle, }) => {
11
15
  const theme = usePsMobileTheme();
16
+ const scheme = usePsMaterialScheme();
17
+ const isMaterial = PsMenuPopupStyleService.isMaterial();
12
18
  const tint = isDanger ? theme.danger : theme.ink;
13
- return (_jsxs(Pressable, { accessibilityRole: 'menuitem', accessibilityState: { disabled: isDisabled, selected: isChecked }, disabled: isDisabled, onPress: onPress, style: ({ pressed }) => ({
14
- alignItems: 'center',
15
- backgroundColor: pressed ? theme.primarySoft : 'transparent',
16
- flexDirection: 'row',
17
- gap: PsMobileControlConfig.rowSpacing,
18
- minHeight: PsMenuPopupConfig.rowHeight,
19
- opacity: isDisabled ? PsStateService.disabledOpacity() : 1,
20
- paddingHorizontal: PsMenuPopupConfig.rowPaddingHorizontal,
21
- }), children: [iconKey === undefined ? null : _jsx(PsIcon, { color: tint, iconKey: iconKey }), _jsxs(View, { style: { flex: 1 }, children: [_jsx(PsText, { numberOfLines: 1, tone: isDanger ? 'danger' : 'ink', weight: isChecked ? 'semibold' : 'regular', children: label }), subtitle === undefined ? null : (_jsx(PsText, { numberOfLines: 1, tone: isWarning ? 'warning' : 'muted', variant: 'small', children: subtitle }))] }), isChecked ? _jsx(PsIcon, { color: theme.primaryText, iconKey: PsIconKey.Check }) : null] }));
19
+ return (_jsxs(Pressable, { accessibilityRole: 'menuitem', accessibilityState: { disabled: isDisabled, selected: isChecked }, android_ripple: PsRippleService.resolve(theme, isDanger ? PsRippleKind.Danger : PsRippleKind.Surface), disabled: isDisabled, onPress: onPress, style: ({ pressed }) => {
20
+ const row = PsMenuPopupStyleService.row(theme, scheme, isMaterial, isChecked, pressed);
21
+ return {
22
+ alignItems: 'center',
23
+ backgroundColor: row.backgroundColor,
24
+ borderRadius: row.borderRadius,
25
+ flexDirection: 'row',
26
+ gap: PsMobileControlConfig.rowSpacing,
27
+ marginHorizontal: row.marginHorizontal,
28
+ minHeight: PsMenuPopupConfig.rowHeight,
29
+ opacity: isDisabled ? PsStateService.disabledOpacity() : 1,
30
+ overflow: 'hidden',
31
+ paddingHorizontal: row.paddingHorizontal,
32
+ };
33
+ }, children: [iconKey === undefined ? null : _jsx(PsIcon, { color: tint, iconKey: iconKey }), _jsxs(View, { style: { flex: 1 }, children: [_jsx(PsText, { numberOfLines: 1, tone: isDanger ? 'danger' : 'ink', weight: PsMenuPopupStyleService.labelWeight(isMaterial, isChecked), children: label }), subtitle === undefined ? null : (_jsx(PsText, { numberOfLines: 1, tone: isWarning ? 'warning' : 'muted', variant: 'small', children: subtitle }))] }), isChecked ? _jsx(PsIcon, { color: theme.primaryText, iconKey: PsIconKey.Check }) : null] }));
22
34
  };
@@ -1,35 +1,46 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
- import { Modal, Pressable, ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native';
3
+ import { Modal, Pressable, ScrollView, StyleSheet, useWindowDimensions } from 'react-native';
4
+ import Animated from 'react-native-reanimated';
4
5
  import { PsMenuPopupConfig } from '../../constants/ps-menu-popup-config';
6
+ import { usePsMaterialScheme } from '../../hooks/use-ps-material-scheme';
7
+ import { usePsMenuPopupReveal } from '../../hooks/use-ps-menu-popup-reveal';
5
8
  import { usePsMobileTheme } from '../../hooks/use-ps-mobile-theme';
6
9
  import { PsMenuPopupService } from '../../services/ps-menu-popup-service';
10
+ import { PsMenuPopupStyleService } from '../../services/ps-menu-popup-style-service';
7
11
  export const PsMenuPopup = ({ anchor, children, isFullWidth = false, onClose, }) => {
8
12
  const theme = usePsMobileTheme();
13
+ const scheme = usePsMaterialScheme();
9
14
  const window = useWindowDimensions();
15
+ const isMaterial = PsMenuPopupStyleService.isMaterial();
10
16
  const [size, setSize] = useState(undefined);
11
17
  const layout = size === undefined
12
18
  ? undefined
13
19
  : PsMenuPopupService.layout(anchor, window, size.height, size.width, isFullWidth);
14
- return (_jsxs(Modal, { animationType: 'fade', navigationBarTranslucent: true, onRequestClose: onClose, statusBarTranslucent: true, transparent: true, visible: true, children: [_jsx(Pressable, { accessibilityElementsHidden: true, onPress: onClose, style: StyleSheet.absoluteFill, testID: 'ps-menu-popup-backdrop' }), _jsx(View, { onLayout: (event) => {
20
+ const container = PsMenuPopupStyleService.container(theme, scheme, isMaterial);
21
+ const reveal = usePsMenuPopupReveal(layout !== undefined, layout === undefined || layout.isBelow, isMaterial);
22
+ return (_jsxs(Modal, { animationType: isMaterial ? 'none' : 'fade', navigationBarTranslucent: true, onRequestClose: onClose, statusBarTranslucent: true, transparent: true, visible: true, children: [_jsx(Pressable, { accessibilityElementsHidden: true, onPress: onClose, style: StyleSheet.absoluteFill, testID: 'ps-menu-popup-backdrop' }), _jsx(Animated.View, { onLayout: (event) => {
15
23
  if (size === undefined)
16
24
  setSize({
17
25
  height: event.nativeEvent.layout.height,
18
26
  width: event.nativeEvent.layout.width,
19
27
  });
20
- }, pointerEvents: layout === undefined ? 'none' : 'auto', style: {
21
- backgroundColor: theme.surface,
22
- borderColor: theme.line,
23
- borderRadius: PsMenuPopupConfig.radius,
24
- borderWidth: 1,
25
- left: layout === undefined ? 0 : layout.left,
26
- maxHeight: layout === undefined ? undefined : layout.maxHeight,
27
- maxWidth: PsMenuPopupConfig.maxWidth,
28
- minWidth: isFullWidth ? undefined : PsMenuPopupConfig.minWidth,
29
- opacity: layout === undefined ? 0 : 1,
30
- overflow: 'hidden',
31
- position: 'absolute',
32
- top: layout === undefined ? 0 : layout.top,
33
- width: layout === undefined ? undefined : layout.width,
34
- }, testID: 'ps-menu-popup', children: _jsx(ScrollView, { bounces: false, contentContainerStyle: { paddingVertical: PsMenuPopupConfig.paddingVertical }, children: children }) })] }));
28
+ }, pointerEvents: layout === undefined ? 'none' : 'auto', style: [
29
+ {
30
+ backgroundColor: container.backgroundColor,
31
+ borderColor: container.borderColor,
32
+ borderRadius: container.borderRadius,
33
+ borderWidth: container.borderWidth,
34
+ elevation: container.elevation,
35
+ left: layout === undefined ? 0 : layout.left,
36
+ maxHeight: layout === undefined ? undefined : layout.maxHeight,
37
+ maxWidth: PsMenuPopupConfig.maxWidth,
38
+ minWidth: isFullWidth ? undefined : PsMenuPopupConfig.minWidth,
39
+ overflow: 'hidden',
40
+ position: 'absolute',
41
+ top: layout === undefined ? 0 : layout.top,
42
+ width: layout === undefined ? undefined : layout.width,
43
+ },
44
+ reveal,
45
+ ], testID: 'ps-menu-popup', children: _jsx(ScrollView, { bounces: false, contentContainerStyle: { paddingVertical: container.paddingVertical }, children: children }) })] }));
35
46
  };
@@ -0,0 +1,12 @@
1
+ export declare const PsMaterialMenuConfig: {
2
+ readonly dividerInset: 8;
3
+ readonly elevation: 3;
4
+ readonly enterEasing: readonly [0.05, 0.7, 0.1, 1];
5
+ readonly enterDurationMs: 150;
6
+ readonly enterScale: 0.8;
7
+ readonly itemInset: 4;
8
+ readonly itemRadius: 12;
9
+ readonly paddingVertical: 4;
10
+ readonly radius: 16;
11
+ readonly rowPaddingHorizontal: 12;
12
+ };
@@ -0,0 +1,12 @@
1
+ export const PsMaterialMenuConfig = {
2
+ dividerInset: 8,
3
+ elevation: 3,
4
+ enterEasing: [0.05, 0.7, 0.1, 1],
5
+ enterDurationMs: 150,
6
+ enterScale: 0.8,
7
+ itemInset: 4,
8
+ itemRadius: 12,
9
+ paddingVertical: 4,
10
+ radius: 16,
11
+ rowPaddingHorizontal: 12,
12
+ };
@@ -0,0 +1,4 @@
1
+ export declare enum PsScanFormat {
2
+ Pdf = "pdf",
3
+ Png = "png"
4
+ }
@@ -0,0 +1,5 @@
1
+ export var PsScanFormat;
2
+ (function (PsScanFormat) {
3
+ PsScanFormat["Pdf"] = "pdf";
4
+ PsScanFormat["Png"] = "png";
5
+ })(PsScanFormat || (PsScanFormat = {}));
@@ -0,0 +1,17 @@
1
+ import { PsScanFormat } from './ps-scan-format';
2
+ export declare const PsScanOutputConfig: {
3
+ readonly extensions: Record<PsScanFormat, string>;
4
+ readonly jpegMarkerPrefix: 255;
5
+ readonly jpegSizeMarkers: readonly [192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207];
6
+ readonly jpegStartMarker: 216;
7
+ readonly jpegSegmentHeaderLength: 2;
8
+ readonly jpegSizeOffset: 3;
9
+ readonly pdfBinaryComment: readonly [37, 226, 227, 207, 211, 10];
10
+ readonly pdfLongEdgePoints: 842;
11
+ readonly pdfObjectsPerPage: 3;
12
+ readonly pdfPageObjectBase: 3;
13
+ readonly pdfType: "application/pdf";
14
+ readonly pngType: "image/png";
15
+ readonly promptDelayMs: 400;
16
+ readonly types: Record<PsScanFormat, string>;
17
+ };
@@ -0,0 +1,23 @@
1
+ import { PsScanFormat } from './ps-scan-format';
2
+ export const PsScanOutputConfig = {
3
+ extensions: {
4
+ [PsScanFormat.Pdf]: 'pdf',
5
+ [PsScanFormat.Png]: 'png',
6
+ },
7
+ jpegMarkerPrefix: 0xff,
8
+ jpegSizeMarkers: [0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf],
9
+ jpegStartMarker: 0xd8,
10
+ jpegSegmentHeaderLength: 2,
11
+ jpegSizeOffset: 3,
12
+ pdfBinaryComment: [0x25, 0xe2, 0xe3, 0xcf, 0xd3, 0x0a],
13
+ pdfLongEdgePoints: 842,
14
+ pdfObjectsPerPage: 3,
15
+ pdfPageObjectBase: 3,
16
+ pdfType: 'application/pdf',
17
+ pngType: 'image/png',
18
+ promptDelayMs: 400,
19
+ types: {
20
+ [PsScanFormat.Pdf]: 'application/pdf',
21
+ [PsScanFormat.Png]: 'image/png',
22
+ },
23
+ };
@@ -3,12 +3,13 @@ import { PsDocumentScannerService } from '../services/ps-document-scanner-servic
3
3
  export const usePsDocumentScanner = (defaults = {}) => {
4
4
  const [isScanning, setIsScanning] = useState(false);
5
5
  const isAvailable = useMemo(() => PsDocumentScannerService.isAvailable(), []);
6
- const { fileName, jpegQuality, maxPages } = defaults;
6
+ const { fileName, formatLabels, jpegQuality, maxPages } = defaults;
7
7
  const scan = useCallback(async (options = {}) => {
8
8
  setIsScanning(true);
9
9
  try {
10
10
  return await PsDocumentScannerService.scan({
11
11
  fileName: options.fileName === undefined ? fileName : options.fileName,
12
+ formatLabels: options.formatLabels === undefined ? formatLabels : options.formatLabels,
12
13
  jpegQuality: options.jpegQuality === undefined ? jpegQuality : options.jpegQuality,
13
14
  maxPages: options.maxPages === undefined ? maxPages : options.maxPages,
14
15
  });
@@ -16,6 +17,6 @@ export const usePsDocumentScanner = (defaults = {}) => {
16
17
  finally {
17
18
  setIsScanning(false);
18
19
  }
19
- }, [fileName, jpegQuality, maxPages]);
20
+ }, [fileName, formatLabels, jpegQuality, maxPages]);
20
21
  return { isAvailable, isScanning, scan };
21
22
  };
@@ -0,0 +1,3 @@
1
+ import type { ViewStyle } from 'react-native';
2
+ import { type AnimatedStyle } from 'react-native-reanimated';
3
+ export declare const usePsMenuPopupReveal: (isPlaced: boolean, isBelow: boolean, isMaterial: boolean) => AnimatedStyle<ViewStyle>;
@@ -0,0 +1,28 @@
1
+ import { useEffect } from 'react';
2
+ import { Easing, interpolate, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated';
3
+ import { PsMaterialMenuConfig } from '../constants/ps-material-menu-config';
4
+ import { usePsMobileTheme } from './use-ps-mobile-theme';
5
+ export const usePsMenuPopupReveal = (isPlaced, isBelow, isMaterial) => {
6
+ const { isReduceMotion } = usePsMobileTheme();
7
+ const isInstant = isReduceMotion || !isMaterial;
8
+ const progress = useSharedValue(isInstant ? 1 : 0);
9
+ useEffect(() => {
10
+ if (!isPlaced)
11
+ return;
12
+ if (isInstant) {
13
+ progress.set(1);
14
+ return;
15
+ }
16
+ progress.set(withTiming(1, {
17
+ duration: PsMaterialMenuConfig.enterDurationMs,
18
+ easing: Easing.bezier(...PsMaterialMenuConfig.enterEasing),
19
+ }));
20
+ }, [isInstant, isPlaced, progress]);
21
+ return useAnimatedStyle(() => ({
22
+ opacity: isPlaced ? progress.get() : 0,
23
+ transform: [
24
+ { scaleY: interpolate(progress.get(), [0, 1], [PsMaterialMenuConfig.enterScale, 1]) },
25
+ ],
26
+ transformOrigin: isBelow ? 'top' : 'bottom',
27
+ }));
28
+ };
package/dist/index.d.ts CHANGED
@@ -49,6 +49,8 @@ export * from './constants/ps-capture-source';
49
49
  export * from './constants/ps-document-kind';
50
50
  export * from './constants/ps-document-load-status';
51
51
  export * from './constants/ps-document-scanner-config';
52
+ export * from './constants/ps-scan-format';
53
+ export * from './interfaces/ps-scan-format-labels';
52
54
  export * from './constants/ps-chart-delta';
53
55
  export * from './constants/ps-cartesian-series-shape';
54
56
  export * from './constants/ps-chart-kind';
package/dist/index.js CHANGED
@@ -49,6 +49,8 @@ export * from './constants/ps-capture-source';
49
49
  export * from './constants/ps-document-kind';
50
50
  export * from './constants/ps-document-load-status';
51
51
  export * from './constants/ps-document-scanner-config';
52
+ export * from './constants/ps-scan-format';
53
+ export * from './interfaces/ps-scan-format-labels';
52
54
  export * from './constants/ps-chart-delta';
53
55
  export * from './constants/ps-cartesian-series-shape';
54
56
  export * from './constants/ps-chart-kind';
@@ -1,6 +1,8 @@
1
+ import type { PsScanFormatLabels } from './ps-scan-format-labels';
1
2
  export interface PsCaptureOptions {
2
3
  documentTypes?: string[];
3
4
  imageQuality?: number;
4
5
  scanFileName?: string;
6
+ scanFormatLabels?: PsScanFormatLabels;
5
7
  scanQuality?: number;
6
8
  }
@@ -1,5 +1,7 @@
1
+ import type { PsScanFormatLabels } from './ps-scan-format-labels';
1
2
  export interface PsDocumentScannerOptions {
2
3
  fileName?: string;
4
+ formatLabels?: PsScanFormatLabels;
3
5
  jpegQuality?: number;
4
6
  maxPages?: number;
5
7
  }
@@ -0,0 +1,19 @@
1
+ export interface PsImageManipulatorResult {
2
+ uri: string;
3
+ }
4
+ export interface PsImageManipulatorContext {
5
+ renderAsync: () => Promise<PsImageManipulatorRendered>;
6
+ }
7
+ export interface PsImageManipulatorRendered {
8
+ saveAsync: (options: {
9
+ format: string;
10
+ }) => Promise<PsImageManipulatorResult>;
11
+ }
12
+ export interface PsImageManipulatorModule {
13
+ ImageManipulator: {
14
+ manipulate: (uri: string) => PsImageManipulatorContext;
15
+ };
16
+ SaveFormat: {
17
+ PNG: string;
18
+ };
19
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ export interface PsMenuPopupContainerStyle {
2
+ backgroundColor: string;
3
+ borderColor: string;
4
+ borderRadius: number;
5
+ borderWidth: number;
6
+ elevation: number;
7
+ paddingVertical: number;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export interface PsMenuPopupDividerStyle {
2
+ backgroundColor: string;
3
+ marginVertical: number;
4
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,5 @@
1
1
  export interface PsMenuPopupLayout {
2
+ isBelow: boolean;
2
3
  left: number;
3
4
  maxHeight: number;
4
5
  top: number;
@@ -0,0 +1,6 @@
1
+ export interface PsMenuPopupRowStyle {
2
+ backgroundColor: string;
3
+ borderRadius: number;
4
+ marginHorizontal: number;
5
+ paddingHorizontal: number;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export interface PsMenuPopupSize {
2
+ height: number;
3
+ width: number;
4
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export interface PsPageSize {
2
+ height: number;
3
+ width: number;
4
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ export interface PsPdfPage {
2
+ bytes: Uint8Array;
3
+ height: number;
4
+ width: number;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ export interface PsScanFormatLabels {
2
+ cancel: string;
3
+ images: string;
4
+ message: string;
5
+ pdf: string;
6
+ title: string;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -23,6 +23,7 @@ export class PsCapturePickerService {
23
23
  }
24
24
  return PsDocumentScannerService.scan({
25
25
  fileName: options.scanFileName === undefined ? PsCaptureConfig.scanFileName : options.scanFileName,
26
+ formatLabels: options.scanFormatLabels,
26
27
  jpegQuality: options.scanQuality === undefined ? PsCaptureConfig.scanQuality : options.scanQuality,
27
28
  });
28
29
  }
@@ -3,7 +3,9 @@ import type { PsScannedFile } from '../interfaces/ps-scanned-file';
3
3
  export declare class PsDocumentScannerService {
4
4
  static isAvailable(): boolean;
5
5
  static scan(options: PsDocumentScannerOptions): Promise<PsScannedFile[]>;
6
+ static format(files: PsScannedFile[], options: PsDocumentScannerOptions): Promise<PsScannedFile[]>;
6
7
  static toFiles(uris: string[], fileName?: string): PsScannedFile[];
8
+ static base(fileName?: string): string;
7
9
  static quality(jpegQuality?: number): number;
8
10
  static pageLimit(maxPages?: number): number | null;
9
11
  private static load;
@@ -1,4 +1,7 @@
1
1
  import { PsDocumentScannerConfig } from '../constants/ps-document-scanner-config';
2
+ import { PsScanOutputConfig } from '../constants/ps-scan-output-config';
3
+ import { PsScanFormatPromptService } from './ps-scan-format-prompt-service';
4
+ import { PsScanOutputService } from './ps-scan-output-service';
2
5
  export class PsDocumentScannerService {
3
6
  static isAvailable() {
4
7
  return PsDocumentScannerService.load() !== null;
@@ -16,18 +19,32 @@ export class PsDocumentScannerService {
16
19
  return [];
17
20
  const uris = response.scannedImages === undefined ? [] : response.scannedImages;
18
21
  const limited = maxPages === null ? uris : uris.slice(0, maxPages);
19
- return PsDocumentScannerService.toFiles(limited, options.fileName);
22
+ const files = PsDocumentScannerService.toFiles(limited, options.fileName);
23
+ return PsDocumentScannerService.format(files, options);
24
+ }
25
+ static async format(files, options) {
26
+ const labels = options.formatLabels;
27
+ if (labels === undefined || files.length === 0 || !PsScanOutputService.isAvailable())
28
+ return files;
29
+ await new Promise((resolve) => setTimeout(resolve, PsScanOutputConfig.promptDelayMs));
30
+ const format = await PsScanFormatPromptService.ask(labels, PsScanFormatPromptService.preferred(files.length));
31
+ return format === null
32
+ ? []
33
+ : PsScanOutputService.produce(files, format, PsDocumentScannerService.base(options.fileName));
20
34
  }
21
35
  static toFiles(uris, fileName) {
22
- const base = fileName === undefined || fileName === ''
23
- ? PsDocumentScannerConfig.defaultFileName
24
- : fileName;
36
+ const base = PsDocumentScannerService.base(fileName);
25
37
  return uris.map((uri, index) => ({
26
38
  name: `${base}-${index + 1}.${PsDocumentScannerConfig.fileExtension}`,
27
39
  type: PsDocumentScannerConfig.fileType,
28
40
  uri,
29
41
  }));
30
42
  }
43
+ static base(fileName) {
44
+ return fileName === undefined || fileName === ''
45
+ ? PsDocumentScannerConfig.defaultFileName
46
+ : fileName;
47
+ }
31
48
  static quality(jpegQuality) {
32
49
  if (jpegQuality === undefined)
33
50
  return PsDocumentScannerConfig.defaultJpegQuality;
@@ -0,0 +1,4 @@
1
+ import type { PsPageSize } from '../interfaces/ps-page-size';
2
+ export declare class PsJpegSizeService {
3
+ static size(bytes: Uint8Array): PsPageSize | null;
4
+ }
@@ -0,0 +1,32 @@
1
+ import { PsScanOutputConfig } from '../constants/ps-scan-output-config';
2
+ export class PsJpegSizeService {
3
+ static size(bytes) {
4
+ const prefix = PsScanOutputConfig.jpegMarkerPrefix;
5
+ if (bytes.length < 4 || bytes[0] !== prefix || bytes[1] !== PsScanOutputConfig.jpegStartMarker)
6
+ return null;
7
+ let cursor = 2;
8
+ while (cursor + 4 <= bytes.length) {
9
+ if (bytes[cursor] !== prefix) {
10
+ cursor += 1;
11
+ continue;
12
+ }
13
+ const marker = bytes[cursor + 1];
14
+ if (marker === prefix) {
15
+ cursor += 1;
16
+ continue;
17
+ }
18
+ const length = (bytes[cursor + 2] << 8) | bytes[cursor + 3];
19
+ if (PsScanOutputConfig.jpegSizeMarkers.some((size) => size === marker)) {
20
+ const at = cursor + 2 + PsScanOutputConfig.jpegSizeOffset;
21
+ if (at + 4 > bytes.length)
22
+ return null;
23
+ return {
24
+ height: (bytes[at] << 8) | bytes[at + 1],
25
+ width: (bytes[at + 2] << 8) | bytes[at + 3],
26
+ };
27
+ }
28
+ cursor += PsScanOutputConfig.jpegSegmentHeaderLength + length;
29
+ }
30
+ return null;
31
+ }
32
+ }
@@ -14,6 +14,7 @@ export class PsMenuPopupService {
14
14
  const alignedRight = anchor.x + menuWidth > window.width - margin;
15
15
  const left = alignedRight ? anchor.x + anchor.width - menuWidth : anchor.x;
16
16
  return {
17
+ isBelow,
17
18
  left: Math.max(margin, Math.min(left, window.width - margin - menuWidth)),
18
19
  maxHeight: Math.max(room, 0),
19
20
  top,
@@ -0,0 +1,13 @@
1
+ import type { PsFontWeight } from '../interfaces/ps-font-weight';
2
+ import type { PsMaterialScheme } from '../interfaces/ps-material-scheme';
3
+ import type { PsMenuPopupContainerStyle } from '../interfaces/ps-menu-popup-container-style';
4
+ import type { PsMenuPopupDividerStyle } from '../interfaces/ps-menu-popup-divider-style';
5
+ import type { PsMenuPopupRowStyle } from '../interfaces/ps-menu-popup-row-style';
6
+ import type { PsMobileTheme } from '../interfaces/ps-mobile-theme';
7
+ export declare class PsMenuPopupStyleService {
8
+ static isMaterial(os?: string): boolean;
9
+ static container(theme: PsMobileTheme, scheme: PsMaterialScheme, isMaterial: boolean): PsMenuPopupContainerStyle;
10
+ static row(theme: PsMobileTheme, scheme: PsMaterialScheme, isMaterial: boolean, isChecked: boolean, isPressed: boolean): PsMenuPopupRowStyle;
11
+ static divider(theme: PsMobileTheme, scheme: PsMaterialScheme, isMaterial: boolean): PsMenuPopupDividerStyle;
12
+ static labelWeight(isMaterial: boolean, isChecked: boolean): PsFontWeight;
13
+ }
@@ -0,0 +1,55 @@
1
+ import { Platform } from 'react-native';
2
+ import { PsMaterialMenuConfig } from '../constants/ps-material-menu-config';
3
+ import { PsMenuPopupConfig } from '../constants/ps-menu-popup-config';
4
+ export class PsMenuPopupStyleService {
5
+ static isMaterial(os = Platform.OS) {
6
+ return os === 'android';
7
+ }
8
+ static container(theme, scheme, isMaterial) {
9
+ if (isMaterial)
10
+ return {
11
+ backgroundColor: scheme.surfaceContainer,
12
+ borderColor: scheme.surfaceContainer,
13
+ borderRadius: PsMaterialMenuConfig.radius,
14
+ borderWidth: 0,
15
+ elevation: PsMaterialMenuConfig.elevation,
16
+ paddingVertical: PsMaterialMenuConfig.paddingVertical,
17
+ };
18
+ return {
19
+ backgroundColor: theme.surface,
20
+ borderColor: theme.line,
21
+ borderRadius: PsMenuPopupConfig.radius,
22
+ borderWidth: 1,
23
+ elevation: 0,
24
+ paddingVertical: PsMenuPopupConfig.paddingVertical,
25
+ };
26
+ }
27
+ static row(theme, scheme, isMaterial, isChecked, isPressed) {
28
+ if (isMaterial)
29
+ return {
30
+ backgroundColor: isChecked ? scheme.secondaryContainer : 'transparent',
31
+ borderRadius: PsMaterialMenuConfig.itemRadius,
32
+ marginHorizontal: PsMaterialMenuConfig.itemInset,
33
+ paddingHorizontal: PsMaterialMenuConfig.rowPaddingHorizontal,
34
+ };
35
+ return {
36
+ backgroundColor: isPressed ? theme.primarySoft : 'transparent',
37
+ borderRadius: 0,
38
+ marginHorizontal: 0,
39
+ paddingHorizontal: PsMenuPopupConfig.rowPaddingHorizontal,
40
+ };
41
+ }
42
+ static divider(theme, scheme, isMaterial) {
43
+ if (isMaterial)
44
+ return {
45
+ backgroundColor: scheme.outlineVariant,
46
+ marginVertical: PsMaterialMenuConfig.dividerInset,
47
+ };
48
+ return { backgroundColor: theme.line, marginVertical: 0 };
49
+ }
50
+ static labelWeight(isMaterial, isChecked) {
51
+ if (isMaterial)
52
+ return 'medium';
53
+ return isChecked ? 'semibold' : 'regular';
54
+ }
55
+ }
@@ -0,0 +1,6 @@
1
+ import { PsScanFormat } from '../constants/ps-scan-format';
2
+ import type { PsScanFormatLabels } from '../interfaces/ps-scan-format-labels';
3
+ export declare class PsScanFormatPromptService {
4
+ static ask(labels: PsScanFormatLabels, preferred: PsScanFormat): Promise<PsScanFormat | null>;
5
+ static preferred(pageCount: number): PsScanFormat;
6
+ }
@@ -0,0 +1,21 @@
1
+ import { Alert } from 'react-native';
2
+ import { PsScanFormat } from '../constants/ps-scan-format';
3
+ export class PsScanFormatPromptService {
4
+ static ask(labels, preferred) {
5
+ return new Promise((resolve) => {
6
+ const choice = (format, text) => ({
7
+ isPreferred: format === preferred,
8
+ onPress: () => resolve(format),
9
+ text,
10
+ });
11
+ Alert.alert(labels.title, labels.message, [
12
+ choice(PsScanFormat.Pdf, labels.pdf),
13
+ choice(PsScanFormat.Png, labels.images),
14
+ { onPress: () => resolve(null), style: 'cancel', text: labels.cancel },
15
+ ], { cancelable: true, onDismiss: () => resolve(null) });
16
+ });
17
+ }
18
+ static preferred(pageCount) {
19
+ return pageCount > 1 ? PsScanFormat.Pdf : PsScanFormat.Png;
20
+ }
21
+ }
@@ -0,0 +1,5 @@
1
+ export declare class PsScanImageConverterService {
2
+ static isAvailable(): boolean;
3
+ static toPng(uri: string): Promise<string>;
4
+ private static load;
5
+ }
@@ -0,0 +1,22 @@
1
+ export class PsScanImageConverterService {
2
+ static isAvailable() {
3
+ return PsScanImageConverterService.load() !== null;
4
+ }
5
+ static async toPng(uri) {
6
+ const loaded = PsScanImageConverterService.load();
7
+ if (loaded === null)
8
+ return uri;
9
+ const rendered = await loaded.ImageManipulator.manipulate(uri).renderAsync();
10
+ const saved = await rendered.saveAsync({ format: loaded.SaveFormat.PNG });
11
+ return saved.uri;
12
+ }
13
+ static load() {
14
+ try {
15
+ const loaded = require('expo-image-manipulator');
16
+ return typeof loaded.ImageManipulator.manipulate === 'function' ? loaded : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,10 @@
1
+ import { PsScanFormat } from '../constants/ps-scan-format';
2
+ import type { PsScannedFile } from '../interfaces/ps-scanned-file';
3
+ export declare class PsScanOutputService {
4
+ static isAvailable(): boolean;
5
+ static produce(pages: PsScannedFile[], format: PsScanFormat, fileName: string): Promise<PsScannedFile[]>;
6
+ static pageName(base: string, index: number): string;
7
+ private static toPdf;
8
+ private static toPng;
9
+ private static loadFileSystem;
10
+ }
@@ -0,0 +1,55 @@
1
+ import { PsScanFormat } from '../constants/ps-scan-format';
2
+ import { PsScanOutputConfig } from '../constants/ps-scan-output-config';
3
+ import { PsJpegSizeService } from './ps-jpeg-size-service';
4
+ import { PsScanImageConverterService } from './ps-scan-image-converter-service';
5
+ import { PsScanPdfService } from './ps-scan-pdf-service';
6
+ export class PsScanOutputService {
7
+ static isAvailable() {
8
+ return (PsScanOutputService.loadFileSystem() !== null && PsScanImageConverterService.isAvailable());
9
+ }
10
+ static async produce(pages, format, fileName) {
11
+ return format === PsScanFormat.Pdf
12
+ ? [await PsScanOutputService.toPdf(pages, fileName)]
13
+ : PsScanOutputService.toPng(pages, fileName);
14
+ }
15
+ static pageName(base, index) {
16
+ return `${base}-${index + 1}.${PsScanOutputConfig.extensions[PsScanFormat.Png]}`;
17
+ }
18
+ static async toPdf(pages, fileName) {
19
+ const system = PsScanOutputService.loadFileSystem();
20
+ if (system === null)
21
+ throw new Error('expo-file-system is not available');
22
+ const loaded = [];
23
+ for (const page of pages) {
24
+ const bytes = new Uint8Array(await new system.File(page.uri).arrayBuffer());
25
+ const size = PsJpegSizeService.size(bytes);
26
+ if (size === null)
27
+ throw new Error('unreadable scan page');
28
+ loaded.push({ bytes, height: size.height, width: size.width });
29
+ }
30
+ const name = `${fileName}.${PsScanOutputConfig.extensions[PsScanFormat.Pdf]}`;
31
+ const file = new system.File(system.Paths.cache, `${Date.now()}-${name}`);
32
+ file.create({ overwrite: true });
33
+ await file.write(PsScanPdfService.fromJpegs(loaded));
34
+ return { name, type: PsScanOutputConfig.pdfType, uri: file.uri };
35
+ }
36
+ static async toPng(pages, fileName) {
37
+ const files = [];
38
+ for (const [index, page] of pages.entries())
39
+ files.push({
40
+ name: PsScanOutputService.pageName(fileName, index),
41
+ type: PsScanOutputConfig.pngType,
42
+ uri: await PsScanImageConverterService.toPng(page.uri),
43
+ });
44
+ return files;
45
+ }
46
+ static loadFileSystem() {
47
+ try {
48
+ const loaded = require('expo-file-system');
49
+ return typeof loaded.File === 'function' ? loaded : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,4 @@
1
+ import type { PsPdfPage } from '../interfaces/ps-pdf-page';
2
+ export declare class PsScanPdfService {
3
+ static fromJpegs(pages: PsPdfPage[]): Uint8Array;
4
+ }
@@ -0,0 +1,6 @@
1
+ import { Common } from '@pallasoft/ps-utilities';
2
+ export class PsScanPdfService {
3
+ static fromJpegs(pages) {
4
+ return Common.JpegPdfService.fromJpegs(pages);
5
+ }
6
+ }
package/jest-setup.js CHANGED
@@ -11,7 +11,7 @@ jest.mock('react-native-reanimated', () => {
11
11
  isSharedValue,
12
12
  FadeIn: builder,
13
13
  FadeInDown: builder,
14
- Easing: { linear: 'linear' },
14
+ Easing: { bezier: () => 'bezier', linear: 'linear' },
15
15
  cancelAnimation: () => undefined,
16
16
  interpolate: (value, input, output) => output[0],
17
17
  withRepeat: (value) => value,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pallasoft/ps-mobile-components",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "private": false,
5
5
  "description": "Native iOS (SwiftUI) and Android (Jetpack Compose) Ps components for Pallasoft mobile apps",
6
6
  "main": "./dist/index.js",
@@ -36,7 +36,7 @@
36
36
  "dependencies": {
37
37
  "@pallasoft/ps-design-system": "^1.19.0",
38
38
  "@pallasoft/ps-icons": "^1.12.0",
39
- "@pallasoft/ps-utilities": "^2.27.0"
39
+ "@pallasoft/ps-utilities": "^2.28.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@expo/ui": "~58.0.0",
@@ -47,6 +47,7 @@
47
47
  "expo-document-picker": "~58.0.0",
48
48
  "expo-file-system": "~58.0.0",
49
49
  "expo-haptics": "~58.0.7",
50
+ "expo-image-manipulator": "~58.0.0",
50
51
  "expo-image-picker": "~58.0.0",
51
52
  "expo-linear-gradient": "~58.0.3",
52
53
  "expo-local-authentication": "~58.0.0",
@@ -74,6 +75,9 @@
74
75
  "expo-file-system": {
75
76
  "optional": true
76
77
  },
78
+ "expo-image-manipulator": {
79
+ "optional": true
80
+ },
77
81
  "expo-image-picker": {
78
82
  "optional": true
79
83
  },