cabloy 5.1.95 → 5.1.96
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.
- package/.cabloy-version +1 -0
- package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +4 -1
- package/.claude/skills/cabloy-resource-field-update/SKILL.md +6 -3
- package/.github/workflows/vona-test-crud.yml +2 -0
- package/.github/workflows/vona-test-mysql.yml +2 -0
- package/.github/workflows/vona-test-pg.yml +2 -0
- package/.github/workflows/vona-test-sqlite3.yml +2 -0
- package/CHANGELOG.md +31 -0
- package/CLAUDE.md +1 -0
- package/cabloy-docs/ai/playbook-backend-module.md +6 -3
- package/cabloy-docs/fullstack/framework-performance.md +3 -3
- package/cabloy-docs/fullstack/quickstart.md +1 -1
- package/package.json +1 -1
- package/scripts/release.ts +27 -1
- package/vona/docker-compose-original/docker-compose-dockerfile-app +4 -0
- package/vona/docker-compose-original/docker-compose.original.yml +0 -1
- package/vona/env/.env +0 -1
- package/vona/env/.env.prod +0 -1
- package/vona/env/.env.prod.docker +0 -7
- package/vona/package.original.json +3 -3
- package/vona/packages-cli/cabloy-cli/package.json +2 -2
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +2 -2
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +70 -2
- package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +2 -2
- package/vona/packages-utils/module-glob/package.json +2 -2
- package/vona/packages-utils/module-info-pro/package.json +2 -2
- package/vona/packages-utils/utils/package.json +2 -2
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +2 -2
- package/vona/packages-vona/vona-core/src/lib/addon/addon.ts +77 -0
- package/vona/packages-vona/vona-core/src/lib/addon/index.ts +3 -0
- package/vona/packages-vona/vona-core/src/lib/addon/sharp.ts +46 -0
- package/vona/packages-vona/vona-core/src/lib/addon/sqlite3.ts +23 -0
- package/vona/packages-vona/vona-core/src/lib/index.ts +1 -0
- package/vona/packages-vona/vona-core/src/lib/utils/index.ts +0 -1
- package/vona/packages-vona/vona-core/src/lib/utils/util.ts +5 -0
- package/vona/packages-vona/vona-mock/package.json +2 -2
- package/vona/pnpm-lock.yaml +414 -64
- package/vona/src/backend/cli.ts +2 -5
- package/vona/src/backend/config/config/config.ts +0 -4
- package/vona/src/suite/a-demo/modules/demo-basic/src/bean/ssrMenu.admin.ts +1 -1
- package/vona/src/suite/a-training/modules/training-record/src/.metadata/index.ts +26 -0
- package/vona/src/suite/a-training/modules/training-record/src/bean/imageScene.sceneImage.ts +11 -0
- package/vona/src/suite/a-training/modules/training-record/src/bean/meta.version.ts +1 -0
- package/vona/src/suite/a-training/modules/training-record/src/config/locale/en-us.ts +1 -0
- package/vona/src/suite/a-training/modules/training-record/src/config/locale/zh-cn.ts +1 -0
- package/vona/src/suite/a-training/modules/training-record/src/dto/recordSelectResItem.tsx +12 -0
- package/vona/src/suite/a-training/modules/training-record/src/dto/recordView.tsx +12 -0
- package/vona/src/suite/a-training/modules/training-record/src/entity/record.tsx +26 -1
- package/vona/src/suite/a-training/modules/training-record/src/index.ts +1 -0
- package/vona/src/suite/a-training/modules/training-student/src/.metadata/index.ts +27 -1
- package/vona/src/suite/a-training/modules/training-student/src/bean/imageScene.studentImage.ts +10 -0
- package/vona/src/suite/a-training/modules/training-student/src/bean/meta.version.ts +1 -0
- package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +1 -0
- package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +1 -0
- package/vona/src/suite/a-training/modules/training-student/src/dto/detailRecordBase.tsx +16 -2
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentCreate.tsx +1 -1
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectResItem.tsx +12 -0
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentUpdate.tsx +1 -1
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentView.tsx +13 -1
- package/vona/src/suite/a-training/modules/training-student/src/entity/student.tsx +26 -1
- package/vona/src/suite/a-training/modules/training-student/src/index.ts +1 -0
- package/vona/src/suite/a-training/modules/training-student/src/model/student.ts +1 -0
- package/vona/src/suite-vendor/a-auth/modules/a-auth/package.json +1 -1
- package/vona/src/suite-vendor/a-auth/modules/a-auth/src/bean/bean.authProvider.ts +2 -5
- package/vona/src/suite-vendor/a-auth/package.json +1 -1
- package/vona/src/suite-vendor/a-image/modules/a-image/cli/imageProvider/boilerplate/{{sceneName}}.{{beanName}}.ts_ +23 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/cli/imageScene/boilerplate/{{sceneName}}.{{beanName}}.ts_ +5 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/package.json +68 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/.metadata/index.ts +482 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/.metadata/locales.ts +18 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/.metadata/this.ts +2 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.image.ts +443 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.imageProvider.ts +55 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.imageUploadPolicy.ts +174 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/meta.redlock.ts +11 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/meta.version.ts +40 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/serializerTransform.resolveView.ts +44 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/serializerTransform.resolveViews.ts +44 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/config/config.ts +17 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/config/locale/en-us.ts +1 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/config/locale/zh-cn.ts +1 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/controller/image.ts +165 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDeliveryRequest.ts +20 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadRequest.ts +36 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadResponse.ts +38 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageTransformOptions.tsx +42 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadResponse.ts +62 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadTokenRequest.ts +24 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadTokenResponse.ts +16 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadUrlRequest.ts +33 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageView.ts +46 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/entity/image.ts +58 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/entity/imageProvider.ts +24 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/index.ts +4 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/lib/imageProvider.ts +9 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/lib/imageScene.ts +7 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/lib/imageVariant.ts +46 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/lib/index.ts +3 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/model/image.ts +10 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/model/imageProvider.ts +10 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/types/image.ts +208 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageProvider.ts +110 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageScene.ts +52 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/src/types/index.ts +3 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/tsconfig.build.json +11 -0
- package/vona/src/suite-vendor/a-image/modules/a-image/tsconfig.json +7 -0
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +49 -0
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/.metadata/index.ts +89 -0
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/.metadata/this.ts +2 -0
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/bean/imageProvider.cloudflare.ts +155 -0
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/index.ts +1 -0
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/lib/cloudflareImageUrl.ts +112 -0
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +273 -0
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/tsconfig.build.json +11 -0
- package/vona/src/suite-vendor/a-image/modules/image-cloudflare/tsconfig.json +7 -0
- package/vona/src/suite-vendor/a-image/modules/image-native/package.json +63 -0
- package/vona/src/suite-vendor/a-image/modules/image-native/src/.metadata/index.ts +114 -0
- package/vona/src/suite-vendor/a-image/modules/image-native/src/.metadata/this.ts +2 -0
- package/vona/src/suite-vendor/a-image/modules/image-native/src/bean/imageProvider.native.ts +131 -0
- package/vona/src/suite-vendor/a-image/modules/image-native/src/config/config.ts +11 -0
- package/vona/src/suite-vendor/a-image/modules/image-native/src/index.ts +1 -0
- package/vona/src/suite-vendor/a-image/modules/image-native/src/monkey.ts +33 -0
- package/vona/src/suite-vendor/a-image/modules/image-native/src/service/imageNative.ts +291 -0
- package/vona/src/suite-vendor/a-image/modules/image-native/tsconfig.build.json +11 -0
- package/vona/src/suite-vendor/a-image/modules/image-native/tsconfig.json +7 -0
- package/vona/src/suite-vendor/a-image/package.json +14 -0
- package/vona/src/suite-vendor/a-image/tsconfig.base.json +4 -0
- package/vona/src/suite-vendor/a-image/tsconfig.json +16 -0
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/service/jwtClient.ts +5 -0
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/types/jwt.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/schema/makeSchemaLikes.ts +6 -3
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/utils.ts +13 -7
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/database.ts +0 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +9 -1
- package/vona/src/suite-vendor/a-vona/modules/a-upload/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-upload/src/bean/interceptor.upload.ts +39 -7
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/packages-cli/cli/package.json +3 -3
- package/zova/packages-cli/cli-set-front/package.json +9 -9
- package/zova/packages-cli/cli-set-front/src/lib/bean/cli.openapi.generate.ts +20 -10
- package/zova/packages-utils/babel-plugin-zova-bean-module/package.json +2 -2
- package/zova/packages-utils/babel-plugin-zova-bean-use/package.json +2 -2
- package/zova/packages-utils/babel-plugin-zova-behavior/package.json +2 -2
- package/zova/packages-utils/babel-plugin-zova-component/package.json +2 -2
- package/zova/packages-utils/zova-jsx/package.json +3 -3
- package/zova/packages-utils/zova-vite/package.json +7 -7
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +3 -3
- package/zova/pnpm-lock.yaml +77 -25
- package/zova/src/suite/a-home/modules/home-api/src/api/captcha.ts +3 -1
- package/zova/src/suite/a-home/modules/home-api/src/api/home.ts +3 -1
- package/zova/src/suite/a-home/modules/home-api/src/api/homeBaseMenu.ts +3 -1
- package/zova/src/suite/a-home/modules/home-api/src/api/homeBasePermission.ts +3 -1
- package/zova/src/suite/a-home/modules/home-api/src/api/homeUserPassport.ts +22 -20
- package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +163 -53
- package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +2060 -675
- package/zova/src/suite/a-home/modules/home-api/src/api/testSsrToolOne.ts +3 -1
- package/zova/src/suite/a-home/modules/home-api/src/apiSchema/captcha.ts +3 -1
- package/zova/src/suite/a-home/modules/home-api/src/apiSchema/home.ts +3 -1
- package/zova/src/suite/a-home/modules/home-api/src/apiSchema/homeBaseMenu.ts +3 -1
- package/zova/src/suite/a-home/modules/home-api/src/apiSchema/homeBasePermission.ts +3 -1
- package/zova/src/suite/a-home/modules/home-api/src/apiSchema/homeUserPassport.ts +13 -11
- package/zova/src/suite/a-home/modules/home-api/src/apiSchema/testSsrToolOne.ts +3 -1
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +54 -16
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +468 -8
- package/zova/src/suite/a-training/modules/training-student/src/apiSchema/trainingStudent.ts +3 -1
- package/zova/src/suite/cabloy-basic/modules/basic-details/src/bean/tableCell.actionUpdate.tsx +1 -1
- package/zova/src/suite/cabloy-basic/modules/basic-details/src/component/actionCreate/controller.tsx +1 -1
- package/zova/src/suite/cabloy-basic/modules/basic-details/src/service/detail.tsx +53 -1
- package/zova/src/suite/cabloy-basic/modules/basic-image/cli/openapi.config.ts +9 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/package.json +60 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/component/formFieldImage.ts +31 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/index.ts +191 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/locales.ts +7 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/this.ts +2 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/image.ts +104 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/baseURL.ts +5 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/index.ts +3 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/schemas.ts +262 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/types.ts +5159 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/apiSchema/image.ts +35 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/bean/tableCell.image.tsx +200 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/component/formFieldImage/controller.tsx +781 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/config/locale/en-us.ts +18 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/config/locale/zh-cn.ts +18 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/index.ts +4 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/lib/index.ts +2 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/lib/preview.ts +105 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/lib/previewDialog.tsx +190 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/types/image.ts +5 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/src/types/index.ts +1 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/tsconfig.build.json +13 -0
- package/zova/src/suite/cabloy-basic/modules/basic-image/tsconfig.json +5 -0
- package/zova/src/suite/cabloy-basic/package.json +1 -0
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/image.ts +1 -0
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/index.ts +1 -0
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +5 -5
- package/zova/src/suite-vendor/a-zova/package.json +3 -3
- package/vona/packages-vona/vona-core/src/lib/utils/sqlite3.ts +0 -50
package/zova/src/suite/cabloy-basic/modules/basic-image/src/component/formFieldImage/controller.tsx
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
import type { TableIdentity } from 'table-identity';
|
|
2
|
+
import type { IComponentOptions } from 'zova';
|
|
3
|
+
import type { IJsxRenderContextFormField } from 'zova-module-a-form';
|
|
4
|
+
import type { ControllerFormField, IFormFieldComponentOptions } from 'zova-module-a-form';
|
|
5
|
+
import type { IImageSceneRecord, IResourceFormFieldOptionsBase } from 'zova-module-a-openapi';
|
|
6
|
+
|
|
7
|
+
import { classes } from 'typestyle';
|
|
8
|
+
import { CircleStencil, Cropper, RectangleStencil } from 'vue-advanced-cropper';
|
|
9
|
+
import { BeanControllerBase, ClientOnly, Use } from 'zova';
|
|
10
|
+
import { Controller } from 'zova-module-a-bean';
|
|
11
|
+
import { ZFormField } from 'zova-module-a-form';
|
|
12
|
+
import {
|
|
13
|
+
buildImagePreviewTitle,
|
|
14
|
+
inferImageRelationName,
|
|
15
|
+
openImagePreviewDialog,
|
|
16
|
+
resolveImagePreviewUrl,
|
|
17
|
+
} from 'zova-module-basic-image';
|
|
18
|
+
|
|
19
|
+
import type { IImageTransformOptions } from '../../types/image.js';
|
|
20
|
+
import 'vue-advanced-cropper/dist/style.css';
|
|
21
|
+
|
|
22
|
+
declare module 'zova-module-a-openapi' {
|
|
23
|
+
export interface IResourceFormFieldRecord {
|
|
24
|
+
'basic-image:formFieldImage'?: IResourceFormFieldImageOptions;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IResourceFormFieldImageResizeOptions {
|
|
29
|
+
width?: number;
|
|
30
|
+
height?: number;
|
|
31
|
+
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
|
32
|
+
background?: string;
|
|
33
|
+
quality?: number;
|
|
34
|
+
format?: IImageTransformOptions['format'];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface IResourceFormFieldImageOptions extends IResourceFormFieldOptionsBase {
|
|
38
|
+
imageScene?: keyof IImageSceneRecord | string;
|
|
39
|
+
relationName?: string;
|
|
40
|
+
multiple?: boolean;
|
|
41
|
+
maxCount?: number;
|
|
42
|
+
accept?: string | string[];
|
|
43
|
+
mimeTypes?: string[];
|
|
44
|
+
extensions?: string[];
|
|
45
|
+
maxSize?: number;
|
|
46
|
+
minSize?: number;
|
|
47
|
+
placeholder?: string;
|
|
48
|
+
enableCrop?: boolean;
|
|
49
|
+
cropAspectRatio?: number;
|
|
50
|
+
cropShape?: 'rect' | 'round';
|
|
51
|
+
resize?: IResourceFormFieldImageResizeOptions;
|
|
52
|
+
upload?: {
|
|
53
|
+
providerName?: string;
|
|
54
|
+
provider?: string;
|
|
55
|
+
clientName?: string;
|
|
56
|
+
maxSize?: number;
|
|
57
|
+
mimeTypes?: string[];
|
|
58
|
+
extensions?: string[];
|
|
59
|
+
accept?: string | string[];
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ControllerFormFieldImageProps extends IFormFieldComponentOptions {
|
|
64
|
+
options?: IResourceFormFieldImageOptions;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface IImagePreviewItem {
|
|
68
|
+
id: TableIdentity;
|
|
69
|
+
url?: string;
|
|
70
|
+
filename?: string;
|
|
71
|
+
width?: number;
|
|
72
|
+
height?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Controller()
|
|
76
|
+
export class ControllerFormFieldImage extends BeanControllerBase {
|
|
77
|
+
static $propsDefault = {
|
|
78
|
+
options: {
|
|
79
|
+
accept: ['image/png', 'image/jpeg', 'image/webp'],
|
|
80
|
+
maxCount: 1,
|
|
81
|
+
enableCrop: true,
|
|
82
|
+
cropAspectRatio: 1,
|
|
83
|
+
cropShape: 'rect',
|
|
84
|
+
resize: {
|
|
85
|
+
width: 512,
|
|
86
|
+
height: 512,
|
|
87
|
+
fit: 'cover',
|
|
88
|
+
format: 'jpeg',
|
|
89
|
+
quality: 90,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
static $componentOptions: IComponentOptions = { inheritAttrs: false, deepExtendDefault: true };
|
|
95
|
+
|
|
96
|
+
fileInputRef?: HTMLInputElement;
|
|
97
|
+
errorMessage?: string;
|
|
98
|
+
isUploading = false;
|
|
99
|
+
currentValue?: TableIdentity | TableIdentity[] | string;
|
|
100
|
+
currentOptions: IResourceFormFieldImageOptions = {};
|
|
101
|
+
$$formField?: ControllerFormField;
|
|
102
|
+
uploadedPreviewMap: Record<string, IImagePreviewItem> = {};
|
|
103
|
+
|
|
104
|
+
@Use({ injectionScope: 'host' })
|
|
105
|
+
$$renderContext: IJsxRenderContextFormField;
|
|
106
|
+
|
|
107
|
+
protected async __init__() {}
|
|
108
|
+
|
|
109
|
+
protected render() {
|
|
110
|
+
if (this.$props.readonly) {
|
|
111
|
+
return this._renderReadonlyPreset();
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
<ZFormField
|
|
115
|
+
{...this.$props}
|
|
116
|
+
slotDefault={({ propsBucket, props }, $$formField) => {
|
|
117
|
+
this.$$formField = $$formField;
|
|
118
|
+
this.currentValue = propsBucket.value as any;
|
|
119
|
+
this.currentOptions = propsBucket.options ?? {};
|
|
120
|
+
const items = this._getPreviewItems(propsBucket.value);
|
|
121
|
+
const hasValidationError = !$$formField.field.state.meta.isValid;
|
|
122
|
+
const cardClass = classes(
|
|
123
|
+
'rounded-box border border-base-300 bg-base-100 p-4',
|
|
124
|
+
(hasValidationError || !!this.errorMessage) && 'border-error',
|
|
125
|
+
props.class,
|
|
126
|
+
);
|
|
127
|
+
return (
|
|
128
|
+
<div class={cardClass}>
|
|
129
|
+
<input
|
|
130
|
+
ref={ref => {
|
|
131
|
+
this.fileInputRef = ref as HTMLInputElement;
|
|
132
|
+
}}
|
|
133
|
+
class="hidden"
|
|
134
|
+
type="file"
|
|
135
|
+
accept={this._getAcceptAttr(propsBucket.options)}
|
|
136
|
+
multiple={!!propsBucket.options?.multiple}
|
|
137
|
+
onChange={event => {
|
|
138
|
+
void this._handleFileChange(event, propsBucket.disableNotifyChanged);
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
class={classes('btn btn-primary', this.isUploading && 'btn-disabled')}
|
|
145
|
+
onClick={() => {
|
|
146
|
+
if (this.isUploading) return;
|
|
147
|
+
this.fileInputRef?.click();
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
{this._getUploadButtonText(items.length)}
|
|
151
|
+
</button>
|
|
152
|
+
{this.isUploading && (
|
|
153
|
+
<span class="inline-flex items-center gap-2 text-sm text-base-content/70">
|
|
154
|
+
<span class="loading loading-spinner loading-sm text-primary"></span>
|
|
155
|
+
{this.scope.locale.Uploading()}
|
|
156
|
+
</span>
|
|
157
|
+
)}
|
|
158
|
+
{!items.length && !this.isUploading && (
|
|
159
|
+
<span class="text-sm text-base-content/60">
|
|
160
|
+
{propsBucket.options?.placeholder ?? this.scope.locale.NoImageSelected()}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
{!!this.errorMessage && <p class="mt-3 text-sm text-error">{this.errorMessage}</p>}
|
|
165
|
+
{!!items.length && (
|
|
166
|
+
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
167
|
+
{items.map((item, index) => {
|
|
168
|
+
return this._renderPreviewCard(
|
|
169
|
+
item,
|
|
170
|
+
index,
|
|
171
|
+
items,
|
|
172
|
+
false,
|
|
173
|
+
propsBucket.disableNotifyChanged,
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}}
|
|
181
|
+
></ZFormField>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private _renderReadonlyPreset() {
|
|
186
|
+
return (
|
|
187
|
+
<ZFormField
|
|
188
|
+
{...this.$props}
|
|
189
|
+
slotDefault={({ propsBucket, props }) => {
|
|
190
|
+
this.currentValue = propsBucket.value as any;
|
|
191
|
+
this.currentOptions = propsBucket.options ?? {};
|
|
192
|
+
const items = this._getPreviewItems(propsBucket.value);
|
|
193
|
+
return <div class={props.class}>{this._renderReadonlyItems(items)}</div>;
|
|
194
|
+
}}
|
|
195
|
+
></ZFormField>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private _renderReadonlyItems(items: IImagePreviewItem[]) {
|
|
200
|
+
if (!items.length) {
|
|
201
|
+
return (
|
|
202
|
+
<span class="text-sm text-base-content/60">{this.scope.locale.NoImageSelected()}</span>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return (
|
|
206
|
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
207
|
+
{items.map((item, index) => this._renderPreviewCard(item, index, items, true, false))}
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private _renderPreviewCard(
|
|
213
|
+
item: IImagePreviewItem,
|
|
214
|
+
index: number,
|
|
215
|
+
items: IImagePreviewItem[],
|
|
216
|
+
readonly: boolean,
|
|
217
|
+
disableNotifyChanged?: boolean,
|
|
218
|
+
) {
|
|
219
|
+
const previewUrl = item.url ? this._resolvePreviewUrl(item.url) : undefined;
|
|
220
|
+
return (
|
|
221
|
+
<div
|
|
222
|
+
key={`${item.id}-${index}`}
|
|
223
|
+
class="rounded-box border border-base-300 bg-base-100 shadow-sm"
|
|
224
|
+
>
|
|
225
|
+
<div class="aspect-square overflow-hidden rounded-t-box bg-base-200">
|
|
226
|
+
{previewUrl ? (
|
|
227
|
+
<img
|
|
228
|
+
class="h-full w-full object-cover"
|
|
229
|
+
src={previewUrl}
|
|
230
|
+
alt={item.filename ?? `image-${index + 1}`}
|
|
231
|
+
/>
|
|
232
|
+
) : (
|
|
233
|
+
<div class="flex h-full w-full items-center justify-center text-sm text-base-content/50">
|
|
234
|
+
#{String(item.id)}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
<div class="space-y-2 p-3">
|
|
239
|
+
<div class="min-h-10 text-sm text-base-content/70">
|
|
240
|
+
<div class="truncate font-medium text-base-content">
|
|
241
|
+
{item.filename ?? `#${String(item.id)}`}
|
|
242
|
+
</div>
|
|
243
|
+
{(item.width || item.height) && (
|
|
244
|
+
<div class="text-xs text-base-content/60">
|
|
245
|
+
{item.width ?? '-'} × {item.height ?? '-'}
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
<div class="flex flex-wrap gap-2">
|
|
250
|
+
{previewUrl && (
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
class="btn btn-sm btn-outline"
|
|
254
|
+
onClick={() => {
|
|
255
|
+
this._openPreviewDialog(items, index);
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
{this.scope.locale.PreviewImage()}
|
|
259
|
+
</button>
|
|
260
|
+
)}
|
|
261
|
+
{!readonly && (
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
class="btn btn-sm btn-outline btn-error"
|
|
265
|
+
onClick={() => {
|
|
266
|
+
this._removeItem(item.id, disableNotifyChanged);
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
{this.scope.locale.RemoveImage()}
|
|
270
|
+
</button>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private _getUploadButtonText(itemCount: number) {
|
|
279
|
+
const options = this.currentOptions ?? {};
|
|
280
|
+
if (options.multiple) {
|
|
281
|
+
return itemCount > 0 ? this.scope.locale.AddImage() : this.scope.locale.SelectImage();
|
|
282
|
+
}
|
|
283
|
+
return itemCount > 0 ? this.scope.locale.ReplaceImage() : this.scope.locale.SelectImage();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async _handleFileChange(event: Event, disableNotifyChanged?: boolean) {
|
|
287
|
+
const input = event.target as HTMLInputElement;
|
|
288
|
+
const files = Array.from(input.files ?? []);
|
|
289
|
+
input.value = '';
|
|
290
|
+
if (files.length === 0) return;
|
|
291
|
+
this.errorMessage = undefined;
|
|
292
|
+
const options = this.currentOptions ?? {};
|
|
293
|
+
const multiple = !!options.multiple;
|
|
294
|
+
const currentIds = this._normalizeValueToImageIds(this.currentValue, multiple);
|
|
295
|
+
const filesToHandle = options.multiple ? files : files.slice(0, 1);
|
|
296
|
+
const maxCount = this._getMaxCount(options);
|
|
297
|
+
const nextCountCandidate = options.multiple
|
|
298
|
+
? currentIds.length + filesToHandle.length
|
|
299
|
+
: filesToHandle.length;
|
|
300
|
+
if (nextCountCandidate > maxCount) {
|
|
301
|
+
this.errorMessage = this.scope.locale.TooManyImages(maxCount);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const uploadTarget = this._resolveUploadTarget();
|
|
305
|
+
this.isUploading = true;
|
|
306
|
+
try {
|
|
307
|
+
const uploadedItems: IImagePreviewItem[] = [];
|
|
308
|
+
for (const file of filesToHandle) {
|
|
309
|
+
const validationMessage = this._validateFile(file, options);
|
|
310
|
+
if (validationMessage) {
|
|
311
|
+
this.errorMessage = validationMessage;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const preparedFile = await this._prepareFile(file, options);
|
|
315
|
+
if (!preparedFile) continue;
|
|
316
|
+
const tokenRes = await this.scope.api.image.createUploadToken({
|
|
317
|
+
...uploadTarget,
|
|
318
|
+
size: preparedFile.size,
|
|
319
|
+
mimeType: preparedFile.type || file.type,
|
|
320
|
+
});
|
|
321
|
+
const uploaded = await this.scope.api.image.upload({
|
|
322
|
+
token: tokenRes.token,
|
|
323
|
+
image: preparedFile,
|
|
324
|
+
});
|
|
325
|
+
const item: IImagePreviewItem = {
|
|
326
|
+
id: uploaded.id,
|
|
327
|
+
url: uploaded.url,
|
|
328
|
+
filename: uploaded.filename,
|
|
329
|
+
width: uploaded.width,
|
|
330
|
+
height: uploaded.height,
|
|
331
|
+
};
|
|
332
|
+
this.uploadedPreviewMap[String(item.id)] = item;
|
|
333
|
+
uploadedItems.push(item);
|
|
334
|
+
}
|
|
335
|
+
if (uploadedItems.length === 0) return;
|
|
336
|
+
const nextIds = options.multiple
|
|
337
|
+
? [...currentIds, ...uploadedItems.map(item => item.id)]
|
|
338
|
+
: [uploadedItems[uploadedItems.length - 1].id];
|
|
339
|
+
this._setFieldValue(nextIds, disableNotifyChanged, multiple);
|
|
340
|
+
this.errorMessage = undefined;
|
|
341
|
+
} catch (err: any) {
|
|
342
|
+
this.errorMessage = err?.message ?? this.scope.locale.ImageUploadFailed();
|
|
343
|
+
} finally {
|
|
344
|
+
this.isUploading = false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private _resolveUploadTarget() {
|
|
349
|
+
const imageScene = this.currentOptions.imageScene;
|
|
350
|
+
if (!imageScene) {
|
|
351
|
+
throw new Error('should specify image upload scene');
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
imageScene,
|
|
355
|
+
} as const;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private _removeItem(imageId: TableIdentity, disableNotifyChanged?: boolean) {
|
|
359
|
+
const multiple = !!this.currentOptions?.multiple;
|
|
360
|
+
const currentIds = this._normalizeValueToImageIds(this.currentValue, multiple);
|
|
361
|
+
const nextIds = currentIds.filter(item => String(item) !== String(imageId));
|
|
362
|
+
delete this.uploadedPreviewMap[String(imageId)];
|
|
363
|
+
this._setFieldValue(nextIds, disableNotifyChanged, multiple);
|
|
364
|
+
this.errorMessage = undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private _setFieldValue(
|
|
368
|
+
imageIds: TableIdentity[],
|
|
369
|
+
disableNotifyChanged: boolean | undefined,
|
|
370
|
+
multiple: boolean,
|
|
371
|
+
) {
|
|
372
|
+
const nextValue = multiple ? imageIds : (imageIds[0] ?? '');
|
|
373
|
+
this.currentValue = nextValue as any;
|
|
374
|
+
this._syncRelationField(imageIds);
|
|
375
|
+
this.$$formField?.setValue(nextValue, disableNotifyChanged);
|
|
376
|
+
this.$$formField?.handleBlur();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private _syncRelationField(imageIds: TableIdentity[]) {
|
|
380
|
+
const relationName = this._getRelationName();
|
|
381
|
+
if (!relationName) return;
|
|
382
|
+
const relationMap = this._getRelationPreviewMap();
|
|
383
|
+
const relationItems = imageIds.map(imageId => {
|
|
384
|
+
const key = String(imageId);
|
|
385
|
+
return this.uploadedPreviewMap[key] ?? relationMap[key] ?? { id: imageId };
|
|
386
|
+
});
|
|
387
|
+
const relationValue = this.currentOptions?.multiple
|
|
388
|
+
? relationItems
|
|
389
|
+
: (relationItems[0] ?? undefined);
|
|
390
|
+
this.$$renderContext.$$form.setFieldValue(relationName as never, relationValue, true);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private _getPreviewItems(value: unknown) {
|
|
394
|
+
const multiple = !!this.currentOptions?.multiple;
|
|
395
|
+
const imageIds = this._normalizeValueToImageIds(value, multiple);
|
|
396
|
+
const relationMap = this._getRelationPreviewMap();
|
|
397
|
+
if (imageIds.length === 0) {
|
|
398
|
+
return Object.values(relationMap);
|
|
399
|
+
}
|
|
400
|
+
return imageIds.map(imageId => {
|
|
401
|
+
const key = String(imageId);
|
|
402
|
+
return this.uploadedPreviewMap[key] ?? relationMap[key] ?? { id: imageId };
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private _getRelationPreviewMap() {
|
|
407
|
+
const relationName = this._getRelationName();
|
|
408
|
+
if (!relationName) return {} as Record<string, IImagePreviewItem>;
|
|
409
|
+
const relationValue = this.$$renderContext.$$form.getFieldValue(relationName as never);
|
|
410
|
+
const relationItems = Array.isArray(relationValue)
|
|
411
|
+
? relationValue
|
|
412
|
+
: relationValue
|
|
413
|
+
? [relationValue]
|
|
414
|
+
: [];
|
|
415
|
+
const map: Record<string, IImagePreviewItem> = {};
|
|
416
|
+
for (const relationItem of relationItems) {
|
|
417
|
+
if (!relationItem?.id) continue;
|
|
418
|
+
map[String(relationItem.id)] = {
|
|
419
|
+
id: relationItem.id,
|
|
420
|
+
url: relationItem.url,
|
|
421
|
+
filename: relationItem.filename,
|
|
422
|
+
width: relationItem.width,
|
|
423
|
+
height: relationItem.height,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return map;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private _getRelationName() {
|
|
430
|
+
return inferImageRelationName(this.$props.name, this.currentOptions.relationName);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private _normalizeValueToImageIds(value: unknown, multiple: boolean): TableIdentity[] {
|
|
434
|
+
if (Array.isArray(value)) {
|
|
435
|
+
return value
|
|
436
|
+
.filter(item => item !== undefined && item !== null && item !== '')
|
|
437
|
+
.map(item => item as TableIdentity);
|
|
438
|
+
}
|
|
439
|
+
if (!multiple && value !== undefined && value !== null && value !== '') {
|
|
440
|
+
return [value as TableIdentity];
|
|
441
|
+
}
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private _getMaxCount(options: IResourceFormFieldImageOptions) {
|
|
446
|
+
if (!options.multiple) return 1;
|
|
447
|
+
return Math.max(options.maxCount ?? 1, 1);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private _getAcceptAttr(options?: IResourceFormFieldImageOptions) {
|
|
451
|
+
if (!options) return 'image/*';
|
|
452
|
+
if (typeof options.accept === 'string') return options.accept;
|
|
453
|
+
if (options.accept?.length) return options.accept.join(',');
|
|
454
|
+
const parts = [...(options.mimeTypes ?? []), ...(options.extensions ?? [])];
|
|
455
|
+
return parts.length > 0 ? parts.join(',') : 'image/*';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private _validateFile(file: File, options: IResourceFormFieldImageOptions) {
|
|
459
|
+
const acceptTokens = this._collectAcceptTokens(options);
|
|
460
|
+
if (acceptTokens.length > 0 && !this._matchesAccept(file, acceptTokens)) {
|
|
461
|
+
return this.scope.locale.InvalidImageType();
|
|
462
|
+
}
|
|
463
|
+
if (options.maxSize && file.size > options.maxSize) {
|
|
464
|
+
return this.scope.locale.ImageTooLarge(this._formatBytes(options.maxSize));
|
|
465
|
+
}
|
|
466
|
+
if (options.minSize && file.size < options.minSize) {
|
|
467
|
+
return this.scope.locale.ImageTooSmall(this._formatBytes(options.minSize));
|
|
468
|
+
}
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private _collectAcceptTokens(options: IResourceFormFieldImageOptions) {
|
|
473
|
+
const accept = options.accept
|
|
474
|
+
? Array.isArray(options.accept)
|
|
475
|
+
? options.accept
|
|
476
|
+
: options.accept.split(',')
|
|
477
|
+
: [];
|
|
478
|
+
return [...accept, ...(options.mimeTypes ?? []), ...(options.extensions ?? [])]
|
|
479
|
+
.map(item => item.trim().toLowerCase())
|
|
480
|
+
.filter(item => !!item);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private _matchesAccept(file: File, acceptTokens: string[]) {
|
|
484
|
+
const mimeType = file.type.toLowerCase();
|
|
485
|
+
const extension = this._getFileExtension(file.name);
|
|
486
|
+
return acceptTokens.some(token => {
|
|
487
|
+
if (token === 'image/*') return mimeType.startsWith('image/');
|
|
488
|
+
if (token.startsWith('.')) return extension === token;
|
|
489
|
+
if (token.endsWith('/*')) {
|
|
490
|
+
const prefix = token.slice(0, token.length - 1);
|
|
491
|
+
return mimeType.startsWith(prefix);
|
|
492
|
+
}
|
|
493
|
+
return mimeType === token;
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private _getFileExtension(filename: string) {
|
|
498
|
+
const index = filename.lastIndexOf('.');
|
|
499
|
+
if (index === -1) return '';
|
|
500
|
+
return filename.slice(index).toLowerCase();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private async _prepareFile(file: File, options: IResourceFormFieldImageOptions) {
|
|
504
|
+
if (!process.env.CLIENT) return file;
|
|
505
|
+
let canvas: HTMLCanvasElement | undefined;
|
|
506
|
+
if (options.enableCrop) {
|
|
507
|
+
canvas = await this._openCropDialog(file, options);
|
|
508
|
+
if (!canvas) return undefined;
|
|
509
|
+
}
|
|
510
|
+
const resizeOptions = options.resize;
|
|
511
|
+
if (!canvas && !resizeOptions) {
|
|
512
|
+
return file;
|
|
513
|
+
}
|
|
514
|
+
const sourceCanvas = canvas ?? (await this._createCanvasFromFile(file));
|
|
515
|
+
const targetCanvas = this._resizeCanvas(sourceCanvas, resizeOptions);
|
|
516
|
+
return await this._canvasToFile(targetCanvas, file, resizeOptions);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private async _openCropDialog(file: File, options: IResourceFormFieldImageOptions) {
|
|
520
|
+
const src = URL.createObjectURL(file);
|
|
521
|
+
try {
|
|
522
|
+
return await new Promise<HTMLCanvasElement | undefined>(resolve => {
|
|
523
|
+
let settled = false;
|
|
524
|
+
let cropperRef: { getResult?: () => { canvas?: HTMLCanvasElement } } | undefined;
|
|
525
|
+
const dialog = this.$appModal.dialog(
|
|
526
|
+
{
|
|
527
|
+
title: this.scope.locale.CropImage(),
|
|
528
|
+
slotDefault: () => {
|
|
529
|
+
return (
|
|
530
|
+
<ClientOnly>
|
|
531
|
+
<div class="space-y-4">
|
|
532
|
+
<div class="h-105 overflow-hidden rounded-box bg-base-200">
|
|
533
|
+
<Cropper
|
|
534
|
+
ref={ref => {
|
|
535
|
+
cropperRef = ref as any;
|
|
536
|
+
}}
|
|
537
|
+
class="h-full w-full"
|
|
538
|
+
src={src}
|
|
539
|
+
stencilComponent={
|
|
540
|
+
options.cropShape === 'round' ? CircleStencil : RectangleStencil
|
|
541
|
+
}
|
|
542
|
+
stencilProps={{
|
|
543
|
+
aspectRatio: options.cropAspectRatio,
|
|
544
|
+
}}
|
|
545
|
+
imageRestriction="stencil"
|
|
546
|
+
></Cropper>
|
|
547
|
+
</div>
|
|
548
|
+
<p class="text-sm text-base-content/70">{this.scope.locale.AdjustImage()}</p>
|
|
549
|
+
</div>
|
|
550
|
+
</ClientOnly>
|
|
551
|
+
);
|
|
552
|
+
},
|
|
553
|
+
slotActions: modal => {
|
|
554
|
+
return (
|
|
555
|
+
<>
|
|
556
|
+
<button
|
|
557
|
+
type="button"
|
|
558
|
+
class="btn btn-ghost"
|
|
559
|
+
onClick={() => {
|
|
560
|
+
if (settled) return;
|
|
561
|
+
settled = true;
|
|
562
|
+
modal.close();
|
|
563
|
+
resolve(undefined);
|
|
564
|
+
}}
|
|
565
|
+
>
|
|
566
|
+
{this.scope.locale.CancelCrop()}
|
|
567
|
+
</button>
|
|
568
|
+
<button
|
|
569
|
+
type="button"
|
|
570
|
+
class="btn btn-primary"
|
|
571
|
+
onClick={() => {
|
|
572
|
+
if (settled) return;
|
|
573
|
+
const canvas = cropperRef?.getResult?.()?.canvas;
|
|
574
|
+
settled = true;
|
|
575
|
+
modal.close();
|
|
576
|
+
resolve(canvas);
|
|
577
|
+
}}
|
|
578
|
+
>
|
|
579
|
+
{this.scope.locale.ApplyCrop()}
|
|
580
|
+
</button>
|
|
581
|
+
</>
|
|
582
|
+
);
|
|
583
|
+
},
|
|
584
|
+
onClose: () => {
|
|
585
|
+
if (settled) return;
|
|
586
|
+
settled = true;
|
|
587
|
+
resolve(undefined);
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
maxWidth: 960,
|
|
592
|
+
showCloseButton: true,
|
|
593
|
+
closeOnBackdrop: false,
|
|
594
|
+
},
|
|
595
|
+
);
|
|
596
|
+
void dialog;
|
|
597
|
+
});
|
|
598
|
+
} finally {
|
|
599
|
+
URL.revokeObjectURL(src);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private async _createCanvasFromFile(file: File) {
|
|
604
|
+
const src = URL.createObjectURL(file);
|
|
605
|
+
try {
|
|
606
|
+
return await this._createCanvasFromUrl(src);
|
|
607
|
+
} finally {
|
|
608
|
+
URL.revokeObjectURL(src);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async _createCanvasFromUrl(src: string) {
|
|
613
|
+
const image = await this._loadImage(src);
|
|
614
|
+
const canvas = document.createElement('canvas');
|
|
615
|
+
canvas.width = image.naturalWidth || image.width;
|
|
616
|
+
canvas.height = image.naturalHeight || image.height;
|
|
617
|
+
const context = canvas.getContext('2d');
|
|
618
|
+
if (!context) throw new Error(this.scope.locale.ImageUploadFailed());
|
|
619
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
620
|
+
return canvas;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private _resizeCanvas(
|
|
624
|
+
sourceCanvas: HTMLCanvasElement,
|
|
625
|
+
resizeOptions?: IResourceFormFieldImageResizeOptions,
|
|
626
|
+
) {
|
|
627
|
+
if (!resizeOptions?.width && !resizeOptions?.height) {
|
|
628
|
+
return sourceCanvas;
|
|
629
|
+
}
|
|
630
|
+
const sourceWidth = sourceCanvas.width;
|
|
631
|
+
const sourceHeight = sourceCanvas.height;
|
|
632
|
+
const fallbackRatio = sourceWidth / Math.max(sourceHeight, 1);
|
|
633
|
+
const targetWidth =
|
|
634
|
+
resizeOptions.width ?? Math.round((resizeOptions.height ?? sourceHeight) * fallbackRatio);
|
|
635
|
+
const targetHeight =
|
|
636
|
+
resizeOptions.height ?? Math.round(targetWidth / Math.max(fallbackRatio, 0.0001));
|
|
637
|
+
const canvas = document.createElement('canvas');
|
|
638
|
+
canvas.width = targetWidth;
|
|
639
|
+
canvas.height = targetHeight;
|
|
640
|
+
const context = canvas.getContext('2d');
|
|
641
|
+
if (!context) return sourceCanvas;
|
|
642
|
+
if (resizeOptions.background) {
|
|
643
|
+
context.fillStyle = resizeOptions.background;
|
|
644
|
+
context.fillRect(0, 0, targetWidth, targetHeight);
|
|
645
|
+
}
|
|
646
|
+
const fit = resizeOptions.fit ?? 'cover';
|
|
647
|
+
if (fit === 'cover' || fit === 'crop') {
|
|
648
|
+
const scale = Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight);
|
|
649
|
+
const width = sourceWidth * scale;
|
|
650
|
+
const height = sourceHeight * scale;
|
|
651
|
+
const left = (targetWidth - width) / 2;
|
|
652
|
+
const top = (targetHeight - height) / 2;
|
|
653
|
+
context.drawImage(sourceCanvas, left, top, width, height);
|
|
654
|
+
return canvas;
|
|
655
|
+
}
|
|
656
|
+
const scaleContain = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight);
|
|
657
|
+
const scale = fit === 'scale-down' ? Math.min(1, scaleContain) : scaleContain;
|
|
658
|
+
const width = sourceWidth * scale;
|
|
659
|
+
const height = sourceHeight * scale;
|
|
660
|
+
const left = (targetWidth - width) / 2;
|
|
661
|
+
const top = (targetHeight - height) / 2;
|
|
662
|
+
context.drawImage(sourceCanvas, left, top, width, height);
|
|
663
|
+
return canvas;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private async _canvasToFile(
|
|
667
|
+
canvas: HTMLCanvasElement,
|
|
668
|
+
file: File,
|
|
669
|
+
resizeOptions?: IResourceFormFieldImageResizeOptions,
|
|
670
|
+
) {
|
|
671
|
+
const mimeType = this._resolveMimeType(file.type, resizeOptions?.format);
|
|
672
|
+
const quality = this._resolveQuality(resizeOptions?.quality);
|
|
673
|
+
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
674
|
+
canvas.toBlob(
|
|
675
|
+
result => {
|
|
676
|
+
if (!result) {
|
|
677
|
+
reject(new Error(this.scope.locale.ImageUploadFailed()));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
resolve(result);
|
|
681
|
+
},
|
|
682
|
+
mimeType,
|
|
683
|
+
quality,
|
|
684
|
+
);
|
|
685
|
+
});
|
|
686
|
+
return new File([blob], this._replaceExtension(file.name, mimeType), {
|
|
687
|
+
type: mimeType,
|
|
688
|
+
lastModified: Date.now(),
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private _resolveMimeType(
|
|
693
|
+
fileType: string | undefined,
|
|
694
|
+
format?: IImageTransformOptions['format'],
|
|
695
|
+
) {
|
|
696
|
+
if (format === 'jpeg') return 'image/jpeg';
|
|
697
|
+
if (format === 'png') return 'image/png';
|
|
698
|
+
if (format === 'webp') return 'image/webp';
|
|
699
|
+
if (format === 'avif') return 'image/avif';
|
|
700
|
+
if (fileType && fileType.startsWith('image/')) return fileType;
|
|
701
|
+
return 'image/jpeg';
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private _resolveQuality(quality?: number) {
|
|
705
|
+
if (quality === undefined) return 0.92;
|
|
706
|
+
if (quality > 1) return Math.min(quality / 100, 1);
|
|
707
|
+
return quality;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private _replaceExtension(filename: string, mimeType: string) {
|
|
711
|
+
const ext =
|
|
712
|
+
mimeType === 'image/png'
|
|
713
|
+
? '.png'
|
|
714
|
+
: mimeType === 'image/webp'
|
|
715
|
+
? '.webp'
|
|
716
|
+
: mimeType === 'image/avif'
|
|
717
|
+
? '.avif'
|
|
718
|
+
: '.jpg';
|
|
719
|
+
const index = filename.lastIndexOf('.');
|
|
720
|
+
if (index === -1) return `${filename}${ext}`;
|
|
721
|
+
return `${filename.slice(0, index)}${ext}`;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private _loadImage(src: string) {
|
|
725
|
+
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
726
|
+
const image = new Image();
|
|
727
|
+
image.onload = () => resolve(image);
|
|
728
|
+
image.onerror = () => reject(new Error(this.scope.locale.ImageUploadFailed()));
|
|
729
|
+
image.src = src;
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private _openPreviewDialog(items: IImagePreviewItem[], currentIndex: number) {
|
|
734
|
+
const previewItems = items
|
|
735
|
+
.map((item, index) => ({
|
|
736
|
+
index,
|
|
737
|
+
item,
|
|
738
|
+
}))
|
|
739
|
+
.filter(({ item }) => !!item.url)
|
|
740
|
+
.map(({ index, item }) => ({
|
|
741
|
+
index,
|
|
742
|
+
item: {
|
|
743
|
+
url: item.url!,
|
|
744
|
+
filename: item.filename,
|
|
745
|
+
},
|
|
746
|
+
}));
|
|
747
|
+
if (previewItems.length === 0) return;
|
|
748
|
+
const initialIndex = previewItems.findIndex(({ index }) => index === currentIndex);
|
|
749
|
+
openImagePreviewDialog({
|
|
750
|
+
appModal: this.$appModal,
|
|
751
|
+
title: this._getPreviewDialogTitle(previewItems.length),
|
|
752
|
+
items: previewItems.map(({ item }) => item),
|
|
753
|
+
initialIndex: initialIndex === -1 ? 0 : initialIndex,
|
|
754
|
+
baseURL: this.sys.config.api.baseURL,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private _getPreviewDialogTitle(count: number) {
|
|
759
|
+
return buildImagePreviewTitle(
|
|
760
|
+
this.$$renderContext.$celScope.property?.title ??
|
|
761
|
+
this.$$renderContext.$celScope.name ??
|
|
762
|
+
this.$props.name,
|
|
763
|
+
count,
|
|
764
|
+
() => this.scope.locale.PreviewImage(),
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private _resolvePreviewUrl(url: string) {
|
|
769
|
+
return resolveImagePreviewUrl(url, this.sys.config.api.baseURL);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private _formatBytes(bytes: number) {
|
|
773
|
+
if (bytes >= 1024 * 1024) {
|
|
774
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
775
|
+
}
|
|
776
|
+
if (bytes >= 1024) {
|
|
777
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
778
|
+
}
|
|
779
|
+
return `${bytes} B`;
|
|
780
|
+
}
|
|
781
|
+
}
|