@ramathibodi/nuxt-commons 0.1.73 → 0.1.75

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 (111) hide show
  1. package/README.md +115 -96
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +1 -0
  4. package/dist/runtime/components/Alert.vue +58 -54
  5. package/dist/runtime/components/BarcodeReader.vue +130 -122
  6. package/dist/runtime/components/ExportCSV.vue +110 -102
  7. package/dist/runtime/components/FileBtn.vue +79 -67
  8. package/dist/runtime/components/ImportCSV.vue +151 -139
  9. package/dist/runtime/components/MrzReader.vue +168 -0
  10. package/dist/runtime/components/SplitterPanel.vue +67 -59
  11. package/dist/runtime/components/TabsGroup.vue +39 -31
  12. package/dist/runtime/components/TextBarcode.vue +66 -54
  13. package/dist/runtime/components/device/IdCardButton.vue +95 -83
  14. package/dist/runtime/components/device/IdCardWebSocket.vue +207 -195
  15. package/dist/runtime/components/device/Scanner.vue +350 -338
  16. package/dist/runtime/components/dialog/Confirm.vue +112 -100
  17. package/dist/runtime/components/dialog/Host.vue +88 -84
  18. package/dist/runtime/components/dialog/Index.vue +84 -72
  19. package/dist/runtime/components/dialog/Loading.vue +51 -39
  20. package/dist/runtime/components/dialog/default/Confirm.vue +112 -100
  21. package/dist/runtime/components/dialog/default/Loading.vue +60 -48
  22. package/dist/runtime/components/dialog/default/Notify.vue +82 -70
  23. package/dist/runtime/components/dialog/default/Printing.vue +46 -34
  24. package/dist/runtime/components/dialog/default/VerifyUser.vue +144 -132
  25. package/dist/runtime/components/document/Form.vue +50 -42
  26. package/dist/runtime/components/document/TemplateBuilder.vue +536 -524
  27. package/dist/runtime/components/form/ActionPad.vue +156 -144
  28. package/dist/runtime/components/form/Birthdate.vue +116 -104
  29. package/dist/runtime/components/form/CheckboxGroup.vue +99 -87
  30. package/dist/runtime/components/form/CodeEditor.vue +45 -37
  31. package/dist/runtime/components/form/Date.vue +270 -258
  32. package/dist/runtime/components/form/DateTime.vue +220 -208
  33. package/dist/runtime/components/form/Dialog.vue +178 -166
  34. package/dist/runtime/components/form/EditPad.vue +157 -145
  35. package/dist/runtime/components/form/File.vue +295 -283
  36. package/dist/runtime/components/form/Hidden.vue +44 -32
  37. package/dist/runtime/components/form/Iterator.vue +538 -526
  38. package/dist/runtime/components/form/Login.vue +143 -131
  39. package/dist/runtime/components/form/Pad.vue +399 -387
  40. package/dist/runtime/components/form/SignPad.vue +226 -218
  41. package/dist/runtime/components/form/System.vue +34 -26
  42. package/dist/runtime/components/form/Table.vue +391 -379
  43. package/dist/runtime/components/form/TableData.vue +236 -224
  44. package/dist/runtime/components/form/Time.vue +177 -165
  45. package/dist/runtime/components/form/images/Capture.vue +245 -237
  46. package/dist/runtime/components/form/images/Edit.vue +133 -121
  47. package/dist/runtime/components/form/images/Field.vue +331 -320
  48. package/dist/runtime/components/form/images/Pad.vue +54 -42
  49. package/dist/runtime/components/label/Date.vue +37 -29
  50. package/dist/runtime/components/label/DateAgo.vue +102 -94
  51. package/dist/runtime/components/label/DateCount.vue +152 -144
  52. package/dist/runtime/components/label/Field.vue +111 -103
  53. package/dist/runtime/components/label/FormatMoney.vue +37 -29
  54. package/dist/runtime/components/label/Mask.vue +46 -38
  55. package/dist/runtime/components/label/Object.vue +21 -13
  56. package/dist/runtime/components/master/Autocomplete.vue +89 -81
  57. package/dist/runtime/components/master/Combobox.vue +88 -80
  58. package/dist/runtime/components/master/RadioGroup.vue +90 -78
  59. package/dist/runtime/components/master/Select.vue +70 -62
  60. package/dist/runtime/components/master/label.vue +55 -47
  61. package/dist/runtime/components/model/Autocomplete.vue +91 -79
  62. package/dist/runtime/components/model/Combobox.vue +90 -78
  63. package/dist/runtime/components/model/Pad.vue +114 -102
  64. package/dist/runtime/components/model/Select.vue +78 -72
  65. package/dist/runtime/components/model/Table.vue +370 -358
  66. package/dist/runtime/components/model/iterator.vue +497 -489
  67. package/dist/runtime/components/model/label.vue +58 -50
  68. package/dist/runtime/components/pdf/Print.vue +75 -63
  69. package/dist/runtime/components/pdf/View.vue +146 -134
  70. package/dist/runtime/composables/alert.d.ts +4 -0
  71. package/dist/runtime/composables/api.d.ts +4 -0
  72. package/dist/runtime/composables/dialog.d.ts +1 -1
  73. package/dist/runtime/composables/document/templateFormHidden.d.ts +4 -0
  74. package/dist/runtime/composables/graphql.d.ts +1 -1
  75. package/dist/runtime/composables/graphqlModel.d.ts +9 -9
  76. package/dist/runtime/composables/graphqlModelItem.d.ts +7 -7
  77. package/dist/runtime/composables/graphqlModelOperation.d.ts +6 -6
  78. package/dist/runtime/composables/localStorageModel.d.ts +4 -0
  79. package/dist/runtime/composables/lookupList.d.ts +4 -0
  80. package/dist/runtime/composables/menu.d.ts +4 -0
  81. package/dist/runtime/composables/useMrzReader.d.ts +48 -0
  82. package/dist/runtime/composables/useMrzReader.js +423 -0
  83. package/dist/runtime/composables/useTesseract.d.ts +16 -0
  84. package/dist/runtime/composables/useTesseract.js +45 -0
  85. package/dist/runtime/composables/userPermission.d.ts +1 -1
  86. package/dist/runtime/labs/Calendar.vue +99 -99
  87. package/dist/runtime/labs/form/EditMobile.vue +152 -152
  88. package/dist/runtime/labs/form/TextFieldMask.vue +43 -43
  89. package/dist/runtime/plugins/clientConfig.d.ts +1 -1
  90. package/dist/runtime/plugins/default.d.ts +1 -1
  91. package/dist/runtime/plugins/dialogManager.d.ts +1 -1
  92. package/dist/runtime/plugins/permission.d.ts +1 -1
  93. package/dist/runtime/types/alert.d.ts +11 -11
  94. package/dist/runtime/types/clientConfig.d.ts +13 -13
  95. package/dist/runtime/types/dialogManager.d.ts +35 -35
  96. package/dist/runtime/types/formDialog.d.ts +5 -5
  97. package/dist/runtime/types/graphqlOperation.d.ts +23 -23
  98. package/dist/runtime/types/menu.d.ts +31 -31
  99. package/dist/runtime/types/modules.d.ts +7 -7
  100. package/dist/runtime/types/permission.d.ts +13 -13
  101. package/dist/runtime/utils/asset.d.ts +2 -0
  102. package/dist/runtime/utils/asset.js +49 -0
  103. package/package.json +131 -122
  104. package/scripts/enrich-vue-docs-from-ai.mjs +197 -0
  105. package/scripts/generate-ai-summary.mjs +321 -0
  106. package/scripts/generate-composables-md.mjs +129 -0
  107. package/scripts/postInstall.cjs +70 -70
  108. package/templates/.codegen/codegen.ts +32 -32
  109. package/templates/.codegen/plugin-schema-object.js +161 -161
  110. package/templates/public/tesseract/mrz.traineddata.gz +0 -0
  111. package/templates/public/tesseract/ocrb.traineddata.gz +0 -0
@@ -1,320 +1,331 @@
1
- <script lang="ts" setup>
2
- import { ref, watch,computed } from 'vue'
3
- import { isEqual } from 'lodash-es'
4
- import { useAlert } from '../../../composables/alert'
5
- import { useAssetFile, type Base64Image, type Base64Asset, type Base64File} from '../../../composables/assetFile'
6
- import { useRuntimeConfig } from "#imports";
7
- import { VInput } from 'vuetify/components/VInput'
8
-
9
- const emit = defineEmits<{
10
- (e: 'update:modelValue', value: Base64Image[]): void
11
- }>()
12
-
13
- const alert = useAlert()
14
- const { fileToBase64, hydrateAssetFile } = useAssetFile()
15
-
16
- interface Props {
17
- modelValue?: Base64Image[]
18
- readonly?: boolean
19
- label?: string
20
- accept?: string
21
- autoHydrate?: boolean
22
- maxFileSize?: number
23
- }
24
- const props = withDefaults(defineProps<Props>(), {
25
- modelValue: () => [] as Base64Image[],
26
- accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.tiff,.tif',
27
- autoHydrate: false,
28
- maxFileSize: 10,
29
- })
30
-
31
- /** Internal state (always Base64Image[]) */
32
- const images = ref<Base64Image[]>([])
33
- const uploadImages = ref<File[]>([])
34
-
35
- /** Dialogs */
36
- const dialog = ref(false) // capture dialog
37
- const dialogUpdate = ref(false) // edit dialog
38
- const dataUpdate = ref<Base64Image>({ imageData: {}, imageTitle: '', imageProps: {} })
39
-
40
- /** Fullscreen preview */
41
- const dialogImageFullScreen = ref(false)
42
- const imageFullScreen = ref<{title: string,image: string|undefined}>({ title: '', image: '' })
43
-
44
- /** ---------- Stable keys + guards ---------- */
45
- let internalSync = false
46
- let lastEmittedSig = '' // signature of last emitted state
47
-
48
- function imageKey(im: Base64Image): string {
49
- const id = im.imageData?.id
50
- if (id != null) return `id:${id}`
51
- const title = im.imageTitle ?? ''
52
- const len = im.imageData?.base64String?.length ?? 0
53
- return `t:${title}|l:${len}`
54
- }
55
-
56
- function signature(arr: Base64Image[]): string {
57
- return arr.map(imageKey).join('|')
58
- }
59
-
60
- /** Emit helper (guarded + updates last signature) */
61
- function emitNow(next: Base64Image[]) {
62
- const sig = signature(next)
63
- if (sig === lastEmittedSig) return
64
- internalSync = true
65
- try {
66
- emit('update:modelValue', next)
67
- lastEmittedSig = sig
68
- } finally {
69
- queueMicrotask(() => { internalSync = false })
70
- }
71
- }
72
-
73
- /** ---------- Helpers ---------- */
74
-
75
- const addImage = (img: Base64Image) => {
76
- images.value.push({
77
- imageData: img.imageData ?? {},
78
- imageTitle: img.imageTitle ?? '',
79
- imageProps: img.imageProps ?? {},
80
- })
81
- dialog.value = false
82
- }
83
-
84
- const remove = (index: number) => {
85
- images.value.splice(index, 1)
86
- }
87
-
88
- const setDataUpdate = (img: Base64Image) => {
89
- dataUpdate.value = {
90
- imageData: { ...(img.imageData ?? {}) },
91
- imageTitle: img.imageTitle ?? '',
92
- imageProps: { ...(img.imageProps ?? {}) },
93
- }
94
- dialogUpdate.value = true
95
- }
96
-
97
- const checkDuplicationName = (name: string) => images.value.some(({ imageTitle }) => isEqual(imageTitle, name))
98
-
99
- const isImageDataUrl = (dataUrl: string) => /^data:image\//i.test(dataUrl)
100
-
101
- const imageSrcFromImageData = (imageData: Base64Image) => {
102
- let assetUrl = useRuntimeConfig().public.ASSET_URL as string
103
- if (imageData?.imageData?.base64String) return useAssetFile().ensureDataUrl(imageData?.imageData?.base64String.trim(),(imageData?.imageData as Base64File).fileType || "image/png")
104
- if (imageData?.imageData?.id) return (assetUrl || '/asset').replace(/\/+$/, '') +"/"+imageData?.imageData?.id
105
- return undefined
106
- }
107
-
108
- /** File → Base64Image using composable */
109
- const fileToBase64Image = async (file: File): Promise<Base64Image | null> => {
110
- try {
111
- const base64 = await fileToBase64(file,props.maxFileSize)
112
- const dataUrl = base64.base64String || ''
113
- if (!isImageDataUrl(dataUrl)) {
114
- alert?.addAlert({ message: `File "${base64.fileName}" is not supported image type.`, alertType: 'error' })
115
- return null
116
- }
117
- return {
118
- imageData: { base64String: dataUrl } as Base64Asset,
119
- imageTitle: base64.fileName,
120
- imageProps: {},
121
- }
122
- } catch (e: any) {
123
- alert?.addAlert({ message: String(e), alertType: 'error' })
124
- return null
125
- }
126
- }
127
-
128
- /** Handle upload button update */
129
- const uploadImageFile = async () => {
130
- const duplicated: string[] = []
131
- for (const file of uploadImages.value) {
132
- if (checkDuplicationName(file.name)) {
133
- duplicated.push(file.name)
134
- continue
135
- }
136
- const base64Image = await fileToBase64Image(file)
137
- if (base64Image) addImage(base64Image)
138
- }
139
- uploadImages.value = []
140
- if (duplicated.length) {
141
- alert?.addAlert({ message: `File(s) are duplicated. ${duplicated.join(', ')}`, alertType: 'error' })
142
- }
143
- }
144
-
145
- /** Capture flow (FormDialog) */
146
- type FormDialogCallback = { done: () => void }
147
- const modelData = ref()
148
-
149
- const captureImage = (payload: any, cb: FormDialogCallback) => {
150
- const dataUrl: string = payload?.imageCapture ?? ''
151
- if (!dataUrl || !isImageDataUrl(dataUrl)) {
152
- alert?.addAlert({ message: 'Invalid image.', alertType: 'error' })
153
- return
154
- }
155
- addImage({
156
- imageData: { base64String: dataUrl },
157
- imageTitle: Math.random().toString(36).slice(2, 11),
158
- imageProps: {},
159
- })
160
- cb?.done?.()
161
- }
162
-
163
- /** Fullscreen preview */
164
- const openImageFullScreen = (img: Base64Image) => {
165
- dialogImageFullScreen.value = true
166
- imageFullScreen.value.title = img.imageTitle ?? ''
167
- imageFullScreen.value.image = imageSrcFromImageData(img)
168
- }
169
-
170
- /** ---------- Watchers (signature-based) ---------- */
171
-
172
- /* Parent → Internal */
173
- watch(
174
- () => props.modelValue,
175
- async (val) => {
176
- if (internalSync) return
177
-
178
- const next = Array.isArray(val) ? [...val] : []
179
- const nextSig = signature(next)
180
-
181
- // Only reassign when truly different
182
- if (nextSig !== signature(images.value)) {
183
- images.value = next
184
-
185
- // optional hydration
186
- if (props.autoHydrate && images.value.length) {
187
- const targets = images.value.filter(
188
- (im) => im.imageData?.id != null && !im.imageData?.base64String
189
- )
190
- if (targets.length) {
191
- await Promise.allSettled(targets.map((im) => hydrateAssetFile(im.imageData!)))
192
- // After hydration, emit once (guarded) to update parent
193
- emitNow(images.value)
194
- }
195
- }
196
-
197
- // sync lastEmittedSig to current internal state so next local change emits
198
- lastEmittedSig = signature(images.value)
199
- }
200
- },
201
- { deep: true, immediate: true }
202
- )
203
-
204
- /* Internal → Parent: watch the signature instead of deep structure */
205
- watch(
206
- () => signature(images.value),
207
- () => {
208
- if (internalSync) return
209
- // Only emit when signature actually changes
210
- emitNow(images.value)
211
- },
212
- { immediate: false }
213
- )
214
-
215
- // validation passthrough
216
- const inputRef = ref<InstanceType<typeof VInput> | null>(null)
217
-
218
- const isValid = computed(() => inputRef.value?.isValid)
219
- const errorMessages = computed(() => inputRef.value?.errorMessages)
220
-
221
- defineExpose({
222
- errorMessages,
223
- isValid,
224
- reset: () => inputRef.value?.reset(),
225
- resetValidation: () => inputRef.value?.resetValidation(),
226
- validate: () => inputRef.value?.validate(),
227
- })
228
- </script>
229
-
230
- <template>
231
- <v-input v-model="images" v-bind="$attrs" ref="inputRef">
232
- <template #default="{ isReadonly, isDisabled }">
233
- <v-container fluid>
234
- <v-row>
235
- <v-col class="pa-0">
236
- <VCard>
237
- <VToolbar density="compact">
238
- <VToolbarTitle>{{ label }}</VToolbarTitle>
239
- <v-spacer />
240
- <VToolbarItems v-if="!readonly">
241
- <FileBtn
242
- v-model="uploadImages"
243
- :accept="accept"
244
- color="primary"
245
- icon="mdi:mdi-image-plus"
246
- icon-only
247
- multiple
248
- variant="text"
249
- @update:model-value="uploadImageFile"
250
- :disabled="isDisabled?.value" :readonly="isReadonly?.value"
251
- />
252
- <v-btn color="primary" icon @click="dialog = true" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
253
- <v-icon>mdi mdi-camera-plus</v-icon>
254
- </v-btn>
255
- </VToolbarItems>
256
- </VToolbar>
257
-
258
- <VCardText>
259
- <VRow dense justify="start">
260
- <VCol v-for="(image, index) in images" :key="`${imageKey(image)}-${index}`" cols="4">
261
- <VCard>
262
- <VToolbar density="compact">
263
- <VToolbarTitle>
264
- {{ image.imageTitle }}
265
- </VToolbarTitle>
266
- <VSpacer />
267
- <VToolbarItems v-if="!readonly">
268
- <v-btn icon @click="remove(index)" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
269
- <v-icon>mdi mdi-delete-outline</v-icon>
270
- </v-btn>
271
- <v-btn color="primary" icon @click="setDataUpdate(image)" v-if="!image.imageData?.id" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
272
- <v-icon>mdi mdi-image-edit-outline</v-icon>
273
- </v-btn>
274
- </VToolbarItems>
275
- </VToolbar>
276
-
277
- <v-img
278
- :src="imageSrcFromImageData(image)"
279
- height="250"
280
- @click="() => { (props.readonly || image.imageData?.id || isReadonly?.value) ? openImageFullScreen(image) : setDataUpdate(image) }"
281
- :disabled="isDisabled?.value"
282
- />
283
- </VCard>
284
- </VCol>
285
- </VRow>
286
- </VCardText>
287
- </VCard>
288
- </v-col>
289
- </v-row>
290
- </v-container>
291
-
292
-
293
- <!-- Edit dialog -->
294
- <VDialog v-model="dialogUpdate" fullscreen transition="dialog-bottom-transition">
295
- <FormImagesPad
296
- v-model="dataUpdate.imageData.base64String"
297
- @closedDialog="dialogUpdate = false"
298
- />
299
- </VDialog>
300
-
301
- <!-- Capture dialog -->
302
- <FormDialog v-model="dialog" :form-data="modelData" @create="captureImage">
303
- <template #default="{ data }">
304
- <FormImagesCapture v-model="data.imageCapture" />
305
- </template>
306
- </FormDialog>
307
-
308
- <!-- Fullscreen preview -->
309
- <v-dialog v-model="dialogImageFullScreen">
310
- <v-toolbar :title="imageFullScreen.title">
311
- <v-spacer />
312
- <v-btn icon="mdi mdi-close" @click="dialogImageFullScreen = false" />
313
- </v-toolbar>
314
- <v-card height="80vh">
315
- <v-img :src="imageFullScreen.image" />
316
- </v-card>
317
- </v-dialog>
318
- </template>
319
- </v-input>
320
- </template>
1
+ <script lang="ts" setup>
2
+ /**
3
+ * FormImagesField handles image capture, editing, preview, and value synchronization for schema-driven form image fields.
4
+ * This doc block is consumed by vue-docgen for generated API documentation.
5
+ */
6
+ import { ref, watch,computed } from 'vue'
7
+ import { isEqual } from 'lodash-es'
8
+ import { VInput } from 'vuetify/components/VInput'
9
+ import { assetUrl } from '#imports'
10
+ import { useAlert } from '../../../composables/alert'
11
+ import { useAssetFile, type Base64Image, type Base64Asset, type Base64File} from '../../../composables/assetFile'
12
+
13
+ /**
14
+ * Custom events emitted by FormImagesField.
15
+ * Parents can listen to these events to react to user actions and internal state changes.
16
+ */
17
+ const emit = defineEmits<{
18
+ (e: 'update:modelValue', value: Base64Image[]): void
19
+ }>()
20
+
21
+ const alert = useAlert()
22
+ const { fileToBase64, hydrateAssetFile } = useAssetFile()
23
+
24
+ interface Props {
25
+ modelValue?: Base64Image[] // Bound value for v-model synchronization with the parent component.
26
+ readonly?: boolean // renders as read-only while keeping value visible
27
+ label?: string // UI label displayed to end users
28
+ accept?: string // Accepted file MIME types or extensions for file selection.
29
+ autoHydrate?: boolean // Converts incoming serialized values into component runtime format on mount/watch.
30
+ maxFileSize?: number // Maximum allowed image file size in MB before conversion.
31
+ }
32
+ /**
33
+ * Public props accepted by FormImagesField.
34
+ * Document each prop field with intent, defaults, and side effects for clear generated docs.
35
+ */
36
+ const props = withDefaults(defineProps<Props>(), {
37
+ modelValue: () => [] as Base64Image[],
38
+ accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.tiff,.tif',
39
+ autoHydrate: false,
40
+ maxFileSize: 10,
41
+ })
42
+
43
+ /** Internal state (always Base64Image[]) */
44
+ const images = ref<Base64Image[]>([])
45
+ const uploadImages = ref<File[]>([])
46
+
47
+ /** Dialogs */
48
+ const dialog = ref(false) // capture dialog
49
+ const dialogUpdate = ref(false) // edit dialog
50
+ const dataUpdate = ref<Base64Image>({ imageData: {}, imageTitle: '', imageProps: {} })
51
+
52
+ /** Fullscreen preview */
53
+ const dialogImageFullScreen = ref(false)
54
+ const imageFullScreen = ref<{title: string,image: string|undefined}>({ title: '', image: '' })
55
+
56
+ /** ---------- Stable keys + guards ---------- */
57
+ let internalSync = false
58
+ let lastEmittedSig = '' // signature of last emitted state
59
+
60
+ function imageKey(im: Base64Image): string {
61
+ const id = im.imageData?.id
62
+ if (id != null) return `id:${id}`
63
+ const title = im.imageTitle ?? ''
64
+ const len = im.imageData?.base64String?.length ?? 0
65
+ return `t:${title}|l:${len}`
66
+ }
67
+
68
+ function signature(arr: Base64Image[]): string {
69
+ return arr.map(imageKey).join('|')
70
+ }
71
+
72
+ /** Emit helper (guarded + updates last signature) */
73
+ function emitNow(next: Base64Image[]) {
74
+ const sig = signature(next)
75
+ if (sig === lastEmittedSig) return
76
+ internalSync = true
77
+ try {
78
+ emit('update:modelValue', next)
79
+ lastEmittedSig = sig
80
+ } finally {
81
+ queueMicrotask(() => { internalSync = false })
82
+ }
83
+ }
84
+
85
+ /** ---------- Helpers ---------- */
86
+
87
+ const addImage = (img: Base64Image) => {
88
+ images.value.push({
89
+ imageData: img.imageData ?? {},
90
+ imageTitle: img.imageTitle ?? '',
91
+ imageProps: img.imageProps ?? {},
92
+ })
93
+ dialog.value = false
94
+ }
95
+
96
+ const remove = (index: number) => {
97
+ images.value.splice(index, 1)
98
+ }
99
+
100
+ const setDataUpdate = (img: Base64Image) => {
101
+ dataUpdate.value = {
102
+ imageData: { ...(img.imageData ?? {}) },
103
+ imageTitle: img.imageTitle ?? '',
104
+ imageProps: { ...(img.imageProps ?? {}) },
105
+ }
106
+ dialogUpdate.value = true
107
+ }
108
+
109
+ const checkDuplicationName = (name: string) => images.value.some(({ imageTitle }) => isEqual(imageTitle, name))
110
+
111
+ const isImageDataUrl = (dataUrl: string) => /^data:image\//i.test(dataUrl)
112
+
113
+ const imageSrcFromImageData = (imageData: Base64Image) => {
114
+ if (imageData?.imageData?.base64String) return useAssetFile().ensureDataUrl(imageData?.imageData?.base64String.trim(),(imageData?.imageData as Base64File).fileType || "image/png")
115
+ if (imageData?.imageData?.id != null) return assetUrl(imageData.imageData.id)
116
+ return undefined
117
+ }
118
+
119
+ /** File → Base64Image using composable */
120
+ const fileToBase64Image = async (file: File): Promise<Base64Image | null> => {
121
+ try {
122
+ const base64 = await fileToBase64(file,props.maxFileSize)
123
+ const dataUrl = base64.base64String || ''
124
+ if (!isImageDataUrl(dataUrl)) {
125
+ alert?.addAlert({ message: `File "${base64.fileName}" is not supported image type.`, alertType: 'error' })
126
+ return null
127
+ }
128
+ return {
129
+ imageData: { base64String: dataUrl } as Base64Asset,
130
+ imageTitle: base64.fileName,
131
+ imageProps: {},
132
+ }
133
+ } catch (e: any) {
134
+ alert?.addAlert({ message: String(e), alertType: 'error' })
135
+ return null
136
+ }
137
+ }
138
+
139
+ /** Handle upload button update */
140
+ const uploadImageFile = async () => {
141
+ const duplicated: string[] = []
142
+ for (const file of uploadImages.value) {
143
+ if (checkDuplicationName(file.name)) {
144
+ duplicated.push(file.name)
145
+ continue
146
+ }
147
+ const base64Image = await fileToBase64Image(file)
148
+ if (base64Image) addImage(base64Image)
149
+ }
150
+ uploadImages.value = []
151
+ if (duplicated.length) {
152
+ alert?.addAlert({ message: `File(s) are duplicated. ${duplicated.join(', ')}`, alertType: 'error' })
153
+ }
154
+ }
155
+
156
+ /** Capture flow (FormDialog) */
157
+ type FormDialogCallback = { done: () => void }
158
+ const modelData = ref()
159
+
160
+ const captureImage = (payload: any, cb: FormDialogCallback) => {
161
+ const dataUrl: string = payload?.imageCapture ?? ''
162
+ if (!dataUrl || !isImageDataUrl(dataUrl)) {
163
+ alert?.addAlert({ message: 'Invalid image.', alertType: 'error' })
164
+ return
165
+ }
166
+ addImage({
167
+ imageData: { base64String: dataUrl },
168
+ imageTitle: Math.random().toString(36).slice(2, 11),
169
+ imageProps: {},
170
+ })
171
+ cb?.done?.()
172
+ }
173
+
174
+ /** Fullscreen preview */
175
+ const openImageFullScreen = (img: Base64Image) => {
176
+ dialogImageFullScreen.value = true
177
+ imageFullScreen.value.title = img.imageTitle ?? ''
178
+ imageFullScreen.value.image = imageSrcFromImageData(img)
179
+ }
180
+
181
+ /** ---------- Watchers (signature-based) ---------- */
182
+
183
+ /* Parent → Internal */
184
+ watch(
185
+ () => props.modelValue,
186
+ async (val) => {
187
+ if (internalSync) return
188
+
189
+ const next = Array.isArray(val) ? [...val] : []
190
+ const nextSig = signature(next)
191
+
192
+ // Only reassign when truly different
193
+ if (nextSig !== signature(images.value)) {
194
+ images.value = next
195
+
196
+ // optional hydration
197
+ if (props.autoHydrate && images.value.length) {
198
+ const targets = images.value.filter(
199
+ (im) => im.imageData?.id != null && !im.imageData?.base64String
200
+ )
201
+ if (targets.length) {
202
+ await Promise.allSettled(targets.map((im) => hydrateAssetFile(im.imageData!)))
203
+ // After hydration, emit once (guarded) to update parent
204
+ emitNow(images.value)
205
+ }
206
+ }
207
+
208
+ // sync lastEmittedSig to current internal state so next local change emits
209
+ lastEmittedSig = signature(images.value)
210
+ }
211
+ },
212
+ { deep: true, immediate: true }
213
+ )
214
+
215
+ /* Internal → Parent: watch the signature instead of deep structure */
216
+ watch(
217
+ () => signature(images.value),
218
+ () => {
219
+ if (internalSync) return
220
+ // Only emit when signature actually changes
221
+ emitNow(images.value)
222
+ },
223
+ { immediate: false }
224
+ )
225
+
226
+ // validation passthrough
227
+ const inputRef = ref<InstanceType<typeof VInput> | null>(null)
228
+
229
+ const isValid = computed(() => inputRef.value?.isValid)
230
+ const errorMessages = computed(() => inputRef.value?.errorMessages)
231
+
232
+ defineExpose({
233
+ errorMessages,
234
+ isValid,
235
+ reset: () => inputRef.value?.reset(),
236
+ resetValidation: () => inputRef.value?.resetValidation(),
237
+ validate: () => inputRef.value?.validate(),
238
+ })
239
+ </script>
240
+
241
+ <template>
242
+ <v-input v-model="images" v-bind="$attrs" ref="inputRef">
243
+ <template #default="{ isReadonly, isDisabled }">
244
+ <v-container fluid>
245
+ <v-row>
246
+ <v-col class="pa-0">
247
+ <VCard>
248
+ <VToolbar density="compact">
249
+ <VToolbarTitle>{{ label }}</VToolbarTitle>
250
+ <v-spacer />
251
+ <VToolbarItems v-if="!readonly">
252
+ <FileBtn
253
+ v-model="uploadImages"
254
+ :accept="accept"
255
+ color="primary"
256
+ icon="mdi:mdi-image-plus"
257
+ icon-only
258
+ multiple
259
+ variant="text"
260
+ @update:model-value="uploadImageFile"
261
+ :disabled="isDisabled?.value" :readonly="isReadonly?.value"
262
+ />
263
+ <v-btn color="primary" icon @click="dialog = true" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
264
+ <v-icon>mdi mdi-camera-plus</v-icon>
265
+ </v-btn>
266
+ </VToolbarItems>
267
+ </VToolbar>
268
+
269
+ <VCardText>
270
+ <VRow dense justify="start">
271
+ <VCol v-for="(image, index) in images" :key="`${imageKey(image)}-${index}`" cols="4">
272
+ <VCard>
273
+ <VToolbar density="compact">
274
+ <VToolbarTitle>
275
+ {{ image.imageTitle }}
276
+ </VToolbarTitle>
277
+ <VSpacer />
278
+ <VToolbarItems v-if="!readonly">
279
+ <v-btn icon @click="remove(index)" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
280
+ <v-icon>mdi mdi-delete-outline</v-icon>
281
+ </v-btn>
282
+ <v-btn color="primary" icon @click="setDataUpdate(image)" v-if="!image.imageData?.id" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
283
+ <v-icon>mdi mdi-image-edit-outline</v-icon>
284
+ </v-btn>
285
+ </VToolbarItems>
286
+ </VToolbar>
287
+
288
+ <v-img
289
+ :src="imageSrcFromImageData(image)"
290
+ height="250"
291
+ @click="() => { (props.readonly || image.imageData?.id || isReadonly?.value) ? openImageFullScreen(image) : setDataUpdate(image) }"
292
+ :disabled="isDisabled?.value"
293
+ />
294
+ </VCard>
295
+ </VCol>
296
+ </VRow>
297
+ </VCardText>
298
+ </VCard>
299
+ </v-col>
300
+ </v-row>
301
+ </v-container>
302
+
303
+
304
+ <!-- Edit dialog -->
305
+ <VDialog v-model="dialogUpdate" fullscreen transition="dialog-bottom-transition">
306
+ <FormImagesPad
307
+ v-model="dataUpdate.imageData.base64String"
308
+ @closedDialog="dialogUpdate = false"
309
+ />
310
+ </VDialog>
311
+
312
+ <!-- Capture dialog -->
313
+ <FormDialog v-model="dialog" :form-data="modelData" @create="captureImage">
314
+ <template #default="{ data }">
315
+ <FormImagesCapture v-model="data.imageCapture" />
316
+ </template>
317
+ </FormDialog>
318
+
319
+ <!-- Fullscreen preview -->
320
+ <v-dialog v-model="dialogImageFullScreen">
321
+ <v-toolbar :title="imageFullScreen.title">
322
+ <v-spacer />
323
+ <v-btn icon="mdi mdi-close" @click="dialogImageFullScreen = false" />
324
+ </v-toolbar>
325
+ <v-card height="80vh">
326
+ <v-img :src="imageFullScreen.image" />
327
+ </v-card>
328
+ </v-dialog>
329
+ </template>
330
+ </v-input>
331
+ </template>