@rdlabo/ionic-angular-kit 0.0.12 → 0.0.13

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
@@ -26,10 +26,18 @@ npm install @rdlabo/ionic-angular-kit
26
26
  | `@ionic/angular` | `^8.0.0` |
27
27
  | `@ionic/storage-angular` | `^4.0.0` |
28
28
  | `@capacitor/core` | `>=6.0.0 <9.0.0` |
29
+ | `@capacitor/haptics` | `>=6.0.0 <9.0.0` |
29
30
  | `@capacitor/keyboard` | `>=6.0.0 <9.0.0` |
30
31
  | `@capacitor/network` | `>=6.0.0 <9.0.0` |
32
+ | `@capacitor/preferences` | `>=6.0.0 <9.0.0` |
33
+ | `@capacitor/status-bar` | `>=6.0.0 <9.0.0` |
34
+ | `@capacitor-community/in-app-review` | `>=6.0.0 <9.0.0` |
35
+ | `@rdlabo/capacitor-brotherprint` | `>=6.0.0 <9.0.0` |
36
+ | `dom-to-image-more` | `^3.0.0` |
31
37
  | `rxjs` | `^7.8.0` |
32
38
 
39
+ Feature-scoped peers are only needed by the features that use them (`status-bar` → `KitThemeController`; `preferences` + `in-app-review` → `kitRequestReview`; `capacitor-brotherprint` + `dom-to-image-more` → the printer helpers); an app that doesn't use a feature can ignore its unmet-peer warning.
40
+
33
41
  ---
34
42
 
35
43
  ## Features
@@ -409,6 +417,43 @@ export class ComposePage {
409
417
 
410
418
  ---
411
419
 
420
+ ### KitThemeController + provideKitTheme
421
+
422
+ Light/dark theme controller that unifies the theme logic that had drifted across the fleet: it persists the user's choice, follows the OS `prefers-color-scheme` until the user overrides it, toggles the configured palette classes, and syncs the native Android status bar. It also fixes a latent leak in one variant where the system-theme listener stayed registered after a manual toggle — `changeTheme()` always detaches the listener first, so a later OS change can't silently flip an app the user pinned.
423
+
424
+ Per-app CSS differences are absorbed by config: `darkClasses` are toggled on when dark, `lightClasses` on when light. The kit ships no class names of its own. Subscribe to `themeSubject` (a `BehaviorSubject`) to reflect the current mode in the UI. It is a controller (not a plain function) because the subject and OS-listener are shared state across the app lifetime.
425
+
426
+ ```typescript
427
+ // app.config.ts
428
+ provideKitTheme({
429
+ storageKey: StorageKeyEnum.theme,
430
+ darkClasses: ['ion-palette-dark', 'a2ui-dark'],
431
+ lightClasses: ['a2ui-light'],
432
+ });
433
+
434
+ // app.component.ts — apply on boot
435
+ inject(KitThemeController).setDefaultThemeMode();
436
+
437
+ // settings page — bind a toggle
438
+ const theme = inject(KitThemeController);
439
+ theme.themeSubject.subscribe((mode) => this.isDark.set(mode === 'dark'));
440
+ theme.changeTheme(true); // force dark, stop following the OS
441
+ ```
442
+
443
+ ---
444
+
445
+ ### kitRequestReview
446
+
447
+ A plain function (no DI — `@capacitor/preferences`, `@capacitor-community/in-app-review` and `Capacitor` are all static) that requests the native in-app review dialog, throttled so the user is prompted at most once per window. A no-op on web. The wait/throttle/record sequence was previously copy-pasted verbatim across the fleet; centralizing it means a single place to tune the prompt cadence. The storage key and throttle window are passed as arguments, so the kit ships no config of its own.
448
+
449
+ ```typescript
450
+ import { kitRequestReview } from '@rdlabo/ionic-angular-kit';
451
+
452
+ await kitRequestReview({ storageKey: StorageEnum.lastRequestRate, throttleMonths: 3 });
453
+ ```
454
+
455
+ ---
456
+
412
457
  ### Utilities
413
458
 
414
459
  Framework-agnostic helpers (no DI required unless noted):
@@ -432,6 +477,61 @@ async onSubmit(event: Event) {
432
477
  }
433
478
  ```
434
479
 
480
+ Ionic-event / lifecycle helpers:
481
+
482
+ ```typescript
483
+ import { kitChangeEventDisabled, kitCreateDidEnter } from '@rdlabo/ionic-angular-kit';
484
+
485
+ // Toggle a signal-held ion-infinite-scroll / ion-refresher's `disabled` (no-op when empty).
486
+ kitChangeEventDisabled(infiniteScrollSignal, true);
487
+
488
+ // Observe an Ionic page's "is entered" state from its lifecycle DOM events (true on didEnter).
489
+ readonly isEntered = toSignal(kitCreateDidEnter(inject(ElementRef)), { initialValue: false });
490
+ ```
491
+
492
+ ---
493
+
494
+ ### kitPresentLanguageActionSheet
495
+
496
+ A plain function (the `ActionSheetController` is passed in — nothing injected) that presents a language picker and, on a new selection, reloads the app at that locale's entry point. Unifies the language-switch flow duplicated across apps: it stashes the current path in `sessionStorage` (to restore after reload), records the chosen locale in `localStorage`, and calls `window.location.replace()` with the app-provided URL. Being a navigation helper, it stays standalone rather than part of a controller. All text, the locale list, and the per-locale URL mapping are injected, so the kit stays free of i18n strings.
497
+
498
+ ```typescript
499
+ import { kitPresentLanguageActionSheet } from '@rdlabo/ionic-angular-kit';
500
+
501
+ await kitPresentLanguageActionSheet(inject(ActionSheetController), {
502
+ header: $localize`言語設定`,
503
+ locales: [{ text: 'English', data: 'en-US' }, { text: '日本語', data: 'ja' }],
504
+ cancelText: $localize`キャンセル`,
505
+ currentLocale: normalizedLocale,
506
+ currentPath: this.#router.url,
507
+ pathnameStorageKey: StorageKeyEnum.pathnameBeforeRedirect,
508
+ buildRedirectUrl: (locale) => location.origin + (localePath[locale.toLowerCase()] ?? '/index.html'),
509
+ enabled: environment.production,
510
+ });
511
+ ```
512
+
513
+ ---
514
+
515
+ ### Printer (Brother label plumbing)
516
+
517
+ Three pure functions (no DI) that extract the i18n-free core of the fleet's Brother label printing, so a device-quirk or print-setting fix lands in every app at once. The UI orchestration — search/channel-selection alerts, loading overlays, and the app-specific paper list — stays in each app, since those diverge (labels, paper options, copies policy).
518
+
519
+ - `kitDomToPng(element, { rotate?, scale? })` — render a DOM element to a base64 PNG with the fleet's device fixes (iOS +2px to avoid bottom clipping, none on Android to avoid a black line; retries up to 10×). The caller presents its own loading UI.
520
+ - `kitRotationImage(base64)` — rotate a base64 image 90° via canvas.
521
+ - `kitBuildBrotherPrintSettings({ modelName, printBase64, label, numberOfCopies, halftoneThreshold })` — assemble the canonical `BRLMPrintOptions` (fit-page, centered, best quality, threshold halftone, standard margins, tape size parsed from the label's `W<w>H<h>` code). Merge `{ port, channelInfo }` from the selected channel before calling `BrotherPrint.printImage()`.
522
+
523
+ ```typescript
524
+ import { kitDomToPng, kitBuildBrotherPrintSettings } from '@rdlabo/ionic-angular-kit';
525
+
526
+ const png = await kitDomToPng(this.preview().nativeElement, { rotate: true });
527
+ const settings = kitBuildBrotherPrintSettings({
528
+ modelName, printBase64: png, label,
529
+ numberOfCopies: printOptions.printNum,
530
+ halftoneThreshold: printOptions.halftoneThreshold,
531
+ });
532
+ await BrotherPrint.printImage({ ...settings, port: channel.port, channelInfo: channel.channelInfo });
533
+ ```
534
+
435
535
  ---
436
536
 
437
537
  ## Consumer Vitest setup notes
@@ -1,15 +1,20 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, Injectable, InjectionToken, makeEnvironmentProviders, ElementRef, Directive } from '@angular/core';
2
+ import { inject, Injectable, InjectionToken, makeEnvironmentProviders, ElementRef, Directive, DOCUMENT } from '@angular/core';
3
3
  import { Storage } from '@ionic/storage-angular';
4
4
  import { ModalController, PopoverController, ToastController, AlertController, NavController } from '@ionic/angular/standalone';
5
5
  import { Capacitor } from '@capacitor/core';
6
6
  import { Keyboard } from '@capacitor/keyboard';
7
7
  import { ImpactStyle, Haptics } from '@capacitor/haptics';
8
+ import { StatusBar, Style } from '@capacitor/status-bar';
9
+ import { BehaviorSubject, from, throwError, retry, timer, Observable, startWith } from 'rxjs';
10
+ import { Preferences } from '@capacitor/preferences';
11
+ import { InAppReview } from '@capacitor-community/in-app-review';
12
+ import { BRLMPrinterHalftone, BRLMPrinterCustomPaperUnit, BRLMPrinterCustomPaperType, BRLMPrinterPrintQuality, BRLMPrinterHorizontalAlignment, BRLMPrinterVerticalAlignment, BRLMPrinterImageRotation, BRLMPrinterScaleMode } from '@rdlabo/capacitor-brotherprint';
13
+ import domtoimage from 'dom-to-image-more';
8
14
  import { Router } from '@angular/router';
9
15
  import { map, mergeMap, catchError, timeout, tap } from 'rxjs/operators';
10
16
  import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
11
17
  import { Network } from '@capacitor/network';
12
- import { from, throwError, retry, timer } from 'rxjs';
13
18
 
14
19
  /**
15
20
  * Thin, typed wrapper around `@ionic/storage-angular`.
@@ -517,6 +522,52 @@ const kitPresentAuthFailedAlert = async (alertCtrl, options) => {
517
522
  await alert.present();
518
523
  };
519
524
 
525
+ /**
526
+ * Present a language picker and, on a new selection, reload the app at that locale's entry point.
527
+ *
528
+ * @remarks
529
+ * A plain function (the `ActionSheetController` is passed in, so nothing is injected) that unifies the
530
+ * language-switch flow duplicated across apps. On a changed selection while {@link enabled} it stashes
531
+ * the current path in `sessionStorage` (so the app can return the user to where they were), records the
532
+ * chosen locale in `localStorage` under `'locale'`, and calls `window.location.replace()` with the
533
+ * app-provided URL. Because it performs navigation, it is a standalone helper rather than part of a
534
+ * controller (mirroring `kitPresentReloadAlert` / `kitPresentAuthFailedAlert`). Centralizing it means a
535
+ * future improvement to the switch flow lands in every app at once.
536
+ *
537
+ * @param actionSheetCtrl - the Ionic `ActionSheetController`
538
+ * @param options - labels, locale list, and redirect configuration; see {@link KitLanguageActionSheetOptions}
539
+ * @returns a Promise that resolves once presented (and, on a new selection, after the reload is triggered)
540
+ * @example
541
+ * ```ts
542
+ * await kitPresentLanguageActionSheet(inject(ActionSheetController), {
543
+ * header: $localize`言語設定`,
544
+ * locales: [{ text: 'English', data: 'en-US' }, { text: '日本語', data: 'ja' }],
545
+ * cancelText: $localize`キャンセル`,
546
+ * currentLocale: normalizedLocale,
547
+ * currentPath: this.#router.url,
548
+ * pathnameStorageKey: StorageKeyEnum.pathnameBeforeRedirect,
549
+ * buildRedirectUrl: (locale) => location.origin + (localePath[locale.toLowerCase()] ?? '/index.html'),
550
+ * enabled: environment.production,
551
+ * });
552
+ * ```
553
+ */
554
+ const kitPresentLanguageActionSheet = async (actionSheetCtrl, options) => {
555
+ const actionSheet = await actionSheetCtrl.create({
556
+ header: options.header,
557
+ buttons: [
558
+ ...options.locales.map((locale) => ({ text: locale.text, data: locale.data })),
559
+ { text: options.cancelText, role: 'cancel' },
560
+ ],
561
+ });
562
+ await actionSheet.present();
563
+ const { data } = await actionSheet.onDidDismiss();
564
+ if (options.enabled && data && data !== options.currentLocale) {
565
+ sessionStorage.setItem(options.pathnameStorageKey, options.currentPath);
566
+ localStorage.setItem('locale', data);
567
+ window.location.replace(options.buildRedirectUrl(data));
568
+ }
569
+ };
570
+
520
571
  /**
521
572
  * Work around iOS `ion-input` autofill values not propagating to the Angular form model.
522
573
  *
@@ -687,6 +738,323 @@ const kitKeyboardInit = async (elementRef, type) => {
687
738
  ];
688
739
  };
689
740
 
741
+ /**
742
+ * Injection token carrying the {@link KitThemeConfig} for `KitThemeController`.
743
+ *
744
+ * @remarks
745
+ * Provide it through {@link provideKitTheme} rather than registering it directly.
746
+ */
747
+ const KIT_THEME_CONFIG = new InjectionToken('@rdlabo/ionic-angular-kit:theme');
748
+ /**
749
+ * Wire `KitThemeController` into the application.
750
+ *
751
+ * @param config - theme configuration: the storage key and the light/dark class lists
752
+ * @returns environment providers to add to the application's provider list
753
+ * @example
754
+ * ```ts
755
+ * bootstrapApplication(AppComponent, {
756
+ * providers: [
757
+ * provideKitTheme({
758
+ * storageKey: StorageKeyEnum.theme,
759
+ * darkClasses: ['ion-palette-dark', 'a2ui-dark'],
760
+ * lightClasses: ['a2ui-light'],
761
+ * }),
762
+ * ],
763
+ * });
764
+ * ```
765
+ */
766
+ const provideKitTheme = (config) => makeEnvironmentProviders([{ provide: KIT_THEME_CONFIG, useValue: config }]);
767
+
768
+ /**
769
+ * Light/dark theme controller: persists the user's choice, follows the OS setting until the user
770
+ * overrides it, toggles the configured palette classes, and syncs the native Android status bar.
771
+ *
772
+ * @remarks
773
+ * Consolidates the theme logic that had drifted across the fleet into one behavior. Notably it fixes
774
+ * a latent leak in one variant where the system-theme listener stayed registered after a manual
775
+ * toggle: {@link changeTheme} always detaches the listener via {@link removeEventListener} before
776
+ * applying the forced theme, so a later OS change can no longer silently flip an app the user pinned.
777
+ *
778
+ * - **Persistence** — the chosen mode is stored via {@link KitStorageService} under the configured key.
779
+ * - **Follow OS until overridden** — on boot with nothing stored, it tracks
780
+ * `prefers-color-scheme` (idempotent registration); once the user calls {@link changeTheme} it stops
781
+ * following and honors the explicit choice.
782
+ * - **Class toggling** — toggles {@link KitThemeConfig.darkClasses} on when dark and
783
+ * {@link KitThemeConfig.lightClasses} on when light, absorbing per-app CSS differences via config.
784
+ * - **Native status bar** — on Android native only, mirrors the Ionic behavior of setting the status
785
+ * bar style to match (iOS derives it from the web content, so it is intentionally left untouched).
786
+ *
787
+ * Subscribe to {@link themeSubject} to reflect the current mode in the UI (e.g. a settings toggle).
788
+ *
789
+ * @example
790
+ * ```ts
791
+ * // On boot (app.component):
792
+ * inject(KitThemeController).setDefaultThemeMode();
793
+ *
794
+ * // From a settings toggle:
795
+ * const theme = inject(KitThemeController);
796
+ * theme.themeSubject.subscribe((mode) => this.isDark.set(mode === 'dark'));
797
+ * theme.changeTheme(true);
798
+ * ```
799
+ */
800
+ class KitThemeController {
801
+ constructor() {
802
+ this.#storage = inject(KitStorageService);
803
+ this.#document = inject(DOCUMENT);
804
+ this.#config = inject(KIT_THEME_CONFIG);
805
+ /**
806
+ * Emits the active theme, seeded with `'light'`.
807
+ *
808
+ * @remarks
809
+ * A `BehaviorSubject`, so a late subscriber immediately receives the current mode; it emits again
810
+ * on every {@link setDefaultThemeMode} / {@link changeTheme} and on OS theme changes while following.
811
+ */
812
+ this.themeSubject = new BehaviorSubject('light');
813
+ }
814
+ #storage;
815
+ #document;
816
+ #config;
817
+ #prefersDark;
818
+ #onSystemThemeChange;
819
+ /**
820
+ * Apply the persisted theme, or start following the OS setting when nothing is stored yet.
821
+ *
822
+ * @remarks
823
+ * Call once on boot (e.g. from `app.component`).
824
+ *
825
+ * @returns a Promise that resolves once the initial theme has been applied
826
+ */
827
+ async setDefaultThemeMode() {
828
+ const stored = await this.#storage.get(this.#config.storageKey);
829
+ if (stored) {
830
+ // 保存済みの選択を強制し、OS 追従は解除する。
831
+ this.#unwatchSystemTheme();
832
+ return this.#applyTheme(stored === 'dark');
833
+ }
834
+ // 未保存 → OS の設定に追従する。
835
+ this.#watchSystemTheme();
836
+ }
837
+ /**
838
+ * Force a theme, persist it, and stop following the OS setting.
839
+ *
840
+ * @param isDark - `true` for the dark theme, `false` for light
841
+ * @returns a Promise that resolves once the theme has been persisted and applied
842
+ */
843
+ async changeTheme(isDark) {
844
+ this.#unwatchSystemTheme();
845
+ await this.#storage.set(this.#config.storageKey, isDark ? 'dark' : 'light');
846
+ await this.#applyTheme(isDark);
847
+ }
848
+ #watchSystemTheme() {
849
+ if (this.#prefersDark) {
850
+ // 既に監視中なら二重登録しない(冪等)。
851
+ return;
852
+ }
853
+ this.#prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
854
+ this.#onSystemThemeChange = (e) => void this.#applyTheme(e.matches);
855
+ this.#prefersDark.addEventListener('change', this.#onSystemThemeChange, { passive: true });
856
+ void this.#applyTheme(this.#prefersDark.matches);
857
+ }
858
+ #unwatchSystemTheme() {
859
+ if (this.#prefersDark && this.#onSystemThemeChange) {
860
+ this.#prefersDark.removeEventListener('change', this.#onSystemThemeChange);
861
+ }
862
+ this.#prefersDark = undefined;
863
+ this.#onSystemThemeChange = undefined;
864
+ }
865
+ async #applyTheme(isDark) {
866
+ if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') {
867
+ await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light });
868
+ }
869
+ const root = this.#document.documentElement;
870
+ for (const cls of this.#config.darkClasses) {
871
+ root.classList.toggle(cls, isDark);
872
+ }
873
+ for (const cls of this.#config.lightClasses) {
874
+ root.classList.toggle(cls, !isDark);
875
+ }
876
+ this.themeSubject.next(isDark ? 'dark' : 'light');
877
+ }
878
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitThemeController, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
879
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitThemeController, providedIn: 'root' }); }
880
+ }
881
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitThemeController, decorators: [{
882
+ type: Injectable,
883
+ args: [{
884
+ providedIn: 'root',
885
+ }]
886
+ }] });
887
+
888
+ /**
889
+ * Request the native in-app review dialog, throttled so the user is prompted at most once per window.
890
+ *
891
+ * @remarks
892
+ * A plain function — no DI needed (`@capacitor/preferences`, `@capacitor-community/in-app-review` and
893
+ * `Capacitor` are all static), so the caller invokes it directly and passes its own config rather
894
+ * than injecting a controller. A no-op on non-native platforms. When enough time has elapsed since
895
+ * the last prompt (per {@link KitRequestReviewOptions.throttleMonths}, tracked under
896
+ * {@link KitRequestReviewOptions.storageKey}), it briefly waits for the app to settle, calls
897
+ * `InAppReview.requestReview()`, and records the new timestamp. The wait/throttle/record sequence
898
+ * was previously copy-pasted verbatim across the fleet; centralizing it means a single place to tune
899
+ * the prompt cadence.
900
+ *
901
+ * @param options - the storage key and throttle window; see {@link KitRequestReviewOptions}
902
+ * @returns a Promise that resolves once the request has been made (or immediately if throttled / on web)
903
+ * @example
904
+ * ```ts
905
+ * await kitRequestReview({ storageKey: StorageEnum.lastRequestRate, throttleMonths: 3 });
906
+ * ```
907
+ */
908
+ const kitRequestReview = async (options) => {
909
+ if (!Capacitor.isNativePlatform()) {
910
+ return;
911
+ }
912
+ await new Promise((resolve) => setTimeout(() => resolve(), 1000));
913
+ const threshold = new Date();
914
+ threshold.setMonth(threshold.getMonth() - options.throttleMonths);
915
+ const { value } = await Preferences.get({ key: options.storageKey });
916
+ if (!value || new Date(Number(value)).getTime() < threshold.getTime()) {
917
+ await InAppReview.requestReview();
918
+ await Preferences.set({ key: options.storageKey, value: new Date().getTime().toString() });
919
+ }
920
+ };
921
+
922
+ /**
923
+ * Rotate a base64 image 90°, returning a new base64 data URL of the same MIME type.
924
+ *
925
+ * @remarks
926
+ * Pure DOM/canvas work — no DI. Used before sending a label to the printer when the artwork must be
927
+ * turned to match the tape orientation. Extracted verbatim from the fleet's printer services so the
928
+ * canvas handling lives in one place.
929
+ *
930
+ * @param imageData - a base64 data URL (e.g. `data:image/png;base64,...`)
931
+ * @returns a Promise resolving to the rotated image as a base64 data URL
932
+ */
933
+ const kitRotationImage = async (imageData) => {
934
+ const imgType = imageData.substring(5, imageData.indexOf(';'));
935
+ const image = new Image();
936
+ const loaded = () => new Promise((resolve) => {
937
+ image.onload = () => resolve();
938
+ });
939
+ setTimeout(() => (image.src = imageData));
940
+ await loaded();
941
+ const canvas = document.createElement('canvas');
942
+ const ctx = canvas.getContext('2d');
943
+ canvas.width = image.height;
944
+ canvas.height = image.width;
945
+ ctx.rotate((90 * Math.PI) / 180);
946
+ ctx.translate(0, -image.height);
947
+ ctx.drawImage(image, 0, 0, image.width, image.height);
948
+ return canvas.toDataURL(imgType);
949
+ };
950
+ /**
951
+ * Render a DOM element to a base64 PNG for label printing, with the fleet's device-specific fixes.
952
+ *
953
+ * @remarks
954
+ * Pure function — no DI (reads the platform from `Capacitor`, uses the global `document`), so the
955
+ * caller presents its own loading UI around it. Centralizes the hard-won device quirks: on iOS it
956
+ * pads width/height by 2px (otherwise the bottom is clipped), on Android it does not (the padding
957
+ * introduces a black line). Retries the `dom-to-image-more` render up to 10 times because the first
958
+ * pass can occasionally return empty. This is exactly the kind of plumbing where a future fix should
959
+ * land in every app at once.
960
+ *
961
+ * @param element - the element to rasterize (e.g. the label preview host)
962
+ * @param options - rendering options; see {@link KitDomToPngOptions}
963
+ * @returns a Promise resolving to the PNG as a base64 data URL (empty string if every attempt failed)
964
+ * @example
965
+ * ```ts
966
+ * const loading = await this.#loadingCtrl.create({ message: this.text.generating });
967
+ * await loading.present();
968
+ * const png = await kitDomToPng(this.preview().nativeElement, { rotate: true });
969
+ * await loading.dismiss();
970
+ * ```
971
+ */
972
+ const kitDomToPng = async (element, options) => {
973
+ await new Promise((resolve) => requestAnimationFrame(resolve));
974
+ const { clientHeight, clientWidth } = element;
975
+ // デバイス毎の問題解決のため、px 調整。
976
+ // iOS: ないと下が途切れる。Android: あると黒線が入る。
977
+ const addClient = Capacitor.getPlatform() === 'ios' ? 2 : 0;
978
+ const dataUrl = await new Promise((resolve) => {
979
+ void (async () => {
980
+ for (let i = 0; i < 10; i++) {
981
+ const url = await domtoimage.toPng(element, {
982
+ width: clientWidth + addClient,
983
+ height: clientHeight + addClient,
984
+ scale: options?.scale ?? 3,
985
+ copyDefaultStyles: false,
986
+ });
987
+ if (url) {
988
+ resolve(url);
989
+ return;
990
+ }
991
+ }
992
+ resolve('');
993
+ })();
994
+ });
995
+ return options?.rotate ? kitRotationImage(dataUrl) : dataUrl;
996
+ };
997
+ /**
998
+ * Assemble the Brother `BRLMPrintOptions` for a die-cut label print, minus the transport fields.
999
+ *
1000
+ * @remarks
1001
+ * Pure function — no DI. Centralizes the fleet's canonical print settings (fit-page scale, centered,
1002
+ * best quality, threshold halftone, 2mm/1mm margins, `gapLength` 2.0) and the tape sizing derived
1003
+ * from the label's `W<width>H<height>` code. The caller merges the printer's `port` / `channelInfo`
1004
+ * onto the result before calling `BrotherPrint.printImage()`, so channel selection and loading UI stay
1005
+ * in the app.
1006
+ *
1007
+ * @param params - model, artwork, label, copies, and halftone threshold; see {@link KitBrotherPrintSettingsParams}
1008
+ * @returns the `BRLMPrintOptions` ready to be spread with `{ port, channelInfo }`
1009
+ * @example
1010
+ * ```ts
1011
+ * const settings = kitBuildBrotherPrintSettings({
1012
+ * modelName, printBase64, label, numberOfCopies: printOptions.printNum, halftoneThreshold: printOptions.halftoneThreshold,
1013
+ * });
1014
+ * await BrotherPrint.printImage({ ...settings, port: channel.port, channelInfo: channel.channelInfo });
1015
+ * ```
1016
+ */
1017
+ const kitBuildBrotherPrintSettings = (params) => {
1018
+ const startPoint = params.printBase64.indexOf(',');
1019
+ const tapeSize = params.label.match(/W(\d+)H(\d+)/);
1020
+ const tapeWidth = tapeSize && tapeSize.length >= 2 ? parseInt(tapeSize[1], 10) : 0;
1021
+ const tapeLength = tapeSize && tapeSize.length >= 3 ? parseInt(tapeSize[2], 10) : 0;
1022
+ // `BRLMPrintOptions` is a `QL | TD` union; a die-cut label legitimately carries fields from both
1023
+ // groups, so the object is composed via spreads to bypass the union's excess-property checks — the
1024
+ // same technique the source printer services used.
1025
+ return {
1026
+ ...{
1027
+ modelName: params.modelName,
1028
+ encodedImage: params.printBase64.slice(startPoint + 1),
1029
+ numberOfCopies: params.numberOfCopies,
1030
+ autoCut: true,
1031
+ scaleMode: BRLMPrinterScaleMode.FitPageAspect,
1032
+ imageRotation: BRLMPrinterImageRotation.Rotate0,
1033
+ verticalAlignment: BRLMPrinterVerticalAlignment.Center,
1034
+ horizontalAlignment: BRLMPrinterHorizontalAlignment.Center,
1035
+ printQuality: BRLMPrinterPrintQuality.Best,
1036
+ },
1037
+ ...{
1038
+ labelName: params.label,
1039
+ },
1040
+ ...{
1041
+ paperType: BRLMPrinterCustomPaperType.dieCutPaper,
1042
+ paperUnit: BRLMPrinterCustomPaperUnit.mm,
1043
+ halftone: BRLMPrinterHalftone.Threshold,
1044
+ halftoneThreshold: params.halftoneThreshold,
1045
+ tapeWidth: Number(tapeWidth.toFixed(1)),
1046
+ tapeLength: Number(tapeLength.toFixed(1)),
1047
+ gapLength: 2.0,
1048
+ marginTop: 1.0,
1049
+ marginRight: 2.0,
1050
+ marginBottom: 1.0,
1051
+ marginLeft: 2.0,
1052
+ paperMarkPosition: 0,
1053
+ paperMarkLength: 0,
1054
+ },
1055
+ };
1056
+ };
1057
+
690
1058
  /**
691
1059
  * Injection token that carries the {@link KitAuthConfig} to the authentication guards.
692
1060
  */
@@ -1175,6 +1543,67 @@ const disableHandler = async (event, work) => {
1175
1543
  target.disabled = false;
1176
1544
  };
1177
1545
 
1546
+ /**
1547
+ * Toggle the `disabled` flag of a signal-held `ion-infinite-scroll` / `ion-refresher` element.
1548
+ *
1549
+ * @remarks
1550
+ * A tiny pure helper for the common pattern of stashing the completing scroll/refresher element in a
1551
+ * signal (e.g. captured from the event) and later enabling/disabling it — for instance disabling
1552
+ * infinite scroll once the last page has loaded. A no-op when the signal holds no element.
1553
+ *
1554
+ * @param completeEvent - a writable signal holding the infinite-scroll / refresher element (or nullish)
1555
+ * @param disabled - the value to set on the element's `disabled` property
1556
+ * @example
1557
+ * ```ts
1558
+ * const infinite = signal<HTMLIonInfiniteScrollElement | null>(null);
1559
+ * // ...on ionInfinite: infinite.set(ev.target); ...when no more pages:
1560
+ * kitChangeEventDisabled(infinite, true);
1561
+ * ```
1562
+ */
1563
+ const kitChangeEventDisabled = (completeEvent, disabled) => {
1564
+ completeEvent.update((event) => {
1565
+ if (event) {
1566
+ event.disabled = disabled;
1567
+ }
1568
+ return event;
1569
+ });
1570
+ };
1571
+
1572
+ /**
1573
+ * Observe an Ionic page's "is currently entered" state from its lifecycle DOM events.
1574
+ *
1575
+ * @remarks
1576
+ * Emits `true` on `ionViewDidEnter` and `false` on `ionViewWillEnter` / `ionViewWillLeave`, seeded
1577
+ * with `false` via `startWith`. Useful to pause/resume work (timers, video, expensive rendering)
1578
+ * while a page is off-screen in Ionic's stack navigation, without wiring the four lifecycle hooks by
1579
+ * hand. The listeners are removed when the Observable is unsubscribed.
1580
+ *
1581
+ * @param el - an `ElementRef` for the page host element (the `ion-page`)
1582
+ * @returns an Observable that emits whether the page is currently entered
1583
+ * @example
1584
+ * ```ts
1585
+ * export class FeedPage {
1586
+ * readonly #host = inject(ElementRef);
1587
+ * readonly isEntered = toSignal(kitCreateDidEnter(this.#host), { initialValue: false });
1588
+ * }
1589
+ * ```
1590
+ */
1591
+ const kitCreateDidEnter = (el) => {
1592
+ return new Observable((observer) => {
1593
+ const willEnter = () => observer.next(false);
1594
+ const didEnter = () => observer.next(true);
1595
+ const willLeave = () => observer.next(false);
1596
+ el.nativeElement.addEventListener('ionViewWillEnter', willEnter);
1597
+ el.nativeElement.addEventListener('ionViewDidEnter', didEnter);
1598
+ el.nativeElement.addEventListener('ionViewWillLeave', willLeave);
1599
+ return () => {
1600
+ el.nativeElement.removeEventListener('ionViewWillEnter', willEnter);
1601
+ el.nativeElement.removeEventListener('ionViewDidEnter', didEnter);
1602
+ el.nativeElement.removeEventListener('ionViewWillLeave', willLeave);
1603
+ };
1604
+ }).pipe(startWith(false));
1605
+ };
1606
+
1178
1607
  /*
1179
1608
  * Public API Surface of @rdlabo/ionic-angular-kit
1180
1609
  */
@@ -1184,5 +1613,5 @@ const disableHandler = async (event, work) => {
1184
1613
  * Generated bundle index. Do not edit.
1185
1614
  */
1186
1615
 
1187
- export { KIT_AUTH_CONFIG, KIT_HTTP_CONFIG, KIT_OVERLAY_CONFIG, KitAutofillDirective, KitOverlayController, KitReloadAlertController, KitStorageService, arrayConcatById, disableHandler, kitAuthInterceptor, kitImpact, kitKeyboardInit, kitPresentAuthFailedAlert, kitRequireAuthorizedGuard, kitRequireConfirmingGuard, kitRequiredUnauthorizedGuard, objectEqual, provideKitAuth, provideKitHttp, provideKitOverlay };
1616
+ export { KIT_AUTH_CONFIG, KIT_HTTP_CONFIG, KIT_OVERLAY_CONFIG, KIT_THEME_CONFIG, KitAutofillDirective, KitOverlayController, KitReloadAlertController, KitStorageService, KitThemeController, arrayConcatById, disableHandler, kitAuthInterceptor, kitBuildBrotherPrintSettings, kitChangeEventDisabled, kitCreateDidEnter, kitDomToPng, kitImpact, kitKeyboardInit, kitPresentAuthFailedAlert, kitPresentLanguageActionSheet, kitRequestReview, kitRequireAuthorizedGuard, kitRequireConfirmingGuard, kitRequiredUnauthorizedGuard, kitRotationImage, objectEqual, provideKitAuth, provideKitHttp, provideKitOverlay, provideKitTheme };
1188
1617
  //# sourceMappingURL=rdlabo-ionic-angular-kit.mjs.map