@dile/scanner 0.0.8 → 0.0.9

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/README.md CHANGED
@@ -22,9 +22,9 @@ Read the docs and find demos on the [dile-components site](https://dile-componen
22
22
 
23
23
  ## ML detector files
24
24
 
25
- The ML detector is optional and off by default. Without `ml-detector` your users download nothing of it. With `ml-detector` the component uses scanic's neural detector, and the browser downloads its files (3.5 MB, about 2.2 MB with gzip) the first time a photo is processed.
25
+ The ML detector is optional and off by default. Without `ml-detector` your users download nothing of it. With `ml-detector` the component uses scanic's neural detector, and the browser downloads its files (3.5 MB, about 2.2 MB with gzip) in the background the first time the scanner is opened.
26
26
 
27
- By default the files come from jsDelivr. The package installs the matching `scanic-ml` version (3.5 MB in `node_modules`, only disk space in your project), so you can serve your own copy with the included Vite plugin:
27
+ By default the files come from jsDelivr. The package installs the matching `scanic-ml` version (3.5 MB in `node_modules`, only disk space in your project), so you can serve your own copy with the included Vite plugin. It serves them in a folder with the `scanic-ml` version (`/dile-scanner-ml/0.2.0/`) and exports that URL in a virtual module:
28
28
 
29
29
  ```javascript
30
30
  // vite.config.js
@@ -33,10 +33,36 @@ import { scannerMlAssets } from '@dile/scanner/vite-plugin.js';
33
33
  export default { plugins: [scannerMlAssets()] };
34
34
  ```
35
35
 
36
- ```html
37
- <dile-scanner ml-detector ml-assets-url="/dile-scanner-ml/"></dile-scanner>
36
+ ```javascript
37
+ import { mlAssetsUrl } from 'virtual:dile-scanner-ml';
38
+
39
+ scanner.mlDetector = true;
40
+ scanner.mlAssetsUrl = mlAssetsUrl;
38
41
  ```
39
42
 
43
+ ## Using it in a PWA
44
+
45
+ The ML files can be cached by the service worker so they are downloaded only once, the first time the scanner is opened. Don't precache them (they would be downloaded when installing the app); add a runtime caching rule instead. With vite-plugin-pwa:
46
+
47
+ ```javascript
48
+ VitePWA({
49
+ workbox: {
50
+ globIgnores: ['**/dile-scanner-ml/**'],
51
+ runtimeCaching: [{
52
+ urlPattern: ({ url }) => url.pathname.includes('/dile-scanner-ml/'),
53
+ handler: 'CacheFirst',
54
+ options: {
55
+ cacheName: 'dile-scanner-ml',
56
+ cacheableResponse: { statuses: [200] },
57
+ expiration: { maxEntries: 3 },
58
+ },
59
+ }],
60
+ },
61
+ })
62
+ ```
63
+
64
+ The versioned folder makes sure an update never mixes cached files of different versions. See the [docs](https://dile-components.com/components/dile-scanner/#pwa) for the details.
65
+
40
66
  ## Maintenance note
41
67
 
42
68
  `scanic` is pinned to an exact version because `src/corner-editor-styles.js` copies the styles of its corner editor (scanic injects them into `document.head`, which does not reach the shadow root). When updating scanic, review that copy against `node_modules/scanic/dist/scanic.js` (search `.scanic-corner-editor`) and update `scanic-ml` (also pinned) to the version that scanic expects (see the jsDelivr URL in `node_modules/scanic/dist/scanic-mlDetector.js`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/scanner",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Web Component to scan documents in the browser: edge detection, perspective correction and multipage PDF or JPEG output, powered by scanic and jsPDF",
5
5
  "keywords": [
6
6
  "scanner",
@@ -36,5 +36,5 @@
36
36
  "scanic": "1.7.1",
37
37
  "scanic-ml": "0.2.0"
38
38
  },
39
- "gitHead": "978a1d5b553cf39a5614223130f3446ec0bb89f2"
39
+ "gitHead": "2aab20e22c7c9ea537e67ca7bacd392ee6936885"
40
40
  }
@@ -2,6 +2,8 @@ import { LitElement, html, css, nothing } from "lit";
2
2
  import "@dile/ui/components/modal/modal.js";
3
3
  import "@dile/ui/components/button/button.js";
4
4
  import "@dile/ui/components/spinner/spinner.js";
5
+ import "@dile/ui/components/progress-bar/progress-bar.js";
6
+ import "@dile/iconlib/dile-iconlib.js";
5
7
  import "@dile/iconlib/lucide-icons/camera.js";
6
8
  import "@dile/iconlib/lucide-icons/image.js";
7
9
  import "@dile/iconlib/lucide-icons/check.js";
@@ -50,8 +52,8 @@ const SIZE_FALLBACKS = [
50
52
  // (output="images"). With "multipage" the user can add several pages, reorder
51
53
  // them, rotate them, adjust them again and remove them. With "ml-detector"
52
54
  // the edges are detected with scanic's neural detector (about 2.2 MB
53
- // downloaded with gzip the first time) instead of the classical one; off by
54
- // default.
55
+ // downloaded with gzip the first time the scanner opens) instead of the
56
+ // classical one; off by default.
55
57
  //
56
58
  // It does not upload anything: when the user saves, it dispatches
57
59
  // "dile-document-scanned" with the files and closes. Closing it without saving
@@ -77,6 +79,14 @@ export class DileScanner extends LitElement {
77
79
  --dile-modal-border-radius: var(--dile-scanner-modal-border-radius, 15px);
78
80
  --dile-modal-content-background-color: var(--dile-background-color, #fff);
79
81
  --dile-modal-content-text-color: var(--dile-on-background-color, #303030);
82
+ /* Grays mixed from the theme background and text colors, so the
83
+ scanner works with light and dark themes without hardcoded grays. */
84
+ --_fg: var(--dile-on-background-color, #303030);
85
+ --_bg: var(--dile-background-color, #fff);
86
+ --_surface: color-mix(in srgb, var(--_fg) 6%, var(--_bg));
87
+ --_surface-strong: color-mix(in srgb, var(--_fg) 12%, var(--_bg));
88
+ --_border: color-mix(in srgb, var(--_fg) 15%, var(--_bg));
89
+ --_muted: color-mix(in srgb, var(--_fg) 68%, var(--_bg));
80
90
  }
81
91
  @media (max-width: 600px) {
82
92
  :host {
@@ -127,14 +137,39 @@ export class DileScanner extends LitElement {
127
137
  }
128
138
  @media (max-width: 600px) {
129
139
  .footer {
130
- justify-content: space-between;
140
+ display: grid;
141
+ grid-auto-columns: 1fr;
142
+ grid-auto-flow: column;
131
143
  --dile-button-padding-x: 0.6rem;
132
144
  --dile-button-font-size: 0.9rem;
133
145
  }
146
+ .footer dile-button {
147
+ display: grid;
148
+ }
134
149
  }
150
+ /* Secondary buttons set their own text color: inheriting
151
+ --dile-on-primary-color can leave text without contrast on their
152
+ background. By default they use the theme background and text colors,
153
+ so they also work with dark themes. Hover keeps the same
154
+ colors so a theme hover color does not ruin the contrast either. */
135
155
  .secondary {
136
- --dile-button-background-color: var(--dile-scanner-secondary-button-color, #888);
137
- --dile-button-border-color: var(--dile-scanner-secondary-button-color, #888);
156
+ --dile-button-background-color: var(--dile-scanner-secondary-button-color, var(--_surface-strong));
157
+ --dile-button-border-color: var(--dile-scanner-secondary-button-color, var(--_surface-strong));
158
+ --dile-button-text-color: var(--dile-scanner-secondary-button-text-color, var(--_fg));
159
+ --dile-button-hover-background-color: var(--dile-scanner-secondary-button-color, var(--_surface-strong));
160
+ --dile-button-hover-border-color: var(--dile-scanner-secondary-button-color, var(--_surface-strong));
161
+ --dile-button-hover-text-color: var(--dile-scanner-secondary-button-text-color, var(--_fg));
162
+ }
163
+ /* Outlined buttons to add pages, lighter than the main action. */
164
+ .outline {
165
+ --dile-button-background-color: transparent;
166
+ --dile-button-hover-background-color: transparent;
167
+ --dile-button-border-color: var(--dile-scanner-outline-button-color, var(--_fg));
168
+ --dile-button-hover-border-color: var(--dile-scanner-outline-button-color, var(--_fg));
169
+ --dile-button-text-color: var(--dile-scanner-outline-button-color, var(--_fg));
170
+ --dile-button-hover-text-color: var(--dile-scanner-outline-button-color, var(--_fg));
171
+ --dile-button-border-width: 1px;
172
+ --dile-button-font-weight: normal;
138
173
  }
139
174
  .file-input {
140
175
  display: none;
@@ -152,53 +187,137 @@ export class DileScanner extends LitElement {
152
187
  height: calc(100dvh - 18rem);
153
188
  }
154
189
  }
190
+ .subtitle {
191
+ margin-top: -0.75rem;
192
+ font-size: 0.9rem;
193
+ color: var(--dile-scanner-muted-text-color, var(--_muted));
194
+ }
195
+ /* Pages in a horizontal scroll area with snap: on a phone each card
196
+ takes most of the width and the next one peeks out; on wider screens
197
+ several cards fit side by side. */
155
198
  .pages {
156
- display: grid;
157
- grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
199
+ display: flex;
158
200
  gap: 0.75rem;
201
+ overflow-x: auto;
202
+ scroll-snap-type: x mandatory;
203
+ padding: 0.25rem 0.25rem 0.75rem;
204
+ margin: 0 -0.25rem;
205
+ scrollbar-width: thin;
159
206
  }
160
207
  .page {
208
+ flex: 0 0 var(--dile-scanner-page-width, min(78%, 250px));
209
+ scroll-snap-align: center;
161
210
  display: flex;
162
211
  flex-direction: column;
163
- gap: 0.25rem;
212
+ gap: 0.5rem;
164
213
  padding: 0.5rem;
165
- border-radius: 0.5rem;
166
- background-color: var(--dile-very-light-color, #f4f4f4);
214
+ border-radius: 0.75rem;
215
+ background-color: var(--dile-scanner-page-background-color, var(--_bg));
216
+ border: 1px solid var(--dile-scanner-page-border-color, var(--_border));
217
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
218
+ }
219
+ .page-header .page-number {
220
+ color: var(--_fg);
167
221
  }
168
222
  .page img {
169
223
  width: 100%;
170
- height: 170px;
224
+ height: 200px;
171
225
  object-fit: contain;
172
- background-color: #fff;
173
- border-radius: 0.25rem;
226
+ padding: 0.5rem;
227
+ background-color: var(--dile-scanner-page-image-background-color, var(--_surface-strong));
228
+ border-radius: 0.5rem;
229
+ }
230
+ .single .pages {
231
+ justify-content: center;
174
232
  }
175
233
  .single .page {
176
- max-width: 420px;
177
- margin: 0 auto;
178
- width: 100%;
234
+ flex-basis: min(100%, 420px);
179
235
  }
180
236
  .single .page img {
181
237
  height: min(55dvh, 520px);
182
238
  }
239
+ .page-header {
240
+ display: flex;
241
+ align-items: center;
242
+ justify-content: space-between;
243
+ gap: 0.25rem;
244
+ padding: 0.25rem;
245
+ border-radius: 0.5rem;
246
+ background-color: var(--dile-scanner-page-header-background-color, var(--_surface));
247
+ --dile-button-border-radius: 0.4rem;
248
+ --dile-button-border-width: 0;
249
+ --dile-button-padding-y: 0.3rem;
250
+ --dile-button-padding-x: 0.3rem;
251
+ --dile-button-background-color: var(--dile-scanner-move-button-color, #5f6368);
252
+ --dile-button-hover-background-color: var(--dile-scanner-move-button-color, #5f6368);
253
+ --dile-button-text-color: #fff;
254
+ --dile-button-hover-text-color: #fff;
255
+ --dile-button-disabled-background-color: transparent;
256
+ --dile-button-disabled-text-color: var(--_border);
257
+ }
183
258
  .page-number {
184
- font-size: 0.85rem;
259
+ flex: 1;
260
+ font-size: 0.9rem;
185
261
  text-align: center;
186
262
  }
187
263
  .page-actions {
188
- display: flex;
189
- justify-content: center;
190
- flex-wrap: wrap;
264
+ display: grid;
265
+ grid-auto-columns: 1fr;
266
+ grid-auto-flow: column;
191
267
  gap: 0.25rem;
192
- --dile-button-padding-y: 0.3rem;
193
- --dile-button-padding-x: 0.4rem;
194
268
  }
195
- .page-actions .remove {
196
- --dile-button-background-color: var(--dile-alert-error-color, #c00);
197
- --dile-button-border-color: var(--dile-alert-error-color, #c00);
269
+ .page-action {
270
+ display: flex;
271
+ flex-direction: column;
272
+ align-items: center;
273
+ gap: 0.2rem;
274
+ padding: 0.35rem 0.2rem;
275
+ border: none;
276
+ border-radius: 0.4rem;
277
+ background: none;
278
+ font: inherit;
279
+ font-size: 0.7rem;
280
+ text-transform: uppercase;
281
+ letter-spacing: 0.02em;
282
+ cursor: pointer;
283
+ color: var(--dile-scanner-page-action-color, var(--_fg));
284
+ --dile-icon-color: currentColor;
285
+ --dile-icon-size: 22px;
286
+ }
287
+ .page-action:hover {
288
+ background-color: var(--dile-scanner-page-header-background-color, var(--_surface));
289
+ }
290
+ .page-action:focus-visible {
291
+ outline: 2px solid var(--dile-button-ring-color, #12c9e9);
292
+ }
293
+ .page-action.remove {
294
+ color: var(--dile-scanner-remove-button-color, var(--dile-alert-error-color, #c00));
295
+ font-weight: bold;
198
296
  }
199
297
  .size {
298
+ display: flex;
299
+ flex-direction: column;
300
+ gap: 0.4rem;
200
301
  font-size: 0.85rem;
201
- color: var(--dile-secondary-dark-color, #888);
302
+ --dile-progress-background: var(--dile-scanner-size-track-color, var(--_surface-strong));
303
+ --dile-progress-bar-background: var(--dile-scanner-size-bar-color, var(--dile-scanner-accent-color, var(--_fg)));
304
+ --dile-progress-bar-min-height: 6px;
305
+ --dile-progress-bar-percentage-font-size: 0;
306
+ --dile-progres-bar-percentage-padding-top: 0;
307
+ --dile-progres-bar-percentage-padding-right: 0;
308
+ --dile-progres-bar-percentage-padding-bottom: 0;
309
+ --dile-progres-bar-percentage-padding-left: 0;
310
+ }
311
+ .size.full {
312
+ --dile-progress-bar-background: var(--dile-alert-error-color, #c00);
313
+ }
314
+ .size-row {
315
+ display: flex;
316
+ justify-content: space-between;
317
+ gap: 0.5rem;
318
+ }
319
+ .size-value {
320
+ color: var(--dile-scanner-muted-text-color, var(--_muted));
202
321
  }
203
322
  .busy {
204
323
  display: flex;
@@ -230,7 +349,7 @@ export class DileScanner extends LitElement {
230
349
  * Detects the edges with scanic's neural detector (more robust on hard
231
350
  * photos), falling back to the classical one when it finds nothing.
232
351
  * Downloads about 2.2 MB with gzip (3.5 MB uncompressed) the first
233
- * time it is used.
352
+ * time the scanner is opened.
234
353
  */
235
354
  mlDetector: { type: Boolean, attribute: "ml-detector" },
236
355
  /**
@@ -266,6 +385,7 @@ export class DileScanner extends LitElement {
266
385
  this._adjusting = null;
267
386
  this._editor = null;
268
387
  this._saved = false;
388
+ this._scrollToPage = null;
269
389
  // Changes on every reset: an async task started before closing or
270
390
  // reopening the scanner checks it to discard its result.
271
391
  this._session = 0;
@@ -284,7 +404,7 @@ export class DileScanner extends LitElement {
284
404
  open() {
285
405
  this._reset();
286
406
  this._saved = false;
287
- preloadEngine();
407
+ preloadEngine({ ml: this.mlDetector, mlAssetsUrl: this.mlAssetsUrl });
288
408
  this._modal.open();
289
409
  }
290
410
 
@@ -293,6 +413,18 @@ export class DileScanner extends LitElement {
293
413
  this._modal.close();
294
414
  }
295
415
 
416
+ updated() {
417
+ // Brings a new or moved page into view in the horizontal page list. It
418
+ // waits while the step is hidden (busy), because a hidden element can't
419
+ // be scrolled.
420
+ if (this._scrollToPage === null || this._busy || this._step !== "pages") {
421
+ return;
422
+ }
423
+ const page = this.shadowRoot.querySelector(`.page[data-index="${this._scrollToPage}"]`);
424
+ this._scrollToPage = null;
425
+ page?.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
426
+ }
427
+
296
428
  disconnectedCallback() {
297
429
  super.disconnectedCallback();
298
430
  this._reset();
@@ -304,6 +436,12 @@ export class DileScanner extends LitElement {
304
436
  <dile-modal id="modal" blocking @dile-modal-closed=${this._onModalClosed}>
305
437
  <div class="scanner">
306
438
  <h2>${labels.title}</h2>
439
+ ${this._step === "pages" && this.multipage && !this._busy
440
+ ? html`<p class="subtitle">
441
+ ${this._pages.length}
442
+ ${this._pages.length === 1 ? labels.pageScanned : labels.pagesScanned}
443
+ </p>`
444
+ : nothing}
307
445
  ${this._busy
308
446
  ? html`<div class="busy">
309
447
  <dile-spinner active></dile-spinner>
@@ -362,10 +500,11 @@ export class DileScanner extends LitElement {
362
500
  `;
363
501
  }
364
502
 
365
- _captureButtons(photoLabel, imageLabel, disabled = false) {
503
+ _captureButtons(photoLabel, imageLabel, disabled = false, className = "") {
366
504
  return html`
367
505
  ${this._isTouchDevice
368
506
  ? html`<dile-button
507
+ class=${className}
369
508
  icon="lucide.camera"
370
509
  ?disabled=${disabled}
371
510
  @click=${this._takePhoto}
@@ -373,6 +512,7 @@ export class DileScanner extends LitElement {
373
512
  >`
374
513
  : nothing}
375
514
  <dile-button
515
+ class=${className}
376
516
  icon="lucide.image"
377
517
  ?disabled=${disabled}
378
518
  @click=${this._chooseImage}
@@ -411,87 +551,100 @@ export class DileScanner extends LitElement {
411
551
 
412
552
  _pagesTemplate(labels) {
413
553
  const limitReached = this._limitReached;
554
+ const count = this._pages.length;
414
555
  return html`
415
556
  <div class="${this.multipage ? "" : "single"}">
416
557
  <div class="pages">
417
558
  ${this._pages.map((page, index) => this._pageTemplate(page, index, labels))}
418
559
  </div>
419
560
  </div>
420
- ${this.maxBytes
421
- ? html`<p class="size">
422
- ${this._formatSize(this._pagesSize)} ${labels.sizeOf}
423
- ${this._formatSize(this.maxBytes)}
424
- </p>`
425
- : nothing}
561
+ ${this.maxBytes ? this._sizeTemplate(labels, limitReached) : nothing}
426
562
  ${this.multipage && limitReached
427
563
  ? html`<p class="message">${labels.sizeLimitReached}</p>`
428
564
  : nothing}
429
565
  <div class="actions">
430
566
  ${this.multipage
431
- ? this._captureButtons(labels.addPhoto, labels.addImage, limitReached)
432
- : this._captureButtons(labels.retakePhoto, labels.replaceImage)}
567
+ ? this._captureButtons(labels.addPhoto, labels.addImage, limitReached, "outline")
568
+ : this._captureButtons(labels.retakePhoto, labels.replaceImage, false, "outline")}
433
569
  </div>
434
570
  <div class="footer">
435
571
  <dile-button class="secondary" icon="lucide.x" @click=${this.close}
436
572
  >${labels.cancel}</dile-button
437
573
  >
438
574
  <dile-button icon="lucide.check" @click=${this._save}
439
- >${labels.confirm}</dile-button
575
+ >${labels.confirm}${this.multipage ? ` (${count})` : ""}</dile-button
440
576
  >
441
577
  </div>
442
578
  `;
443
579
  }
444
580
 
581
+ _sizeTemplate(labels, limitReached) {
582
+ const percent = Math.min(100, Math.round((this._pagesSize / this.maxBytes) * 100));
583
+ return html`
584
+ <div class="size ${limitReached ? "full" : ""}">
585
+ <div class="size-row">
586
+ <span>${labels.documentSize}</span>
587
+ <span class="size-value"
588
+ >${this._formatSize(this._pagesSize)} ${labels.sizeOf}
589
+ ${this._formatSize(this.maxBytes)}</span
590
+ >
591
+ </div>
592
+ <dile-progress-bar value=${percent} aria-hidden="true"></dile-progress-bar>
593
+ </div>
594
+ `;
595
+ }
596
+
445
597
  _pageTemplate(page, index, labels) {
446
- const isFirst = index === 0;
447
- const isLast = index === this._pages.length - 1;
448
- const showOrder = this.multipage && this._pages.length > 1;
598
+ const count = this._pages.length;
599
+ const showOrder = this.multipage && count > 1;
449
600
  return html`
450
- <div class="page">
451
- <img src=${page.url} alt="${labels.page} ${index + 1}" />
601
+ <div class="page" data-index=${index}>
452
602
  ${this.multipage
453
- ? html`<div class="page-number">${labels.page} ${index + 1}</div>`
603
+ ? html`<div class="page-header">
604
+ ${showOrder
605
+ ? html`<dile-button
606
+ icon="lucide.chevron-left"
607
+ label=${labels.moveBefore}
608
+ title=${labels.moveBefore}
609
+ ?disabled=${index === 0}
610
+ @click=${() => this._movePage(index, -1)}
611
+ ></dile-button>`
612
+ : nothing}
613
+ <span class="page-number"
614
+ >${labels.page} ${index + 1} ${labels.pageOf} ${count}</span
615
+ >
616
+ ${showOrder
617
+ ? html`<dile-button
618
+ icon="lucide.chevron-right"
619
+ label=${labels.moveAfter}
620
+ title=${labels.moveAfter}
621
+ ?disabled=${index === count - 1}
622
+ @click=${() => this._movePage(index, 1)}
623
+ ></dile-button>`
624
+ : nothing}
625
+ </div>`
454
626
  : nothing}
627
+ <img src=${page.url} alt="${labels.page} ${index + 1}" />
455
628
  <div class="page-actions">
456
- ${showOrder
457
- ? html`<dile-button
458
- icon="lucide.chevron-left"
459
- label=${labels.moveBefore}
460
- ?disabled=${isFirst}
461
- @click=${() => this._movePage(index, -1)}
462
- ></dile-button>`
463
- : nothing}
464
- <dile-button
465
- icon="lucide.rotate-cw"
466
- label=${labels.rotate}
467
- @click=${() => this._rotatePage(index)}
468
- ></dile-button>
469
- <dile-button
470
- icon="lucide.crop"
471
- label=${labels.adjust}
472
- @click=${() => this._adjustPage(index)}
473
- ></dile-button>
629
+ ${this._pageAction("lucide.rotate-cw", labels.rotate, () => this._rotatePage(index))}
630
+ ${this._pageAction("lucide.crop", labels.adjust, () => this._adjustPage(index))}
474
631
  ${this.multipage
475
- ? html`<dile-button
476
- class="remove"
477
- icon="lucide.trash-2"
478
- label=${labels.remove}
479
- @click=${() => this._removePage(index)}
480
- ></dile-button>`
481
- : nothing}
482
- ${showOrder
483
- ? html`<dile-button
484
- icon="lucide.chevron-right"
485
- label=${labels.moveAfter}
486
- ?disabled=${isLast}
487
- @click=${() => this._movePage(index, 1)}
488
- ></dile-button>`
632
+ ? this._pageAction("lucide.trash-2", labels.remove, () => this._removePage(index), "remove")
489
633
  : nothing}
490
634
  </div>
491
635
  </div>
492
636
  `;
493
637
  }
494
638
 
639
+ _pageAction(icon, label, onClick, className = "") {
640
+ return html`
641
+ <button type="button" class="page-action ${className}" data-icon=${icon} @click=${onClick}>
642
+ <dile-iconlib icon=${icon} aria-hidden="true"></dile-iconlib>
643
+ <span>${label}</span>
644
+ </button>
645
+ `;
646
+ }
647
+
495
648
  // ---- Capture ----
496
649
 
497
650
  _takePhoto() {
@@ -667,6 +820,7 @@ export class DileScanner extends LitElement {
667
820
  const pages = [...this._pages];
668
821
  if (index === null || index === undefined) {
669
822
  pages.push(page);
823
+ this._scrollToPage = pages.length - 1;
670
824
  } else {
671
825
  URL.revokeObjectURL(pages[index].url);
672
826
  pages[index] = page;
@@ -729,6 +883,7 @@ export class DileScanner extends LitElement {
729
883
  const pages = [...this._pages];
730
884
  [pages[index], pages[target]] = [pages[target], pages[index]];
731
885
  this._pages = pages;
886
+ this._scrollToPage = target;
732
887
  }
733
888
 
734
889
  _removePage(index) {
@@ -19,11 +19,15 @@ export const DEFAULT_LABELS = {
19
19
  cancel: "Cancel",
20
20
  confirm: "Save document",
21
21
  page: "Page",
22
+ pageOf: "of",
23
+ pageScanned: "page scanned",
24
+ pagesScanned: "pages scanned",
25
+ documentSize: "Document size",
22
26
  rotate: "Rotate",
23
- adjust: "Adjust edges",
27
+ adjust: "Crop",
24
28
  moveBefore: "Move before",
25
29
  moveAfter: "Move after",
26
- remove: "Remove page",
30
+ remove: "Delete",
27
31
  processing: "Processing…",
28
32
  readError:
29
33
  "The image could not be read. Try with another image (JPG, PNG or WebP).",
@@ -13,11 +13,27 @@ function loadScanic() {
13
13
  }
14
14
 
15
15
  // Starts downloading scanic in the background (e.g. when the scanner opens),
16
- // so it is ready by the time the first photo arrives.
17
- export function preloadEngine() {
18
- loadScanic().catch(() => {
19
- // Ignored here: the error shows up again when the engine is really used.
20
- });
16
+ // so it is ready by the time the first photo arrives. With "ml" it also
17
+ // downloads the neural detector files and creates its session, so the first
18
+ // photo does not wait for about 2.2 MB. scanic keeps the session cached by
19
+ // URL (also while it is loading), so detectCorners() reuses it and calling
20
+ // this on every open() downloads nothing again. When it fails (e.g. no
21
+ // network) scanic forgets it, so the next open() or photo tries again.
22
+ // Scanner.initialize() ignores errors itself.
23
+ export function preloadEngine({ ml = false, mlAssetsUrl = "" } = {}) {
24
+ loadScanic()
25
+ .then(({ Scanner }) => {
26
+ if (ml) {
27
+ return new Scanner({ detector: "ml", ml: mlOptions(mlAssetsUrl) }).initialize();
28
+ }
29
+ })
30
+ .catch(() => {
31
+ // Ignored here: the error shows up again when the engine is really used.
32
+ });
33
+ }
34
+
35
+ function mlOptions(mlAssetsUrl) {
36
+ return mlAssetsUrl ? { assetBaseUrl: mlAssetsUrl } : {};
21
37
  }
22
38
 
23
39
  // Returns the corners of the document or null when none is found.
@@ -28,7 +44,8 @@ export function preloadEngine() {
28
44
  // not as a retry, because on hard photos the classical detector does not
29
45
  // always fail: it can return a wrong shape (e.g. a line of text) as if it
30
46
  // were the document. The neural detector downloads about 2.2 MB with gzip
31
- // (3.5 MB uncompressed: model + ONNX runtime) the first time, from jsDelivr
47
+ // (3.5 MB uncompressed: model + ONNX runtime) the first time (when the
48
+ // scanner opens, see preloadEngine), from jsDelivr
32
49
  // unless mlAssetsUrl points to a self-hosted copy of the "scanic-ml" package
33
50
  // files. When it finds nothing or fails (e.g. no network), the classical
34
51
  // detector is used.
@@ -40,7 +57,7 @@ export async function detectCorners(canvas, { ml = false, mlAssetsUrl = "" } = {
40
57
  const mlResult = await scanner.scan(canvas, {
41
58
  mode: "detect",
42
59
  detector: "ml",
43
- ml: mlAssetsUrl ? { assetBaseUrl: mlAssetsUrl } : {},
60
+ ml: mlOptions(mlAssetsUrl),
44
61
  });
45
62
  if (mlResult.success) {
46
63
  return mlResult.corners;
@@ -135,7 +135,10 @@ export async function openScanner(attrs) {
135
135
  export const query = (el, selector) => el.shadowRoot.querySelector(selector);
136
136
  export const queryAll = (el, selector) => [...el.shadowRoot.querySelectorAll(selector)];
137
137
  export const button = (el, icon) => query(el, `.step:not([hidden]) dile-button[icon="lucide.${icon}"]`);
138
- export const pageButtons = (el, icon) => queryAll(el, `.page dile-button[icon="lucide.${icon}"]`);
138
+ // Buttons of the page cards: the move arrows (dile-button in the header) and
139
+ // the actions (native buttons with data-icon).
140
+ export const pageButtons = (el, icon) =>
141
+ queryAll(el, `.page dile-button[icon="lucide.${icon}"], .page button[data-icon="lucide.${icon}"]`);
139
142
  export const pages = (el) => queryAll(el, '.page');
140
143
  export const errorMessage = (el) => query(el, '.message.error')?.textContent.trim();
141
144
 
package/vite-plugin.js CHANGED
@@ -6,11 +6,19 @@
6
6
  // import { scannerMlAssets } from '@dile/scanner/vite-plugin.js';
7
7
  // export default { plugins: [scannerMlAssets()] };
8
8
  //
9
- // <dile-scanner ml-detector ml-assets-url="/dile-scanner-ml/"></dile-scanner>
9
+ // // app code
10
+ // import { mlAssetsUrl } from 'virtual:dile-scanner-ml';
11
+ // scanner.mlAssetsUrl = mlAssetsUrl; // "/dile-scanner-ml/0.2.0/"
10
12
  //
11
13
  // The files are served by the dev server and copied to the build with their
12
14
  // original names (scanic expects them together in one folder). They are only
13
15
  // downloaded by the browser when the ML detector is actually used.
16
+ //
17
+ // The default folder includes the scanic-ml version, so updating
18
+ // @dile/scanner changes the URL of the files. That way a cache (HTTP or a PWA
19
+ // service worker) never mixes the model of one version with the runtime of
20
+ // another. The "virtual:dile-scanner-ml" module exports the current URL, so
21
+ // the app does not have to write the version by hand.
14
22
  import { createRequire } from "node:module";
15
23
  import { readFileSync } from "node:fs";
16
24
  import { dirname, extname, join } from "node:path";
@@ -21,24 +29,54 @@ export const ML_ASSET_FILES = [
21
29
  "ort-wasm-simd-threaded.wasm",
22
30
  ];
23
31
 
32
+ export const ML_VIRTUAL_MODULE = "virtual:dile-scanner-ml";
33
+ const RESOLVED_VIRTUAL_MODULE = `\0${ML_VIRTUAL_MODULE}`;
34
+
24
35
  const CONTENT_TYPES = {
25
36
  ".ort": "application/octet-stream",
26
37
  ".mjs": "text/javascript",
27
38
  ".wasm": "application/wasm",
28
39
  };
29
40
 
41
+ function mlPackageJson() {
42
+ const require = createRequire(import.meta.url);
43
+ return require.resolve("scanic-ml/package.json");
44
+ }
45
+
30
46
  // Folder of the "scanic-ml" files, resolved from this package so it is the
31
47
  // version pinned by @dile/scanner.
32
48
  export function mlAssetsDir() {
33
- const require = createRequire(import.meta.url);
34
- return join(dirname(require.resolve("scanic-ml/package.json")), "dist");
49
+ return join(dirname(mlPackageJson()), "dist");
35
50
  }
36
51
 
37
- export function scannerMlAssets({ path = "dile-scanner-ml" } = {}) {
52
+ // Version of the "scanic-ml" files (e.g. "0.2.0").
53
+ export function mlAssetsVersion() {
54
+ return JSON.parse(readFileSync(mlPackageJson(), "utf8")).version;
55
+ }
56
+
57
+ export function scannerMlAssets({
58
+ path = `dile-scanner-ml/${mlAssetsVersion()}`,
59
+ } = {}) {
38
60
  const folder = path.replace(/^\/+|\/+$/g, "");
39
61
  const assetsDir = mlAssetsDir();
62
+ let base = "/";
40
63
  return {
41
64
  name: "dile-scanner-ml-assets",
65
+ configResolved(config) {
66
+ base = config.base.endsWith("/") ? config.base : `${config.base}/`;
67
+ },
68
+ resolveId(id) {
69
+ return id === ML_VIRTUAL_MODULE ? RESOLVED_VIRTUAL_MODULE : null;
70
+ },
71
+ load(id) {
72
+ if (id !== RESOLVED_VIRTUAL_MODULE) {
73
+ return null;
74
+ }
75
+ return [
76
+ `export const mlAssetsUrl = ${JSON.stringify(`${base}${folder}/`)};`,
77
+ `export const mlAssetsVersion = ${JSON.stringify(mlAssetsVersion())};`,
78
+ ].join("\n");
79
+ },
42
80
  configureServer(server) {
43
81
  server.middlewares.use((req, res, next) => {
44
82
  const prefix = `${server.config.base}${folder}/`;