inicontent 1.0.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 (205) hide show
  1. package/.github/pull_request.yml +18 -0
  2. package/CONTRIBUTING.md +67 -0
  3. package/LICENSE +21 -0
  4. package/README.md +142 -0
  5. package/app/app.vue +106 -0
  6. package/app/assets/main.css +974 -0
  7. package/app/components/Api/EndpointCard.vue +169 -0
  8. package/app/components/Api/SchemaDocsTable.vue +208 -0
  9. package/app/components/Asset/Card.vue +1363 -0
  10. package/app/components/Asset/Grid.vue +295 -0
  11. package/app/components/Asset/Icon.vue +38 -0
  12. package/app/components/Asset/Preview.vue +486 -0
  13. package/app/components/Asset/Scanner.vue +700 -0
  14. package/app/components/Asset/Thumb.vue +708 -0
  15. package/app/components/Auth/Reset.vue +99 -0
  16. package/app/components/Auth/index.vue +346 -0
  17. package/app/components/ChatInterface.vue +2326 -0
  18. package/app/components/ChatPagePreview.vue +34 -0
  19. package/app/components/Column/Array.vue +29 -0
  20. package/app/components/Column/Asset.vue +21 -0
  21. package/app/components/Column/Boolean.vue +12 -0
  22. package/app/components/Column/Color.vue +9 -0
  23. package/app/components/Column/Date.vue +57 -0
  24. package/app/components/Column/Edit.vue +215 -0
  25. package/app/components/Column/Email.vue +9 -0
  26. package/app/components/Column/Html.vue +27 -0
  27. package/app/components/Column/Id.vue +20 -0
  28. package/app/components/Column/Locale.vue +32 -0
  29. package/app/components/Column/Object.vue +18 -0
  30. package/app/components/Column/Password.vue +7 -0
  31. package/app/components/Column/Role.vue +15 -0
  32. package/app/components/Column/S.vue +14 -0
  33. package/app/components/Column/Table/Single.vue +49 -0
  34. package/app/components/Column/Table/index.vue +42 -0
  35. package/app/components/Column/Tags.vue +31 -0
  36. package/app/components/Column/Text.vue +9 -0
  37. package/app/components/Column/Url.vue +9 -0
  38. package/app/components/Column/index.vue +41 -0
  39. package/app/components/Dashboard/Editor.vue +221 -0
  40. package/app/components/Dashboard/Grid.vue +270 -0
  41. package/app/components/Dashboard/View.vue +131 -0
  42. package/app/components/Dashboard/Widget/BarChart.vue +73 -0
  43. package/app/components/Dashboard/Widget/Counter.vue +51 -0
  44. package/app/components/Dashboard/Widget/LineChart.vue +76 -0
  45. package/app/components/Dashboard/Widget/PieChart.vue +75 -0
  46. package/app/components/Dashboard/Widget/Table.vue +94 -0
  47. package/app/components/Dashboard/WidgetEditor.vue +274 -0
  48. package/app/components/Data/Array.vue +102 -0
  49. package/app/components/Data/Html.vue +9 -0
  50. package/app/components/Data/Icon.vue +9 -0
  51. package/app/components/Data/Object.vue +10 -0
  52. package/app/components/Data/S.vue +16 -0
  53. package/app/components/Data/Table.vue +53 -0
  54. package/app/components/Data/Value.vue +34 -0
  55. package/app/components/Data/index.vue +14 -0
  56. package/app/components/Field/Array.vue +324 -0
  57. package/app/components/Field/Asset/Actions.vue +97 -0
  58. package/app/components/Field/Asset/index.vue +639 -0
  59. package/app/components/Field/Boolean.vue +26 -0
  60. package/app/components/Field/Checkbox.vue +48 -0
  61. package/app/components/Field/Color.vue +27 -0
  62. package/app/components/Field/Date.vue +30 -0
  63. package/app/components/Field/Email.vue +51 -0
  64. package/app/components/Field/EmailTemplate.vue +420 -0
  65. package/app/components/Field/Html/ColorPicker.vue +127 -0
  66. package/app/components/Field/Html/Editor.vue +544 -0
  67. package/app/components/Field/Html/index.vue +25 -0
  68. package/app/components/Field/Icon.vue +114 -0
  69. package/app/components/Field/JSON.vue +113 -0
  70. package/app/components/Field/Mention.vue +36 -0
  71. package/app/components/Field/Number.vue +27 -0
  72. package/app/components/Field/Object.vue +92 -0
  73. package/app/components/Field/Password.vue +55 -0
  74. package/app/components/Field/Radio.vue +47 -0
  75. package/app/components/Field/S.vue +44 -0
  76. package/app/components/Field/Select.vue +49 -0
  77. package/app/components/Field/Table.vue +348 -0
  78. package/app/components/Field/Tags.vue +113 -0
  79. package/app/components/Field/Text.vue +31 -0
  80. package/app/components/Field/Textarea.vue +33 -0
  81. package/app/components/Field/Url.vue +40 -0
  82. package/app/components/Field/Wrapper.vue +128 -0
  83. package/app/components/Field/index.vue +68 -0
  84. package/app/components/FloatingChatbot.vue +73 -0
  85. package/app/components/Form/Card.vue +151 -0
  86. package/app/components/Form/Drawer.vue +195 -0
  87. package/app/components/Form/index.vue +621 -0
  88. package/app/components/Header.vue +328 -0
  89. package/app/components/Offline/ConflictModal.vue +200 -0
  90. package/app/components/Offline/SyncStatus.vue +249 -0
  91. package/app/components/PlatformFeatureLanding.vue +65 -0
  92. package/app/components/Table/Backups.vue +638 -0
  93. package/app/components/Table/Flows.vue +814 -0
  94. package/app/components/Table/Grid.vue +296 -0
  95. package/app/components/Table/Icon.vue +36 -0
  96. package/app/components/Table/Logs.vue +181 -0
  97. package/app/components/Table/Schedules.vue +722 -0
  98. package/app/components/Table/Search/Button.vue +256 -0
  99. package/app/components/Table/Search/Items.vue +286 -0
  100. package/app/components/Table/Search/index.vue +87 -0
  101. package/app/components/Table/Settings/Schema.vue +801 -0
  102. package/app/components/Table/Settings/index.vue +631 -0
  103. package/app/components/Table/TranslateDrawer.vue +526 -0
  104. package/app/components/Table/Views/Kanban.vue +289 -0
  105. package/app/components/Table/Views/Table.vue +1273 -0
  106. package/app/components/Table/index.vue +890 -0
  107. package/app/composables/Translation/define.ts +41 -0
  108. package/app/composables/Translation/fetch.ts +107 -0
  109. package/app/composables/Translation/languages.ts +1 -0
  110. package/app/composables/Translation/loadCoreTranslations.ts +38 -0
  111. package/app/composables/Translation/translate.ts +250 -0
  112. package/app/composables/databaseCookies.ts +79 -0
  113. package/app/composables/fieldValidator.ts +72 -0
  114. package/app/composables/fieldsList.ts +301 -0
  115. package/app/composables/formatDatabase.ts +101 -0
  116. package/app/composables/index.ts +444 -0
  117. package/app/composables/openDrawer.ts +118 -0
  118. package/app/composables/optimizeFile.ts +287 -0
  119. package/app/composables/renderLabel.ts +88 -0
  120. package/app/composables/schemaClipboard.ts +95 -0
  121. package/app/composables/search.ts +353 -0
  122. package/app/composables/setThemeConfig.ts +48 -0
  123. package/app/composables/translationValue.ts +169 -0
  124. package/app/composables/useAssetPreview.ts +84 -0
  125. package/app/composables/useAssetUploader.ts +56 -0
  126. package/app/composables/useDashboardData.ts +310 -0
  127. package/app/composables/useDocumentThumbnail.ts +351 -0
  128. package/app/composables/useEcharts.ts +33 -0
  129. package/app/composables/useLocalDatabaseConfig.ts +51 -0
  130. package/app/composables/useOcr.ts +300 -0
  131. package/app/composables/useOfflineFetch.ts +194 -0
  132. package/app/composables/useOfflineItem.ts +107 -0
  133. package/app/composables/useOfflineQueue.ts +302 -0
  134. package/app/composables/useOfflineSync.ts +235 -0
  135. package/app/composables/usePasskeyAuth.ts +156 -0
  136. package/app/composables/usePdfCompressor.ts +180 -0
  137. package/app/composables/usePlatformRedirect.ts +72 -0
  138. package/app/composables/useRealtimeSync.ts +233 -0
  139. package/app/composables/useReferencedItems.ts +180 -0
  140. package/app/composables/useScanner.ts +664 -0
  141. package/app/composables/useSubscription.ts +650 -0
  142. package/app/composables/useThumbnailCache.ts +136 -0
  143. package/app/composables/useVideoCompressor.ts +194 -0
  144. package/app/error.vue +112 -0
  145. package/app/layouts/api.vue +188 -0
  146. package/app/layouts/dashboard.vue +91 -0
  147. package/app/layouts/default.vue +228 -0
  148. package/app/layouts/table.vue +471 -0
  149. package/app/locales/ar.ts +996 -0
  150. package/app/locales/en.ts +655 -0
  151. package/app/locales/es.ts +996 -0
  152. package/app/locales/fr.ts +997 -0
  153. package/app/middleware/dashboard.ts +33 -0
  154. package/app/middleware/database.ts +83 -0
  155. package/app/middleware/global.ts +1 -0
  156. package/app/middleware/sid-ingest.global.ts +29 -0
  157. package/app/middleware/table.ts +54 -0
  158. package/app/middleware/user.ts +26 -0
  159. package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
  160. package/app/pages/[[database]]/admin/api/index.vue +18 -0
  161. package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
  162. package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
  163. package/app/pages/[[database]]/admin/billing/index.vue +16 -0
  164. package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
  165. package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
  166. package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
  167. package/app/pages/[[database]]/admin/index.vue +28 -0
  168. package/app/pages/[[database]]/admin/settings.vue +526 -0
  169. package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
  170. package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
  171. package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
  172. package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
  173. package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
  174. package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
  175. package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
  176. package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
  177. package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
  178. package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
  179. package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
  180. package/app/pages/[[database]]/admin/tables/index.vue +20 -0
  181. package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
  182. package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
  183. package/app/pages/[[database]]/api/index.vue +120 -0
  184. package/app/pages/[[database]]/auth/index.vue +12 -0
  185. package/app/pages/[[database]]/auth/reset.vue +10 -0
  186. package/app/pages/[[database]]/index.vue +10 -0
  187. package/app/plugins/offline-warmup.client.ts +53 -0
  188. package/app/plugins/template-globals.ts +9 -0
  189. package/app/spa-loading-template.html +93 -0
  190. package/biome.json +53 -0
  191. package/index.d.ts +380 -0
  192. package/modules/naiveui.ts +41 -0
  193. package/nuxt.config.ts +269 -0
  194. package/package.json +56 -0
  195. package/pnpm-workspace.yaml +5 -0
  196. package/public/apple-touch-icon.png +0 -0
  197. package/public/favicon-16x16.png +0 -0
  198. package/public/favicon-32x32.png +0 -0
  199. package/public/favicon.ico +0 -0
  200. package/public/pwa-192x192.png +0 -0
  201. package/public/pwa-512x512.png +0 -0
  202. package/public/pwa-maskable-192x192.png +0 -0
  203. package/public/pwa-maskable-512x512.png +0 -0
  204. package/public/vendor/opencv.js +48 -0
  205. package/tsconfig.json +9 -0
@@ -0,0 +1,700 @@
1
+ <template>
2
+ <NModal :show="show" @update:show="onVisibilityChange" :mask-closable="false"
3
+ preset="card" :title="t('documentScanner')" style="max-width: 720px; width: 95vw;"
4
+ :segmented="{ content: true, footer: true }">
5
+ <!-- Tabs: Camera / Upload -->
6
+ <NTabs v-model:value="activeTab" type="segment" animated>
7
+ <!-- ═══════════════ Camera Tab ═══════════════ -->
8
+ <NTabPane name="camera" :tab="t('camera')">
9
+ <NFlex vertical align="center" :size="16">
10
+ <!-- Inline OpenCV status banners (do NOT block the camera preview) -->
11
+ <NFlex v-if="scannerLoading" align="center" :size="8">
12
+ <NSpin size="small" />
13
+ <NText depth="3" style="font-size: 13px;">{{ t('loadingScanner') }}…</NText>
14
+ </NFlex>
15
+ <NAlert v-else-if="scannerError" type="error" :title="t('error')" closable
16
+ @close="dismissScannerError">
17
+ {{ scannerError }}
18
+ </NAlert>
19
+
20
+ <!-- Always rendered; hidden until a stream is attached
21
+ (cameraReady is only set after the refs exist) -->
22
+ <div style="position: relative; width: 100%; border-radius: 8px; overflow: hidden; background: #000;">
23
+ <video ref="videoEl" autoplay playsinline muted
24
+ :style="cameraReady ? 'width: 100%; display: block;' : 'display: none;'"
25
+ @loadedmetadata="onVideoReady" />
26
+ <canvas ref="overlayCanvas"
27
+ :style="cameraReady ? 'position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none;' : 'display: none;'" />
28
+ <NFlex v-if="cameraStarting" style="position: absolute; inset: 0;" align="center"
29
+ justify="center">
30
+ <NSpin size="large" />
31
+ </NFlex>
32
+ </div>
33
+
34
+ <!-- Live detection guidance -->
35
+ <NTag v-if="cameraReady && detectionStatus" :bordered="false"
36
+ :color="{ color: detectionStatus.color, textColor: '#fff' }" size="small">
37
+ <template #icon>
38
+ <NIcon>
39
+ <Icon :name="detectionStatus.ok ? 'tabler:check' : 'tabler:alert-triangle'" />
40
+ </NIcon>
41
+ </template>
42
+ {{ detectionStatus.label }}
43
+ </NTag>
44
+
45
+ <NFlex v-if="cameraError" vertical align="center">
46
+ <NAlert type="warning" :title="t('cameraUnavailable')">
47
+ {{ cameraError }}
48
+ </NAlert>
49
+ <NButton size="small" @click="openCamera">{{ t('retry') }}</NButton>
50
+ </NFlex>
51
+
52
+ <NButton v-if="!cameraReady" block @click="openCamera" :loading="cameraStarting">
53
+ <template #icon>
54
+ <NIcon>
55
+ <Icon name="tabler:camera" />
56
+ </NIcon>
57
+ </template>
58
+ {{ t('openCamera') }}
59
+ </NButton>
60
+ <NButton v-else type="primary" block @click="capture">
61
+ <template #icon>
62
+ <NIcon>
63
+ <Icon name="tabler:camera" />
64
+ </NIcon>
65
+ </template>
66
+ {{ t('capture') }}
67
+ </NButton>
68
+ </NFlex>
69
+ </NTabPane>
70
+
71
+ <!-- ═══════════════ Upload Tab ═══════════════ -->
72
+ <NTabPane name="upload" :tab="t('uploadImage')">
73
+ <NFlex vertical align="center" :size="16">
74
+ <input ref="fileInput" type="file" accept="image/*" style="display: none;"
75
+ @change="onFileSelected" />
76
+
77
+ <!-- Drop zone -->
78
+ <div v-if="!previewCanvas"
79
+ style="width: 100%; border: 2px dashed #ccc; border-radius: 8px; padding: 48px 16px; text-align: center; cursor: pointer;"
80
+ @click="fileInput?.click()"
81
+ @dragover.prevent
82
+ @drop.prevent="onDrop">
83
+ <NIcon size="48" depth="3" style="margin-bottom: 8px;">
84
+ <Icon name="tabler:photo-up" />
85
+ </NIcon>
86
+ <NText depth="3" style="display: block; font-size: 14px;">
87
+ {{ t('dropImageOrClick') }}
88
+ </NText>
89
+ </div>
90
+ </NFlex>
91
+ </NTabPane>
92
+ </NTabs>
93
+
94
+ <!-- ═══════════════ Preview (shown after capture or upload) ═══════════════ -->
95
+ <NFlex v-if="previewCanvas" vertical :size="12" style="margin-top: 16px;">
96
+ <NText strong>{{ t('preview') }}</NText>
97
+
98
+ <!-- Manual corner adjustment mode -->
99
+ <div v-if="manualAdjust" style="width: 100%;">
100
+ <div ref="adjustBoxEl" class="adjust-box" style="position: relative; width: 100%; border-radius: 8px; overflow: hidden; touch-action: none;"
101
+ @pointermove="onAdjustPointerMove" @pointerup="onAdjustPointerUp"
102
+ @pointercancel="onAdjustPointerUp">
103
+ <canvas ref="adjustCanvasEl" :width="rawCanvas?.width ?? 1"
104
+ :height="rawCanvas?.height ?? 1"
105
+ style="width: 100%; display: block; pointer-events: none;" />
106
+ <svg :viewBox="`0 0 ${(rawCanvas?.width ?? 1)} ${(rawCanvas?.height ?? 1)}`"
107
+ preserveAspectRatio="none"
108
+ style="position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none;">
109
+ <polygon :points="quadPoints" fill="rgba(22, 163, 74, 0.18)" stroke="#16a34a"
110
+ stroke-width="3" vector-effect="non-scaling-stroke" />
111
+ </svg>
112
+ <div v-for="key in cornerKeys" :key="key" class="corner-handle"
113
+ :data-corner="key" :style="cornerStyle(key)"
114
+ @pointerdown.stop.prevent="onAdjustPointerDown($event, key)" />
115
+ </div>
116
+ <NText depth="3" style="display: block; font-size: 12px; margin-top: 6px; text-align: center;">
117
+ {{ t('dragHandlesToAdjust') }}
118
+ </NText>
119
+ <NFlex justify="space-between" style="margin-top: 10px;">
120
+ <NButton size="small" quaternary @click="cancelManualAdjust">{{ t('cancel') }}</NButton>
121
+ <NButton size="small" type="primary" @click="applyManualCrop" :disabled="adjusting">
122
+ <template #icon>
123
+ <NIcon>
124
+ <Icon name="tabler:crop" />
125
+ </NIcon>
126
+ </template>
127
+ {{ t('applyCrop') }}
128
+ </NButton>
129
+ </NFlex>
130
+ </div>
131
+
132
+ <template v-else>
133
+ <!-- Auto-extraction failed warning -->
134
+ <NAlert v-if="autoDetectionFailed" type="warning" :title="t('documentNotDetected')" closable
135
+ @close="autoDetectionFailed = false">
136
+ {{ detectionMessage }}
137
+ </NAlert>
138
+
139
+ <div style="width: 100%; border-radius: 8px; overflow: hidden; border: 1px solid var(--n-border-color, #eee);">
140
+ <canvas ref="previewEl" style="width: 100%; display: block;" />
141
+ </div>
142
+ </template>
143
+
144
+ <!-- Extract progress -->
145
+ <NFlex v-if="extracting" align="center" :size="8">
146
+ <NSpin size="small" />
147
+ <NText depth="3" style="font-size: 13px;">{{ t('extractingDocument') }}…</NText>
148
+ </NFlex>
149
+
150
+ <!-- OCR progress (only when allowOCR is true and OCR is running) -->
151
+ <NFlex v-if="ocrRunning" align="center" :size="8">
152
+ <NProgress type="line" :percentage="ocrProgress" :show-indicator="false"
153
+ style="flex: 1;" status="success" />
154
+ <NText depth="3" style="font-size: 12px;">{{ t('ocr') }} {{ ocrProgress }}%</NText>
155
+ </NFlex>
156
+
157
+ <!-- Adjust corners (escape hatch when auto-detection is weak) -->
158
+ <NButton v-if="!manualAdjust" size="small" quaternary circle
159
+ style="align-self: center;" @click="enterManualAdjust" :disabled="adjusting">
160
+ <template #icon>
161
+ <NIcon>
162
+ <Icon name="tabler:adjustments-horizontal" />
163
+ </NIcon>
164
+ </template>
165
+ {{ t('adjustCorners') }}
166
+ </NButton>
167
+
168
+ <!-- OCR language selector -->
169
+ <NFlex v-if="allowOCR" align="center" :size="8">
170
+ <NText depth="3" style="font-size: 13px;">{{ t('language') }}:</NText>
171
+ <NSelect :options="ocrLanguageOptions" v-model:value="selectedOcrLanguage" size="small"
172
+ style="width: 140px;" />
173
+ </NFlex>
174
+ </NFlex>
175
+
176
+ <!-- ═══════════════ OCR Output (only when allowOCR) ═══════════════ -->
177
+ <NFlex v-if="allowOCR && ocrResult" vertical :size="8" style="margin-top: 12px;">
178
+ <NFlex align="center" justify="space-between">
179
+ <NText strong>{{ t('extractedText') }}</NText>
180
+ <NButton size="tiny" quaternary @click="copyOcrText">
181
+ <template #icon>
182
+ <NIcon>
183
+ <Icon name="tabler:copy" />
184
+ </NIcon>
185
+ </template>
186
+ {{ t('copy') }}
187
+ </NButton>
188
+ </NFlex>
189
+ <NInput type="textarea" :value="ocrResult.text" :autosize="{ minRows: 3, maxRows: 8 }"
190
+ readonly style="font-size: 13px;" />
191
+ </NFlex>
192
+
193
+ <template #footer>
194
+ <NFlex justify="space-between">
195
+ <NButton v-if="previewCanvas" @click="reset" quaternary :disabled="adjusting">
196
+ <template #icon>
197
+ <NIcon>
198
+ <Icon name="tabler:refresh" />
199
+ </NIcon>
200
+ </template>
201
+ {{ allowOCR && ocrResult ? t('retake') : t('reset') }}
202
+ </NButton>
203
+ <NFlex :size="8">
204
+ <NButton v-if="allowOCR && ocrResult && !manualAdjust" type="primary" @click="emitInsertText">
205
+ <template #icon>
206
+ <NIcon>
207
+ <Icon name="tabler:article" />
208
+ </NIcon>
209
+ </template>
210
+ {{ t('insertAsText') }}
211
+ </NButton>
212
+ <NButton v-if="previewCanvas && !manualAdjust" type="primary" @click="emitInsertImage"
213
+ :disabled="extracting || adjusting">
214
+ <template #icon>
215
+ <NIcon>
216
+ <Icon name="tabler:photo" />
217
+ </NIcon>
218
+ </template>
219
+ {{ allowOCR ? t('insertAsImage') : t('useThisScan') }}
220
+ </NButton>
221
+ </NFlex>
222
+ </NFlex>
223
+ </template>
224
+ </NModal>
225
+ </template>
226
+
227
+ <script setup lang="ts">
228
+ import { copyToClipboard } from "~/composables";
229
+ import type { OcrResult } from "~/composables/useOcr";
230
+ import type { Corners, PaperAnalysis } from "~/composables/useScanner";
231
+
232
+ const props = withDefaults(
233
+ defineProps<{
234
+ show: boolean;
235
+ /** Enable OCR text extraction + "Insert as Text" button */
236
+ allowOCR?: boolean;
237
+ }>(),
238
+ { allowOCR: false },
239
+ );
240
+
241
+ const emit = defineEmits<{
242
+ "update:show": [value: boolean];
243
+ /** Emitted when user clicks "Insert as Image" */
244
+ scanned: [blob: Blob, dataUrl: string];
245
+ /** Emitted when user clicks "Insert as Text" (only if allowOCR) */
246
+ insertText: [text: string, blocks: any[]];
247
+ }>();
248
+
249
+ // `t` is auto-imported globally (see composables/index.ts + template-globals plugin).
250
+
251
+ // ── Scanner (jscanify) ───────────────────────────────────────
252
+ const {
253
+ scanner,
254
+ loading: scannerLoading,
255
+ error: scannerError,
256
+ loadScanner,
257
+ startCamera,
258
+ stopCamera,
259
+ startHighlightLoop,
260
+ stopHighlightLoop,
261
+ captureFrame,
262
+ smartExtract,
263
+ warpCorners,
264
+ fullFrameCorners,
265
+ cornersSize,
266
+ analysisHint,
267
+ canvasToBlob,
268
+ } = useScanner();
269
+
270
+ // ── OCR ──────────────────────────────────────────────────────
271
+ // Always instantiate the composable (hooks must not be conditional);
272
+ // usage is gated on allowOCR.
273
+ const ocr = useOcr();
274
+
275
+ const ocrLanguageOptions = [
276
+ { label: "English", value: "eng" },
277
+ { label: "العربية", value: "ara" },
278
+ { label: "Français", value: "fra" },
279
+ { label: "Español", value: "spa" },
280
+ ];
281
+
282
+ const selectedOcrLanguage = ref<string>("eng");
283
+ const ocrRunning = ref(false);
284
+ const ocrProgress = computed(() => ocr.progress.value);
285
+ const ocrResult = ref<OcrResult | null>(null);
286
+
287
+ watch(selectedOcrLanguage, async (lang) => {
288
+ if (props.allowOCR) await ocr.setLanguage(lang);
289
+ });
290
+
291
+ // ── State ────────────────────────────────────────────────────
292
+ const activeTab = ref("camera");
293
+
294
+ const videoEl = ref<HTMLVideoElement>();
295
+ const overlayCanvas = ref<HTMLCanvasElement>();
296
+ const previewEl = ref<HTMLCanvasElement>();
297
+ const adjustBoxEl = ref<HTMLDivElement>();
298
+ const adjustCanvasEl = ref<HTMLCanvasElement>();
299
+ const fileInput = ref<HTMLInputElement>();
300
+
301
+ const cameraReady = ref(false);
302
+ const cameraStarting = ref(false);
303
+ const cameraError = ref<string | null>(null);
304
+ const previewCanvas = ref<HTMLCanvasElement | null>(null);
305
+ const extracting = ref(false);
306
+
307
+ /** The original (uncropped) capture/upload – the source for manual adjustment */
308
+ const rawCanvas = ref<HTMLCanvasElement | null>(null);
309
+ /** Result of the last auto-detection attempt */
310
+ const lastAnalysis = ref<PaperAnalysis | null>(null);
311
+ const autoDetectionFailed = ref(false);
312
+ const detectionMessage = ref<string | null>(null);
313
+ /** Live camera overlay guidance chip */
314
+ const detectionStatus = ref<{
315
+ label: string;
316
+ color: string;
317
+ ok: boolean;
318
+ } | null>(null);
319
+
320
+ // Manual corner adjustment
321
+ const cornerKeys: (keyof Corners)[] = [
322
+ "topLeft",
323
+ "topRight",
324
+ "bottomRight",
325
+ "bottomLeft",
326
+ ];
327
+ const manualAdjust = ref(false);
328
+ const adjusting = ref(false);
329
+ const draggingCorner = ref<keyof Corners | null>(null);
330
+ const manualCorners = ref<Corners | null>(null);
331
+
332
+ function clamp(v: number, min: number, max: number): number {
333
+ return Math.max(min, Math.min(max, v));
334
+ }
335
+
336
+ // ── Init scanner on mount ────────────────────────────────────
337
+ async function initScanner() {
338
+ try {
339
+ await loadScanner();
340
+ } catch {
341
+ // Error is exposed via scannerError
342
+ }
343
+ }
344
+
345
+ onMounted(() => {
346
+ initScanner();
347
+ });
348
+
349
+ // ── Camera ───────────────────────────────────────────────────
350
+ async function openCamera() {
351
+ cameraStarting.value = true;
352
+ cameraError.value = null;
353
+
354
+ try {
355
+ const stream = await startCamera();
356
+ // Mount the video element first, then attach the stream
357
+ cameraReady.value = true;
358
+ await nextTick();
359
+ if (videoEl.value) {
360
+ videoEl.value.srcObject = stream;
361
+ // muted+playsinline autoplays by default; play() is a safety net
362
+ await videoEl.value.play().catch(() => {});
363
+ } else {
364
+ cameraReady.value = false;
365
+ cameraError.value = "Video element unavailable";
366
+ }
367
+ } catch (e: any) {
368
+ cameraReady.value = false;
369
+ cameraError.value = e?.message ?? "Camera access denied";
370
+ } finally {
371
+ cameraStarting.value = false;
372
+ }
373
+ }
374
+
375
+ function onVideoReady() {
376
+ if (videoEl.value && overlayCanvas.value) {
377
+ startHighlightLoop(videoEl.value, overlayCanvas.value, (status) => {
378
+ detectionStatus.value = status;
379
+ });
380
+ }
381
+ }
382
+
383
+ function capture() {
384
+ if (!videoEl.value) return;
385
+ stopHighlightLoop();
386
+
387
+ rawCanvas.value = captureFrame(videoEl.value);
388
+ autoExtract();
389
+ }
390
+
391
+ // ── File upload ──────────────────────────────────────────────
392
+ function onFileSelected(e: Event) {
393
+ const input = e.target as HTMLInputElement;
394
+ const file = input.files?.[0];
395
+ if (file) loadImageFile(file);
396
+ input.value = "";
397
+ }
398
+
399
+ function onDrop(e: DragEvent) {
400
+ const file = e.dataTransfer?.files?.[0];
401
+ if (file && file.type.startsWith("image/")) loadImageFile(file);
402
+ }
403
+
404
+ async function loadImageFile(file: File) {
405
+ const url = URL.createObjectURL(file);
406
+ const img = new Image();
407
+ img.onload = () => {
408
+ const canvas = document.createElement("canvas");
409
+ canvas.width = img.naturalWidth;
410
+ canvas.height = img.naturalHeight;
411
+ const ctx = canvas.getContext("2d")!;
412
+ ctx.drawImage(img, 0, 0);
413
+ URL.revokeObjectURL(url);
414
+
415
+ rawCanvas.value = canvas;
416
+ autoExtract();
417
+ };
418
+ img.src = url;
419
+ }
420
+
421
+ /**
422
+ * Run detection + perspective extraction on the raw frame.
423
+ * Falls back to the raw image (with a warning) when no good quad found.
424
+ */
425
+ async function autoExtract() {
426
+ if (!rawCanvas.value) return;
427
+
428
+ // Ensure the scanner is loaded before attempting detection
429
+ if (!scanner.value) {
430
+ try {
431
+ await loadScanner();
432
+ } catch {
433
+ // scannerError banner covers this
434
+ }
435
+ }
436
+
437
+ let canvas: HTMLCanvasElement | null = null;
438
+ let analysis: PaperAnalysis | null = null;
439
+ if (scanner.value) {
440
+ const result = smartExtract(rawCanvas.value);
441
+ canvas = result.canvas;
442
+ analysis = result.analysis;
443
+ }
444
+ lastAnalysis.value = analysis;
445
+
446
+ if (canvas) {
447
+ showPreview(canvas);
448
+ } else {
449
+ showPreview(rawCanvas.value);
450
+ autoDetectionFailed.value = true;
451
+ detectionMessage.value = analysis
452
+ ? analysisHint(analysis)
453
+ : t("documentNotDetected");
454
+ }
455
+ }
456
+
457
+ // ── Preview ──────────────────────────────────────────────────
458
+ async function showPreview(canvas: HTMLCanvasElement) {
459
+ previewCanvas.value = canvas;
460
+
461
+ await nextTick();
462
+ if (previewEl.value) {
463
+ previewEl.value.width = canvas.width;
464
+ previewEl.value.height = canvas.height;
465
+ const ctx = previewEl.value.getContext("2d")!;
466
+ ctx.drawImage(canvas, 0, 0);
467
+ }
468
+
469
+ // Auto-run OCR if enabled
470
+ if (props.allowOCR && ocr) {
471
+ runOcr(canvas);
472
+ }
473
+
474
+ // Stop camera if it was active
475
+ if (activeTab.value === "camera") {
476
+ stopCamera();
477
+ cameraReady.value = false;
478
+ }
479
+ }
480
+
481
+ async function runOcr(canvas: HTMLCanvasElement) {
482
+ if (!ocr) return;
483
+ ocrRunning.value = true;
484
+ ocrResult.value = null;
485
+
486
+ try {
487
+ ocrResult.value = await ocr.recognize(canvas);
488
+ } catch (e) {
489
+ console.error("OCR failed:", e);
490
+ } finally {
491
+ ocrRunning.value = false;
492
+ }
493
+ }
494
+
495
+ // ── Manual corner adjustment ────────────────────────────────
496
+ async function enterManualAdjust() {
497
+ if (!rawCanvas.value) return;
498
+
499
+ // Seed from the last detection if available, otherwise the full frame
500
+ manualCorners.value =
501
+ lastAnalysis.value?.corners ??
502
+ fullFrameCorners(rawCanvas.value.width, rawCanvas.value.height);
503
+ manualAdjust.value = true;
504
+
505
+ await nextTick();
506
+ if (adjustCanvasEl.value && rawCanvas.value) {
507
+ const ctx = adjustCanvasEl.value.getContext("2d")!;
508
+ ctx.clearRect(
509
+ 0,
510
+ 0,
511
+ adjustCanvasEl.value.width,
512
+ adjustCanvasEl.value.height,
513
+ );
514
+ ctx.drawImage(rawCanvas.value, 0, 0);
515
+ }
516
+ }
517
+
518
+ function cancelManualAdjust() {
519
+ manualAdjust.value = false;
520
+ draggingCorner.value = null;
521
+ }
522
+
523
+ async function applyManualCrop() {
524
+ if (!rawCanvas.value || !manualCorners.value) return;
525
+ adjusting.value = true;
526
+ try {
527
+ const size = cornersSize(manualCorners.value);
528
+ const outW = Math.min(900, Math.round(size.width));
529
+ const outH = Math.max(
530
+ 1,
531
+ Math.round(outW * (size.height / Math.max(1, size.width))),
532
+ );
533
+ const canvas = warpCorners(
534
+ rawCanvas.value,
535
+ manualCorners.value,
536
+ outW,
537
+ outH,
538
+ );
539
+ if (canvas) {
540
+ manualAdjust.value = false;
541
+ autoDetectionFailed.value = false;
542
+ detectionMessage.value = null;
543
+ showPreview(canvas);
544
+ } else {
545
+ detectionMessage.value = "Perspective transform failed";
546
+ }
547
+ } finally {
548
+ adjusting.value = false;
549
+ }
550
+ }
551
+
552
+ function cornerStyle(key: keyof Corners) {
553
+ const raw = rawCanvas.value;
554
+ const c = manualCorners.value?.[key];
555
+ if (!raw || !c) return {};
556
+ return {
557
+ left: `${(c.x / raw.width) * 100}%`,
558
+ top: `${(c.y / raw.height) * 100}%`,
559
+ transform: "translate(-50%, -50%)",
560
+ };
561
+ }
562
+
563
+ const quadPoints = computed(() => {
564
+ const raw = rawCanvas.value;
565
+ const c = manualCorners.value;
566
+ if (!raw || !c) return "";
567
+ return cornerKeys.map((k) => `${c[k].x},${c[k].y}`).join(" ");
568
+ });
569
+
570
+ function onAdjustPointerDown(e: PointerEvent, key: keyof Corners) {
571
+ draggingCorner.value = key;
572
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
573
+ updateCornerPosition(e);
574
+ }
575
+
576
+ function onAdjustPointerMove(e: PointerEvent) {
577
+ if (!draggingCorner.value) return;
578
+ updateCornerPosition(e);
579
+ }
580
+
581
+ function onAdjustPointerUp(e: PointerEvent) {
582
+ (e.target as HTMLElement).releasePointerCapture?.(e.pointerId);
583
+ draggingCorner.value = null;
584
+ }
585
+
586
+ function updateCornerPosition(e: PointerEvent) {
587
+ const box = adjustBoxEl.value;
588
+ const raw = rawCanvas.value;
589
+ const key = draggingCorner.value;
590
+ if (!box || !raw || !key || !manualCorners.value) return;
591
+
592
+ const rect = box.getBoundingClientRect();
593
+ manualCorners.value = {
594
+ ...manualCorners.value,
595
+ [key]: {
596
+ x: clamp(
597
+ ((e.clientX - rect.left) / rect.width) * raw.width,
598
+ 0,
599
+ raw.width,
600
+ ),
601
+ y: clamp(
602
+ ((e.clientY - rect.top) / rect.height) * raw.height,
603
+ 0,
604
+ raw.height,
605
+ ),
606
+ },
607
+ };
608
+ }
609
+
610
+ // ── Actions ──────────────────────────────────────────────────
611
+ async function emitInsertImage() {
612
+ if (!previewCanvas.value || extracting.value) return;
613
+ extracting.value = true;
614
+ try {
615
+ const blob = await canvasToBlob(previewCanvas.value);
616
+ const dataUrl = previewCanvas.value.toDataURL("image/png");
617
+ emit("scanned", blob, dataUrl);
618
+ close();
619
+ } finally {
620
+ extracting.value = false;
621
+ }
622
+ }
623
+
624
+ function emitInsertText() {
625
+ if (!ocrResult.value || !ocr) return;
626
+ const blocks = ocr.parseStructure(ocrResult.value.text);
627
+ const tiptapJson = ocr.blocksToTiptapJson(blocks);
628
+ emit("insertText", ocrResult.value.text, tiptapJson);
629
+ close();
630
+ }
631
+
632
+ function copyOcrText() {
633
+ if (ocrResult.value?.text) {
634
+ copyToClipboard(ocrResult.value.text);
635
+ }
636
+ }
637
+
638
+ function reset() {
639
+ previewCanvas.value = null;
640
+ rawCanvas.value = null;
641
+ ocrResult.value = null;
642
+ extracting.value = false;
643
+ manualAdjust.value = false;
644
+ autoDetectionFailed.value = false;
645
+ detectionMessage.value = null;
646
+ lastAnalysis.value = null;
647
+ draggingCorner.value = null;
648
+
649
+ // Re-open camera if on camera tab
650
+ if (activeTab.value === "camera") {
651
+ openCamera();
652
+ }
653
+ }
654
+
655
+ function dismissScannerError() {
656
+ scannerError.value = null;
657
+ }
658
+
659
+ function onVisibilityChange(visible: boolean) {
660
+ if (!visible) {
661
+ stopCamera();
662
+ stopHighlightLoop();
663
+ cameraReady.value = false;
664
+ previewCanvas.value = null;
665
+ rawCanvas.value = null;
666
+ ocrResult.value = null;
667
+ manualAdjust.value = false;
668
+ autoDetectionFailed.value = false;
669
+ detectionMessage.value = null;
670
+ lastAnalysis.value = null;
671
+ detectionStatus.value = null;
672
+ draggingCorner.value = null;
673
+ }
674
+ emit("update:show", visible);
675
+ }
676
+
677
+ function close() {
678
+ onVisibilityChange(false);
679
+ }
680
+ </script>
681
+
682
+ <style scoped>
683
+ .corner-handle {
684
+ position: absolute;
685
+ width: 20px;
686
+ height: 20px;
687
+ border-radius: 50%;
688
+ background: #16a34a;
689
+ border: 2px solid #fff;
690
+ box-shadow: 0 1px 4px rgb(0 0 0 / 0.45);
691
+ cursor: grab;
692
+ z-index: 2;
693
+ user-select: none;
694
+ -webkit-user-select: none;
695
+ }
696
+
697
+ .corner-handle:active {
698
+ cursor: grabbing;
699
+ }
700
+ </style>