From 1340d7e2b4ed3f843f7dc7fdc9dfb36280d39f1a Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 22 Aug 2026 19:52:42 +0900 Subject: [PATCH 1/3] feat!: finalize v22 library APIs --- .github/workflows/lint.yml | 3 + .github/workflows/release.yml | 4 +- README.md | 16 +- docs/migration.md | 165 +++++++++-- e2e/photo-editor-ios26.spec.ts | 1 + ios/App/App.xcodeproj/project.pbxproj | 8 +- ios/App/Podfile | 2 +- package-lock.json | 239 +++++---------- package.json | 7 +- projects/demo/src/app/app.config.ts | 4 + .../kit/auth/pages/signin/signin.page.html | 7 +- .../kit/auth/pages/signup/signup.page.html | 7 +- .../demo-photo-editor-page.component.ts | 43 ++- projects/demo/src/index.html | 3 - projects/kit/README.md | 7 +- .../directives/auth-input.directive.spec.ts | 16 +- .../lib/directives/auth-input.directive.ts | 101 +------ .../src/lib/storage/kit-auth-email-store.ts | 8 +- projects/photo-editor/README.md | 65 +++-- projects/photo-editor/docs/editor.md | 69 ++++- projects/photo-editor/docs/photo-file.md | 104 +++++-- projects/photo-editor/docs/theme.md | 10 +- projects/photo-editor/docs/viewer.md | 73 +++-- projects/photo-editor/editor/ng-package.json | 6 + .../editor/src/lib/filter-preset.ts | 9 + .../photo-editor/editor/src/lib/internals.ts | 52 ++++ .../{ => editor}/src/lib/ion-components.ts | 0 .../editor/src/lib/photo-editor.page.html | 117 ++++++++ .../src/lib}/photo-editor.page.scss | 0 .../editor/src/lib/photo-editor.page.ts | 276 ++++++++++++++++++ .../photo-editor/editor/src/public-api.ts | 2 + .../photo-editor/editor/tui/ng-package.json | 5 + .../photo-editor/editor/tui/src/public-api.ts | 7 + .../file/capacitor/ng-package.json | 5 + .../file/capacitor/src/public-api.ts | 26 ++ projects/photo-editor/file/ng-package.json | 6 + .../file/src/lib/photo-file.service.ts | 189 ++++++++++++ projects/photo-editor/file/src/public-api.ts | 2 + projects/photo-editor/package.json | 20 +- projects/photo-editor/src/lib/dictionaries.ts | 27 +- .../photo-editor/src/lib/filter-preset.ts | 44 --- projects/photo-editor/src/lib/pages/core.scss | 73 ++--- .../pages/photo-editor/photo-editor.page.html | 99 ------- .../pages/photo-editor/photo-editor.page.ts | 222 -------------- .../pages/photo-viewer/photo-viewer.page.html | 36 --- .../photo-editor/src/lib/pages/util.spec.ts | 26 -- projects/photo-editor/src/lib/pages/util.ts | 48 --- .../src/lib/photo-editor.config.ts | 105 +++++++ .../photo-editor/src/lib/photoEditorErrors.ts | 5 - .../lib/services/photo-file.service.spec.ts | 255 ---------------- .../src/lib/services/photo-file.service.ts | 165 ----------- projects/photo-editor/src/lib/types.ts | 90 ++++-- projects/photo-editor/src/public-api.ts | 4 +- .../photo-editor.page.spec.ts | 124 ++++++-- .../src/tests/photo-file.service.spec.ts | 264 +++++++++++++++++ .../photo-viewer.page.spec.ts | 79 ++++- projects/photo-editor/viewer/ng-package.json | 6 + .../photo-editor/viewer/src/lib/internals.ts | 15 + .../viewer/src/lib/photo-viewer.page.html | 36 +++ .../src/lib}/photo-viewer.page.scss | 0 .../src/lib}/photo-viewer.page.ts | 99 ++++--- .../photo-editor/viewer/src/public-api.ts | 2 + .../lib/directives/scroll-header.directive.ts | 27 +- .../virtual-scroll-header.directive.ts | 46 +-- projects/scroll-header/src/lib/util.spec.ts | 21 -- projects/scroll-header/src/lib/util.ts | 11 - .../dynamic-size-virtual-scroll-strategy.ts | 25 +- ...amic-size-virtual-scroll.directive.spec.ts | 23 +- .../dynamic-size-virtual-scroll.util.spec.ts | 7 - .../lib/dynamic-size-virtual-scroll.util.ts | 33 --- projects/scroll-strategies/src/public-api.ts | 2 +- scripts/test-package-consumer.mjs | 136 +++++++++ tsconfig.json | 6 + 73 files changed, 2213 insertions(+), 1632 deletions(-) create mode 100644 projects/photo-editor/editor/ng-package.json create mode 100644 projects/photo-editor/editor/src/lib/filter-preset.ts create mode 100644 projects/photo-editor/editor/src/lib/internals.ts rename projects/photo-editor/{ => editor}/src/lib/ion-components.ts (100%) create mode 100644 projects/photo-editor/editor/src/lib/photo-editor.page.html rename projects/photo-editor/{src/lib/pages/photo-editor => editor/src/lib}/photo-editor.page.scss (100%) create mode 100644 projects/photo-editor/editor/src/lib/photo-editor.page.ts create mode 100644 projects/photo-editor/editor/src/public-api.ts create mode 100644 projects/photo-editor/editor/tui/ng-package.json create mode 100644 projects/photo-editor/editor/tui/src/public-api.ts create mode 100644 projects/photo-editor/file/capacitor/ng-package.json create mode 100644 projects/photo-editor/file/capacitor/src/public-api.ts create mode 100644 projects/photo-editor/file/ng-package.json create mode 100644 projects/photo-editor/file/src/lib/photo-file.service.ts create mode 100644 projects/photo-editor/file/src/public-api.ts delete mode 100644 projects/photo-editor/src/lib/filter-preset.ts delete mode 100644 projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.html delete mode 100644 projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.ts delete mode 100644 projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.html delete mode 100644 projects/photo-editor/src/lib/pages/util.spec.ts delete mode 100644 projects/photo-editor/src/lib/pages/util.ts create mode 100644 projects/photo-editor/src/lib/photo-editor.config.ts delete mode 100644 projects/photo-editor/src/lib/photoEditorErrors.ts delete mode 100644 projects/photo-editor/src/lib/services/photo-file.service.spec.ts delete mode 100644 projects/photo-editor/src/lib/services/photo-file.service.ts rename projects/photo-editor/src/{lib/pages/photo-editor => tests}/photo-editor.page.spec.ts (51%) create mode 100644 projects/photo-editor/src/tests/photo-file.service.spec.ts rename projects/photo-editor/src/{lib/pages/photo-viewer => tests}/photo-viewer.page.spec.ts (59%) create mode 100644 projects/photo-editor/viewer/ng-package.json create mode 100644 projects/photo-editor/viewer/src/lib/internals.ts create mode 100644 projects/photo-editor/viewer/src/lib/photo-viewer.page.html rename projects/photo-editor/{src/lib/pages/photo-viewer => viewer/src/lib}/photo-viewer.page.scss (100%) rename projects/photo-editor/{src/lib/pages/photo-viewer => viewer/src/lib}/photo-viewer.page.ts (58%) create mode 100644 projects/photo-editor/viewer/src/public-api.ts delete mode 100644 projects/scroll-header/src/lib/util.spec.ts delete mode 100644 projects/scroll-header/src/lib/util.ts create mode 100644 scripts/test-package-consumer.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b283925..4b03846 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,6 +29,7 @@ jobs: - run: npm ci - run: npm run lint - run: npm run build + - run: npm run test:package-consumer - run: npm run test:offline-types:nonstrict test: runs-on: ubuntu-latest @@ -94,6 +95,8 @@ jobs: typescript@~5.9.3 - name: Build libraries and consuming demo with Angular 21 run: npm run build + - name: Type-check a consumer against packed Angular 21 artifacts + run: npm run test:package-consumer e2e: runs-on: ubuntu-latest container: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58ea8e8..48e90ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,9 @@ jobs: - name: Build all projects run: npm run prebuild + - name: Verify packed package entry points + run: npm run test:package-consumer + - name: Commit changes run: | git config --local user.email "action@github.com" @@ -90,4 +93,3 @@ jobs: fi cd ../.. done - diff --git a/README.md b/README.md index 4be796b..1fc3205 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,16 @@ Upgrading from an earlier release? Read the [library migration guide](docs/migra The compatibility table describes package requirements, not application-framework migration steps. Follow the upstream Angular, Ionic, and Capacitor guides separately, then apply this repository's [library-specific migrations](docs/migration.md). +Native v22 applications require an iOS/iPadOS deployment target of 16.4 or later. + ## packages -| package name | description | path | -| ----------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| @rdlabo/ionic-angular-kit | Auth guards, Firebase flows, storage, overlays, HTTP, and shared utilities. | [/projects/kit](https://github.com/rdlabo-dev/ionic-angular-library/tree/main/projects/kit#readme) | -| @rdlabo/ionic-angular-photo-editor | Photo editor/viewer modals and camera or album file loading. | [/projects/photo-editor](https://github.com/rdlabo-dev/ionic-angular-library/tree/main/projects/photo-editor#readme) | -| @rdlabo/ionic-angular-scroll-header | Directives for scroll-linked Ionic headers. | [/projects/scroll-header](https://github.com/rdlabo-dev/ionic-angular-library/tree/main/projects/scroll-header#readme) | -| @rdlabo/ngx-cdk-scroll-strategies | Dynamic-size virtual scroll strategies for Angular CDK. | [/projects/scroll-strategies](https://github.com/rdlabo-dev/ionic-angular-library/tree/main/projects/scroll-strategies#readme) | +| package name | description | path | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| @rdlabo/ionic-angular-kit | Auth guards, Firebase flows, storage, overlays, HTTP, and shared utilities. | [/projects/kit](https://github.com/rdlabo-dev/ionic-angular-library/tree/main/projects/kit#readme) | +| @rdlabo/ionic-angular-photo-editor | Photo editor/viewer modals and camera or album file loading. Root import: types and `providePhotoEditor`. Components and services: `/editor`, `/viewer`, `/file`. | [/projects/photo-editor](https://github.com/rdlabo-dev/ionic-angular-library/tree/main/projects/photo-editor#readme) | +| @rdlabo/ionic-angular-scroll-header | Directives for scroll-linked Ionic headers. | [/projects/scroll-header](https://github.com/rdlabo-dev/ionic-angular-library/tree/main/projects/scroll-header#readme) | +| @rdlabo/ngx-cdk-scroll-strategies | Dynamic-size virtual scroll strategies for Angular CDK. | [/projects/scroll-strategies](https://github.com/rdlabo-dev/ionic-angular-library/tree/main/projects/scroll-strategies#readme) | ### Release @@ -40,4 +42,4 @@ The demo app includes a **Kit** tab with a Firebase Auth harness (`/main/kit/aut 1. Fill `projects/demo/src/environments/environment.ts` (`firebase`). 2. `npm start` — open the Kit tab. 3. `npm run e2e` — Playwright signs up with a UUID email; `window.__E2E__` skips email confirmation. -4. `npm run cap` — copy a production build to iOS/Android for device checks (e.g. `kitAuthInput` autofill). +4. `npm run cap` — copy a production build to iOS/Android for device checks. diff --git a/docs/migration.md b/docs/migration.md index 8f6efe0..e34d735 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -6,7 +6,9 @@ This guide covers consumer-facing changes in the packages published from this re ### Compatibility requirements -Version 22 supports Angular 21 and 22. The kit, photo-editor, and scroll-header packages require Ionic 9; scroll-strategies requires only Angular and Angular CDK. Native kit and photo-editor features require Capacitor 7 or 8. Upgrade the host application with the upstream tools first, then update only the rdlabo packages it uses. Keep rdlabo packages on the same release line when an application uses more than one of them. +Version 22 supports Angular 21 and 22. The kit, photo-editor, and scroll-header packages require Ionic 9; scroll-strategies requires only Angular and Angular CDK. Native kit and photo-editor features require Capacitor 7 or 8. **The minimum iOS/iPadOS deployment target is 16.4.** Upgrade the host application with the upstream tools first, then update only the rdlabo packages it uses. Keep rdlabo packages on the same release line when an application uses more than one of them. + +Set `platform :ios, '16.4'` in the application Podfile and `IPHONEOS_DEPLOYMENT_TARGET = 16.4` in the Xcode project. This minimum is required because the obsolete auth autofill workaround is removed only after the corresponding WebKit fix shipped in the iOS 16.4 generation. While v22 is available under the npm `beta` dist-tag, install the prerelease with: @@ -21,52 +23,133 @@ After stable v22 is published, use `@^22` instead of `@beta`. Omit packages that ### @rdlabo/ionic-angular-photo-editor -#### Choose the header button scheme +#### Split entry points + +Components and services moved to secondary entry points. Import types and configuration from the root package only. -`PhotoEditorPage` and `PhotoViewerPage` now require `headerButtonColorScheme`. The library cannot infer the final `ion-toolbar` appearance after application CSS, translucency, and runtime theme overrides are applied. +| Before (v21) | After (v22) | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `import { PhotoEditorPage } from '@rdlabo/ionic-angular-photo-editor'` | `import { PhotoEditorPage } from '@rdlabo/ionic-angular-photo-editor/editor'` | +| `import { PhotoViewerPage } from '@rdlabo/ionic-angular-photo-editor'` | `import { PhotoViewerPage } from '@rdlabo/ionic-angular-photo-editor/viewer'` | +| `import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor'` | `import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor/file'` | +| — | `import { providePhotoEditor, PhotoEditorProps } from '@rdlabo/ionic-angular-photo-editor'` | -Use `dark` for a dark or black toolbar and `light` for a light or white toolbar. Define typed props before passing them to Ionic because `ModalController` does not enforce the component's input types. +Optional implementations are explicit adapters in v22: ```typescript -import { PhotoEditorPage, PhotoEditorProps } from '@rdlabo/ionic-angular-photo-editor'; +import { providePhotoEditor } from '@rdlabo/ionic-angular-photo-editor'; +import { createTuiImageEditor } from '@rdlabo/ionic-angular-photo-editor/editor/tui'; +import { loadCapacitorPhotoCamera } from '@rdlabo/ionic-angular-photo-editor/file/capacitor'; + +providePhotoEditor({ + createImageEditor: createTuiImageEditor, + loadCamera: loadCapacitorPhotoCamera, // omit for browser-only applications +}); +``` + +Install `tui-image-editor` only when importing `/editor/tui`, `@capacitor/camera` only when importing `/file/capacitor`, and `swiper` only when importing `/viewer`. The root, `/editor`, and `/file` entry points no longer hide runtime imports of these optional peers. Without the corresponding configured adapter, editing/resizing or native selection fails with `PhotoLoadError('unavailable')`. + +#### Rename toolbarColorScheme + +`headerButtonColorScheme` is now `toolbarColorScheme`. It remains required on both modals. + +```typescript +import { PhotoEditorProps } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoEditorPage } from '@rdlabo/ionic-angular-photo-editor/editor'; const componentProps = { value, - headerButtonColorScheme: 'dark', + toolbarColorScheme: 'dark', } satisfies PhotoEditorProps; -await modalController.create({ - component: PhotoEditorPage, - componentProps, -}); +await modalController.create({ component: PhotoEditorPage, componentProps }); ``` ```typescript -import { PhotoViewerPage, PhotoViewerProps } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoViewerProps } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoViewerPage } from '@rdlabo/ionic-angular-photo-editor/viewer'; const componentProps = { imageUrls, - headerButtonColorScheme: 'dark', + toolbarColorScheme: 'dark', } satisfies PhotoViewerProps; -await modalController.create({ - component: PhotoViewerPage, - componentProps, +await modalController.create({ component: PhotoViewerPage, componentProps }); +``` + +#### Typed modal results + +Dismiss payloads are now discriminated by `action`: + +```typescript +// Editor — before +const { data } = await modal.onWillDismiss(); +if (data?.value) { + /* saved */ +} + +// Editor — after +const { data } = await modal.onWillDismiss(); +if (data?.action === 'save') { + /* data.value */ +} + +// Viewer — before +const { data } = await modal.onWillDismiss(); +if (data?.delete) { + /* data.delete.index, data.delete.value */ +} + +// Viewer — after +const { data } = await modal.onWillDismiss(); +if (data?.action === 'delete') { + /* data.index, data.value */ +} +``` + +Removed types: `IPhotoEditorDismiss`, `IPhotoViewerDismiss`, `IDictionaryForEditor`, `IDictionaryForViewer`, `IDictionaryForService`. Use `PhotoEditorResult`, `PhotoViewerResult`, `PhotoEditorLabels`, `PhotoViewerLabels`, and `PhotoFileLabels` instead. + +#### PhotoFileService API + +```typescript +// Before +photoFileService.photoMaxSize = 1000; +photoFileService.labels = { camera: '…', album: '…', cancel: '…' }; +const files = await photoFileService.loadPhoto(2); + +// After — register defaults once +providePhotoEditor({ + maxPhotoSize: 1000, + fileLabels: { … }, + createImageEditor: createTuiImageEditor, + loadCamera: loadCapacitorPhotoCamera, }); + +// Per call +const files = await photoFileService.loadPhoto({ limit: 2, maxSize: 1000, labels: { … } }); ``` -`PhotoViewerProps.imageUrls` is now correctly declared as required. It was already a required component input, so ensure every viewer invocation supplies it. +Cancellation and validation failures now throw `PhotoLoadError` with `code: 'cancelled' | 'invalid-type' | 'unavailable'` instead of returning empty arrays or generic errors. -#### Update direct-template selectors +Remove any static `` from `index.html`. On web, the service creates and removes a hidden file input synchronously when `loadPhoto()` runs; there is no fixed input id. + +#### Accessibility and labels + +- Pass `imageAlt` on the viewer for meaningful slide `alt` text (string or `(url, index) => string`). +- Override UI strings with `labels` on editor, viewer, and `loadPhoto({ labels })`. Built-in defaults remain Japanese (for example `保存`, `閉じる`, `削除`, `カメラ撮影`). -The public components now use package-prefixed selectors: +#### Update direct-template selectors | Before | After | | -------------------- | ----------------------- | | `` | `` | | `` | `` | -No selector update is needed when presenting `PhotoEditorPage` or `PhotoViewerPage` by component class through `ModalController`. +No selector update is needed when presenting pages by component class through `ModalController`. + +#### Stop using component internals + +Component implementation members are no longer public API. This includes editor state and controllers such as `setLabels`, `modalCtrl`, `filters`, `footerMenu`, `currentCrop`, `currentRotate`, `photoCrop`, and `isCropped`, plus equivalent viewer internals such as `setLabels`, `swiper`, and swipe subscriptions. Use modal `componentProps`, `labels`, `imageAlt`, and the typed dismiss results instead of reading or mutating component instances. #### Optional iOS 26 theme integration @@ -79,11 +162,27 @@ Applications using `@rdlabo/ionic-theme-ios26` v3 can import the photo-editor ad @import '@rdlabo/ionic-angular-photo-editor/css/ios26-header-button-color-scheme.css'; ``` -Do not import the adapter when the application does not use the iOS 26 theme. See the photo editor [theme guide](../projects/photo-editor/docs/theme.md) for color overrides and alternative dark-mode imports. +Do not import the adapter when the application does not use the iOS 26 theme. See the photo editor [theme guide](../projects/photo-editor/docs/theme.md). ### @rdlabo/ionic-angular-kit -Version 22 changes the supported host framework range but does not rename or remove kit public APIs. After satisfying the compatibility requirements, existing kit imports and provider configuration remain valid. +#### Remove kitAuthInput="autofill" + +The `'autofill'` mode is removed. [WebKit bug 226023](https://bugs.webkit.org/show_bug.cgi?id=226023) (iOS autofill not propagating from `ion-input` to Angular forms) is fixed in the v22 minimum iOS/iPadOS 16.4 runtime, so the workaround is no longer shipped. Applications that must continue supporting an older iOS version cannot remove their app-local workaround and should remain on the v21 library line. + +Remove `kitAuthInput="autofill"` from password and other non-email fields. Do not add the attribute unless email persistence is intended. + +Email persistence modes are unchanged: + +```html + + + + + +``` + +`kitAuthInput` is now required on every use (no default). Other kit public APIs are unchanged after satisfying the compatibility requirements above. ### @rdlabo/ionic-angular-scroll-header @@ -91,8 +190,28 @@ Version 22 has no package-specific API migration. Existing `rdlaboScrollHeader`, ### @rdlabo/ngx-cdk-scroll-strategies -Version 22 has no package-specific API migration. Existing dynamic-size virtual-scroll configuration remains valid after the dependency update. +#### Remove calcIndex + +`calcIndex` is removed from the public API. Replace it with `calculateItemCountForPixelDistance`: + +```typescript +// Before +import { calcIndex } from '@rdlabo/ngx-cdk-scroll-strategies'; +const index = calcIndex(sizes, pixelOffset); + +// After +import { calculateItemCountForPixelDistance } from '@rdlabo/ngx-cdk-scroll-strategies'; +const index = calculateItemCountForPixelDistance(sizes, pixelOffset); +``` + +This is an intentional semantic correction, not a drop-in rename. The old function could undercount a partially consumed item: for sizes `[{ itemSize: 55 }, { itemSize: 55 }]` and distance `60`, it returned approximately `0.0909`; the replacement returns approximately `1.0909`. Revalidate thresholds, `startIndex`, and reverse-scroll behavior in every consumer rather than relying on the old numeric result. + +#### Remove internal strategy factory export + +`_dynamicSizeVirtualScrollStrategyFactory` and the public `_scrollStrategy` field on `CdkDynamicSizeVirtualScroll` are removed. They were internal wiring for the CDK `VIRTUAL_SCROLL_STRATEGY` token. Application code should inject `VIRTUAL_SCROLL_STRATEGY` or use `CdkDynamicSizeVirtualScroll.scrollOffset` instead of reaching into the directive. + +Existing dynamic-size virtual-scroll template configuration remains valid. ### Verification -After applying the relevant package migrations, run the host application's normal checks and exercise the affected UI on each supported platform. For photo-editor, verify both editor and viewer modals against every toolbar color used by the application. +After applying the relevant package migrations, run the host application's normal checks and exercise the affected UI on each supported platform. For photo-editor, verify editor and viewer modals for every `toolbarColorScheme` the application uses, web photo picking without a static file input, and viewer `imageAlt` / label overrides where applicable. diff --git a/e2e/photo-editor-ios26.spec.ts b/e2e/photo-editor-ios26.spec.ts index 65d7a90..9e3cfbf 100644 --- a/e2e/photo-editor-ios26.spec.ts +++ b/e2e/photo-editor-ios26.spec.ts @@ -31,6 +31,7 @@ test.describe('Photo editor iOS 26 header button color scheme', () => { await page.getByText('Launch Photo Editor', { exact: true }).click(); const header = page.locator('ion-modal rdlabo-photo-editor ion-header'); + await expect(page.locator('ion-modal .tui-image-editor-canvas-container canvas').first()).toBeAttached(); await header.evaluate((element) => { element.classList.remove('photo-editor-header-buttons-dark'); element.classList.add('photo-editor-header-buttons-light'); diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index f19d2bb..fcb1cfb 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -281,7 +281,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -332,7 +332,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; @@ -349,7 +349,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = P7E2DJ2G49; INFOPLIST_FILE = App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; @@ -370,7 +370,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = P7E2DJ2G49; INFOPLIST_FILE = App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = jp.rdlabo.library.demo; diff --git a/ios/App/Podfile b/ios/App/Podfile index 7ce5ca5..1aaef9a 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -1,6 +1,6 @@ require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' -platform :ios, '13.0' +platform :ios, '16.4' use_frameworks! # workaround to avoid Xcode caching of Pods that requires diff --git a/package-lock.json b/package-lock.json index 8e6f2ce..df2d24c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,9 +55,11 @@ "angular-eslint": "^22.1.0", "child_process": "^1.0.2", "dom-to-image-more": "^3.10.0", + "esbuild": "^0.28.2", "eslint": "^9.39.4", "firebase": "^11.10.0", "husky": "^8.0.3", + "ionicons": "^8.1.0", "jsdom": "^26.0.0", "lint-staged": "^15.2.0", "ng-packagr": "^22.1.0", @@ -65,6 +67,8 @@ "path": "^0.12.7", "pdf-lib": "^1.17.1", "prettier": "^3.1.1", + "swiper": "^11.1.3", + "tui-image-editor": "^3.15.3", "typescript": "~6.0.0", "typescript-eslint": "^8.61.1", "vitest": "^4.0.0", @@ -79,7 +83,7 @@ }, "dist/photo-editor": { "name": "@rdlabo/ionic-angular-photo-editor", - "version": "22.0.0", + "version": "22.0.0-0", "dependencies": { "tslib": "^2.3.0" }, @@ -87,16 +91,30 @@ "@angular/cdk": ">=21.0.0 <23.0.0", "@angular/common": ">=21.0.0 <23.0.0", "@angular/core": ">=21.0.0 <23.0.0", + "@angular/forms": ">=21.0.0 <23.0.0", "@capacitor/camera": ">=7.0.0 <9.0.0", "@capacitor/core": ">=7.0.0 <9.0.0", "@ionic/angular": "^9.0.0", + "ionicons": ">=7.0.0 <9.0.0", + "rxjs": "^7.8.0", "swiper": "^11.1.3", "tui-image-editor": "^3.15.3" + }, + "peerDependenciesMeta": { + "@capacitor/camera": { + "optional": true + }, + "swiper": { + "optional": true + }, + "tui-image-editor": { + "optional": true + } } }, "dist/scroll-header": { "name": "@rdlabo/ionic-angular-scroll-header", - "version": "22.0.0", + "version": "22.0.0-0", "dependencies": { "tslib": "^2.3.0" }, @@ -109,7 +127,7 @@ }, "dist/scroll-strategies": { "name": "@rdlabo/ngx-cdk-scroll-strategies", - "version": "22.0.0", + "version": "22.0.0-0", "dependencies": { "tslib": "^2.3.0" }, @@ -6045,7 +6063,6 @@ "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^2.0.0", "https-proxy-agent": "^5.0.0", @@ -6066,8 +6083,7 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { "version": "6.0.2", @@ -6075,7 +6091,6 @@ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "debug": "4" }, @@ -6088,8 +6103,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@mapbox/node-pre-gyp/node_modules/brace-expansion": { "version": "1.1.15", @@ -6097,7 +6111,6 @@ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6109,7 +6122,6 @@ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "license": "ISC", "optional": true, - "peer": true, "engines": { "node": ">=10" } @@ -6120,7 +6132,6 @@ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "minipass": "^3.0.0" }, @@ -6134,7 +6145,6 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -6149,7 +6159,6 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -6171,7 +6180,6 @@ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -6186,7 +6194,6 @@ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "semver": "^6.0.0" }, @@ -6203,7 +6210,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "optional": true, - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -6214,7 +6220,6 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6228,7 +6233,6 @@ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", "license": "ISC", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -6239,7 +6243,6 @@ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -6254,7 +6257,6 @@ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -6268,7 +6270,6 @@ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "abbrev": "1" }, @@ -6286,7 +6287,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -6304,7 +6304,6 @@ "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -6322,8 +6321,7 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", @@ -9623,8 +9621,7 @@ "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "deprecated": "Use your platform's native atob() and btoa() methods instead", "license": "BSD-3-Clause", - "optional": true, - "peer": true + "optional": true }, "node_modules/accepts": { "version": "2.0.0", @@ -9659,7 +9656,6 @@ "integrity": "sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "acorn": "^6.0.1", "acorn-walk": "^6.0.1" @@ -9671,7 +9667,6 @@ "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", "license": "MIT", "optional": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9695,7 +9690,6 @@ "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.4.0" } @@ -9975,8 +9969,7 @@ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/are-we-there-yet": { "version": "2.0.0", @@ -9985,7 +9978,6 @@ "deprecated": "This package is no longer supported.", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" @@ -10017,7 +10009,6 @@ "integrity": "sha512-gUHx76KtnhEgB3HOuFYiCm3FIdEs6ocM2asHvNTkfu/Y09qQVrrVVaOKENmS2KkSaGoxgXNqC+ZVtR/n0MOkSA==", "license": "MIT", "optional": true, - "peer": true, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -10035,7 +10026,6 @@ "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "safer-buffer": "~2.1.0" } @@ -10061,7 +10051,6 @@ "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.8" } @@ -10091,8 +10080,7 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/at-least-node": { "version": "1.0.0", @@ -10158,7 +10146,6 @@ "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", "license": "Apache-2.0", "optional": true, - "peer": true, "engines": { "node": "*" } @@ -10168,8 +10155,7 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/axobject-query": { "version": "4.1.0", @@ -10281,7 +10267,6 @@ "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "tweetnacl": "^0.14.3" } @@ -10587,8 +10572,7 @@ "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", "license": "BSD-2-Clause", - "optional": true, - "peer": true + "optional": true }, "node_modules/browserslist": { "version": "4.28.8", @@ -10837,8 +10821,7 @@ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "license": "Apache-2.0", - "optional": true, - "peer": true + "optional": true }, "node_modules/chai": { "version": "6.2.2", @@ -11323,7 +11306,6 @@ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", "license": "ISC", "optional": true, - "peer": true, "bin": { "color-support": "bin.js" } @@ -11341,7 +11323,6 @@ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -11534,8 +11515,7 @@ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/content-disposition": { "version": "1.1.0", @@ -11796,8 +11776,7 @@ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cssstyle": { "version": "4.6.0", @@ -11819,7 +11798,6 @@ "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "assert-plus": "^1.0.0" }, @@ -12094,7 +12072,6 @@ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.4.0" } @@ -12104,8 +12081,7 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/depd": { "version": "2.0.0", @@ -12236,7 +12212,6 @@ "deprecated": "Use your platform's native DOMException instead", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "webidl-conversions": "^4.0.2" } @@ -12246,8 +12221,7 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "license": "BSD-2-Clause", - "optional": true, - "peer": true + "optional": true }, "node_modules/domhandler": { "version": "5.0.3", @@ -12317,7 +12291,6 @@ "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -12625,7 +12598,6 @@ "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "esprima": "^4.0.1", "estraverse": "^4.2.0", @@ -12649,7 +12621,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", "optional": true, - "peer": true, "engines": { "node": ">=4.0" } @@ -12660,7 +12631,6 @@ "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" @@ -12675,7 +12645,6 @@ "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", @@ -12693,7 +12662,6 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", "optional": true, - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -12704,7 +12672,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12715,7 +12682,6 @@ "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "prelude-ls": "~1.1.2" }, @@ -12947,7 +12913,6 @@ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "license": "BSD-2-Clause", "optional": true, - "peer": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -13175,8 +13140,7 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/external-editor": { "version": "3.1.0", @@ -13221,15 +13185,14 @@ "node >=0.6.0" ], "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/fabric": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/fabric/-/fabric-4.6.0.tgz", "integrity": "sha512-MhJXCD/ZugOGV5aPHIG0MY1q2EfrlzC2sasrAHj0HHXN50JTe1bHFrlRdkXBijCJ0dG81fGu/A/Pct9DyuwCzQ==", + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8.0.0" }, @@ -13244,7 +13207,6 @@ "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "license": "MIT", "optional": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -13259,7 +13221,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@mapbox/node-pre-gyp": "^1.0.0", "nan": "^2.17.0", @@ -13275,7 +13236,6 @@ "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "cssom": "~0.3.6" }, @@ -13288,8 +13248,7 @@ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/fabric/node_modules/data-urls": { "version": "1.1.0", @@ -13297,7 +13256,6 @@ "integrity": "sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "abab": "^2.0.0", "whatwg-mimetype": "^2.2.0", @@ -13310,7 +13268,6 @@ "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "mimic-response": "^2.0.0" }, @@ -13324,7 +13281,6 @@ "integrity": "sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "whatwg-encoding": "^1.0.1" } @@ -13335,7 +13291,6 @@ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -13349,7 +13304,6 @@ "integrity": "sha512-fAl1W0/7T2G5vURSyxBzrJ1LSdQn6Tr5UX/xD4PXDx/PDgwygedfW6El/KIj3xJ7FU61TTYnc/l/B7P49Eqt6g==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "abab": "^2.0.0", "acorn": "^7.1.0", @@ -13396,7 +13350,6 @@ "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8" }, @@ -13409,8 +13362,7 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", "integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/fabric/node_modules/saxes": { "version": "3.1.11", @@ -13418,7 +13370,6 @@ "integrity": "sha512-Ydydq3zC+WYDJK1+gRxRapLIED9PWeSuuS41wqyoRmzvhhh9nc+QQrVMKJYzJFULazeGhzSV0QleN2wD3boh2g==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "xmlchars": "^2.1.1" }, @@ -13432,7 +13383,6 @@ "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "decompress-response": "^4.2.0", "once": "^1.3.1", @@ -13445,7 +13395,6 @@ "integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "ip-regex": "^2.1.0", "psl": "^1.1.28", @@ -13461,7 +13410,6 @@ "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -13472,7 +13420,6 @@ "integrity": "sha512-p10l/ayESzrBMYWRID6xbuCKh2Fp77+sA0doRuGn4tTIMrrZVeqfpKjXHY+oDh3K4nLdPgNwMTVP6Vp4pvqbNg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "domexception": "^1.0.1", "webidl-conversions": "^4.0.2", @@ -13484,8 +13431,7 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "license": "BSD-2-Clause", - "optional": true, - "peer": true + "optional": true }, "node_modules/fabric/node_modules/whatwg-encoding": { "version": "1.0.5", @@ -13494,7 +13440,6 @@ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "iconv-lite": "0.4.24" } @@ -13504,8 +13449,7 @@ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/fabric/node_modules/whatwg-url": { "version": "7.1.0", @@ -13513,7 +13457,6 @@ "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", @@ -13526,7 +13469,6 @@ "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -13548,8 +13490,7 @@ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", "license": "Apache-2.0", - "optional": true, - "peer": true + "optional": true }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -13911,7 +13852,6 @@ "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", "license": "Apache-2.0", "optional": true, - "peer": true, "engines": { "node": "*" } @@ -13922,7 +13862,6 @@ "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -13938,7 +13877,6 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -13949,7 +13887,6 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -14045,7 +13982,6 @@ "deprecated": "This package is no longer supported.", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.2", @@ -14066,8 +14002,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/gensync": { "version": "1.0.0-beta.2", @@ -14160,7 +14095,6 @@ "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "assert-plus": "^1.0.0" } @@ -14348,7 +14282,6 @@ "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", "license": "ISC", "optional": true, - "peer": true, "engines": { "node": ">=4" } @@ -14360,7 +14293,6 @@ "deprecated": "this library is no longer supported", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" @@ -14375,7 +14307,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14392,8 +14323,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/has-ansi": { "version": "2.0.0", @@ -14446,8 +14376,7 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/has-yarn": { "version": "3.0.0", @@ -14691,7 +14620,6 @@ "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -15628,7 +15556,6 @@ "integrity": "sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=4" } @@ -16034,8 +15961,7 @@ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/issue-regex": { "version": "4.3.0", @@ -16415,8 +16341,7 @@ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/jsdom": { "version": "26.1.0", @@ -16509,8 +16434,7 @@ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)", - "optional": true, - "peer": true + "optional": true }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -16538,8 +16462,7 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/json5": { "version": "2.2.3", @@ -16579,7 +16502,6 @@ "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -18258,8 +18180,7 @@ "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/lodash.zip": { "version": "4.2.0", @@ -18838,7 +18759,6 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "license": "MIT", "optional": true, - "peer": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -18926,8 +18846,7 @@ "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/nanoid": { "version": "3.3.18", @@ -19161,7 +19080,6 @@ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -19182,16 +19100,14 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause", - "optional": true, - "peer": true + "optional": true }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", @@ -19199,7 +19115,6 @@ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -19775,7 +19690,6 @@ "deprecated": "This package is no longer supported.", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "are-we-there-yet": "^2.0.0", "console-control-strings": "^1.1.0", @@ -19819,7 +19733,6 @@ "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", "license": "Apache-2.0", "optional": true, - "peer": true, "engines": { "node": "*" } @@ -20618,8 +20531,7 @@ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/picocolors": { "version": "1.1.1", @@ -20778,8 +20690,7 @@ "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/postcss": { "version": "8.5.25", @@ -21164,7 +21075,6 @@ "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -21590,7 +21500,6 @@ "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -21623,7 +21532,6 @@ "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "lodash": "^4.17.19" }, @@ -21641,7 +21549,6 @@ "deprecated": "request-promise-native has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "request-promise-core": "1.1.4", "stealthy-require": "^1.1.1", @@ -21660,7 +21567,6 @@ "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -21675,7 +21581,6 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -21686,7 +21591,6 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -21700,7 +21604,6 @@ "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "engines": { "node": ">=0.6" } @@ -21711,7 +21614,6 @@ "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -21727,7 +21629,6 @@ "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "license": "MIT", "optional": true, - "peer": true, "bin": { "uuid": "bin/uuid" } @@ -22637,8 +22538,7 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/setprototypeof": { "version": "1.2.0", @@ -22811,8 +22711,7 @@ } ], "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/sisteransi": { "version": "1.0.5", @@ -23039,7 +22938,6 @@ "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -23103,7 +23001,6 @@ "integrity": "sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==", "license": "ISC", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -23230,6 +23127,7 @@ "version": "11.2.10", "resolved": "https://registry.npmjs.org/swiper/-/swiper-11.2.10.tgz", "integrity": "sha512-RMeVUUjTQH+6N3ckimK93oxz6Sn5la4aDlgPzB+rBrG/smPdCTicXyhxa+woIpopz+jewEloiEE3lKo1h9w2YQ==", + "devOptional": true, "funding": [ { "type": "patreon", @@ -23241,7 +23139,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">= 4.7.0" } @@ -23626,22 +23523,22 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/tui-code-snippet/-/tui-code-snippet-2.3.3.tgz", "integrity": "sha512-5NEHTDFKillDNPy6MCgpXDNBTB7SZkHBFOF6vXfCDIFZcBdFYFXTd2xvAVvIeM3UYFRWu27xUf/Kxl5f9+RooQ==", - "license": "MIT", - "peer": true + "devOptional": true, + "license": "MIT" }, "node_modules/tui-color-picker": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/tui-color-picker/-/tui-color-picker-2.2.8.tgz", "integrity": "sha512-q5sE9NQ5NR9lYpilYjcI7Sdv0KCogo+W8fZY+AYTj/HYg+9fscYy3UuJ6UQiV1bF+ARCLwFRWC8UcOt9kuUctQ==", - "license": "MIT", - "peer": true + "devOptional": true, + "license": "MIT" }, "node_modules/tui-image-editor": { "version": "3.15.3", "resolved": "https://registry.npmjs.org/tui-image-editor/-/tui-image-editor-3.15.3.tgz", "integrity": "sha512-7B5YUxe2eSSh+8YrlREmjqkgeFFKrVkdy0D/G0dWCJIQ2WLPQ/Bkm02WXQeWJiFPzgvargZjn7eAe80p3qBHPQ==", + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "fabric": "^4.2.0", "tui-code-snippet": "^2.3.3", @@ -23654,7 +23551,6 @@ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -23667,8 +23563,7 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense", - "optional": true, - "peer": true + "optional": true }, "node_modules/type-check": { "version": "0.4.0", @@ -24091,7 +23986,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -24103,8 +23997,7 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/vite": { "version": "8.1.5", @@ -24605,7 +24498,6 @@ "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "browser-process-hrtime": "^1.0.0" } @@ -25501,7 +25393,6 @@ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } diff --git a/package.json b/package.json index ddeed09..96b1aa8 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,11 @@ "test:actions": "node --test .github/actions/classify-mobile-release/classify-mobile-release.spec.mjs && vitest run --config .github/actions/vitest.config.mjs", "test:offline-types:nonstrict": "npm run prebuild:kit && tsc -p projects/kit/offline/tsconfig.nonstrict-null.json", "test:sqlite-concurrency": "node --test projects/kit/offline/src/lib/sqlite-concurrency.node.test.mjs", + "test:package-consumer": "node scripts/test-package-consumer.mjs", "e2e": "playwright test", "e2e:ui": "playwright test --ui" }, - "private": false, + "private": true, "dependencies": { "@angular/animations": "^22.1.0", "@angular/common": "^22.1.0", @@ -86,8 +87,10 @@ "child_process": "^1.0.2", "dom-to-image-more": "^3.10.0", "eslint": "^9.39.4", + "esbuild": "^0.28.2", "firebase": "^11.10.0", "husky": "^8.0.3", + "ionicons": "^8.1.0", "jsdom": "^26.0.0", "lint-staged": "^15.2.0", "ng-packagr": "^22.1.0", @@ -95,6 +98,8 @@ "path": "^0.12.7", "pdf-lib": "^1.17.1", "prettier": "^3.1.1", + "swiper": "^11.1.3", + "tui-image-editor": "^3.15.3", "typescript": "~6.0.0", "typescript-eslint": "^8.61.1", "vitest": "^4.0.0", diff --git a/projects/demo/src/app/app.config.ts b/projects/demo/src/app/app.config.ts index 3ea3d29..0a9b7d6 100644 --- a/projects/demo/src/app/app.config.ts +++ b/projects/demo/src/app/app.config.ts @@ -5,6 +5,9 @@ import { provideIonicAngular } from '@ionic/angular'; import { IonicStorageModule } from '@ionic/storage-angular'; import { provideKitAuth, provideKitOverlay } from '@rdlabo/ionic-angular-kit'; import { provideKitFirebase } from '@rdlabo/ionic-angular-kit/auth-firebase'; +import { providePhotoEditor } from '@rdlabo/ionic-angular-photo-editor'; +import { createTuiImageEditor } from 'photo-editor/editor/tui'; +import { loadCapacitorPhotoCamera } from 'photo-editor/file/capacitor'; import { routes } from './app.routes'; import { DemoAuthService } from './kit/auth/auth.service'; @@ -18,6 +21,7 @@ export const appConfig: ApplicationConfig = { importProvidersFrom(IonicStorageModule.forRoot({ name: '__kit_demo_db' })), provideKitFirebase({ firebaseConfig: environment.firebase }), provideKitOverlay({ labels: { close: 'Close', cancel: 'Cancel' } }), + providePhotoEditor({ createImageEditor: createTuiImageEditor, loadCamera: loadCapacitorPhotoCamera }), provideKitAuth(() => { const auth = inject(DemoAuthService); return { diff --git a/projects/demo/src/app/kit/auth/pages/signin/signin.page.html b/projects/demo/src/app/kit/auth/pages/signin/signin.page.html index ef7c307..bab6342 100644 --- a/projects/demo/src/app/kit/auth/pages/signin/signin.page.html +++ b/projects/demo/src/app/kit/auth/pages/signin/signin.page.html @@ -30,7 +30,6 @@ labelPlacement="floating" autocomplete="current-password" [formField]="loginForm.password" - kitAuthInput="autofill" > @@ -38,10 +37,8 @@
@if (isLoading()) { - - } @else { - Sign in - } + + } @else { Sign in }
diff --git a/projects/demo/src/app/kit/auth/pages/signup/signup.page.html b/projects/demo/src/app/kit/auth/pages/signup/signup.page.html index 06bf402..1fefe02 100644 --- a/projects/demo/src/app/kit/auth/pages/signup/signup.page.html +++ b/projects/demo/src/app/kit/auth/pages/signup/signup.page.html @@ -30,7 +30,6 @@ labelPlacement="floating" autocomplete="new-password" [formField]="loginForm.password" - kitAuthInput="autofill" >
@@ -38,10 +37,8 @@
@if (isLoading()) { - - } @else { - Create account - } + + } @else { Create account }
diff --git a/projects/demo/src/app/photo-editor/demo-photo-editor-page.component.ts b/projects/demo/src/app/photo-editor/demo-photo-editor-page.component.ts index d263d2c..62238f6 100644 --- a/projects/demo/src/app/photo-editor/demo-photo-editor-page.component.ts +++ b/projects/demo/src/app/photo-editor/demo-photo-editor-page.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { @@ -17,7 +17,10 @@ import { IonToolbar, ModalController, } from '@ionic/angular'; -import { IPhotoEditorDismiss, IPhotoViewerDismiss, PhotoEditorPage, PhotoFileService, PhotoViewerPage } from 'photo-editor'; +import { PhotoEditorProps, PhotoEditorResult, PhotoViewerProps, PhotoViewerResult } from 'photo-editor'; +import { PhotoEditorPage } from 'photo-editor/editor'; +import { PhotoFileService } from 'photo-editor/file'; +import { PhotoViewerPage } from 'photo-editor/viewer'; @Component({ selector: 'app-photo-editor', @@ -46,37 +49,31 @@ export class DemoPhotoEditorPage { private readonly photoFileService = inject(PhotoFileService); private readonly modalCtrl = inject(ModalController); - constructor() { - this.photoFileService.photoMaxSize = 1000; - this.photoFileService.labels = { - camera: 'Camera', - album: 'Album', - cancel: 'Cancel', - }; - } - async selectPhoto(type: 'editor' | 'viewer') { + const labels = { camera: 'Camera', album: 'Album', cancel: 'Cancel' }; if (type === 'editor') { - const data = await this.photoFileService.loadPhoto(1); + const data = await this.photoFileService.loadPhoto({ limit: 1, maxSize: 1000, labels }); await this.launchEditor(data[0]); } else { - const data = await this.photoFileService.loadPhoto(2); + const data = await this.photoFileService.loadPhoto({ limit: 2, maxSize: 1000, labels }); await this.launchViewer(data); } } - async launchEditor(photoData = 'https://picsum.photos/200/300') { + async launchEditor( + photoData = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEAQH/2kF7WQAAAABJRU5ErkJggg==', + ) { const modal = await this.modalCtrl.create({ component: PhotoEditorPage, componentProps: { requireSquare: false, value: photoData, - headerButtonColorScheme: 'dark', - }, + toolbarColorScheme: 'dark', + } satisfies PhotoEditorProps, }); await modal.present(); - const { data } = await modal.onWillDismiss(); - if (data?.value) { + const { data } = await modal.onWillDismiss(); + if (data?.action === 'save') { console.log(data.value); } } @@ -89,16 +86,16 @@ export class DemoPhotoEditorPage { index: 1, isCircle: false, enableDelete: true, - headerButtonColorScheme: 'dark', + toolbarColorScheme: 'dark', labels: { delete: 'Delete', }, - }, + } satisfies PhotoViewerProps, }); await modal.present(); - const { data } = await modal.onWillDismiss(); - if (data?.delete) { - console.log(data.delete); + const { data } = await modal.onWillDismiss(); + if (data?.action === 'delete') { + console.log(data); // User delete image } } diff --git a/projects/demo/src/index.html b/projects/demo/src/index.html index 06ac9a8..36db971 100644 --- a/projects/demo/src/index.html +++ b/projects/demo/src/index.html @@ -20,8 +20,5 @@ -
- -
diff --git a/projects/kit/README.md b/projects/kit/README.md index 7ffa1ca..37b4e15 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -10,10 +10,11 @@ npm install @rdlabo/ionic-angular-kit | Package | Supported version | | ---------------------------------- | ----------------- | -| Angular | 21.x | -| Ionic Angular | 8.x | +| Angular | 21.x–22.x | +| Ionic Angular | 9.x | | RxJS | 7.8.x | -| Capacitor core and feature plugins | 6.x through 8.x | +| Capacitor core and feature plugins | 7.x–8.x | +| iOS/iPadOS deployment target | 16.4 or later | Install `@ionic/storage-angular` when using storage. Other peers are feature-scoped: install only the Capacitor, Firebase, printing, or Live Update packages used by your selected entry points. diff --git a/projects/kit/src/lib/directives/auth-input.directive.spec.ts b/projects/kit/src/lib/directives/auth-input.directive.spec.ts index 6ad8ca2..3ce1400 100644 --- a/projects/kit/src/lib/directives/auth-input.directive.spec.ts +++ b/projects/kit/src/lib/directives/auth-input.directive.spec.ts @@ -50,9 +50,7 @@ const setup = async (mode: KitAuthInputMode, initialEmail = '', seed?: string) = }; const fireIonChange = (fixture: { nativeElement: HTMLElement }, value: string) => - fixture.nativeElement - .querySelector('input')! - .dispatchEvent(new CustomEvent('ionChange', { detail: { value }, bubbles: true })); + fixture.nativeElement.querySelector('input')!.dispatchEvent(new CustomEvent('ionChange', { detail: { value }, bubbles: true })); describe('KitAuthInputDirective', () => { afterEach(() => TestBed.resetTestingModule()); @@ -72,11 +70,6 @@ describe('KitAuthInputDirective', () => { const { host } = await setup('email-remember', '', 'saved@example.com'); expect(host.f.email().value()).toBe(''); }); - - it('"autofill" never prefills', async () => { - const { host } = await setup('autofill', '', 'saved@example.com'); - expect(host.f.email().value()).toBe(''); - }); }); describe('remember on ionChange', () => { @@ -100,13 +93,6 @@ describe('KitAuthInputDirective', () => { await new Promise((resolve) => queueMicrotask(resolve)); expect(storage.map.has(KIT_LAST_AUTH_EMAIL_KEY)).toBe(false); }); - - it('is inert in "autofill" mode', async () => { - const { fixture, storage } = await setup('autofill'); - fireIonChange(fixture, 'x@example.com'); - await new Promise((resolve) => queueMicrotask(resolve)); - expect(storage.map.has(KIT_LAST_AUTH_EMAIL_KEY)).toBe(false); - }); }); describe('forget on clear/invalid', () => { diff --git a/projects/kit/src/lib/directives/auth-input.directive.ts b/projects/kit/src/lib/directives/auth-input.directive.ts index ab4de71..eec9124 100644 --- a/projects/kit/src/lib/directives/auth-input.directive.ts +++ b/projects/kit/src/lib/directives/auth-input.directive.ts @@ -1,111 +1,56 @@ import type { OnInit } from '@angular/core'; -import { computed, Directive, ElementRef, HostListener, inject, Injector, input } from '@angular/core'; +import { computed, Directive, HostListener, inject, Injector, input } from '@angular/core'; import { FORM_FIELD } from '@angular/forms/signals'; -import { Capacitor } from '@capacitor/core'; import { KitStorageService } from '../storage/kit-storage.service'; import { kitForgetEmail, kitIsValidEmail, kitRecallEmail, kitRememberEmail } from '../storage/kit-auth-email-store'; /** - * The mode of {@link KitAuthInputDirective}. + * The email persistence mode of {@link KitAuthInputDirective}. * - * - `'autofill'` — iOS autofill propagation only (use on the password input). - * - `'email'` — sign-in email: prefill from storage + remember on change + forget when cleared. - * - `'email-remember'` — sign-up email: remember on change only (no prefill, no forget). + * - `'email'` — sign-in email: prefill from storage, remember on change, and forget when cleared. + * - `'email-remember'` — sign-up email: remember on change only, with no prefill or forget behavior. */ -export type KitAuthInputMode = 'autofill' | 'email' | 'email-remember'; +export type KitAuthInputMode = 'email' | 'email-remember'; /** - * Input conveniences for an `ion-input` in a sign-in / sign-up form, applied via the `kitAuthInput` - * attribute. The mode is a typed union so a typo is a compile-time error. + * Email persistence conveniences for an `ion-input`, applied via the `kitAuthInput` attribute. * - * 1. **iOS autofill propagation (always on, every mode).** On iOS, when the browser autofills an - * `ion-input` (e.g. a saved password) the value is written to the underlying native `` - * but the `change` event is not forwarded to the host `ion-input`, so the Angular form model - * never sees it. This directive mirrors the first inner-input `change` back onto the host, - * restoring two-way binding. It is a no-op on every other platform. - * - * 2. **Remember / prefill the email (`'email'` and `'email-remember'`).** - * - `'email'` (sign-in) — on init recalls the last entered email and, *only while the field is - * still empty*, seeds it via the bound Signal Forms field, so a browser/OS autofill the user - * actually picks always wins over the prefill. On every committed change (`ionChange`) it - * remembers a well-formed address, or **forgets** the stored one when the field is cleared - * (empty after trim) or holds an invalid address — the user intentionally removing the prefill. - * - `'email-remember'` (sign-up) — remembers a well-formed address on change, but never prefills - * and never forgets. So the first-sign-up address is captured without pre-populating a - * stranger's email into a new-account form, and clearing the field does not wipe an email - * remembered elsewhere. - * - * A well-formed email is persisted even if it later fails to sign in — by design; the user simply - * re-enters it. A malformed/partial entry is never stored (see {@link kitRememberEmail}). + * The `'email'` mode recalls the last entered address and seeds an empty Signal Forms field. Both + * modes remember a well-formed address on `ionChange`; only `'email'` forgets a stored value when + * the field is cleared or invalid. Browser and OS autofill always wins over an asynchronous prefill. * * @example * ```html - * * - * * - * - * * ``` * * @remarks - * Prefill writes through the Signal Forms `FORM_FIELD` bound on the same element; with no such field - * (e.g. `ngModel` / reactive forms) prefill is skipped — remember/forget still work off the DOM - * event. Storage is resolved lazily so the iOS mirror still works in apps without `@ionic/storage`. + * Prefill writes through the Signal Forms `FORM_FIELD` bound on the same element. With no field, + * prefill is skipped while remember/forget still work from the DOM event. Storage is resolved lazily + * so applications that do not configure `@ionic/storage` remain unaffected. */ @Directive({ selector: '[kitAuthInput]', standalone: true, }) export class KitAuthInputDirective implements OnInit { - readonly #el = inject(ElementRef); readonly #injector = inject(Injector); - /** The Signal Forms field bound on this same element, if any (used to seed the prefill). */ readonly #field = inject(FORM_FIELD, { optional: true, self: true }); - /** Mode selector; see {@link KitAuthInputMode}. */ - readonly kitAuthInput = input('autofill'); + /** Required persistence mode; see {@link KitAuthInputMode}. */ + readonly kitAuthInput = input.required(); - /** - * Register the iOS autofill workaround and, in `'email'` mode, seed the field from storage. - */ + /** Seeds an empty sign-in email field from storage. */ ngOnInit(): void { if (this.#prefills()) { void this.#prefillEmail(); } - if (Capacitor.getPlatform() !== 'ios') { - return; - } - setTimeout(() => { - const input = this.#el.nativeElement.children[0]; - if (!input) { - return; - } - input.addEventListener( - 'change', - (e: Event) => { - this.#el.nativeElement.value = (e.target as HTMLInputElement).value; - }, - { - capture: false, - once: true, - passive: true, - }, - ); - }, 100); // Need some time for the ion-input to create the input element } - /** - * On every committed change (`ionChange`): remember a well-formed email, or — only in the - * prefilling `'email'` (sign-in) mode — forget the stored one when the field is cleared or invalid, - * which is the user intentionally removing the prefill. `'email-remember'` never forgets, so - * editing the sign-up field cannot wipe an email remembered from sign-in. - */ + /** Remembers a valid committed email and applies the selected clearing policy. */ @HostListener('ionChange', ['$event']) onIonChange(event: Event): void { - if (!this.#remembers()) { - return; - } const storage = this.#resolveStorage(); if (!storage) { return; @@ -118,19 +63,9 @@ export class KitAuthInputDirective implements OnInit { } } - /** Whether this instance persists the entered email (`'email'` or `'email-remember'`). */ - readonly #remembers = computed(() => { - const mode = this.kitAuthInput(); - return mode === 'email' || mode === 'email-remember'; - }); - - /** Whether this instance prefills the field from storage (`'email'` only). */ readonly #prefills = computed(() => this.kitAuthInput() === 'email'); - - /** Whether clearing/invalidating the field forgets the stored email (`'email'` only). */ readonly #forgetsOnClear = computed(() => this.kitAuthInput() === 'email'); - /** Resolve `KitStorageService` lazily; returns `null` when storage is not configured. */ #resolveStorage(): KitStorageService | null { try { return this.#injector.get(KitStorageService); @@ -139,10 +74,6 @@ export class KitAuthInputDirective implements OnInit { } } - /** - * Seed the bound field with the remembered email, but only while it is still empty — so a value - * the browser/OS autofills during the async recall is not clobbered. - */ async #prefillEmail(): Promise { const field = this.#field; if (!field) { diff --git a/projects/kit/src/lib/storage/kit-auth-email-store.ts b/projects/kit/src/lib/storage/kit-auth-email-store.ts index 793031d..ea16aa0 100644 --- a/projects/kit/src/lib/storage/kit-auth-email-store.ts +++ b/projects/kit/src/lib/storage/kit-auth-email-store.ts @@ -10,7 +10,7 @@ * that later fails to sign in is still remembered by design: the user simply re-enters/corrects it. * * These live in the main entry (next to {@link KitStorageService}) rather than in `auth-firebase`, - * so the `KitAutofillDirective` can consume them without the main entry depending on the Firebase + * so `KitAuthInputDirective` can consume them without the main entry depending on the Firebase * entry. */ @@ -55,8 +55,7 @@ export const kitRememberEmail = async (store: KitEmailStore, email: string): Pro * @param store - the app's storage (e.g. `KitStorageService`) * @returns the stored email, or `null` when none has been remembered */ -export const kitRecallEmail = (store: KitEmailStore): Promise => - store.get(KIT_LAST_AUTH_EMAIL_KEY); +export const kitRecallEmail = (store: KitEmailStore): Promise => store.get(KIT_LAST_AUTH_EMAIL_KEY); /** * Forget the remembered email. @@ -67,5 +66,4 @@ export const kitRecallEmail = (store: KitEmailStore): Promise => * * @param store - the app's storage (e.g. `KitStorageService`) */ -export const kitForgetEmail = (store: KitEmailStore): Promise => - store.remove(KIT_LAST_AUTH_EMAIL_KEY); +export const kitForgetEmail = (store: KitEmailStore): Promise => store.remove(KIT_LAST_AUTH_EMAIL_KEY); diff --git a/projects/photo-editor/README.md b/projects/photo-editor/README.md index bcac481..06ebbf2 100644 --- a/projects/photo-editor/README.md +++ b/projects/photo-editor/README.md @@ -2,7 +2,7 @@ ## Overview -This is a photo editor and viewer for modal page of Ionic Angular project using Capacitor. +Photo editor and viewer modal pages for Ionic Angular applications, with Capacitor camera/album support. ## Features @@ -18,15 +18,43 @@ This is a photo editor and viewer for modal page of Ionic Angular project using ## Quick start -After [Installation](#installation), load a photo: +After [Installation](#installation), register optional defaults and load a photo: ```typescript -import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor'; - -const files = await this.photoFileService.loadPhoto(1); +import { providePhotoEditor } from '@rdlabo/ionic-angular-photo-editor'; +import { createTuiImageEditor } from '@rdlabo/ionic-angular-photo-editor/editor/tui'; +import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor/file'; +import { loadCapacitorPhotoCamera } from '@rdlabo/ionic-angular-photo-editor/file/capacitor'; + +// app.config.ts +export const appConfig = { + providers: [ + providePhotoEditor({ + maxPhotoSize: 1000, + createImageEditor: createTuiImageEditor, + loadCamera: loadCapacitorPhotoCamera, + }), + ], +}; + +// component +const files = await this.photoFileService.loadPhoto({ limit: 1 }); ``` -Then present the editor or viewer. Details: [PhotoFileService](./docs/photo-file.md), [Photo Editor](./docs/editor.md), [Photo Viewer](./docs/viewer.md). +Present the editor or viewer from their secondary entry points. Details: [PhotoFileService](./docs/photo-file.md), [Photo Editor](./docs/editor.md), [Photo Viewer](./docs/viewer.md). + +## Package entry points + +| Import path | Exports | +| --------------------------------------------------- | -------------------------------------------------------------------- | +| `@rdlabo/ionic-angular-photo-editor` | Types, `providePhotoEditor`, `PHOTO_EDITOR_CONFIG`, `PhotoLoadError` | +| `@rdlabo/ionic-angular-photo-editor/editor` | `PhotoEditorPage` | +| `@rdlabo/ionic-angular-photo-editor/editor/tui` | opt-in `createTuiImageEditor` adapter | +| `@rdlabo/ionic-angular-photo-editor/viewer` | `PhotoViewerPage` | +| `@rdlabo/ionic-angular-photo-editor/file` | `PhotoFileService` | +| `@rdlabo/ionic-angular-photo-editor/file/capacitor` | opt-in `loadCapacitorPhotoCamera` adapter | + +Import components and services only from their entry point. Import shared types and configuration from the root package. ## Installation @@ -34,22 +62,25 @@ Then present the editor or viewer. Details: [PhotoFileService](./docs/photo-file npm install @rdlabo/ionic-angular-photo-editor ``` -If you use capacitor, you need to install plugin: +Install only the optional feature dependencies used by the application: ```bash -npm install @capacitor/camera swiper tui-image-editor -``` - -And set permission. more info is here: [Camera](https://capacitorjs.com/docs/apis/camera#android-configuration) +# editor, and resizing in PhotoFileService +npm install tui-image-editor -If you public your project to the web, you need to add the following input tag to the index.html. +# viewer +npm install swiper -```html -
- -
+# native camera and album selection +npm install @capacitor/camera ``` +Configure Android/iOS camera permissions as described in the [Capacitor Camera docs](https://capacitorjs.com/docs/apis/camera#android-configuration). +Native iOS applications must target iOS/iPadOS 16.4 or later. + +No static `` in `index.html` is required. On web, `PhotoFileService` creates and attaches a hidden file input synchronously when `loadPhoto()` is called. +The root, `/editor`, and `/file` entry points do not import optional implementations. Opt in through `/editor/tui` and `/file/capacitor`; web-only consumers can omit the Capacitor adapter and dependency. + ## Documentation Start with [Installation](#installation), then pick a guide. @@ -57,7 +88,7 @@ Start with [Installation](#installation), then pick a guide. - [PhotoFileService](./docs/photo-file.md) — camera and album. - [Photo Editor](./docs/editor.md) — crop and edit in a modal. - [Photo Viewer](./docs/viewer.md) — browse images in a modal. -- [Theme](./docs/theme.md) — CSS variables. +- [Theme](./docs/theme.md) — CSS variables and toolbar color scheme. - [Migration guide](https://github.com/rdlabo-dev/ionic-angular-library/blob/main/docs/migration.md#rdlaboionic-angular-photo-editor) — breaking changes and required consumer updates. diff --git a/projects/photo-editor/docs/editor.md b/projects/photo-editor/docs/editor.md index 5b48faa..0a6e37e 100644 --- a/projects/photo-editor/docs/editor.md +++ b/projects/photo-editor/docs/editor.md @@ -1,15 +1,20 @@ Present `PhotoEditorPage` in an Ionic modal. Call this after [Installation](../README.md#installation). ```typescript -import { PhotoEditorPage, IPhotoEditorDismiss, PhotoEditorProps } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoEditorProps, PhotoEditorResult, providePhotoEditor } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoEditorPage } from '@rdlabo/ionic-angular-photo-editor/editor'; +import { createTuiImageEditor } from '@rdlabo/ionic-angular-photo-editor/editor/tui'; + +// app.config.ts +providePhotoEditor({ createImageEditor: createTuiImageEditor }); (async () => { const componentProps = { requireSquare: false, value: 'https://picsum.photos/200/300', - headerButtonColorScheme: 'dark', + toolbarColorScheme: 'dark', labels: { - save: '送信', // change '保存' to '送信' + save: '送信', // override default '保存' }, } satisfies PhotoEditorProps; const modal = await this.modalCtrl.create({ @@ -17,29 +22,63 @@ import { PhotoEditorPage, IPhotoEditorDismiss, PhotoEditorProps } from '@rdlabo/ componentProps, }); await modal.present(); - const { data } = await modal.onWillDismiss(); - if (data?.value) { + const { data } = await modal.onWillDismiss(); + if (data?.action === 'save') { console.log(data.value); } })(); ``` -### Options +## Modal result + +On save, the modal dismisses with: + +```typescript +interface PhotoEditorResult { + action: 'save'; + value: string; // data URL of the edited image +} +``` + +Closing without saving dismisses with no data. + +## Options -#### requireSquare: boolean +### requireSquare: boolean -If true, the image must be cropped to a square at first. +When `true`, the image must be cropped to a square before editing continues. -#### value: string +### value: string -The image url or base64 string. +Image URL or data URL to edit. -#### labels: IDictionaryForEditor +### toolbarColorScheme: 'light' | 'dark' -If set, the label is overwritten. +**Required.** Use `dark` for a dark/black `ion-toolbar` and `light` for a light/white toolbar. The library cannot infer toolbar appearance from CSS, translucency, or runtime theme overrides. See [Theme](./theme.md). -List is [here](https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/dictionaries.ts). +### labels: Partial<PhotoEditorLabels> -#### headerButtonColorScheme: 'light' | 'dark' +Overrides default UI strings. Unspecified keys keep the built-in Japanese defaults: -Required. Select `dark` for a dark/black `ion-toolbar` and `light` for a light/white toolbar. The library cannot infer the toolbar appearance from CSS, translucent content, or runtime theme overrides. +| Key | Default (ja) | +| ---------- | -------------- | +| save | 保存 | +| close | 閉じる | +| back | 戻る | +| apply | 適用 | +| crop | 切り抜き・回転 | +| rotate | 回転 | +| cropCover | 画像に合わせる | +| crop16x9 | 16対9 | +| cropSquare | 正方形 | +| cropFree | 自由 | +| filter | フィルター | +| brightness | 明るさ | +| original | オリジナル | +| invert | 反転 | +| sepia | セピア | +| vintage | ヴィンテージ | +| blur | ぼかし | +| grayscale | グレースケール | +| sharpen | 輪郭 | +| emboss | エンボス | diff --git a/projects/photo-editor/docs/photo-file.md b/projects/photo-editor/docs/photo-file.md index 25d0c07..c166295 100644 --- a/projects/photo-editor/docs/photo-file.md +++ b/projects/photo-editor/docs/photo-file.md @@ -1,35 +1,103 @@ Load photos from the camera or album. Call this after [Installation](../README.md#installation). ```typescript -import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor'; +import { providePhotoEditor, PhotoLoadError } from '@rdlabo/ionic-angular-photo-editor'; +import { createTuiImageEditor } from '@rdlabo/ionic-angular-photo-editor/editor/tui'; +import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor/file'; +import { loadCapacitorPhotoCamera } from '@rdlabo/ionic-angular-photo-editor/file/capacitor'; +// app.config.ts — optional global defaults +export const appConfig = { + providers: [ + providePhotoEditor({ + maxPhotoSize: 1000, + fileLabels: { + camera: 'Camera', + album: 'Album', + cancel: 'Cancel', + }, + createImageEditor: createTuiImageEditor, + loadCamera: loadCapacitorPhotoCamera, + }), + ], +}; + +// component export class AppComponent { private photoFileService = inject(PhotoFileService); - constructor() { - this.photoFileService.photoMaxSize = 1000; - this.photoFileService.labels = { - camera: 'Camera', - album: 'Album', - cancel: 'Cancel', - }; - } - async upload() { - const files = await this.photoFileService.loadPhoto(1); - if (files.length > 0) { - // upload files + try { + const files = await this.photoFileService.loadPhoto({ + limit: 1, + maxSize: 1000, + labels: { camera: 'Camera' }, // merges over configured defaults + }); + if (files.length > 0) { + // upload files + } + } catch (error) { + if (error instanceof PhotoLoadError && error.code === 'cancelled') { + return; + } + throw error; } } } ``` -#### Options +## loadPhoto(options?) + +Opens the platform photo picker and returns normalized data URLs. + +| Option | Default | Description | +| --------- | --------------------------------------------- | ---------------------------------------------- | +| `limit` | `1` | Maximum number of images (album and web only). | +| `maxSize` | `providePhotoEditor().maxPhotoSize` or `1000` | Longest edge in pixels after resize. | +| `labels` | configured `fileLabels` | Action sheet button text (Capacitor only). | + +### Browser behavior + +On web, `loadPhoto()` synchronously creates a hidden ``, attaches it to `document.body`, and calls `click()` in the same turn as the caller's gesture. This preserves WebKit transient user activation. The input has no fixed id; it is removed after selection or cancellation. + +Do not add a static file input to `index.html`. + +### Capacitor behavior -##### photoMaxSize +On native platforms, an action sheet asks the user to choose camera or album. Per-request `labels` merge over values from `providePhotoEditor({ fileLabels })`. +Register `loadCapacitorPhotoCamera` from `/file/capacitor`; the base `/file` entry point stays usable by browser-only applications without `@capacitor/camera`. -The maximum size of the photo. Default is 1000. +### Errors -##### labels +Expected failures throw `PhotoLoadError`: + +| Code | When | +| -------------- | ----------------------------------------- | +| `cancelled` | User dismissed the picker or action sheet | +| `invalid-type` | Selected file is not an image (web only) | +| `unavailable` | File could not be read or resized | + +## Default labels (ja) + +When no `fileLabels` or per-request `labels` are supplied: + +| Key | Default (ja) | +| ------ | ---------------- | +| camera | カメラ撮影 | +| album | アルバムから選択 | +| cancel | キャンセル | + +## providePhotoEditor(config?) + +Register application-wide defaults in `app.config.ts`: + +```typescript +providePhotoEditor({ + maxPhotoSize: 1200, + fileLabels: { camera: '…', album: '…', cancel: '…' }, + createImageEditor: createTuiImageEditor, + loadCamera: loadCapacitorPhotoCamera, // omit in browser-only applications +}); +``` -If set, the label is overwritten. +`PhotoFileService` reads these adapters plus `maxPhotoSize` and `fileLabels` from this configuration. Per-request `maxSize` and `labels` override them for a single call. A resize requires `createImageEditor`; a native picker requires `loadCamera`. Missing adapters throw `PhotoLoadError` with `code: 'unavailable'`. diff --git a/projects/photo-editor/docs/theme.md b/projects/photo-editor/docs/theme.md index 34220b6..e8c2b81 100644 --- a/projects/photo-editor/docs/theme.md +++ b/projects/photo-editor/docs/theme.md @@ -1,8 +1,6 @@ Override the editor colors after [Installation](../README.md#installation). -Default color is set, but user can overwrite it: https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/pages/core.scss - -## How to overwrite +Default colors are defined in the library stylesheet. Override them with CSS variables: ```scss :root { @@ -21,9 +19,11 @@ Default color is set, but user can overwrite it: https://github.com/rdlabo-dev/i } ``` -## Header button color scheme +Source reference: [`core.scss`](https://github.com/rdlabo-dev/ionic-angular-library/blob/main/projects/photo-editor/src/lib/pages/core.scss). + +## Toolbar color scheme -`PhotoEditorPage` and `PhotoViewerPage` require `headerButtonColorScheme: 'light' | 'dark'` in their modal `componentProps`. Select `dark` for a dark/black `ion-toolbar` and `light` for a light/white toolbar. The consumer must choose because the library cannot reliably infer the final toolbar appearance from CSS, translucency, or runtime theme overrides. +`PhotoEditorPage` and `PhotoViewerPage` require `toolbarColorScheme: 'light' | 'dark'` in modal `componentProps`. Select `dark` for a dark/black `ion-toolbar` and `light` for a light/white toolbar. The consumer must choose because the library cannot reliably infer the final toolbar appearance from CSS, translucency, or runtime theme overrides. For `@rdlabo/ionic-theme-ios26` v3, import the optional integration stylesheet after the iOS 26 theme and dark-mode styles: diff --git a/projects/photo-editor/docs/viewer.md b/projects/photo-editor/docs/viewer.md index 1d1e8cf..40e84fb 100644 --- a/projects/photo-editor/docs/viewer.md +++ b/projects/photo-editor/docs/viewer.md @@ -1,55 +1,84 @@ Present `PhotoViewerPage` in an Ionic modal. Call this after [Installation](../README.md#installation). ```typescript -import { PhotoViewerPage, IPhotoViewerDismiss, PhotoViewerProps } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoViewerProps, PhotoViewerResult } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoViewerPage } from '@rdlabo/ionic-angular-photo-editor/viewer'; (async () => { const componentProps = { - imageUrls: ['https://picsum.photos/200/300', 'https://picsum.photos/200/300'], + imageUrls: ['https://picsum.photos/200/300', 'https://picsum.photos/200/301'], index: 0, isCircle: false, - headerButtonColorScheme: 'dark', + enableDelete: true, + toolbarColorScheme: 'dark', + imageAlt: (url, index) => `Photo ${index + 1}`, + labels: { + delete: 'Delete', + }, } satisfies PhotoViewerProps; const modal = await this.modalCtrl.create({ component: PhotoViewerPage, componentProps, }); await modal.present(); - const { data } = await modal.onWillDismiss(); - if (data?.delete) { - // User delete image + const { data } = await modal.onWillDismiss(); + if (data?.action === 'delete') { + console.log(data.index, data.value); } })(); ``` -### Options +## Modal result -#### imageUrls: string[] +When the user taps delete, the modal dismisses with: -The image url or base64 string[]. +```typescript +interface PhotoViewerResult { + action: 'delete'; + index: number; + value: string; // URL of the image at index +} +``` + +Closing or swiping down dismisses with no data. + +## Options + +### imageUrls: string[] + +**Required.** Image URLs or data URLs to display. + +### index: number + +Initial slide index. Default `0`. + +### isCircle: boolean -#### index: number +When `true`, images render in a circle. -The index of imageUrls. +### enableDelete: boolean -#### isCircle: boolean +When `true`, shows the delete button. -If set, the image is displayed in a circle. +### enableFooterSafeArea: boolean -#### enableDelete: boolean +When `true`, adds footer safe-area padding on iOS. -If true, the delete button is displayed. +### toolbarColorScheme: 'light' | 'dark' -#### enableFooterSafeArea: boolean +**Required.** Use `dark` for a dark/black `ion-toolbar` and `light` for a light/white toolbar. See [Theme](./theme.md). -If true, enable footer safe area for iOS. +### imageAlt: string | ((url: string, index: number) => string) -#### labels: IDictionaryForViewer +Accessible `alt` text for each slide image. Default is an empty string. Pass a function when alt text depends on the URL or index. -If set, the label is overwritten. +### labels: Partial<PhotoViewerLabels> -List is [here](https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/dictionaries.ts). +Overrides default UI strings. Unspecified keys keep the built-in Japanese defaults: -#### headerButtonColorScheme: 'light' | 'dark' +| Key | Default (ja) | +| ------ | ------------ | +| close | 閉じる | +| delete | 削除 | -Required. Select `dark` for a dark/black `ion-toolbar` and `light` for a light/white toolbar. The library cannot infer the toolbar appearance from CSS, translucent content, or runtime theme overrides. +The close button also uses `aria-label` from the `close` label. diff --git a/projects/photo-editor/editor/ng-package.json b/projects/photo-editor/editor/ng-package.json new file mode 100644 index 0000000..d0a2dcd --- /dev/null +++ b/projects/photo-editor/editor/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/photo-editor/editor/src/lib/filter-preset.ts b/projects/photo-editor/editor/src/lib/filter-preset.ts new file mode 100644 index 0000000..39c2c0f --- /dev/null +++ b/projects/photo-editor/editor/src/lib/filter-preset.ts @@ -0,0 +1,9 @@ +import { PhotoEditorLabels, PhotoFilterPreset } from '@rdlabo/ionic-angular-photo-editor'; + +export const filterPreset = (dictionary: PhotoEditorLabels): PhotoFilterPreset[] => [ + { name: dictionary.original, type: 'Default', option: null }, + { name: dictionary.sepia, type: 'Sepia', option: null }, + { name: dictionary.vintage, type: 'vintage', option: null }, + { name: dictionary.blur, type: 'Blur', option: { blur: 0.1 } }, + { name: dictionary.grayscale, type: 'Grayscale', option: null }, +]; diff --git a/projects/photo-editor/editor/src/lib/internals.ts b/projects/photo-editor/editor/src/lib/internals.ts new file mode 100644 index 0000000..6dfc604 --- /dev/null +++ b/projects/photo-editor/editor/src/lib/internals.ts @@ -0,0 +1,52 @@ +import { PhotoEditorLabels } from '@rdlabo/ionic-angular-photo-editor'; +import { addIcons } from 'ionicons'; +import { + checkmarkOutline, + closeOutline, + colorFilterOutline, + cropOutline, + expandOutline, + refreshOutline, + send, + squareOutline, + sunnyOutline, + tabletLandscapeOutline, +} from 'ionicons/icons'; + +export const dictionaryForEditor = (): PhotoEditorLabels => ({ + save: '保存', + close: '閉じる', + back: '戻る', + apply: '適用', + crop: '切り抜き・回転', + rotate: '回転', + cropCover: '画像に合わせる', + crop16x9: '16対9', + cropSquare: '正方形', + cropFree: '自由', + filter: 'フィルター', + brightness: '明るさ', + original: 'オリジナル', + invert: '反転', + sepia: 'セピア', + vintage: 'ヴィンテージ', + blur: 'ぼかし', + grayscale: 'グレースケール', + sharpen: '輪郭', + emboss: 'エンボス', +}); + +export const initializeEditorIcons = (): void => { + addIcons({ + closeOutline, + send, + cropOutline, + colorFilterOutline, + sunnyOutline, + expandOutline, + tabletLandscapeOutline, + squareOutline, + refreshOutline, + checkmarkOutline, + }); +}; diff --git a/projects/photo-editor/src/lib/ion-components.ts b/projects/photo-editor/editor/src/lib/ion-components.ts similarity index 100% rename from projects/photo-editor/src/lib/ion-components.ts rename to projects/photo-editor/editor/src/lib/ion-components.ts diff --git a/projects/photo-editor/editor/src/lib/photo-editor.page.html b/projects/photo-editor/editor/src/lib/photo-editor.page.html new file mode 100644 index 0000000..63f4fc7 --- /dev/null +++ b/projects/photo-editor/editor/src/lib/photo-editor.page.html @@ -0,0 +1,117 @@ + + + + + + {{ dictionary().save }} + + + + + + +
+
+ + + @if (this.footerMenu() === 'menu') { + + } @if (this.footerMenu() === 'filter') { + + } @if (this.footerMenu() === 'crop' && !requireSquare()) { + + + + + + + + + + + + + + + + } @if (['brightness'].includes(this.footerMenu())) { +
+ +
+ } +
+ @if (this.footerMenu() !== 'menu') { + + @if (this.footerMenu() === 'filter') { + + {{ dictionary().back }} + + } @else { + + @if (!requireSquare() || isCropped()) { + + } + @if (footerMenu() === 'crop') { {{ dictionary().crop }} } @else if (footerMenu() === 'brightness') { {{ dictionary().brightness }} + } + } + + } +
diff --git a/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.scss b/projects/photo-editor/editor/src/lib/photo-editor.page.scss similarity index 100% rename from projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.scss rename to projects/photo-editor/editor/src/lib/photo-editor.page.scss diff --git a/projects/photo-editor/editor/src/lib/photo-editor.page.ts b/projects/photo-editor/editor/src/lib/photo-editor.page.ts new file mode 100644 index 0000000..7ee0285 --- /dev/null +++ b/projects/photo-editor/editor/src/lib/photo-editor.page.ts @@ -0,0 +1,276 @@ +import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + inject, + input, + OnDestroy, + signal, + viewChild, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { IonContent, ModalController, RangeCustomEvent, ViewDidEnter, ViewDidLeave } from '@ionic/angular'; +import { + PHOTO_EDITOR_CONFIG, + PhotoEditorLabels, + PhotoEditorResult, + PhotoFilter, + PhotoImageEditor, + PhotoSize, + PhotoToolbarColorScheme, +} from '@rdlabo/ionic-angular-photo-editor'; +import { filterPreset } from './filter-preset'; +import { ionComponents } from './ion-components'; +import { dictionaryForEditor, initializeEditorIcons } from './internals'; + +@Component({ + selector: 'rdlabo-photo-editor', + templateUrl: './photo-editor.page.html', + styleUrls: ['../../../src/lib/pages/core.scss', './photo-editor.page.scss'], + imports: [CommonModule, FormsModule, ...ionComponents], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +/** Ionic modal page for cropping, rotating, filtering, and saving a photo. */ +export class PhotoEditorPage implements OnDestroy, ViewDidEnter, ViewDidLeave { + protected readonly modalCtrl = inject(ModalController); + readonly #config = inject(PHOTO_EDITOR_CONFIG); + + readonly requireSquare = input(false, { + transform: coerceBooleanProperty, + }); + readonly labels = input | undefined>(undefined); + readonly toolbarColorScheme = input.required(); + readonly value = input.required(); + + protected readonly dictionary = computed(() => ({ ...dictionaryForEditor(), ...this.labels() })); + private readonly editorRef = viewChild.required('imageEditor'); + private readonly ionContent = viewChild.required(IonContent, { read: ElementRef }); + + protected readonly filters = signal([]); + protected readonly footerMenu = signal<'filter' | 'menu' | 'crop' | 'brightness'>('menu'); + protected readonly currentCrop = signal<'cover' | '16/9' | '1' | 'auto'>('cover'); + protected readonly currentRotate = signal(0); + protected readonly photoCrop = signal({ + width: 0, + height: 0, + }); + protected readonly isCropped = signal(false); + private readonly adoptFilter = signal(undefined); + protected readonly filterPreset = computed(() => filterPreset(this.dictionary())); + + private readonly filterImageSize = 240; + private editorInstance: PhotoImageEditor | undefined; + private initializationGeneration = 0; + + private readonly footerMenuEffect = effect(() => { + const menu = this.footerMenu(); + const editor = this.editorInstance; + if (!editor) { + return; + } + if (menu === 'filter') { + void this.initializeFilterMenu(); + } else if (menu === 'crop') { + editor.startDrawingMode('CROPPER'); + this.changeCrop(this.requireSquare() ? '1' : 'cover'); + } + }); + + private canvasContainer: HTMLElement | undefined; + private readonly canvasContainerObserver = new MutationObserver(() => this.syncCanvasSize()); + + constructor() { + initializeEditorIcons(); + } + + ngOnDestroy() { + this.initializationGeneration += 1; + this.destroyEditor(); + } + + async ionViewDidEnter() { + const generation = ++this.initializationGeneration; + const editor = await this.#config.createImageEditor(this.editorRef().nativeElement, { + cssMaxWidth: this.ionContent().nativeElement.clientWidth - 32, + cssMaxHeight: this.ionContent().nativeElement.clientHeight - 32, + }); + if (generation !== this.initializationGeneration) { + editor.destroy(); + return; + } + this.editorInstance = editor; + return this.initializeEditor(editor, generation).catch((error: unknown) => { + if (this.editorInstance === editor) { + this.destroyEditor(); + } + throw error; + }); + } + + private async initializeEditor(editor: PhotoImageEditor, generation: number): Promise { + const response = await fetch(this.value()); + if (!this.isCurrentEditor(editor, generation)) { + return; + } + if (!response.ok) { + throw new Error(`Unable to load photo: ${response.status}`); + } + const blob = await response.blob(); + if (!this.isCurrentEditor(editor, generation)) { + return; + } + await editor.loadImageFromFile(new File([blob], 'data.png', { type: blob.type })); + if (!this.isCurrentEditor(editor, generation)) { + return; + } + const editorElement = this.editorRef().nativeElement as HTMLElement; + this.canvasContainer = editorElement.querySelector('.tui-image-editor-canvas-container') ?? undefined; + if (this.canvasContainer) { + this.syncCanvasSize(); + this.canvasContainerObserver.observe(this.canvasContainer, { + attributes: true, + childList: false, + subtree: true, + }); + } + this.footerMenu.set(this.requireSquare() ? 'crop' : 'menu'); + } + + ionViewDidLeave() { + this.initializationGeneration += 1; + this.destroyEditor(); + } + + changeCrop(crop: 'cover' | '16/9' | '1' | 'auto') { + const rect = crop === 'cover' ? this.photoCrop().width / this.photoCrop().height : crop === '16/9' ? 16 / 9 : 1; + this.editorInstance?.setCropzoneRect(crop !== 'auto' ? rect : undefined); + this.currentCrop.set(crop); + } + + async rotate() { + const editor = this.requireEditor(); + editor.stopDrawingMode(); + await editor.rotate(90); + this.currentRotate.update((value) => value + 90); + editor.startDrawingMode('CROPPER'); + requestAnimationFrame(() => this.changeCrop(this.currentCrop())); + } + + async closeCrop(type: 'cancel' | 'apply') { + const editor = this.requireEditor(); + if (this.footerMenu() === 'crop') { + if (type === 'cancel') { + await editor.rotate(this.currentRotate() * -1); + } else { + await editor.crop(editor.getCropzoneRect()); + this.isCropped.set(true); + } + this.currentRotate.set(0); + this.currentCrop.set('cover'); + editor.stopDrawingMode(); + } else if (this.footerMenu() === 'brightness') { + if (type === 'cancel' && editor.hasFilter('brightness')) { + await editor.removeFilter('brightness'); + } + } + this.footerMenu.set('menu'); + } + + async changeRange(event: RangeCustomEvent) { + const editor = this.requireEditor(); + if (editor.hasFilter('brightness')) { + await editor.removeFilter('brightness'); + } + await editor.applyFilter('brightness', { + brightness: Number(event.detail.value) / 255, + }); + } + + imageSave() { + const value = this.requireEditor().toDataURL(); + void this.modalCtrl.dismiss({ action: 'save', value } satisfies PhotoEditorResult); + } + + private async initializeFilterMenu() { + const editor = this.requireEditor(); + const filters: PhotoFilter[] = []; + + const defaultInstance = await this.#config.createImageEditor(document.createElement('div'), { + cssMaxWidth: this.filterImageSize, + cssMaxHeight: (this.photoCrop().height * this.filterImageSize) / this.photoCrop().width, + }); + const blob = await fetch( + editor.toDataURL({ + multiplier: this.filterImageSize / this.photoCrop().width, + }), + ).then((res) => res.blob()); + await defaultInstance.loadImageFromFile(new File([blob], 'defaultInstance.png', { type: blob.type })); + + for (const filter of this.filterPreset()) { + if (filter.type !== 'Default') { + await defaultInstance.applyFilter(filter.type, filter.option ?? undefined); + } + filters.push({ + name: filter.name, + type: filter.type, + option: filter.option, + data: defaultInstance.toDataURL(), + width: this.filterImageSize, + height: (this.photoCrop().height * this.filterImageSize) / this.photoCrop().width, + }); + if (filter.type !== 'Default') { + await defaultInstance.removeFilter(filter.type); + } + } + this.filters.set(filters); + defaultInstance.destroy(); + } + + async filterImage(filter: PhotoFilter) { + const editor = this.requireEditor(); + if (this.adoptFilter()) { + await editor.removeFilter(this.adoptFilter()!.type); + } + if (filter.type === 'Default') { + this.adoptFilter.set(undefined); + return; + } + await editor.applyFilter(filter.type, filter.option ?? undefined); + this.adoptFilter.set(filter); + } + + private requireEditor(): PhotoImageEditor { + if (!this.editorInstance) { + throw new Error('Photo editor is not initialized.'); + } + return this.editorInstance; + } + + private destroyEditor(): void { + this.canvasContainerObserver.disconnect(); + this.canvasContainer = undefined; + this.editorInstance?.destroy(); + this.editorInstance = undefined; + } + + private isCurrentEditor(editor: PhotoImageEditor, generation: number): boolean { + return generation === this.initializationGeneration && editor === this.editorInstance; + } + + private syncCanvasSize(): void { + const container = this.canvasContainer; + const canvas = container?.querySelector('canvas'); + if (!container || !canvas) { + return; + } + const bounds = container.parentElement ?? container; + const editor = this.editorRef().nativeElement; + editor.style.minWidth = bounds.style.maxWidth; + editor.style.minHeight = bounds.style.maxHeight; + this.photoCrop.set({ width: canvas.width, height: canvas.height }); + } +} diff --git a/projects/photo-editor/editor/src/public-api.ts b/projects/photo-editor/editor/src/public-api.ts new file mode 100644 index 0000000..c5b63eb --- /dev/null +++ b/projects/photo-editor/editor/src/public-api.ts @@ -0,0 +1,2 @@ +/** Photo editing modal entry point. */ +export * from './lib/photo-editor.page'; diff --git a/projects/photo-editor/editor/tui/ng-package.json b/projects/photo-editor/editor/tui/ng-package.json new file mode 100644 index 0000000..fbafcc4 --- /dev/null +++ b/projects/photo-editor/editor/tui/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/photo-editor/editor/tui/src/public-api.ts b/projects/photo-editor/editor/tui/src/public-api.ts new file mode 100644 index 0000000..707debb --- /dev/null +++ b/projects/photo-editor/editor/tui/src/public-api.ts @@ -0,0 +1,7 @@ +import { PhotoImageEditorFactory } from '@rdlabo/ionic-angular-photo-editor'; + +/** Creates a TUI Image Editor adapter in a bundler-resolvable lazy chunk. */ +export const createTuiImageEditor: PhotoImageEditorFactory = async (host, options) => { + const { default: ImageEditor } = await import('tui-image-editor'); + return new ImageEditor(host, options); +}; diff --git a/projects/photo-editor/file/capacitor/ng-package.json b/projects/photo-editor/file/capacitor/ng-package.json new file mode 100644 index 0000000..fbafcc4 --- /dev/null +++ b/projects/photo-editor/file/capacitor/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/photo-editor/file/capacitor/src/public-api.ts b/projects/photo-editor/file/capacitor/src/public-api.ts new file mode 100644 index 0000000..0ab7d21 --- /dev/null +++ b/projects/photo-editor/file/capacitor/src/public-api.ts @@ -0,0 +1,26 @@ +import { PhotoCameraLoader } from '@rdlabo/ionic-angular-photo-editor'; + +/** Loads the Capacitor Camera implementation in a bundler-resolvable lazy chunk. */ +export const loadCapacitorPhotoCamera: PhotoCameraLoader = async () => { + const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera'); + return { + getPhoto: async (options) => { + const image = await Camera.getPhoto({ + quality: options.quality, + width: options.width, + allowEditing: false, + resultType: CameraResultType.DataUrl, + source: CameraSource.Camera, + presentationStyle: 'popover', + }); + return { dataUrl: image.dataUrl, webPath: image.webPath }; + }, + pickImages: (options) => + Camera.pickImages({ + quality: options.quality, + width: options.width, + limit: options.limit, + presentationStyle: 'popover', + }), + }; +}; diff --git a/projects/photo-editor/file/ng-package.json b/projects/photo-editor/file/ng-package.json new file mode 100644 index 0000000..d0a2dcd --- /dev/null +++ b/projects/photo-editor/file/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/photo-editor/file/src/lib/photo-file.service.ts b/projects/photo-editor/file/src/lib/photo-file.service.ts new file mode 100644 index 0000000..5c3ee4e --- /dev/null +++ b/projects/photo-editor/file/src/lib/photo-file.service.ts @@ -0,0 +1,189 @@ +import { inject, Injectable } from '@angular/core'; +import { ActionSheetController, Platform } from '@ionic/angular'; +import { + PHOTO_EDITOR_CONFIG, + PhotoFileLabels, + PhotoImageEditorFactory, + PhotoLoadError, + PhotoLoadOptions, +} from '@rdlabo/ionic-angular-photo-editor'; + +/** Selects and normalizes photos from browser and Capacitor sources. */ +@Injectable({ + providedIn: 'root', +}) +export class PhotoFileService { + readonly #actionSheetCtrl = inject(ActionSheetController); + readonly #platform = inject(Platform); + readonly #config = inject(PHOTO_EDITOR_CONFIG); + + /** Opens the platform photo picker and returns normalized data URLs. */ + async loadPhoto(options: PhotoLoadOptions = {}): Promise { + const limit = Math.max(1, Math.trunc(options.limit ?? 1)); + const maxSize = Math.max(1, Math.trunc(options.maxSize ?? this.#config.maxPhotoSize)); + const labels = { ...this.#config.fileLabels, ...options.labels } satisfies PhotoFileLabels; + + if (!this.#platform.is('capacitor')) { + return this.#getPictureFromBrowser(limit, maxSize); + } + + const source = await this.#chooseNativeSource(labels); + if (source === 'camera') { + return this.#getPictureFromCamera(maxSize); + } + return this.#getPicturesFromAlbum(limit, maxSize); + } + + async #chooseNativeSource(labels: PhotoFileLabels): Promise<'camera' | 'album'> { + const actionSheet = await this.#actionSheetCtrl.create({ + buttons: [ + { + text: labels.camera, + handler: () => { + void actionSheet.dismiss('camera'); + }, + }, + { + text: labels.album, + handler: () => { + void actionSheet.dismiss('album'); + }, + }, + { + text: labels.cancel, + role: 'cancel', + }, + ], + }); + await actionSheet.present(); + const { data } = await actionSheet.onDidDismiss<'camera' | 'album'>(); + if (!data) { + throw new PhotoLoadError('cancelled'); + } + return data; + } + + async #getPictureFromCamera(maxSize: number): Promise { + const camera = await this.#config.loadCamera(); + const image = await camera + .getPhoto({ + quality: 100, + width: maxSize, + source: 'camera', + }) + .catch(() => undefined); + if (!image?.dataUrl) { + throw new PhotoLoadError('cancelled'); + } + if (!image.dataUrl.includes('capacitor://localhost')) { + return [image.dataUrl]; + } + return [await this.#loadPhotoFromFilePath(image.dataUrl, maxSize)]; + } + + async #getPicturesFromAlbum(limit: number, maxSize: number): Promise { + const camera = await this.#config.loadCamera(); + const images = await camera.pickImages({ quality: 100, width: maxSize, limit }).catch(() => undefined); + if (!images) { + throw new PhotoLoadError('cancelled'); + } + return Promise.all( + images.photos.slice(0, limit).map(({ webPath }) => { + if (!webPath) { + throw new PhotoLoadError('unavailable'); + } + return this.#loadPhotoFromFilePath(webPath, maxSize); + }), + ); + } + + #getPictureFromBrowser(limit: number, maxSize: number): Promise { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.multiple = limit > 1; + input.tabIndex = -1; + input.setAttribute('aria-hidden', 'true'); + input.style.position = 'fixed'; + input.style.inset = '0 auto auto -10000px'; + document.body.append(input); + + return new Promise((resolve, reject) => { + const cleanup = () => { + input.removeEventListener('cancel', cancel); + input.removeEventListener('change', change); + input.remove(); + }; + const cancel = () => { + cleanup(); + reject(new PhotoLoadError('cancelled')); + }; + const change = () => { + const files = Array.from(input.files ?? []).slice(0, limit); + input.value = ''; + cleanup(); + if (files.length === 0) { + reject(new PhotoLoadError('cancelled')); + return; + } + if (files.some(({ type }) => !type.startsWith('image/'))) { + reject(new PhotoLoadError('invalid-type')); + return; + } + void Promise.all(files.map((file) => this.#readBrowserFile(file, maxSize))).then(resolve, reject); + }; + + input.addEventListener('cancel', cancel); + input.addEventListener('change', change); + // Keep this synchronous with the caller's click/tap so WebKit retains transient user activation. + try { + input.click(); + } catch (error) { + cleanup(); + reject(new PhotoLoadError('unavailable', { cause: error })); + } + }); + } + + #readBrowserFile(file: File, maxSize: number): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + const result = event.target?.result; + if (typeof result !== 'string') { + reject(new PhotoLoadError('unavailable')); + return; + } + void this.#loadPhotoFromFilePath(result, maxSize).then(resolve, reject); + }; + reader.onerror = () => reject(new PhotoLoadError('unavailable', { cause: reader.error })); + reader.onabort = () => reject(new PhotoLoadError('cancelled')); + reader.readAsDataURL(file); + }); + } + + async #loadPhotoFromFilePath(filePath: string, maxPhotoSize: number): Promise { + const editor = await this.#config.createImageEditor(document.createElement('div'), { + cssMaxWidth: maxPhotoSize, + cssMaxHeight: maxPhotoSize, + }); + return this.#resizePhoto(editor, filePath, maxPhotoSize) + .catch((error: unknown) => { + throw error instanceof PhotoLoadError ? error : new PhotoLoadError('unavailable', { cause: error }); + }) + .finally(() => editor.destroy()); + } + + async #resizePhoto(editor: Awaited>, filePath: string, maxPhotoSize: number): Promise { + const response = await fetch(filePath); + if (!response.ok) { + throw new Error(`Unable to load photo: ${response.status}`); + } + const blob = await response.blob(); + const loaded = await editor.loadImageFromFile(new File([blob], 'data.png', { type: blob.type })); + const longestEdge = Math.max(loaded.newWidth, loaded.newHeight); + return editor.toDataURL({ + multiplier: longestEdge > 0 ? Math.min(1, maxPhotoSize / longestEdge) : 1, + }); + } +} diff --git a/projects/photo-editor/file/src/public-api.ts b/projects/photo-editor/file/src/public-api.ts new file mode 100644 index 0000000..7ede8bd --- /dev/null +++ b/projects/photo-editor/file/src/public-api.ts @@ -0,0 +1,2 @@ +/** Photo source selection and normalization entry point. */ +export * from './lib/photo-file.service'; diff --git a/projects/photo-editor/package.json b/projects/photo-editor/package.json index 0dd3a52..786d0e9 100644 --- a/projects/photo-editor/package.json +++ b/projects/photo-editor/package.json @@ -9,19 +9,33 @@ "@angular/cdk": ">=21.0.0 <23.0.0", "@angular/common": ">=21.0.0 <23.0.0", "@angular/core": ">=21.0.0 <23.0.0", + "@angular/forms": ">=21.0.0 <23.0.0", "@capacitor/camera": ">=7.0.0 <9.0.0", "@capacitor/core": ">=7.0.0 <9.0.0", "@ionic/angular": "^9.0.0", + "ionicons": ">=7.0.0 <9.0.0", + "rxjs": "^7.8.0", "swiper": "^11.1.3", "tui-image-editor": "^3.15.3" }, + "peerDependenciesMeta": { + "@capacitor/camera": { + "optional": true + }, + "swiper": { + "optional": true + }, + "tui-image-editor": { + "optional": true + } + }, "dependencies": { "tslib": "^2.3.0" }, "exports": { - "./css/*": { - "style": "./css/*", - "default": "./css/*" + "./css/ios26-header-button-color-scheme.css": { + "style": "./css/ios26-header-button-color-scheme.css", + "default": "./css/ios26-header-button-color-scheme.css" } }, "sideEffects": false diff --git a/projects/photo-editor/src/lib/dictionaries.ts b/projects/photo-editor/src/lib/dictionaries.ts index 36c0ab1..5240b27 100644 --- a/projects/photo-editor/src/lib/dictionaries.ts +++ b/projects/photo-editor/src/lib/dictionaries.ts @@ -1,29 +1,6 @@ -import { IDictionaryForEditor, IDictionaryForService, IDictionaryForViewer } from './types'; +import { PhotoFileLabels } from './types'; -export const dictionaryForEditor = (): IDictionaryForEditor => ({ - // UI labels - save: '保存', - crop: '切り抜き・回転', - filter: 'フィルター', - brightness: '明るさ', - - // Filter labels - original: 'オリジナル', - invert: '反転', - sepia: 'セピア', - vintage: 'ヴィンテージ', - blur: 'ぼかし', - grayscale: 'グレースケール', - sharpen: '輪郭', - emboss: 'エンボス', -}); - -export const dictionaryForViewer = (): IDictionaryForViewer => ({ - // UI labels - delete: '削除', -}); - -export const dictionaryForService = (): IDictionaryForService => ({ +export const dictionaryForService = (): PhotoFileLabels => ({ // UI labels camera: 'カメラ撮影', album: 'アルバムから選択', diff --git a/projects/photo-editor/src/lib/filter-preset.ts b/projects/photo-editor/src/lib/filter-preset.ts deleted file mode 100644 index 37087f8..0000000 --- a/projects/photo-editor/src/lib/filter-preset.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { IDictionaryForEditor, IFilterPreset } from './types'; - -export const filterPreset = (dictionary: IDictionaryForEditor): IFilterPreset[] => [ - { - name: dictionary.original, - type: 'Default', - option: null, - }, - // { - // name: dictionary.invert, - // type: 'Invert', - // option: null, - // }, - { - name: dictionary.sepia, - type: 'Sepia', - option: null, - }, - { - name: dictionary.vintage, - type: 'vintage', - option: null, - }, - { - name: 'ぼかし', - type: 'Blur', - option: { blur: 0.1 }, - }, - { - name: dictionary.grayscale, - type: 'Grayscale', - option: null, - }, - // { - // name: dictionary.sharpen, - // type: 'Sharpen', - // option: null, - // }, - // { - // name: dictionary.emboss, - // type: 'Emboss', - // option: null, - // }, -]; diff --git a/projects/photo-editor/src/lib/pages/core.scss b/projects/photo-editor/src/lib/pages/core.scss index 657f7d6..2650e5a 100644 --- a/projects/photo-editor/src/lib/pages/core.scss +++ b/projects/photo-editor/src/lib/pages/core.scss @@ -1,30 +1,33 @@ :host { - --editor-background: var(--ion-photo-editor-background, --ion-color-step-100, #2a2a2a); - --editor-background-tint: var(--ion-photo-editor-background-tint, --ion-color-step-200, #414141); + --editor-background: var(--ion-photo-editor-background, var(--ion-color-step-100, #2a2a2a)); + --editor-background-tint: var(--ion-photo-editor-background-tint, var(--ion-color-step-200, #414141)); - --editor-color: var(--ion-photo-editor-color, --ion-color-step-950, #f0f0f0); - --editor-color-tint: var(--ion-photo-editor-color-tint, --ion-color-step-850, #dbdbdb); + --editor-color: var(--ion-photo-editor-color, var(--ion-color-step-950, #f0f0f0)); + --editor-color-tint: var(--ion-photo-editor-color-tint, var(--ion-color-step-850, #dbdbdb)); - --ion-color-photo-editor-primary: var(--ion-photo-editor-primary, --ion-color-primary, #4d8dff); - //--ion-color-photo-editor-primary-rgb: var(--ion-photo-editor-primary-rgb, --ion-color-primary-rgb, '77, 141, 255'); - //--ion-color-photo-editor-primary-contrast: var(--ion-photo-editor-primary-contrast, --ion-color-primary-contrast, #000); - //--ion-color-photo-editor-primary-contrast-rgb: var(--ion-photo-editor-primary-contrast-rgb, --ion-color-primary-rgb, '0,0,0'); - //--ion-color-photo-editor-primary-shade: var(--ion-photo-editor-primary-shade --ion-color-primary-shade, #447ce0); - //--ion-color-photo-editor-primary-tint: var(--ion-photo-editor-primary-tint, --ion-color-primary-tint, #5f98ff); + --ion-color-photo-editor-primary: var(--ion-photo-editor-primary, var(--ion-color-primary, #4d8dff)); + --ion-color-photo-editor-primary-rgb: var(--ion-photo-editor-primary-rgb, var(--ion-color-primary-rgb, 77, 141, 255)); + --ion-color-photo-editor-primary-contrast: var(--ion-photo-editor-primary-contrast, var(--ion-color-primary-contrast, #000)); + --ion-color-photo-editor-primary-contrast-rgb: var( + --ion-photo-editor-primary-contrast-rgb, + var(--ion-color-primary-contrast-rgb, 0, 0, 0) + ); + --ion-color-photo-editor-primary-shade: var(--ion-photo-editor-primary-shade, var(--ion-color-primary-shade, #447ce0)); + --ion-color-photo-editor-primary-tint: var(--ion-photo-editor-primary-tint, var(--ion-color-primary-tint, #5f98ff)); --ion-color-photo-editor-danger: var(--ion-photo-editor-danger, #f24c58); - //--ion-color-photo-editor-danger-rgb: var(--ion-photo-editor-danger, '242,76,88'); - //--ion-color-photo-editor-danger-contrast: var(--ion-photo-editor-danger, #000000); - //--ion-color-photo-editor-danger-contrast-rgb: var(--ion-photo-editor-danger, '0,0,0'); - //--ion-color-photo-editor-danger-shade: var(--ion-photo-editor-danger, #d5434d); - //--ion-color-photo-editor-danger-tint: var(--ion-photo-editor-danger, #f35e69); + --ion-color-photo-editor-danger-rgb: var(--ion-photo-editor-danger-rgb, 242, 76, 88); + --ion-color-photo-editor-danger-contrast: var(--ion-photo-editor-danger-contrast, #000); + --ion-color-photo-editor-danger-contrast-rgb: var(--ion-photo-editor-danger-contrast-rgb, 0, 0, 0); + --ion-color-photo-editor-danger-shade: var(--ion-photo-editor-danger-shade, #d5434d); + --ion-color-photo-editor-danger-tint: var(--ion-photo-editor-danger-tint, #f35e69); --ion-color-photo-editor-success: var(--ion-photo-editor-success, #2dd55b); - //--ion-color-photo-editor-success-rgb: var(--ion-photo-editor-success, '45,213,91'); - //--ion-color-photo-editor-success-contrast: var(--ion-photo-editor-success, #000000); - //--ion-color-photo-editor-success-contrast-rgb: var(--ion-photo-editor-success, '0,0,0'); - //--ion-color-photo-editor-success-shade: var(--ion-photo-editor-success, #28bb50); - //--ion-color-photo-editor-success-tint: var(--ion-photo-editor-success, #42d96b); + --ion-color-photo-editor-success-rgb: var(--ion-photo-editor-success-rgb, 45, 213, 91); + --ion-color-photo-editor-success-contrast: var(--ion-photo-editor-success-contrast, #000); + --ion-color-photo-editor-success-contrast-rgb: var(--ion-photo-editor-success-contrast-rgb, 0, 0, 0); + --ion-color-photo-editor-success-shade: var(--ion-photo-editor-success-shade, #28bb50); + --ion-color-photo-editor-success-tint: var(--ion-photo-editor-success-tint, #42d96b); } ion-header.photo-editor-header-buttons-light ion-button:not(.ion-color) { @@ -37,29 +40,29 @@ ion-header.photo-editor-header-buttons-dark ion-button:not(.ion-color) { .ion-color-photo-editor-primary { --ion-color-base: var(--ion-color-photo-editor-primary); - //--ion-color-base-rgb: var(--ion-color-photo-editor-primary-rgb); - //--ion-color-contrast: var(--ion-color-photo-editor-primary-contrast); - //--ion-color-contrast-rgb: var(--ion-color-photo-editor-primary-contrast-rgb); - //--ion-color-shade: var(--ion-color-photo-editor-primary-shade); - //--ion-color-tint: var(--ion-color-photo-editor-primary-tint); + --ion-color-base-rgb: var(--ion-color-photo-editor-primary-rgb); + --ion-color-contrast: var(--ion-color-photo-editor-primary-contrast); + --ion-color-contrast-rgb: var(--ion-color-photo-editor-primary-contrast-rgb); + --ion-color-shade: var(--ion-color-photo-editor-primary-shade); + --ion-color-tint: var(--ion-color-photo-editor-primary-tint); } .ion-color-photo-editor-danger { --ion-color-base: var(--ion-color-photo-editor-danger); - //--ion-color-base-rgb: var(--ion-color-photo-editor-danger-rgb); - //--ion-color-contrast: var(--ion-color-photo-editor-danger-contrast); - //--ion-color-contrast-rgb: var(--ion-color-photo-editor-danger-contrast-rgb); - //--ion-color-shade: var(--ion-color-photo-editor-danger-shade); - //--ion-color-tint: var(--ion-color-photo-editor-danger-tint); + --ion-color-base-rgb: var(--ion-color-photo-editor-danger-rgb); + --ion-color-contrast: var(--ion-color-photo-editor-danger-contrast); + --ion-color-contrast-rgb: var(--ion-color-photo-editor-danger-contrast-rgb); + --ion-color-shade: var(--ion-color-photo-editor-danger-shade); + --ion-color-tint: var(--ion-color-photo-editor-danger-tint); } .ion-color-photo-editor-success { --ion-color-base: var(--ion-color-photo-editor-success); - //--ion-color-base-rgb: var(--ion-color-photo-editor-success-rgb); - //--ion-color-contrast: var(--ion-color-photo-editor-success-contrast); - //--ion-color-contrast-rgb: var(--ion-color-photo-editor-success-contrast-rgb); - //--ion-color-shade: var(--ion-color-photo-editor-success-shade); - //--ion-color-tint: var(--ion-color-photo-editor-success-tint); + --ion-color-base-rgb: var(--ion-color-photo-editor-success-rgb); + --ion-color-contrast: var(--ion-color-photo-editor-success-contrast); + --ion-color-contrast-rgb: var(--ion-color-photo-editor-success-contrast-rgb); + --ion-color-shade: var(--ion-color-photo-editor-success-shade); + --ion-color-tint: var(--ion-color-photo-editor-success-tint); } ion-content, diff --git a/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.html b/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.html deleted file mode 100644 index 31aa6e6..0000000 --- a/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.html +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - {{ dictionary().save }} - - - - - - -
-
- - - @if (this.footerMenu() === 'menu') { - - } - - @if (this.footerMenu() === 'filter') { - - } - - @if (this.footerMenu() === 'crop' && !requireSquare()) { - - - - - - - - - - - - - - - - } - - @if (['brightness'].includes(this.footerMenu())) { -
- -
- } -
- @if (this.footerMenu() !== 'menu') { - - @if (this.footerMenu() === 'filter') { - - 戻る - - } @else { - - @if (!requireSquare() || isCropped()) { - - } - @if (footerMenu() === 'crop') { - {{ dictionary().crop }} - } @else if (footerMenu() === 'brightness') { - {{ dictionary().brightness }} - } - } - - } -
diff --git a/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.ts b/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.ts deleted file mode 100644 index 9785d98..0000000 --- a/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; -import { - ChangeDetectionStrategy, - Component, - computed, - effect, - ElementRef, - inject, - input, - OnDestroy, - OnInit, - signal, - viewChild, -} from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { IonContent, ModalController, RangeCustomEvent, ViewDidEnter, ViewDidLeave } from '@ionic/angular'; -import ImageEditor from 'tui-image-editor'; -import { filterPreset } from '../../filter-preset'; -import { Subscription } from 'rxjs'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { IDictionaryForEditor, IFilter, IPhotoEditorDismiss, ISize, PhotoEditorHeaderButtonColorScheme } from '../../types'; -import { ionComponents } from '../../ion-components'; -import { dictionaryForEditor } from '../../dictionaries'; -import { initializeEditorIcons, waitToFindDom } from '../util'; - -@Component({ - selector: 'rdlabo-photo-editor', - templateUrl: './photo-editor.page.html', - styleUrls: ['../core.scss', './photo-editor.page.scss'], - imports: [CommonModule, FormsModule, ...ionComponents], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class PhotoEditorPage implements OnInit, OnDestroy, ViewDidEnter, ViewDidLeave { - readonly modalCtrl = inject(ModalController); - - protected readonly dictionary = signal(dictionaryForEditor()); - - readonly requireSquare = input(false, { - transform: coerceBooleanProperty, - }); - readonly labels = input | undefined>(undefined); - readonly headerButtonColorScheme = input.required(); - readonly setLabels = effect(() => { - if (this.labels()) { - this.dictionary.update((value) => ({ ...value, ...this.labels() })); - } - }); - readonly value = input.required(); - - readonly editorRef = viewChild.required('imageEditor'); - readonly ionContent = viewChild.required(IonContent, { read: ElementRef }); - - readonly filters = signal([]); - readonly footerMenu = signal<'filter' | 'menu' | 'crop' | 'brightness'>('menu'); - readonly currentCrop = signal<'cover' | '16/9' | '1' | 'auto'>('cover'); - readonly currentRotate = signal(0); - readonly photoCrop = signal({ - width: 0, - height: 0, - }); - readonly isCropped = signal(false); - private readonly adoptFilter = signal(undefined); - protected readonly filterPreset = computed(() => filterPreset(this.dictionary())); - - private readonly footerMenu$ = toObservable(this.footerMenu); - - private readonly initSubscription$: Subscription[] = []; - private readonly filterImageSize = 240; - private editorInstance!: ImageEditor; - - private canvasContainerObserver: MutationObserver = new MutationObserver((mutationsList: MutationRecord[]) => { - if (mutationsList.find((mutation) => mutation.type === 'attributes' && mutation.attributeName === 'style')) { - // Cover the image editor with the parent element - const editorRef = this.editorRef(); - editorRef.nativeElement.style.minWidth = mutationsList[0].target.parentElement!.style.maxWidth; - editorRef.nativeElement.style.minHeight = mutationsList[0].target.parentElement!.style.maxHeight; - - this.photoCrop.set({ - width: mutationsList[0].target.parentElement!.querySelector('canvas')!.width, - height: mutationsList[0].target.parentElement!.querySelector('canvas')!.height, - }); - } - }); - - constructor() { - initializeEditorIcons(); - } - - ngOnInit() { - this.initSubscription$.push( - this.footerMenu$.subscribe((value) => { - if (value === 'filter') { - this.initializeFilterMenu().then(); - } - if (value === 'crop') { - this.editorInstance.startDrawingMode('CROPPER'); - this.changeCrop(this.requireSquare() ? '1' : 'cover'); - } - }), - ); - } - - ngOnDestroy() { - this.initSubscription$.forEach((subscription) => subscription.unsubscribe()); - } - - async ionViewDidEnter() { - this.editorInstance = new ImageEditor(this.editorRef().nativeElement, { - cssMaxWidth: this.ionContent().nativeElement.clientWidth - 32, - cssMaxHeight: this.ionContent().nativeElement.clientHeight - 32, - }); - waitToFindDom(this.editorRef().nativeElement, '.tui-image-editor-canvas-container').then(() => { - this.canvasContainerObserver.observe(this.editorRef().nativeElement.querySelector('.tui-image-editor-canvas-container'), { - attributes: true, - childList: false, - subtree: true, - }); - }); - const blob = await fetch(this.value()).then((res) => res.blob()); - await this.editorInstance.loadImageFromFile(new File([blob], 'data.png', { type: blob.type })); - this.footerMenu.set(this.requireSquare() ? 'crop' : 'menu'); - } - - ionViewDidLeave() { - this.editorInstance.destroy(); - this.canvasContainerObserver.disconnect(); - } - - changeCrop(crop: 'cover' | '16/9' | '1' | 'auto') { - const rect = crop === 'cover' ? this.photoCrop().width / this.photoCrop().height : crop === '16/9' ? 16 / 9 : 1; - this.editorInstance.setCropzoneRect(crop !== 'auto' ? rect : undefined); - this.currentCrop.set(crop); - } - - async rotate() { - this.editorInstance.stopDrawingMode(); - await this.editorInstance.rotate(90); - this.currentRotate.update((value) => value + 90); - this.editorInstance.startDrawingMode('CROPPER'); - requestAnimationFrame(() => this.changeCrop(this.currentCrop())); - } - - async closeCrop(type: 'cancel' | 'apply') { - if (this.footerMenu() === 'crop') { - if (type === 'cancel') { - await this.editorInstance.rotate(this.currentRotate() * -1); - } else { - await this.editorInstance.crop(this.editorInstance.getCropzoneRect()); - this.isCropped.set(true); - } - this.currentRotate.set(0); - this.currentCrop.set('cover'); - this.editorInstance.stopDrawingMode(); - } else if (this.footerMenu() === 'brightness') { - if (type === 'cancel' && this.editorInstance.hasFilter('brightness')) { - await this.editorInstance.removeFilter('brightness'); - } - } - this.footerMenu.set('menu'); - } - - async changeRange(event: RangeCustomEvent) { - if (this.editorInstance.hasFilter('brightness')) { - await this.editorInstance.removeFilter('brightness'); - } - await this.editorInstance.applyFilter('brightness', { - brightness: Number(event.detail.value) / 255, - }); - } - - imageSave() { - const value = this.editorInstance.toDataURL(); - this.modalCtrl.dismiss({ value } as IPhotoEditorDismiss); - } - - private async initializeFilterMenu() { - const filters: IFilter[] = []; - - const defaultInstance = new ImageEditor(document.createElement('div'), { - cssMaxWidth: this.filterImageSize, - cssMaxHeight: (this.photoCrop().height * this.filterImageSize) / this.photoCrop().width, - }); - const blob = await fetch( - this.editorInstance.toDataURL({ - multiplier: this.filterImageSize / this.photoCrop().width, - }), - ).then((res) => res.blob()); - await defaultInstance.loadImageFromFile(new File([blob], 'defaultInstance.png', { type: blob.type })); - - for (const filter of this.filterPreset()) { - if (filter.type !== 'Default') { - await defaultInstance.applyFilter(filter.type, filter.option); - } - filters.push({ - name: filter.name, - type: filter.type, - option: filter.option, - data: defaultInstance.toDataURL(), - width: this.filterImageSize, - height: (this.photoCrop().height * this.filterImageSize) / this.photoCrop().width, - }); - if (filter.type !== 'Default') { - await defaultInstance.removeFilter(filter.type); - } - } - this.filters.set(filters); - defaultInstance.destroy(); - } - - async filterImage(filter: IFilter) { - if (this.adoptFilter()) { - await this.editorInstance.removeFilter(this.adoptFilter()!.type); - } - if (filter.type === 'Default') { - this.adoptFilter.set(undefined); - return; - } - await this.editorInstance.applyFilter(filter.type, filter.option); - this.adoptFilter.set(filter); - } -} diff --git a/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.html b/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.html deleted file mode 100644 index 4744811..0000000 --- a/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - @if (enableDelete()) { - - {{ dictionary().delete }} - - } - - - - - - @for (url of imageUrls(); track url) { - -
-
- } -
-
- -@if (enableFooterSafeArea()) { - - - - -} diff --git a/projects/photo-editor/src/lib/pages/util.spec.ts b/projects/photo-editor/src/lib/pages/util.spec.ts deleted file mode 100644 index 4361067..0000000 --- a/projects/photo-editor/src/lib/pages/util.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { vi } from 'vitest'; - -import { waitToFindDom } from './util'; - -describe('waitToFindDom', () => { - afterEach(() => vi.restoreAllMocks()); - - it('resolves after the requested descendant is added', async () => { - const host = document.createElement('div'); - const clearInterval = vi.spyOn(globalThis, 'clearInterval'); - let resolved = false; - const result = waitToFindDom(host, '.ready').then(() => { - resolved = true; - }); - - expect(resolved).toBe(false); - - const child = document.createElement('span'); - child.className = 'ready'; - host.append(child); - await result; - - expect(resolved).toBe(true); - expect(clearInterval).toHaveBeenCalledOnce(); - }); -}); diff --git a/projects/photo-editor/src/lib/pages/util.ts b/projects/photo-editor/src/lib/pages/util.ts deleted file mode 100644 index ce75574..0000000 --- a/projects/photo-editor/src/lib/pages/util.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { addIcons } from 'ionicons'; -import { - checkmarkOutline, - closeOutline, - colorFilterOutline, - cropOutline, - expandOutline, - refreshOutline, - removeOutline, - send, - squareOutline, - sunnyOutline, - tabletLandscapeOutline, -} from 'ionicons/icons'; - -export const initializeViewerIcons = (): void => { - addIcons({ - closeOutline, - removeOutline, - }); -}; - -export const initializeEditorIcons = (): void => { - addIcons({ - closeOutline, - send, - cropOutline, - colorFilterOutline, - sunnyOutline, - expandOutline, - tabletLandscapeOutline, - squareOutline, - refreshOutline, - checkmarkOutline, - }); -}; - -export const waitToFindDom = (nativeElement: HTMLElement, selector: string): Promise => { - return new Promise((resolve) => { - const interval = setInterval(() => { - const find = nativeElement.querySelector(selector); - if (find) { - clearInterval(interval); - resolve(); - } - }); - }); -}; diff --git a/projects/photo-editor/src/lib/photo-editor.config.ts b/projects/photo-editor/src/lib/photo-editor.config.ts new file mode 100644 index 0000000..95e6109 --- /dev/null +++ b/projects/photo-editor/src/lib/photo-editor.config.ts @@ -0,0 +1,105 @@ +import { EnvironmentProviders, InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import { dictionaryForService } from './dictionaries'; +import { PhotoFileLabels, PhotoFilterOptions, PhotoLoadError } from './types'; + +/** Canvas bounds passed to the underlying image editor implementation. */ +export interface PhotoImageEditorOptions { + cssMaxWidth: number; + cssMaxHeight: number; +} + +/** Rectangle returned by the editor crop tool. */ +export interface PhotoCropRect { + left: number; + top: number; + width: number; + height: number; +} + +/** Minimal image-editor contract required by the library entry points. */ +export interface PhotoImageEditor { + applyFilter(type: string, options?: Exclude): Promise; + crop(rect: PhotoCropRect): Promise; + destroy(): void; + getCropzoneRect(): PhotoCropRect; + hasFilter(type: string): boolean; + loadImageFromFile(file: File): Promise<{ newWidth: number; newHeight: number }>; + removeFilter(type: string): Promise; + rotate(angle: number): Promise; + setCropzoneRect(ratio?: number): void; + startDrawingMode(mode: string): void; + stopDrawingMode(): void; + toDataURL(options?: { multiplier?: number }): string; +} + +/** Lazily creates the image editor used for editing and resizing. */ +export type PhotoImageEditorFactory = (host: Element, options: PhotoImageEditorOptions) => Promise; + +/** Options passed to a native camera adapter. */ +export interface PhotoCameraOptions { + quality: number; + width: number; + limit?: number; + source?: 'camera'; +} + +/** Image metadata returned by a native camera adapter. */ +export interface PhotoCameraImage { + dataUrl?: string; + webPath?: string; +} + +/** Minimal native camera contract required by {@link PhotoFileService}. */ +export interface PhotoCameraAdapter { + getPhoto(options: PhotoCameraOptions): Promise; + pickImages(options: PhotoCameraOptions): Promise<{ photos: PhotoCameraImage[] }>; +} + +/** Lazily creates the native camera adapter used on Capacitor platforms. */ +export type PhotoCameraLoader = () => Promise; + +/** Global defaults for photo selection and image editor construction. */ +export interface PhotoEditorConfig { + maxPhotoSize?: number; + fileLabels?: Partial; + createImageEditor?: PhotoImageEditorFactory; + loadCamera?: PhotoCameraLoader; +} + +interface ResolvedPhotoEditorConfig { + maxPhotoSize: number; + fileLabels: PhotoFileLabels; + createImageEditor: PhotoImageEditorFactory; + loadCamera: PhotoCameraLoader; +} + +const missingImageEditor: PhotoImageEditorFactory = () => + Promise.reject(new PhotoLoadError('unavailable', { cause: new Error('Configure createImageEditor with the editor adapter.') })); +const missingCamera: PhotoCameraLoader = () => + Promise.reject(new PhotoLoadError('unavailable', { cause: new Error('Configure loadCamera with the Capacitor adapter.') })); + +/** Resolved photo editor configuration used by library services and components. */ +export const PHOTO_EDITOR_CONFIG = new InjectionToken('PHOTO_EDITOR_CONFIG', { + providedIn: 'root', + factory: () => ({ + maxPhotoSize: 1000, + fileLabels: dictionaryForService(), + createImageEditor: missingImageEditor, + loadCamera: missingCamera, + }), +}); + +/** Provides application-wide photo editor defaults and an optional editor adapter. */ +export function providePhotoEditor(config: PhotoEditorConfig = {}): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: PHOTO_EDITOR_CONFIG, + useValue: { + maxPhotoSize: config.maxPhotoSize ?? 1000, + fileLabels: { ...dictionaryForService(), ...config.fileLabels }, + createImageEditor: config.createImageEditor ?? missingImageEditor, + loadCamera: config.loadCamera ?? missingCamera, + } satisfies ResolvedPhotoEditorConfig, + }, + ]); +} diff --git a/projects/photo-editor/src/lib/photoEditorErrors.ts b/projects/photo-editor/src/lib/photoEditorErrors.ts deleted file mode 100644 index 841231d..0000000 --- a/projects/photo-editor/src/lib/photoEditorErrors.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum PhotoEditorErrors { - initialize = 'input dom is not found', - cancel = 'user canceled', - type = 'upload file is not image.', -} diff --git a/projects/photo-editor/src/lib/services/photo-file.service.spec.ts b/projects/photo-editor/src/lib/services/photo-file.service.spec.ts deleted file mode 100644 index d1f6222..0000000 --- a/projects/photo-editor/src/lib/services/photo-file.service.spec.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { ActionSheetController, Platform } from '@ionic/angular'; -import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; -import ImageEditor from 'tui-image-editor'; -import { vi } from 'vitest'; - -import { PhotoEditorErrors } from '../photoEditorErrors'; -import { PhotoFileService } from './photo-file.service'; - -vi.mock('@capacitor/camera', () => ({ - Camera: { - getPhoto: vi.fn(), - pickImages: vi.fn(), - }, - CameraResultType: { DataUrl: 'dataUrl' }, - CameraSource: { Camera: 'CAMERA' }, -})); - -vi.mock('tui-image-editor', () => ({ default: vi.fn() })); - -describe('PhotoFileService', () => { - let service: PhotoFileService; - let isPlatform: ReturnType; - let createActionSheet: ReturnType; - let getPhoto: ReturnType; - let pickImages: ReturnType; - let dismissData: 'camera' | 'album' | undefined; - let actionSheetOptions: { buttons: { text: string; role?: string; handler?: () => void }[] } | undefined; - - beforeEach(() => { - dismissData = undefined; - actionSheetOptions = undefined; - isPlatform = vi.fn().mockReturnValue(true); - getPhoto = vi.mocked(Camera.getPhoto); - pickImages = vi.mocked(Camera.pickImages); - getPhoto.mockReset(); - pickImages.mockReset(); - createActionSheet = vi.fn().mockImplementation(async (options) => { - actionSheetOptions = options; - return { - dismiss: vi.fn().mockResolvedValue(true), - present: vi.fn().mockResolvedValue(undefined), - onDidDismiss: vi.fn().mockImplementation(async () => ({ data: dismissData })), - }; - }); - - TestBed.configureTestingModule({ - providers: [ - PhotoFileService, - { provide: Platform, useValue: { is: isPlatform } }, - { provide: ActionSheetController, useValue: { create: createActionSheet } }, - ], - }); - service = TestBed.inject(PhotoFileService); - }); - - afterEach(() => { - document.querySelector('input#browserPhotoUploader')?.remove(); - vi.restoreAllMocks(); - }); - - it('rejects browser loading when the required file input is missing', async () => { - isPlatform.mockReturnValue(false); - - await expect(service.loadPhoto(1)).rejects.toBe(PhotoEditorErrors.initialize); - expect(createActionSheet).not.toHaveBeenCalled(); - }); - - it('rejects browser loading when the file chooser is cancelled', async () => { - isPlatform.mockReturnValue(false); - const input = document.createElement('input'); - input.id = 'browserPhotoUploader'; - document.body.append(input); - vi.spyOn(input, 'click').mockImplementation(() => undefined); - - const result = service.loadPhoto(1); - input.dispatchEvent(new Event('cancel')); - - await expect(result).rejects.toBe(PhotoEditorErrors.cancel); - expect(input.click).toHaveBeenCalledOnce(); - }); - - it('rejects an empty browser selection without attempting to resize it', async () => { - isPlatform.mockReturnValue(false); - const input = document.createElement('input'); - input.id = 'browserPhotoUploader'; - Object.defineProperty(input, 'files', { configurable: true, value: [] }); - document.body.append(input); - vi.spyOn(input, 'click').mockImplementation(() => undefined); - const resize = vi.spyOn(service as unknown as { loadPhotoFromFilePath(path: string): Promise }, 'loadPhotoFromFilePath'); - - const result = service.loadPhoto(1); - input.dispatchEvent(new Event('change')); - - await expect(result).rejects.toBe(PhotoEditorErrors.cancel); - expect(resize).not.toHaveBeenCalled(); - }); - - it('returns the resized result selected by the browser file input', async () => { - isPlatform.mockReturnValue(false); - const input = document.createElement('input'); - input.id = 'browserPhotoUploader'; - Object.defineProperty(input, 'files', { - configurable: true, - value: [new File(['image'], 'photo.png', { type: 'image/png' })], - }); - document.body.append(input); - vi.spyOn(input, 'click').mockImplementation(() => undefined); - const resize = vi - .spyOn(service as unknown as { loadPhotoFromFilePath(path: string): Promise }, 'loadPhotoFromFilePath') - .mockResolvedValue('data:image/png;base64,resized'); - - const result = service.loadPhoto(1); - input.dispatchEvent(new Event('change')); - - await expect(result).resolves.toEqual(['data:image/png;base64,resized']); - expect(resize).toHaveBeenCalledWith(expect.stringMatching(/^data:image\/png;base64,/)); - }); - - it('rejects browser loading when resizing the selected image fails', async () => { - isPlatform.mockReturnValue(false); - const input = document.createElement('input'); - input.id = 'browserPhotoUploader'; - Object.defineProperty(input, 'files', { - configurable: true, - value: [new File(['image'], 'photo.png', { type: 'image/png' })], - }); - document.body.append(input); - vi.spyOn(input, 'click').mockImplementation(() => undefined); - const resizeError = new Error('resize failed'); - vi.spyOn(service as unknown as { loadPhotoFromFilePath(path: string): Promise }, 'loadPhotoFromFilePath').mockRejectedValue( - resizeError, - ); - - const result = service.loadPhoto(1); - input.dispatchEvent(new Event('change')); - - await expect(result).rejects.toBe(resizeError); - }); - - it('rejects a non-image browser file without attempting to resize it', async () => { - isPlatform.mockReturnValue(false); - const input = document.createElement('input'); - input.id = 'browserPhotoUploader'; - let inputValue = 'selected'; - Object.defineProperty(input, 'value', { - configurable: true, - get: () => inputValue, - set: (value: string) => { - inputValue = value; - }, - }); - Object.defineProperty(input, 'files', { - configurable: true, - value: [new File(['text'], 'notes.txt', { type: 'text/plain' })], - }); - document.body.append(input); - vi.spyOn(input, 'click').mockImplementation(() => undefined); - const resize = vi.spyOn(service as unknown as { loadPhotoFromFilePath(path: string): Promise }, 'loadPhotoFromFilePath'); - - const result = service.loadPhoto(1); - input.dispatchEvent(new Event('change')); - - await expect(result).rejects.toBe(PhotoEditorErrors.type); - expect(resize).not.toHaveBeenCalled(); - expect(input.value).toBe(''); - }); - - it('uses configured labels and rejects a dismissed native source sheet', async () => { - service.labels = { - camera: 'Take photo', - album: 'Choose photo', - cancel: 'Close', - }; - - await expect(service.loadPhoto(2)).rejects.toBe(PhotoEditorErrors.cancel); - expect(actionSheetOptions?.buttons.map(({ text, role }) => ({ text, role }))).toEqual([ - { text: 'Take photo', role: undefined }, - { text: 'Choose photo', role: undefined }, - { text: 'Close', role: 'cancel' }, - ]); - }); - - it('returns a camera data URL and passes the configured maximum size', async () => { - dismissData = 'camera'; - service.photoMaxSize = 2048; - getPhoto.mockResolvedValue({ - dataUrl: 'data:image/jpeg;base64,camera', - format: 'jpeg', - saved: false, - }); - - await expect(service.loadPhoto(1)).resolves.toEqual(['data:image/jpeg;base64,camera']); - expect(getPhoto).toHaveBeenCalledWith({ - quality: 100, - width: 2048, - allowEditing: false, - resultType: CameraResultType.DataUrl, - source: CameraSource.Camera, - presentationStyle: 'popover', - }); - }); - - it('rejects when native camera capture does not return a data URL', async () => { - dismissData = 'camera'; - getPhoto.mockRejectedValue(new Error('camera unavailable')); - - await expect(service.loadPhoto(1)).rejects.toBe(PhotoEditorErrors.cancel); - }); - - it('loads every selected album path and preserves selection order', async () => { - dismissData = 'album'; - pickImages.mockResolvedValue({ - photos: [ - { webPath: 'file://first', format: 'jpeg' }, - { webPath: 'file://second', format: 'png' }, - ], - }); - const loadPhotoFromFilePath = vi - .spyOn(service as unknown as { loadPhotoFromFilePath(path: string): Promise }, 'loadPhotoFromFilePath') - .mockImplementation(async (path) => `resized:${path}`); - - await expect(service.loadPhoto(2)).resolves.toEqual(['resized:file://first', 'resized:file://second']); - expect(pickImages).toHaveBeenCalledWith({ - quality: 100, - width: 1000, - limit: 2, - presentationStyle: 'popover', - }); - expect(loadPhotoFromFilePath).toHaveBeenCalledTimes(2); - }); - - it('scales the longest image edge to the configured maximum and destroys the editor', async () => { - service.photoMaxSize = 1200; - const editor = { - destroy: vi.fn(), - loadImageFromFile: vi.fn().mockResolvedValue({ newWidth: 4800, newHeight: 3200 }), - toDataURL: vi.fn().mockReturnValue('data:image/jpeg;base64,resized'), - }; - vi.mocked(ImageEditor).mockImplementation(function MockImageEditor() { - return editor as never; - }); - vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - blob: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' })), - } as never); - - const result = await (service as unknown as { loadPhotoFromFilePath(path: string): Promise }).loadPhotoFromFilePath( - 'file://large-image', - ); - - expect(result).toBe('data:image/jpeg;base64,resized'); - expect(editor.toDataURL).toHaveBeenCalledWith({ multiplier: 0.25 }); - expect(editor.destroy).toHaveBeenCalledOnce(); - }); -}); diff --git a/projects/photo-editor/src/lib/services/photo-file.service.ts b/projects/photo-editor/src/lib/services/photo-file.service.ts deleted file mode 100644 index 33583f8..0000000 --- a/projects/photo-editor/src/lib/services/photo-file.service.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { inject, Injectable, signal } from '@angular/core'; -import { ActionSheetController, Platform } from '@ionic/angular'; -import { Camera, CameraResultType, CameraSource, ImageOptions } from '@capacitor/camera'; -import ImageEditor from 'tui-image-editor'; -import { PhotoEditorErrors } from '../photoEditorErrors'; -import { dictionaryForService } from '../dictionaries'; -import { IDictionaryForService } from '../types'; - -@Injectable({ - providedIn: 'root', -}) -export class PhotoFileService { - private readonly $photoMaxSize = signal(1000); - private readonly actionSheetCtrl = inject(ActionSheetController); - private readonly platform = inject(Platform); - private readonly dictionary = signal(dictionaryForService()); - - set photoMaxSize(value: number) { - this.$photoMaxSize.set(value); - } - - set labels(d: IDictionaryForService) { - this.dictionary.update((value) => ({ ...value, ...d })); - } - - async loadPhoto(limit: number): Promise { - /** - * Using Input for browser - */ - if (!this.platform.is('capacitor')) { - return this.getPictureFromBrowser(); - } - - const actionSheet = await this.actionSheetCtrl.create({ - buttons: [ - { - text: this.dictionary().camera, - handler: () => { - actionSheet.dismiss('camera'); - }, - }, - { - text: this.dictionary().album, - handler: () => { - actionSheet.dismiss('album'); - }, - }, - { - text: this.dictionary().cancel, - role: 'cancel', - }, - ], - }); - await actionSheet.present(); - const { data } = await actionSheet.onDidDismiss<'camera' | 'album'>(); - if (!data) { - return Promise.reject(PhotoEditorErrors.cancel); - } - - if (data === 'camera') { - const defaultCamera: ImageOptions = { - quality: 100, - width: this.$photoMaxSize(), - allowEditing: false, - resultType: CameraResultType.DataUrl, - source: CameraSource.Camera, - presentationStyle: 'popover', - }; - const image = await Camera.getPhoto(defaultCamera).catch(() => undefined); - if (!image?.dataUrl) { - return Promise.reject(PhotoEditorErrors.cancel); - } - - if (!image.dataUrl.includes('capacitor://localhost')) { - return [image.dataUrl]; - } - return Promise.all([image.dataUrl].map(async (image) => await this.loadPhotoFromFilePath(image))); - } - - if (data === 'album') { - const images = await Camera.pickImages({ - quality: 100, - width: this.$photoMaxSize(), - limit, - presentationStyle: 'popover', - }).catch(() => undefined); - - if (!images) { - return Promise.reject(PhotoEditorErrors.cancel); - } - - return Promise.all(images.photos.map(async (image) => await this.loadPhotoFromFilePath(image.webPath))); - } - - // Not run on this line. This is for lint - return []; - } - - private getPictureFromBrowser(): Promise { - const inputFile: HTMLInputElement | null = document.querySelector('input#browserPhotoUploader'); - - if (!inputFile) { - return Promise.reject(PhotoEditorErrors.initialize); - } - - return new Promise((resolve, reject) => { - const cancelMethod = () => { - inputFile!.removeEventListener('cancel', cancelMethod, false); - inputFile!.removeEventListener('change', changeMethod, false); - - reject(PhotoEditorErrors.cancel); - }; - const changeMethod = (e: Event) => { - inputFile!.removeEventListener('cancel', cancelMethod, false); - inputFile!.removeEventListener('change', changeMethod, false); - - const file = (e.target as HTMLInputElement).files?.[0]; - inputFile.value = ''; - if (!file) { - reject(PhotoEditorErrors.cancel); - return; - } - if (file.type.indexOf('image') < 0) { - reject(PhotoEditorErrors.type); - return; - } - const reader = new FileReader(); - - reader.onload = (event) => { - const result = event.target!.result as string; - void this.loadPhotoFromFilePath(result).then((data) => resolve([data]), reject); - }; - reader.onerror = () => { - reject(reader.error ?? new Error('Failed to read the selected image.')); - }; - reader.onabort = () => { - reject(PhotoEditorErrors.cancel); - }; - - reader.readAsDataURL(file); - }; - - inputFile!.addEventListener('cancel', cancelMethod, false); - inputFile!.addEventListener('change', changeMethod, false); - inputFile.click(); - }); - } - - private async loadPhotoFromFilePath(filePath: string): Promise { - const defaultInstance = new ImageEditor(document.createElement('div'), { - cssMaxWidth: this.$photoMaxSize(), - cssMaxHeight: this.$photoMaxSize(), - }); - const blob = await fetch(filePath).then((res) => res.blob()); - const loaded = await defaultInstance.loadImageFromFile(new File([blob], 'data.png', { type: blob.type })); - - const maxSize = Math.max(loaded.newWidth, loaded.newHeight); - const dataUrl = defaultInstance.toDataURL({ - multiplier: this.$photoMaxSize() / maxSize, - }); - defaultInstance.destroy(); - - return dataUrl; - } -} diff --git a/projects/photo-editor/src/lib/types.ts b/projects/photo-editor/src/lib/types.ts index 677cc83..69eb315 100644 --- a/projects/photo-editor/src/lib/types.ts +++ b/projects/photo-editor/src/lib/types.ts @@ -1,20 +1,42 @@ -export interface IFilter { +/** Filter options supported by the bundled photo editor presets. */ +export type PhotoFilterOptions = + | { blur: number } + | { brightness: number } + | { noise: number } + | { blocksize: number } + | { color: string; distance: number; useAlpha?: boolean } + | { mode: string; color: string; alpha?: number } + | { maskObjId: number } + | null; + +/** Rendered filter preview and the operation applied when it is selected. */ +export interface PhotoFilter { name: string; type: string; - option: any; + option: PhotoFilterOptions; data: string; width: number; height: number; } -export interface ISize { +/** Two-dimensional pixel size. */ +export interface PhotoSize { width: number; height: number; } -export interface IDictionaryForEditor { +/** Localized labels rendered by {@link PhotoEditorPage}. */ +export interface PhotoEditorLabels { save: string; + close: string; + back: string; + apply: string; crop: string; + rotate: string; + cropCover: string; + crop16x9: string; + cropSquare: string; + cropFree: string; filter: string; brightness: string; original: string; @@ -27,35 +49,41 @@ export interface IDictionaryForEditor { emboss: string; } -export interface IFilterPreset { +/** A filter offered in the editor's preset menu. */ +export interface PhotoFilterPreset { name: string; type: string; - option: any; + option: PhotoFilterOptions; } -export interface IPhotoEditorDismiss { +/** Successful result returned when the editor saves an image. */ +export interface PhotoEditorResult { + action: 'save'; value: string; } -export interface IPhotoViewerDismiss { - delete: { - index: number; - value: string; - }; +/** Result returned when the viewer asks the consumer to delete an image. */ +export interface PhotoViewerResult { + action: 'delete'; + index: number; + value: string; } -export interface IDictionaryForViewer { +/** Localized labels rendered by {@link PhotoViewerPage}. */ +export interface PhotoViewerLabels { + close: string; delete: string; } -export interface IDictionaryForService { +/** Localized labels used by the native photo source chooser. */ +export interface PhotoFileLabels { camera: string; album: string; cancel: string; } -/** Color scheme used by photo editor and viewer header buttons. */ -export type PhotoEditorHeaderButtonColorScheme = 'light' | 'dark'; +/** Color scheme of the toolbar behind photo editor and viewer header buttons. */ +export type PhotoToolbarColorScheme = 'light' | 'dark'; /** Props for presenting {@link PhotoViewerPage} via Ionic Modal `componentProps`. */ export interface PhotoViewerProps { @@ -64,14 +92,36 @@ export interface PhotoViewerProps { isCircle?: boolean; enableDelete?: boolean; enableFooterSafeArea?: boolean; - labels?: Partial; - headerButtonColorScheme: PhotoEditorHeaderButtonColorScheme; + labels?: Partial; + imageAlt?: string | ((url: string, index: number) => string); + toolbarColorScheme: PhotoToolbarColorScheme; } /** Props for presenting {@link PhotoEditorPage} via Ionic Modal `componentProps`. */ export interface PhotoEditorProps { requireSquare?: boolean; value: string; - labels?: Partial; - headerButtonColorScheme: PhotoEditorHeaderButtonColorScheme; + labels?: Partial; + toolbarColorScheme: PhotoToolbarColorScheme; +} + +/** Per-request options for {@link PhotoFileService.loadPhoto}. */ +export interface PhotoLoadOptions { + limit?: number; + maxSize?: number; + labels?: Partial; +} + +/** Error codes produced by expected photo selection failures. */ +export type PhotoLoadErrorCode = 'cancelled' | 'invalid-type' | 'unavailable'; + +/** Typed error produced by expected photo selection failures. */ +export class PhotoLoadError extends Error { + constructor( + readonly code: PhotoLoadErrorCode, + options?: ErrorOptions, + ) { + super(code, options); + this.name = 'PhotoLoadError'; + } } diff --git a/projects/photo-editor/src/public-api.ts b/projects/photo-editor/src/public-api.ts index 396f675..c73e60e 100644 --- a/projects/photo-editor/src/public-api.ts +++ b/projects/photo-editor/src/public-api.ts @@ -2,7 +2,5 @@ * Public API Surface of photo-editor */ -export * from './lib/pages/photo-editor/photo-editor.page'; -export * from './lib/pages/photo-viewer/photo-viewer.page'; -export * from './lib/services/photo-file.service'; +export * from './lib/photo-editor.config'; export * from './lib/types'; diff --git a/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.spec.ts b/projects/photo-editor/src/tests/photo-editor.page.spec.ts similarity index 51% rename from projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.spec.ts rename to projects/photo-editor/src/tests/photo-editor.page.spec.ts index bc69360..d81d27d 100644 --- a/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.spec.ts +++ b/projects/photo-editor/src/tests/photo-editor.page.spec.ts @@ -1,9 +1,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ComponentRef } from '@angular/core'; +import { ComponentRef, WritableSignal } from '@angular/core'; +import { ModalController } from '@ionic/angular'; import { vi } from 'vitest'; -import { PhotoEditorPage } from './photo-editor.page'; -import { testConfig } from '../../../../../util/test.config'; -import { IFilter } from '../../types'; +import { PhotoFilter, PhotoSize, providePhotoEditor } from '@rdlabo/ionic-angular-photo-editor'; +import { testConfig } from '../../../util/test.config'; +import { PhotoEditorPage } from '../../editor/src/lib/photo-editor.page'; interface EditorMock { applyFilter: ReturnType; @@ -11,6 +12,7 @@ interface EditorMock { destroy: ReturnType; getCropzoneRect: ReturnType; hasFilter: ReturnType; + loadImageFromFile: ReturnType; removeFilter: ReturnType; rotate: ReturnType; setCropzoneRect: ReturnType; @@ -25,6 +27,7 @@ const createEditorMock = (): EditorMock => ({ destroy: vi.fn(), getCropzoneRect: vi.fn().mockReturnValue({ left: 1, top: 2, width: 30, height: 40 }), hasFilter: vi.fn().mockReturnValue(false), + loadImageFromFile: vi.fn().mockResolvedValue({ newWidth: 400, newHeight: 200 }), removeFilter: vi.fn().mockResolvedValue(undefined), rotate: vi.fn().mockResolvedValue(undefined), setCropzoneRect: vi.fn(), @@ -38,21 +41,36 @@ describe('PhotoEditorPage', () => { let fixture: ComponentFixture; let componentRef: ComponentRef; let editor: EditorMock; + let createImageEditor: ReturnType; + let modalCtrl: ModalController; + let state: { + currentCrop: WritableSignal<'cover' | '16/9' | '1' | 'auto'>; + currentRotate: WritableSignal; + footerMenu: WritableSignal<'filter' | 'menu' | 'crop' | 'brightness'>; + isCropped: WritableSignal; + photoCrop: WritableSignal; + editorRef: () => { nativeElement: HTMLElement }; + }; beforeEach(() => { + editor = createEditorMock(); + createImageEditor = vi.fn().mockResolvedValue(editor); TestBed.configureTestingModule({ - providers: testConfig.providers, + providers: [testConfig.providers, providePhotoEditor({ createImageEditor: createImageEditor as never })], }); fixture = TestBed.createComponent(PhotoEditorPage); component = fixture.componentInstance; componentRef = fixture.componentRef; componentRef.setInput('value', 'data:image/png;base64,'); - componentRef.setInput('headerButtonColorScheme', 'dark'); + componentRef.setInput('toolbarColorScheme', 'dark'); fixture.detectChanges(); - editor = createEditorMock(); + modalCtrl = TestBed.inject(ModalController); + state = component as unknown as typeof state; Reflect.set(component, 'editorInstance', editor); }); + afterEach(() => vi.restoreAllMocks()); + it('should create', () => { expect(component).toBeTruthy(); }); @@ -70,7 +88,7 @@ describe('PhotoEditorPage', () => { }); it('applies the configured light color scheme to regular Ionic buttons', () => { - componentRef.setInput('headerButtonColorScheme', 'light'); + componentRef.setInput('toolbarColorScheme', 'light'); fixture.detectChanges(); const header = fixture.nativeElement.querySelector('ion-header'); const closeButton = header.querySelector('ion-button'); @@ -84,12 +102,13 @@ describe('PhotoEditorPage', () => { }); it('merges custom labels without replacing the remaining dictionary', async () => { - componentRef.setInput('labels', { save: 'Upload' }); + componentRef.setInput('labels', { save: 'Upload', close: 'Close editor' }); fixture.detectChanges(); await fixture.whenStable(); expect(fixture.nativeElement.textContent).toContain('Upload'); expect(fixture.nativeElement.textContent).toContain('切り抜き・回転'); + expect(fixture.nativeElement.querySelector('ion-button').getAttribute('aria-label')).toBe('Close editor'); }); it.each([ @@ -98,16 +117,16 @@ describe('PhotoEditorPage', () => { ['1', 1], ['auto', undefined], ] as const)('sets the %s crop ratio', (crop, expectedRatio) => { - component.photoCrop.set({ width: 400, height: 200 }); + state.photoCrop.set({ width: 400, height: 200 }); component.changeCrop(crop); expect(editor.setCropzoneRect).toHaveBeenCalledWith(expectedRatio); - expect(component.currentCrop()).toBe(crop); + expect(state.currentCrop()).toBe(crop); }); it('applies the crop and resets crop state when closing the crop menu', async () => { - component.footerMenu.set('crop'); + state.footerMenu.set('crop'); await fixture.whenStable(); vi.clearAllMocks(); @@ -115,23 +134,23 @@ describe('PhotoEditorPage', () => { expect(editor.crop).toHaveBeenCalledWith({ left: 1, top: 2, width: 30, height: 40 }); expect(editor.stopDrawingMode).toHaveBeenCalledOnce(); - expect(component.isCropped()).toBe(true); - expect(component.currentCrop()).toBe('cover'); - expect(component.currentRotate()).toBe(0); - expect(component.footerMenu()).toBe('menu'); + expect(state.isCropped()).toBe(true); + expect(state.currentCrop()).toBe('cover'); + expect(state.currentRotate()).toBe(0); + expect(state.footerMenu()).toBe('menu'); }); it('replaces an adopted filter and clears it when Default is selected', async () => { - const sepia: IFilter = { name: 'Sepia', type: 'Sepia', option: null, data: '', width: 1, height: 1 }; - const grayscale: IFilter = { name: 'Gray', type: 'Grayscale', option: null, data: '', width: 1, height: 1 }; - const original: IFilter = { name: 'Original', type: 'Default', option: null, data: '', width: 1, height: 1 }; + const sepia: PhotoFilter = { name: 'Sepia', type: 'Sepia', option: null, data: '', width: 1, height: 1 }; + const grayscale: PhotoFilter = { name: 'Gray', type: 'Grayscale', option: null, data: '', width: 1, height: 1 }; + const original: PhotoFilter = { name: 'Original', type: 'Default', option: null, data: '', width: 1, height: 1 }; await component.filterImage(sepia); await component.filterImage(grayscale); await component.filterImage(original); - expect(editor.applyFilter).toHaveBeenNthCalledWith(1, 'Sepia', null); - expect(editor.applyFilter).toHaveBeenNthCalledWith(2, 'Grayscale', null); + expect(editor.applyFilter).toHaveBeenNthCalledWith(1, 'Sepia', undefined); + expect(editor.applyFilter).toHaveBeenNthCalledWith(2, 'Grayscale', undefined); expect(editor.removeFilter).toHaveBeenNthCalledWith(1, 'Sepia'); expect(editor.removeFilter).toHaveBeenNthCalledWith(2, 'Grayscale'); }); @@ -146,12 +165,71 @@ describe('PhotoEditorPage', () => { }); it('dismisses with the current editor data URL when saving', () => { - const dismiss = vi.spyOn(component.modalCtrl, 'dismiss').mockResolvedValue(true); + const dismiss = vi.spyOn(modalCtrl, 'dismiss').mockResolvedValue(true); component.imageSave(); expect(editor.toDataURL).toHaveBeenCalledOnce(); - expect(dismiss).toHaveBeenCalledWith({ value: 'data:image/png;base64,saved' }); + expect(dismiss).toHaveBeenCalledWith({ action: 'save', value: 'data:image/png;base64,saved' }); + }); + + it('creates and loads the editor through the configured adapter on entry', async () => { + const content = fixture.nativeElement.querySelector('ion-content'); + Object.defineProperties(content, { + clientWidth: { configurable: true, value: 432 }, + clientHeight: { configurable: true, value: 632 }, + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + blob: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/png' })), + } as never); + const bounds = document.createElement('div'); + bounds.style.maxWidth = '400px'; + bounds.style.maxHeight = '600px'; + const canvasContainer = document.createElement('div'); + canvasContainer.className = 'tui-image-editor-canvas-container'; + const canvas = document.createElement('canvas'); + canvas.width = 400; + canvas.height = 200; + canvasContainer.append(canvas); + bounds.append(canvasContainer); + state.editorRef().nativeElement.append(bounds); + + await component.ionViewDidEnter(); + + expect(createImageEditor).toHaveBeenCalledWith(state.editorRef().nativeElement, { + cssMaxWidth: 400, + cssMaxHeight: 600, + }); + expect(editor.loadImageFromFile).toHaveBeenCalledWith(expect.objectContaining({ name: 'data.png', type: 'image/png' })); + expect(state.photoCrop()).toEqual({ width: 400, height: 200 }); + expect(state.editorRef().nativeElement.style.minWidth).toBe('400px'); + expect(state.editorRef().nativeElement.style.minHeight).toBe('600px'); + }); + + it('destroys an adapter that resolves after the modal has left', async () => { + Reflect.set(component, 'editorInstance', undefined); + const delayedEditor = createEditorMock(); + let resolveEditor!: (value: EditorMock) => void; + createImageEditor.mockReturnValueOnce(new Promise((resolve) => (resolveEditor = resolve))); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const entering = component.ionViewDidEnter(); + component.ionViewDidLeave(); + resolveEditor(delayedEditor); + await entering; + + expect(delayedEditor.destroy).toHaveBeenCalledOnce(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('destroys an initialized adapter when loading the source fails', async () => { + Reflect.set(component, 'editorInstance', undefined); + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network failure')); + + await expect(component.ionViewDidEnter()).rejects.toThrow('network failure'); + + expect(editor.destroy).toHaveBeenCalledOnce(); }); it('destroys the editor when the view leaves', () => { diff --git a/projects/photo-editor/src/tests/photo-file.service.spec.ts b/projects/photo-editor/src/tests/photo-file.service.spec.ts new file mode 100644 index 0000000..4c72d4e --- /dev/null +++ b/projects/photo-editor/src/tests/photo-file.service.spec.ts @@ -0,0 +1,264 @@ +import { TestBed } from '@angular/core/testing'; +import { ActionSheetController, Platform } from '@ionic/angular'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; +import { vi } from 'vitest'; + +import { providePhotoEditor } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoFileService } from '../../file/src/lib/photo-file.service'; +import { loadCapacitorPhotoCamera } from '../../file/capacitor/src/public-api'; + +vi.mock('@capacitor/camera', () => ({ + Camera: { + getPhoto: vi.fn(), + pickImages: vi.fn(), + }, + CameraResultType: { DataUrl: 'dataUrl' }, + CameraSource: { Camera: 'CAMERA' }, +})); + +interface EditorAdapter { + destroy: ReturnType; + loadImageFromFile: ReturnType; + toDataURL: ReturnType; +} + +const createAdapter = (value = 'data:image/png;base64,resized'): EditorAdapter => ({ + destroy: vi.fn(), + loadImageFromFile: vi.fn().mockResolvedValue({ newWidth: 2400, newHeight: 1200 }), + toDataURL: vi.fn().mockReturnValue(value), +}); + +describe('PhotoFileService', () => { + let service: PhotoFileService; + let isPlatform: ReturnType; + let createActionSheet: ReturnType; + let createImageEditor: ReturnType; + let getPhoto: ReturnType; + let pickImages: ReturnType; + let adapter: EditorAdapter; + let dismissData: 'camera' | 'album' | undefined; + let actionSheetOptions: { buttons: { text: string; role?: string; handler?: () => void }[] } | undefined; + + beforeEach(() => { + dismissData = undefined; + actionSheetOptions = undefined; + isPlatform = vi.fn().mockReturnValue(true); + adapter = createAdapter(); + createImageEditor = vi.fn().mockResolvedValue(adapter); + getPhoto = vi.mocked(Camera.getPhoto); + pickImages = vi.mocked(Camera.pickImages); + getPhoto.mockReset(); + pickImages.mockReset(); + createActionSheet = vi.fn().mockImplementation(async (options) => { + actionSheetOptions = options; + return { + dismiss: vi.fn().mockResolvedValue(true), + present: vi.fn().mockResolvedValue(undefined), + onDidDismiss: vi.fn().mockImplementation(async () => ({ data: dismissData })), + }; + }); + + TestBed.configureTestingModule({ + providers: [ + PhotoFileService, + { provide: Platform, useValue: { is: isPlatform } }, + { provide: ActionSheetController, useValue: { create: createActionSheet } }, + providePhotoEditor({ + maxPhotoSize: 1200, + fileLabels: { camera: 'Take photo', album: 'Choose photo', cancel: 'Close' }, + createImageEditor: createImageEditor as never, + loadCamera: loadCapacitorPhotoCamera, + }), + ], + }); + service = TestBed.inject(PhotoFileService); + }); + + afterEach(() => { + document.querySelectorAll('input[type="file"]').forEach((input) => input.remove()); + vi.restoreAllMocks(); + }); + + const pendingBrowserSelection = (options?: Parameters[0]) => { + isPlatform.mockReturnValue(false); + vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => undefined); + const result = service.loadPhoto(options); + const input = document.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + return { input: input!, result }; + }; + + const setFiles = (input: HTMLInputElement, files: File[]) => { + Object.defineProperty(input, 'files', { configurable: true, value: files }); + }; + + it('creates a hidden browser input dynamically and removes it after cancellation', async () => { + const { input, result } = pendingBrowserSelection({ limit: 2 }); + + expect(input.accept).toBe('image/*'); + expect(input.multiple).toBe(true); + expect(input.getAttribute('aria-hidden')).toBe('true'); + expect(input.click).toHaveBeenCalledOnce(); + + input.dispatchEvent(new Event('cancel')); + + await expect(result).rejects.toMatchObject({ code: 'cancelled' }); + expect(input.isConnected).toBe(false); + }); + + it('cleans up and reports unavailable when the browser picker cannot open', async () => { + isPlatform.mockReturnValue(false); + vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => { + throw new Error('picker unavailable'); + }); + + await expect(service.loadPhoto()).rejects.toMatchObject({ code: 'unavailable' }); + + expect(document.querySelector('input[type="file"]')).toBeNull(); + }); + + it('rejects an empty or invalid browser selection and always cleans up the input', async () => { + const empty = pendingBrowserSelection(); + setFiles(empty.input, []); + empty.input.dispatchEvent(new Event('change')); + await expect(empty.result).rejects.toMatchObject({ code: 'cancelled' }); + expect(empty.input.isConnected).toBe(false); + + const invalid = pendingBrowserSelection(); + setFiles(invalid.input, [new File(['text'], 'notes.txt', { type: 'text/plain' })]); + invalid.input.dispatchEvent(new Event('change')); + await expect(invalid.result).rejects.toMatchObject({ code: 'invalid-type' }); + expect(invalid.input.isConnected).toBe(false); + expect(createImageEditor).not.toHaveBeenCalled(); + }); + + it('limits browser files and resizes them through the configured adapter', async () => { + const adapters = [createAdapter('resized:first'), createAdapter('resized:second')]; + createImageEditor.mockImplementation(async () => adapters.shift()!); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + blob: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/png' })), + } as never); + const { input, result } = pendingBrowserSelection({ limit: 2, maxSize: 600 }); + setFiles(input, [ + new File(['first'], 'first.png', { type: 'image/png' }), + new File(['second'], 'second.png', { type: 'image/png' }), + new File(['third'], 'third.png', { type: 'image/png' }), + ]); + + input.dispatchEvent(new Event('change')); + + await expect(result).resolves.toEqual(['resized:first', 'resized:second']); + expect(createImageEditor).toHaveBeenCalledTimes(2); + expect(createImageEditor).toHaveBeenCalledWith(expect.any(HTMLElement), { cssMaxWidth: 600, cssMaxHeight: 600 }); + expect(input.isConnected).toBe(false); + }); + + it('uses configured labels and reports a dismissed native source sheet as cancelled', async () => { + await expect(service.loadPhoto()).rejects.toMatchObject({ code: 'cancelled' }); + + expect(actionSheetOptions?.buttons.map(({ text, role }) => ({ text, role }))).toEqual([ + { text: 'Take photo', role: undefined }, + { text: 'Choose photo', role: undefined }, + { text: 'Close', role: 'cancel' }, + ]); + }); + + it('merges request labels over configured defaults', async () => { + await expect(service.loadPhoto({ labels: { camera: 'Camera override' } })).rejects.toMatchObject({ + code: 'cancelled', + }); + + expect(actionSheetOptions?.buttons.map(({ text }) => text)).toEqual(['Camera override', 'Choose photo', 'Close']); + }); + + it('returns a camera data URL and applies normalized request options', async () => { + dismissData = 'camera'; + getPhoto.mockResolvedValue({ + dataUrl: 'data:image/jpeg;base64,camera', + format: 'jpeg', + saved: false, + }); + + await expect(service.loadPhoto({ limit: 0, maxSize: 2048.9 })).resolves.toEqual(['data:image/jpeg;base64,camera']); + expect(getPhoto).toHaveBeenCalledWith({ + quality: 100, + width: 2048, + allowEditing: false, + resultType: CameraResultType.DataUrl, + source: CameraSource.Camera, + presentationStyle: 'popover', + }); + }); + + it('reports unavailable camera capture as cancelled', async () => { + dismissData = 'camera'; + getPhoto.mockRejectedValue(new Error('camera unavailable')); + + await expect(service.loadPhoto()).rejects.toMatchObject({ code: 'cancelled' }); + }); + + it('loads album paths in order with the configured maximum size', async () => { + dismissData = 'album'; + pickImages.mockResolvedValue({ + photos: [ + { webPath: 'file://first', format: 'jpeg' }, + { webPath: 'file://second', format: 'png' }, + ], + }); + const adapters = [createAdapter('resized:first'), createAdapter('resized:second')]; + createImageEditor.mockImplementation(async () => adapters.shift()!); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + blob: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' })), + } as never); + + await expect(service.loadPhoto({ limit: 2 })).resolves.toEqual(['resized:first', 'resized:second']); + expect(pickImages).toHaveBeenCalledWith({ + quality: 100, + width: 1200, + limit: 2, + presentationStyle: 'popover', + }); + expect(globalThis.fetch).toHaveBeenNthCalledWith(1, 'file://first'); + expect(globalThis.fetch).toHaveBeenNthCalledWith(2, 'file://second'); + }); + + it('scales the longest edge and destroys the configured adapter', async () => { + dismissData = 'album'; + pickImages.mockResolvedValue({ photos: [{ webPath: 'file://large', format: 'jpeg' }] }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + blob: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' })), + } as never); + + await expect(service.loadPhoto({ maxSize: 600 })).resolves.toEqual(['data:image/png;base64,resized']); + + expect(createImageEditor).toHaveBeenCalledWith(expect.any(HTMLElement), { cssMaxWidth: 600, cssMaxHeight: 600 }); + expect(adapter.toDataURL).toHaveBeenCalledWith({ multiplier: 0.25 }); + expect(adapter.destroy).toHaveBeenCalledOnce(); + }); + + it('destroys the adapter when loading the source fails', async () => { + dismissData = 'album'; + pickImages.mockResolvedValue({ photos: [{ webPath: 'file://missing', format: 'jpeg' }] }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, status: 404 } as never); + + await expect(service.loadPhoto()).rejects.toMatchObject({ code: 'unavailable' }); + expect(adapter.destroy).toHaveBeenCalledOnce(); + }); + + it('does not upscale an image that is already below the maximum size', async () => { + dismissData = 'album'; + pickImages.mockResolvedValue({ photos: [{ webPath: 'file://small', format: 'jpeg' }] }); + adapter.loadImageFromFile.mockResolvedValue({ newWidth: 400, newHeight: 200 }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + blob: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' })), + } as never); + + await service.loadPhoto({ maxSize: 600 }); + + expect(adapter.toDataURL).toHaveBeenCalledWith({ multiplier: 1 }); + }); +}); diff --git a/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.spec.ts b/projects/photo-editor/src/tests/photo-viewer.page.spec.ts similarity index 59% rename from projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.spec.ts rename to projects/photo-editor/src/tests/photo-viewer.page.spec.ts index 8513728..2744ad9 100644 --- a/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.spec.ts +++ b/projects/photo-editor/src/tests/photo-viewer.page.spec.ts @@ -1,13 +1,35 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentRef } from '@angular/core'; +import { ModalController } from '@ionic/angular'; import { vi } from 'vitest'; -import { PhotoViewerPage } from './photo-viewer.page'; -import { testConfig } from '../../../../../util/test.config'; +import { testConfig } from '../../../util/test.config'; +import { PhotoViewerPage } from '../../viewer/src/lib/photo-viewer.page'; + +class TestSwiperContainer extends HTMLElement { + readonly initialize = vi.fn(); + readonly swiper = { + activeIndex: 0, + update: vi.fn(), + zoom: { enable: vi.fn() }, + }; +} + +if (!customElements.get('swiper-container')) { + customElements.define('swiper-container', TestSwiperContainer); +} +if (!customElements.get('swiper-slide')) { + customElements.define('swiper-slide', class extends HTMLElement {}); +} describe('PhotoViewerPage', () => { let component: PhotoViewerPage; let fixture: ComponentFixture; let componentRef: ComponentRef; + let modalCtrl: ModalController; + let internals: { + swiper: () => { nativeElement: { swiper: unknown } } | undefined; + watchSwipe$: { unsubscribe: () => void }; + }; beforeEach(() => { TestBed.configureTestingModule({ @@ -17,8 +39,10 @@ describe('PhotoViewerPage', () => { component = fixture.componentInstance; componentRef = fixture.componentRef; componentRef.setInput('imageUrls', []); - componentRef.setInput('headerButtonColorScheme', 'dark'); + componentRef.setInput('toolbarColorScheme', 'dark'); fixture.detectChanges(); + modalCtrl = TestBed.inject(ModalController); + internals = component as unknown as typeof internals; }); it('should create', () => { @@ -38,7 +62,7 @@ describe('PhotoViewerPage', () => { }); it('applies the configured light color scheme to regular Ionic buttons', () => { - componentRef.setInput('headerButtonColorScheme', 'light'); + componentRef.setInput('toolbarColorScheme', 'light'); fixture.detectChanges(); const header = fixture.nativeElement.querySelector('ion-header'); const closeButton = header.querySelector('ion-button'); @@ -52,6 +76,7 @@ describe('PhotoViewerPage', () => { }); it('coerces modal primitive inputs and renders the configured delete label', async () => { + componentRef.setInput('imageUrls', ['first.jpg', 'second.jpg', 'third.jpg']); componentRef.setInput('index', '2'); componentRef.setInput('isCircle', 'true'); componentRef.setInput('enableDelete', 'true'); @@ -67,24 +92,48 @@ describe('PhotoViewerPage', () => { expect(fixture.nativeElement.textContent).toContain('Remove photo'); }); + it('applies per-image alt text and the localized close-button label', async () => { + componentRef.setInput('imageUrls', ['same.jpg', 'same.jpg']); + componentRef.setInput('imageAlt', (_url: string, index: number) => `Uploaded photo ${index + 1}`); + componentRef.setInput('labels', { close: 'Close viewer' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(Array.from(fixture.nativeElement.querySelectorAll('img')).map(({ alt }) => alt)).toEqual([ + 'Uploaded photo 1', + 'Uploaded photo 2', + ]); + expect(fixture.nativeElement.querySelector('ion-button').getAttribute('aria-label')).toBe('Close viewer'); + expect(fixture.nativeElement.querySelectorAll('swiper-slide')).toHaveLength(2); + }); + it('dismisses with the active image index and value when removing', () => { componentRef.setInput('imageUrls', ['first.jpg', 'second.jpg']); fixture.detectChanges(); - component.swiper().nativeElement.swiper = { activeIndex: 1 } as never; - const dismiss = vi.spyOn(component.modalCtrl, 'dismiss').mockResolvedValue(true); + internals.swiper()!.nativeElement.swiper = { activeIndex: 1 } as never; + const dismiss = vi.spyOn(modalCtrl, 'dismiss').mockResolvedValue(true); component.remove(); - expect(dismiss).toHaveBeenCalledWith({ - delete: { - index: 1, - value: 'second.jpg', - }, - }); + expect(dismiss).toHaveBeenCalledWith({ action: 'delete', index: 1, value: 'second.jpg' }); + }); + + it('does not delete an empty list and clamps an out-of-range active index', () => { + const dismiss = vi.spyOn(modalCtrl, 'dismiss').mockResolvedValue(true); + + component.remove(); + expect(dismiss).not.toHaveBeenCalled(); + + componentRef.setInput('imageUrls', ['first.jpg', 'second.jpg']); + fixture.detectChanges(); + internals.swiper()!.nativeElement.swiper = { activeIndex: 99 } as never; + component.remove(); + + expect(dismiss).toHaveBeenCalledWith({ action: 'delete', index: 1, value: 'second.jpg' }); }); it('dismisses on a downward swipe when the active slide is not zoomed', () => { - const dismiss = vi.spyOn(component.modalCtrl, 'dismiss').mockResolvedValue(true); + const dismiss = vi.spyOn(modalCtrl, 'dismiss').mockResolvedValue(true); const host = fixture.nativeElement; host.dispatchEvent(new CustomEvent('touchstart', { detail: [undefined, { clientX: 0, clientY: 0 }] })); @@ -99,7 +148,7 @@ describe('PhotoViewerPage', () => { fixture.detectChanges(); const activeSlide = fixture.nativeElement.querySelector('swiper-slide'); activeSlide.classList.add('swiper-slide-active', 'swiper-slide-zoomed'); - const dismiss = vi.spyOn(component.modalCtrl, 'dismiss').mockResolvedValue(true); + const dismiss = vi.spyOn(modalCtrl, 'dismiss').mockResolvedValue(true); const host = fixture.nativeElement; host.dispatchEvent(new CustomEvent('touchstart', { detail: [undefined, { clientX: 0, clientY: 0 }] })); @@ -110,7 +159,7 @@ describe('PhotoViewerPage', () => { }); it('unsubscribes swipe handling on destroy', () => { - const unsubscribe = vi.spyOn(component.watchSwipe$, 'unsubscribe'); + const unsubscribe = vi.spyOn(internals.watchSwipe$, 'unsubscribe'); component.ngOnDestroy(); diff --git a/projects/photo-editor/viewer/ng-package.json b/projects/photo-editor/viewer/ng-package.json new file mode 100644 index 0000000..d0a2dcd --- /dev/null +++ b/projects/photo-editor/viewer/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/photo-editor/viewer/src/lib/internals.ts b/projects/photo-editor/viewer/src/lib/internals.ts new file mode 100644 index 0000000..419e9d1 --- /dev/null +++ b/projects/photo-editor/viewer/src/lib/internals.ts @@ -0,0 +1,15 @@ +import { PhotoViewerLabels } from '@rdlabo/ionic-angular-photo-editor'; +import { IonButton, IonButtons, IonContent, IonFooter, IonHeader, IonIcon, IonToolbar } from '@ionic/angular'; +import { addIcons } from 'ionicons'; +import { closeOutline, removeOutline } from 'ionicons/icons'; + +export const ionComponents = [IonHeader, IonToolbar, IonButtons, IonButton, IonIcon, IonContent, IonFooter]; + +export const dictionaryForViewer = (): PhotoViewerLabels => ({ + close: '閉じる', + delete: '削除', +}); + +export const initializeViewerIcons = (): void => { + addIcons({ closeOutline, removeOutline }); +}; diff --git a/projects/photo-editor/viewer/src/lib/photo-viewer.page.html b/projects/photo-editor/viewer/src/lib/photo-viewer.page.html new file mode 100644 index 0000000..57d4fa3 --- /dev/null +++ b/projects/photo-editor/viewer/src/lib/photo-viewer.page.html @@ -0,0 +1,36 @@ + + + + + + + + @if (enableDelete() && imageUrls().length > 0) { + + {{ dictionary().delete }} + + } + + + + + + @for (url of imageUrls(); track $index; let index = $index) { + +
+
+ } +
+
+ +@if (enableFooterSafeArea()) { + + + + +} diff --git a/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.scss b/projects/photo-editor/viewer/src/lib/photo-viewer.page.scss similarity index 100% rename from projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.scss rename to projects/photo-editor/viewer/src/lib/photo-viewer.page.scss diff --git a/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.ts b/projects/photo-editor/viewer/src/lib/photo-viewer.page.ts similarity index 58% rename from projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.ts rename to projects/photo-editor/viewer/src/lib/photo-viewer.page.ts index 9a176b6..5e876f6 100644 --- a/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.ts +++ b/projects/photo-editor/viewer/src/lib/photo-viewer.page.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, + computed, CUSTOM_ELEMENTS_SCHEMA, effect, ElementRef, @@ -8,28 +9,26 @@ import { input, OnDestroy, OnInit, - signal, viewChild, } from '@angular/core'; import { IonicSlides, ModalController } from '@ionic/angular'; import { Navigation, Zoom } from 'swiper/modules'; import { fromEvent, Subscription, throttleTime, withLatestFrom, zipWith } from 'rxjs'; import { SwiperContainer } from 'swiper/element'; -import { ionComponents } from '../../ion-components'; -import { IDictionaryForViewer, IPhotoViewerDismiss, PhotoEditorHeaderButtonColorScheme } from '../../types'; +import { PhotoToolbarColorScheme, PhotoViewerLabels, PhotoViewerResult } from '@rdlabo/ionic-angular-photo-editor'; import { register } from 'swiper/element/bundle'; -import { dictionaryForViewer } from '../../dictionaries'; import { BooleanInput, coerceBooleanProperty, coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; -import { initializeViewerIcons, waitToFindDom } from '../util'; +import { dictionaryForViewer, initializeViewerIcons, ionComponents } from './internals'; @Component({ selector: 'rdlabo-photo-viewer', templateUrl: './photo-viewer.page.html', - styleUrls: ['../core.scss', './photo-viewer.page.scss'], + styleUrls: ['../../../src/lib/pages/core.scss', './photo-viewer.page.scss'], imports: [...ionComponents], schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, }) +/** Ionic modal page for zooming, browsing, and optionally deleting photos. */ export class PhotoViewerPage implements OnInit, OnDestroy { readonly imageUrls = input.required(); readonly index = input(0, { @@ -44,47 +43,47 @@ export class PhotoViewerPage implements OnInit, OnDestroy { readonly enableFooterSafeArea = input(false, { transform: coerceBooleanProperty, }); - readonly labels = input>(); - readonly headerButtonColorScheme = input.required(); - readonly setLabels = effect(() => { - if (this.labels()) { - this.dictionary.update((value) => ({ ...value, ...this.labels() })); - } - }); + readonly labels = input>(); + readonly imageAlt = input string)>(''); + readonly toolbarColorScheme = input.required(); - readonly swiper = viewChild.required>('swiper'); - protected readonly dictionary = signal(dictionaryForViewer()); + private readonly swiper = viewChild>('swiper'); + protected readonly dictionary = computed(() => ({ ...dictionaryForViewer(), ...this.labels() })); - readonly watchSwipe$ = new Subscription(); - readonly modalCtrl = inject(ModalController); + private readonly watchSwipe$ = new Subscription(); + protected readonly modalCtrl = inject(ModalController); private readonly el = inject(ElementRef); + private swiperInitialized = false; + + private readonly initializeSwiper = effect(() => { + const swiper = this.swiper()?.nativeElement; + if (!swiper || this.swiperInitialized) { + return; + } + this.swiperInitialized = true; + const index = this.index(); + Object.assign(swiper, { + modules: [Navigation, Zoom, IonicSlides], + initialSlide: index, + slidesPerView: 1, + pagination: { + enabled: true, + clickable: true, + }, + zoom: true, + }); + swiper.initialize(); + swiper.swiper.zoom.enable(); + swiper.swiper.activeIndex = index; + swiper.swiper.update(); + }); constructor() { register(); initializeViewerIcons(); } - async ngOnInit() { - waitToFindDom(this.el.nativeElement, 'swiper-container').then(() => { - const index = this.index(); - const swiper = this.swiper(); - Object.assign(swiper.nativeElement, { - modules: [Navigation, Zoom, IonicSlides], - initialSlide: index, - slidesPerView: 1, - pagination: { - enabled: true, - clickable: true, - }, - zoom: true, - }); - swiper.nativeElement.initialize(); - swiper.nativeElement.swiper.zoom.enable(); - - swiper.nativeElement.swiper.activeIndex = index; - swiper.nativeElement.swiper.update(); - }); - + ngOnInit() { this.watchSwipe$.add( fromEvent(this.el.nativeElement, 'touchstart') .pipe( @@ -105,7 +104,7 @@ export class PhotoViewerPage implements OnInit, OnDestroy { const xDiff = touchstartClientX - touchmoveClientX; const yDiff = touchstartClientY - touchmoveClientY; - const slides = (this.swiper() as any).nativeElement.querySelectorAll('swiper-slide') as HTMLElement[]; + const slides = this.swiper()?.nativeElement.querySelectorAll('swiper-slide') ?? []; const isZoomed = Array.from(slides).find((slide: HTMLElement) => { return ['swiper-slide-zoomed', 'swiper-slide-active'].every((c) => slide.classList.contains(c)); }); @@ -125,11 +124,21 @@ export class PhotoViewerPage implements OnInit, OnDestroy { } remove() { - this.modalCtrl.dismiss({ - delete: { - index: this.swiper().nativeElement.swiper.activeIndex, - value: this.imageUrls()[this.swiper().nativeElement.swiper.activeIndex], - }, - } as IPhotoViewerDismiss); + const imageUrls = this.imageUrls(); + if (imageUrls.length === 0) { + return; + } + const activeIndex = this.swiper()?.nativeElement.swiper.activeIndex ?? this.index(); + const index = Math.min(imageUrls.length - 1, Math.max(0, Math.trunc(activeIndex))); + void this.modalCtrl.dismiss({ + action: 'delete', + index, + value: imageUrls[index], + } satisfies PhotoViewerResult); + } + + protected resolveImageAlt(url: string, index: number): string { + const alt = this.imageAlt(); + return typeof alt === 'function' ? alt(url, index) : alt; } } diff --git a/projects/photo-editor/viewer/src/public-api.ts b/projects/photo-editor/viewer/src/public-api.ts new file mode 100644 index 0000000..8e38f99 --- /dev/null +++ b/projects/photo-editor/viewer/src/public-api.ts @@ -0,0 +1,2 @@ +/** Photo viewing modal entry point. */ +export * from './lib/photo-viewer.page'; diff --git a/projects/scroll-header/src/lib/directives/scroll-header.directive.ts b/projects/scroll-header/src/lib/directives/scroll-header.directive.ts index bb1c54e..c8552b4 100644 --- a/projects/scroll-header/src/lib/directives/scroll-header.directive.ts +++ b/projects/scroll-header/src/lib/directives/scroll-header.directive.ts @@ -1,11 +1,10 @@ -import { contentChild, Directive, ElementRef, HostListener, inject, OnInit, signal } from '@angular/core'; +import { contentChild, Directive, effect, ElementRef, HostListener, inject, signal } from '@angular/core'; import { IonContent, IonHeader, ScrollDetail } from '@ionic/angular'; -import { waitFindDom } from '../util'; @Directive({ selector: 'ion-content[rdlaboScrollHeader]', }) -export class ScrollHeaderDirective implements OnInit { +export class ScrollHeaderDirective { readonly #elementRef = inject(ElementRef); readonly scrollHeader = contentChild(IonHeader, { read: ElementRef }); @@ -17,15 +16,19 @@ export class ScrollHeaderDirective implements OnInit { readonly #scrollThrottle = 16; // 60fps #lastScrollTime = 0; - async ngOnInit() { - await waitFindDom(this.#elementRef.nativeElement, 'ion-header'); - this.#elementRef.nativeElement.scrollEvents = true; - if ( - this.#elementRef.nativeElement.previousElementSibling && - this.#elementRef.nativeElement.previousElementSibling.classList.contains('native-header') - ) { - this.#nativeHeader.set(this.#elementRef.nativeElement.previousElementSibling); - } + constructor() { + effect(() => { + if (!this.scrollHeader()) { + return; + } + this.#elementRef.nativeElement.scrollEvents = true; + if ( + this.#elementRef.nativeElement.previousElementSibling && + this.#elementRef.nativeElement.previousElementSibling.classList.contains('native-header') + ) { + this.#nativeHeader.set(this.#elementRef.nativeElement.previousElementSibling); + } + }); } @HostListener('ionScroll', ['$event']) diff --git a/projects/scroll-header/src/lib/directives/virtual-scroll-header.directive.ts b/projects/scroll-header/src/lib/directives/virtual-scroll-header.directive.ts index e832af7..28bad77 100644 --- a/projects/scroll-header/src/lib/directives/virtual-scroll-header.directive.ts +++ b/projects/scroll-header/src/lib/directives/virtual-scroll-header.directive.ts @@ -1,13 +1,12 @@ -import { contentChild, Directive, ElementRef, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { contentChild, Directive, effect, ElementRef, inject, OnDestroy, signal } from '@angular/core'; import { IonContent, IonHeader } from '@ionic/angular'; import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { fromEvent, Subscription } from 'rxjs'; -import { waitFindDom } from '../util'; @Directive({ selector: 'ion-content[rdlaboVirtualScrollHeader]', }) -export class VirtualScrollHeaderDirective implements OnInit, OnDestroy { +export class VirtualScrollHeaderDirective implements OnDestroy { readonly #elementRef = inject(ElementRef); readonly virtualScroll = contentChild(CdkVirtualScrollViewport); @@ -21,27 +20,32 @@ export class VirtualScrollHeaderDirective implements OnInit, OnDestroy { readonly #scrollHeaderSize = signal(0); readonly #beforeScrollTop = signal(0); readonly #scrollSubscription = new Subscription(); + #initialized = false; + + constructor() { + effect(() => { + const header = this.scrollHeader(); + const viewport = this.virtualScroll(); + if (!header || !viewport || this.#initialized) { + return; + } + this.#initialized = true; - async ngOnInit() { - await Promise.all([ - waitFindDom(this.#elementRef.nativeElement, 'ion-header'), - waitFindDom(this.#elementRef.nativeElement, 'cdk-virtual-scroll-viewport'), - ]); - - this.#elementRef.nativeElement.classList.add('scroll-header-animated'); + this.#elementRef.nativeElement.classList.add('scroll-header-animated'); - if ( - this.#elementRef.nativeElement.previousElementSibling && - this.#elementRef.nativeElement.previousElementSibling.classList.contains('native-header') - ) { - this.#nativeHeader.set(this.#elementRef.nativeElement.previousElementSibling); - } + if ( + this.#elementRef.nativeElement.previousElementSibling && + this.#elementRef.nativeElement.previousElementSibling.classList.contains('native-header') + ) { + this.#nativeHeader.set(this.#elementRef.nativeElement.previousElementSibling); + } - this.#scrollSubscription.add( - fromEvent(this.virtualScroll()!.elementRef.nativeElement, 'scroll').subscribe(() => { - this.onWindowScroll(this.virtualScroll()!.measureScrollOffset('top')); - }), - ); + this.#scrollSubscription.add( + fromEvent(viewport.elementRef.nativeElement, 'scroll').subscribe(() => { + this.onWindowScroll(viewport.measureScrollOffset('top')); + }), + ); + }); } ngOnDestroy() { diff --git a/projects/scroll-header/src/lib/util.spec.ts b/projects/scroll-header/src/lib/util.spec.ts deleted file mode 100644 index bf5c5c7..0000000 --- a/projects/scroll-header/src/lib/util.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { waitFindDom } from './util'; - -describe('waitFindDom', () => { - it('resolves once the requested descendant is added', async () => { - const host = document.createElement('div'); - let resolved = false; - const result = waitFindDom(host, '.target').then(() => { - resolved = true; - }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(resolved).toBe(false); - - const target = document.createElement('span'); - target.className = 'target'; - host.append(target); - await result; - - expect(resolved).toBe(true); - }); -}); diff --git a/projects/scroll-header/src/lib/util.ts b/projects/scroll-header/src/lib/util.ts deleted file mode 100644 index b711ba9..0000000 --- a/projects/scroll-header/src/lib/util.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const waitFindDom = (nativeElement: HTMLElement, selector: string): Promise => { - return new Promise((resolve) => { - const interval = setInterval(() => { - const find = nativeElement.querySelector(selector); - if (find) { - clearInterval(interval); - resolve(); - } - }); - }); -}; diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts index 8b68858..51b6aeb 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.ts @@ -244,15 +244,9 @@ export class DynamicSizeVirtualScrollStrategy implements VirtualScrollStrategy { } } -/** - * Provider factory for `FixedSizeVirtualScrollStrategy` that simply extracts the already created - * `FixedSizeVirtualScrollStrategy` from the given directive. - * @param fixedSizeDir The instance of `CdkFixedSizeVirtualScroll` to extract the - * `FixedSizeVirtualScrollStrategy` from. - */ -export function _dynamicSizeVirtualScrollStrategyFactory(fixedSizeDir: CdkDynamicSizeVirtualScroll) { - return fixedSizeDir._scrollStrategy; -} +const scrollStrategyAccessor = Symbol('scrollStrategyAccessor'); + +const dynamicSizeVirtualScrollStrategyFactory = (directive: CdkDynamicSizeVirtualScroll) => directive[scrollStrategyAccessor](); /** Directive that installs dynamic-size virtual scrolling on a CDK viewport. */ @Directive({ @@ -260,7 +254,7 @@ export function _dynamicSizeVirtualScrollStrategyFactory(fixedSizeDir: CdkDynami providers: [ { provide: VIRTUAL_SCROLL_STRATEGY, - useFactory: _dynamicSizeVirtualScrollStrategyFactory, + useFactory: dynamicSizeVirtualScrollStrategyFactory, deps: [forwardRef(() => CdkDynamicSizeVirtualScroll)], }, ], @@ -291,8 +285,7 @@ export class CdkDynamicSizeVirtualScroll { transform: coerceBooleanProperty, }); - /** The scroll strategy used by this directive. */ - readonly _scrollStrategy = new DynamicSizeVirtualScrollStrategy( + readonly #scrollStrategy = new DynamicSizeVirtualScrollStrategy( this.itemDynamicSizes(), this.minBufferPx(), this.maxBufferPx(), @@ -308,12 +301,16 @@ export class CdkDynamicSizeVirtualScroll { } }); effect(() => - this._scrollStrategy.updateItemAndBufferSize(this.itemDynamicSizes(), this.minBufferPx(), this.maxBufferPx(), this.isReverse()), + this.#scrollStrategy.updateItemAndBufferSize(this.itemDynamicSizes(), this.minBufferPx(), this.maxBufferPx(), this.isReverse()), ); } /** For isReverse scroll. Because virtualScroll.measureScrollOffset is not work. **/ get scrollOffset(): number { - return this._scrollStrategy.measureScrollOffset; + return this.#scrollStrategy.measureScrollOffset; + } + + [scrollStrategyAccessor](): DynamicSizeVirtualScrollStrategy { + return this.#scrollStrategy; } } diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.directive.spec.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.directive.spec.ts index 7c512e6..716fb09 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.directive.spec.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.directive.spec.ts @@ -1,7 +1,7 @@ import { Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; -import { CdkDynamicSizeVirtualScroll, _dynamicSizeVirtualScrollStrategyFactory } from './dynamic-size-virtual-scroll-strategy'; +import { CdkVirtualScrollViewport, VIRTUAL_SCROLL_STRATEGY } from '@angular/cdk/scrolling'; +import { CdkDynamicSizeVirtualScroll, DynamicSizeVirtualScrollStrategy } from './dynamic-size-virtual-scroll-strategy'; import { itemDynamicSize } from './dynamic-size-virtual-scroll.util'; import { testConfig } from '../../../util/test.config'; @@ -33,6 +33,11 @@ describe('CdkDynamicSizeVirtualScroll', () => { .query((element) => element.nativeElement.tagName.toLowerCase() === 'cdk-virtual-scroll-viewport') .injector.get(CdkDynamicSizeVirtualScroll); + const getStrategy = (): DynamicSizeVirtualScrollStrategy => + fixture.debugElement + .query((element) => element.nativeElement.tagName.toLowerCase() === 'cdk-virtual-scroll-viewport') + .injector.get(VIRTUAL_SCROLL_STRATEGY) as DynamicSizeVirtualScrollStrategy; + beforeEach(() => { TestBed.configureTestingModule({ providers: testConfig.providers, @@ -44,7 +49,7 @@ describe('CdkDynamicSizeVirtualScroll', () => { it('exposes the directive scroll offset from the installed strategy', () => { const directive = getDirective(); - directive._scrollStrategy.measureScrollOffset = 42; + getStrategy().measureScrollOffset = 42; expect(directive.scrollOffset).toBe(42); }); @@ -66,8 +71,7 @@ describe('CdkDynamicSizeVirtualScroll', () => { }); it('updates the strategy when measured item sizes change', () => { - const directive = getDirective(); - const updateSpy = vi.spyOn(directive._scrollStrategy, 'updateItemAndBufferSize'); + const updateSpy = vi.spyOn(getStrategy(), 'updateItemAndBufferSize'); const sizes = [{ itemSize: 40 }, { itemSize: 60 }, { itemSize: 80 }]; host.sizes.set(sizes); @@ -77,8 +81,7 @@ describe('CdkDynamicSizeVirtualScroll', () => { }); it('coerces numeric string buffer inputs before updating the strategy', () => { - const directive = getDirective(); - const updateSpy = vi.spyOn(directive._scrollStrategy, 'updateItemAndBufferSize'); + const updateSpy = vi.spyOn(getStrategy(), 'updateItemAndBufferSize'); host.minBufferPx.set('15'); host.maxBufferPx.set('45'); @@ -93,9 +96,7 @@ describe('CdkDynamicSizeVirtualScroll', () => { expect(() => fixture.detectChanges()).toThrow(/index 0/); }); - it('provides the directive strategy through the factory helper', () => { - const directive = getDirective(); - - expect(_dynamicSizeVirtualScrollStrategyFactory(directive)).toBe(directive._scrollStrategy); + it('provides the directive strategy through the CDK token', () => { + expect(getStrategy()).toBeInstanceOf(DynamicSizeVirtualScrollStrategy); }); }); diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts index 5c590dc..e2ceecc 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts @@ -1,5 +1,4 @@ import { - calcIndex, calculateItemCountForPixelDistance, createPrefixSums, endIndexForOffset, @@ -19,12 +18,6 @@ describe('dynamic size virtual scroll utilities', () => { expect(sumItemSize(items(30, 70, 20), 99)).toBe(120); }); - it('preserves the legacy calcIndex results for existing consumers', () => { - const sizes = items(55, 55, 42); - expect(calcIndex(sizes, 50)).toBe(0); - expect(calcIndex(sizes, 60)).toBe(0.09090909090909091); - }); - it('converts pixels to a mathematically continuous item count', () => { const sizes = items(30, 70, 20); expect(calculateItemCountForPixelDistance(sizes, 0)).toBe(0); diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.ts index 582dbf0..e6e234b 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.ts @@ -6,39 +6,6 @@ export const sumItemSize = (dynamicSize: itemDynamicSize[], endIndex: number): n return dynamicSize.slice(0, endIndex).reduce((acc, item) => acc + item.itemSize, 0); }; -/** - * Retains the package's legacy pixel-to-index calculation for compatibility. - * @deprecated Use `calculateItemCountForPixelDistance` for mathematically continuous results. - */ -export const calcIndex = (dynamicSize: itemDynamicSize[], itemSizeRange: number, startIndex = 0, isReverse = false): number => { - let sum = 0; - let diffIndex = 0; - const item = isReverse ? [...dynamicSize].reverse() : dynamicSize; - if (isReverse) { - startIndex = dynamicSize.length - startIndex; - } - const calculatedIndex = item.reduce((acc, currentValue, index) => { - if (index < startIndex || acc !== -1) { - return acc; - } - sum += currentValue.itemSize; - if (sum >= itemSizeRange) { - if (sum === itemSizeRange) { - return index - startIndex; - } - const diff = sum - itemSizeRange; - const getOver = item[index].itemSize - diff; - diffIndex = getOver / item[index].itemSize; - return Math.max(0, index - 1 + diffIndex - startIndex); - } - if (index === item.length - 1) { - return index - startIndex + 1; - } - return acc; - }, -1); - return calculatedIndex === -1 ? 0 : calculatedIndex; -}; - /** * Converts a pixel distance into an exact fractional item count. * diff --git a/projects/scroll-strategies/src/public-api.ts b/projects/scroll-strategies/src/public-api.ts index ebcc03a..e51fa72 100644 --- a/projects/scroll-strategies/src/public-api.ts +++ b/projects/scroll-strategies/src/public-api.ts @@ -4,4 +4,4 @@ export * from './lib/dynamic-size-virtual-scroll.service'; export * from './lib/dynamic-size-virtual-scroll-strategy'; -export { calcIndex, calculateItemCountForPixelDistance, sumItemSize, type itemDynamicSize } from './lib/dynamic-size-virtual-scroll.util'; +export { calculateItemCountForPixelDistance, sumItemSize, type itemDynamicSize } from './lib/dynamic-size-virtual-scroll.util'; diff --git a/scripts/test-package-consumer.mjs b/scripts/test-package-consumer.mjs new file mode 100644 index 0000000..3b56bfb --- /dev/null +++ b/scripts/test-package-consumer.mjs @@ -0,0 +1,136 @@ +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { build } from 'esbuild'; + +const workspace = process.cwd(); +const temporaryDirectory = mkdtempSync(join(tmpdir(), 'ionic-angular-package-consumer-')); +const packageProjects = ['kit', 'photo-editor', 'scroll-header', 'scroll-strategies']; +const commandEnvironment = { ...process.env, npm_config_cache: join(temporaryDirectory, 'npm-cache') }; +const installedPackages = new Map(); + +const installPackedPackage = (project) => { + const distribution = join(workspace, 'dist', project); + if (!existsSync(join(distribution, 'package.json'))) { + throw new Error(`Build output is missing for ${project}. Run npm run prebuild first.`); + } + const [{ filename }] = JSON.parse( + execFileSync('npm', ['pack', distribution, '--json', '--pack-destination', temporaryDirectory], { + encoding: 'utf8', + env: commandEnvironment, + }), + ); + const manifest = JSON.parse(readFileSync(join(distribution, 'package.json'), 'utf8')); + const segments = manifest.name.split('/'); + const parent = join(temporaryDirectory, 'node_modules', ...segments.slice(0, -1)); + const target = join(parent, segments.at(-1)); + mkdirSync(parent, { recursive: true }); + execFileSync('tar', ['-xzf', join(temporaryDirectory, filename), '-C', parent]); + renameSync(join(parent, 'package'), target); + installedPackages.set(manifest.name, { manifest, target }); +}; + +const exportedModule = (packageName, exportName) => { + const { manifest, target } = installedPackages.get(packageName); + const modulePath = manifest.exports?.[exportName]?.default; + assert.equal(typeof modulePath, 'string', `${packageName} is missing export ${exportName}`); + return resolve(target, modulePath); +}; + +const bundlePhotoSurface = async (exportName, forbiddenPackages, requiredImports = []) => { + const packageName = '@rdlabo/ionic-angular-photo-editor'; + const primaryModule = exportedModule(packageName, '.'); + const observedImports = new Set(); + await build({ + entryPoints: [exportedModule(packageName, exportName)], + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + nodePaths: [join(workspace, 'node_modules')], + logLevel: 'silent', + plugins: [ + { + name: 'optional-peer-isolation', + setup(buildContext) { + buildContext.onResolve({ filter: /.*/ }, ({ path }) => { + if (forbiddenPackages.includes(path)) { + throw new Error(`${exportName} has a static dependency on optional peer ${path}`); + } + if (requiredImports.includes(path)) { + observedImports.add(path); + return undefined; + } + if (path === packageName) { + return { path: primaryModule }; + } + if (!path.startsWith('.') && !path.startsWith('/')) { + return { path, external: true }; + } + return undefined; + }); + }, + }, + ], + }); + assert.deepEqual([...observedImports].sort(), [...requiredImports].sort(), `${exportName} must use literal adapter imports`); +}; + +try { + packageProjects.forEach(installPackedPackage); + + const photoManifest = installedPackages.get('@rdlabo/ionic-angular-photo-editor').manifest; + for (const dependency of ['@angular/forms', 'ionicons', 'rxjs']) { + assert.equal(typeof photoManifest.peerDependencies?.[dependency], 'string', `Missing runtime peer ${dependency}`); + } + + await bundlePhotoSurface('.', ['@capacitor/camera', 'swiper', 'tui-image-editor']); + await bundlePhotoSurface('./editor', ['@capacitor/camera', 'swiper']); + await bundlePhotoSurface('./viewer', ['@capacitor/camera', 'tui-image-editor']); + await bundlePhotoSurface('./file', ['@capacitor/camera', 'swiper']); + await bundlePhotoSurface('./editor/tui', ['@capacitor/camera', 'swiper'], ['tui-image-editor']); + await bundlePhotoSurface('./file/capacitor', ['swiper', 'tui-image-editor'], ['@capacitor/camera']); + + writeFileSync( + join(temporaryDirectory, 'consumer.ts'), + `import { type KitAuthInputMode } from '@rdlabo/ionic-angular-kit'; +import { providePhotoEditor, type PhotoEditorProps, type PhotoEditorResult, type PhotoViewerProps, type PhotoViewerResult } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoEditorPage } from '@rdlabo/ionic-angular-photo-editor/editor'; +import { createTuiImageEditor } from '@rdlabo/ionic-angular-photo-editor/editor/tui'; +import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor/file'; +import { loadCapacitorPhotoCamera } from '@rdlabo/ionic-angular-photo-editor/file/capacitor'; +import { PhotoViewerPage } from '@rdlabo/ionic-angular-photo-editor/viewer'; +import { ScrollHeaderDirective } from '@rdlabo/ionic-angular-scroll-header'; +import { CdkDynamicSizeVirtualScroll, calculateItemCountForPixelDistance } from '@rdlabo/ngx-cdk-scroll-strategies'; + +const mode: KitAuthInputMode = 'email'; +const editorProps: PhotoEditorProps = { value: 'data:image/png;base64,', toolbarColorScheme: 'dark' }; +const viewerProps: PhotoViewerProps = { imageUrls: [], toolbarColorScheme: 'light', imageAlt: (_, index) => String(index) }; +const editorResult: PhotoEditorResult = { action: 'save', value: editorProps.value }; +const viewerResult: PhotoViewerResult = { action: 'delete', index: 0, value: '' }; +const symbols = [providePhotoEditor, PhotoEditorPage, createTuiImageEditor, PhotoFileService, loadCapacitorPhotoCamera, PhotoViewerPage, ScrollHeaderDirective, CdkDynamicSizeVirtualScroll]; +void [mode, viewerProps, editorResult, viewerResult, symbols, calculateItemCountForPixelDistance([{ itemSize: 10 }], 5)]; +`, + ); + writeFileSync( + join(temporaryDirectory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: true, + target: 'ES2022', + module: 'preserve', + moduleResolution: 'bundler', + lib: ['ES2022', 'DOM'], + }, + files: ['./consumer.ts'], + }), + ); + execFileSync(join(workspace, 'node_modules', '.bin', 'tsc'), ['-p', join(temporaryDirectory, 'tsconfig.json')], { stdio: 'inherit' }); + console.log('Packed package consumer type-check and optional-peer bundle matrix passed.'); +} finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/tsconfig.json b/tsconfig.json index c3f8bce..82fb97b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,12 @@ "@rdlabo/ionic-angular-kit/auth-firebase/social": ["./projects/kit/auth-firebase/social/src/public-api.ts"], "scroll-strategies": ["./dist/scroll-strategies"], "photo-editor": ["./dist/photo-editor"], + "@rdlabo/ionic-angular-photo-editor": ["./projects/photo-editor/src/public-api.ts"], + "photo-editor/editor": ["./dist/photo-editor/editor"], + "photo-editor/editor/tui": ["./dist/photo-editor/editor/tui"], + "photo-editor/viewer": ["./dist/photo-editor/viewer"], + "photo-editor/file": ["./dist/photo-editor/file"], + "photo-editor/file/capacitor": ["./dist/photo-editor/file/capacitor"], "scroll-header": ["./dist/scroll-header"] }, "target": "ES2022", From 087a9c708077a4c5de91ad25b8022fc81cab5a7c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 22 Aug 2026 20:11:50 +0900 Subject: [PATCH 2/3] fix: address third-party acceptance feedback --- docs/migration.md | 59 ++++++++++++++----- projects/photo-editor/docs/editor.md | 4 +- projects/photo-editor/docs/photo-file.md | 36 ++++++----- .../editor/src/lib/photo-editor.page.html | 8 ++- .../file/src/lib/photo-file.service.ts | 21 +++++-- .../src/tests/photo-editor.page.spec.ts | 6 +- .../src/tests/photo-file.service.spec.ts | 11 +++- 7 files changed, 102 insertions(+), 43 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index e34d735..3d86f8a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -41,17 +41,23 @@ import { providePhotoEditor } from '@rdlabo/ionic-angular-photo-editor'; import { createTuiImageEditor } from '@rdlabo/ionic-angular-photo-editor/editor/tui'; import { loadCapacitorPhotoCamera } from '@rdlabo/ionic-angular-photo-editor/file/capacitor'; -providePhotoEditor({ - createImageEditor: createTuiImageEditor, - loadCamera: loadCapacitorPhotoCamera, // omit for browser-only applications -}); +export const appConfig = { + providers: [ + providePhotoEditor({ + createImageEditor: createTuiImageEditor, + loadCamera: loadCapacitorPhotoCamera, // omit for browser-only applications + }), + ], +}; ``` Install `tui-image-editor` only when importing `/editor/tui`, `@capacitor/camera` only when importing `/file/capacitor`, and `swiper` only when importing `/viewer`. The root, `/editor`, and `/file` entry points no longer hide runtime imports of these optional peers. Without the corresponding configured adapter, editing/resizing or native selection fails with `PhotoLoadError('unavailable')`. -#### Rename toolbarColorScheme +#### Add the required toolbarColorScheme -`headerButtonColorScheme` is now `toolbarColorScheme`. It remains required on both modals. +Both modals require the new `toolbarColorScheme` prop in v22. Version 21 did not expose a toolbar color-scheme prop, so add it to every editor and viewer presentation. Choose `dark` for a dark/black `ion-toolbar` and `light` for a light/white toolbar; only the consuming application can determine the toolbar appearance. + +`PhotoViewerProps.imageUrls` is also required in v22; it was optional in the v21 declaration. Pass the current image array explicitly, including `[]` when there are no images. ```typescript import { PhotoEditorProps } from '@rdlabo/ionic-angular-photo-editor'; @@ -107,7 +113,18 @@ if (data?.action === 'delete') { } ``` -Removed types: `IPhotoEditorDismiss`, `IPhotoViewerDismiss`, `IDictionaryForEditor`, `IDictionaryForViewer`, `IDictionaryForService`. Use `PhotoEditorResult`, `PhotoViewerResult`, `PhotoEditorLabels`, `PhotoViewerLabels`, and `PhotoFileLabels` instead. +Removed types and replacements: + +| Removed v21 export | v22 replacement | +| ----------------------- | ------------------- | +| `IPhotoEditorDismiss` | `PhotoEditorResult` | +| `IPhotoViewerDismiss` | `PhotoViewerResult` | +| `IDictionaryForEditor` | `PhotoEditorLabels` | +| `IDictionaryForViewer` | `PhotoViewerLabels` | +| `IDictionaryForService` | `PhotoFileLabels` | +| `IFilter` | `PhotoFilter` | +| `IFilterPreset` | `PhotoFilterPreset` | +| `ISize` | `PhotoSize` | #### PhotoFileService API @@ -117,16 +134,20 @@ photoFileService.photoMaxSize = 1000; photoFileService.labels = { camera: '…', album: '…', cancel: '…' }; const files = await photoFileService.loadPhoto(2); -// After — register defaults once -providePhotoEditor({ - maxPhotoSize: 1000, - fileLabels: { … }, - createImageEditor: createTuiImageEditor, - loadCamera: loadCapacitorPhotoCamera, -}); +// After — register defaults once in ApplicationConfig.providers +export const appConfig = { + providers: [ + providePhotoEditor({ + maxPhotoSize: 1000, + fileLabels: { camera: 'Camera', album: 'Album', cancel: 'Cancel' }, + createImageEditor: createTuiImageEditor, + loadCamera: loadCapacitorPhotoCamera, + }), + ], +}; // Per call -const files = await photoFileService.loadPhoto({ limit: 2, maxSize: 1000, labels: { … } }); +const files = await photoFileService.loadPhoto({ limit: 2, maxSize: 1000, labels: { camera: 'Camera' } }); ``` Cancellation and validation failures now throw `PhotoLoadError` with `code: 'cancelled' | 'invalid-type' | 'unavailable'` instead of returning empty arrays or generic errors. @@ -170,7 +191,13 @@ Do not import the adapter when the application does not use the iOS 26 theme. Se The `'autofill'` mode is removed. [WebKit bug 226023](https://bugs.webkit.org/show_bug.cgi?id=226023) (iOS autofill not propagating from `ion-input` to Angular forms) is fixed in the v22 minimum iOS/iPadOS 16.4 runtime, so the workaround is no longer shipped. Applications that must continue supporting an older iOS version cannot remove their app-local workaround and should remain on the v21 library line. -Remove `kitAuthInput="autofill"` from password and other non-email fields. Do not add the attribute unless email persistence is intended. +Remove both `kitAuthInput="autofill"` and the value-less `kitAuthInput` attribute from password and other non-email fields. In v21, a value-less attribute selected the default autofill mode; v22 has no default mode. Keep the directive only when email persistence is intended, and set it explicitly to `"email"` or `"email-remember"`. + +```html + + + +``` Email persistence modes are unchanged: diff --git a/projects/photo-editor/docs/editor.md b/projects/photo-editor/docs/editor.md index 0a6e37e..ae1d81d 100644 --- a/projects/photo-editor/docs/editor.md +++ b/projects/photo-editor/docs/editor.md @@ -6,7 +6,9 @@ import { PhotoEditorPage } from '@rdlabo/ionic-angular-photo-editor/editor'; import { createTuiImageEditor } from '@rdlabo/ionic-angular-photo-editor/editor/tui'; // app.config.ts -providePhotoEditor({ createImageEditor: createTuiImageEditor }); +export const appConfig = { + providers: [providePhotoEditor({ createImageEditor: createTuiImageEditor })], +}; (async () => { const componentProps = { diff --git a/projects/photo-editor/docs/photo-file.md b/projects/photo-editor/docs/photo-file.md index c166295..0d9b9cb 100644 --- a/projects/photo-editor/docs/photo-file.md +++ b/projects/photo-editor/docs/photo-file.md @@ -50,11 +50,11 @@ export class AppComponent { Opens the platform photo picker and returns normalized data URLs. -| Option | Default | Description | -| --------- | --------------------------------------------- | ---------------------------------------------- | -| `limit` | `1` | Maximum number of images (album and web only). | -| `maxSize` | `providePhotoEditor().maxPhotoSize` or `1000` | Longest edge in pixels after resize. | -| `labels` | configured `fileLabels` | Action sheet button text (Capacitor only). | +| Option | Default | Description | +| --------- | ----------------------------------- | ---------------------------------------------- | +| `limit` | `1` | Maximum number of images (album and web only). | +| `maxSize` | configured `maxPhotoSize` or `1000` | Longest edge in pixels after resize. | +| `labels` | configured `fileLabels` | Action sheet button text (Capacitor only). | ### Browser behavior @@ -71,11 +71,11 @@ Register `loadCapacitorPhotoCamera` from `/file/capacitor`; the base `/file` ent Expected failures throw `PhotoLoadError`: -| Code | When | -| -------------- | ----------------------------------------- | -| `cancelled` | User dismissed the picker or action sheet | -| `invalid-type` | Selected file is not an image (web only) | -| `unavailable` | File could not be read or resized | +| Code | When | +| -------------- | -------------------------------------------------------- | +| `cancelled` | User dismissed the picker or action sheet | +| `invalid-type` | Selected file is not an image (web only) | +| `unavailable` | Permission, plugin, picker, file-read, or resize failure | ## Default labels (ja) @@ -92,12 +92,16 @@ When no `fileLabels` or per-request `labels` are supplied: Register application-wide defaults in `app.config.ts`: ```typescript -providePhotoEditor({ - maxPhotoSize: 1200, - fileLabels: { camera: '…', album: '…', cancel: '…' }, - createImageEditor: createTuiImageEditor, - loadCamera: loadCapacitorPhotoCamera, // omit in browser-only applications -}); +export const appConfig = { + providers: [ + providePhotoEditor({ + maxPhotoSize: 1200, + fileLabels: { camera: '…', album: '…', cancel: '…' }, + createImageEditor: createTuiImageEditor, + loadCamera: loadCapacitorPhotoCamera, // omit in browser-only applications + }), + ], +}; ``` `PhotoFileService` reads these adapters plus `maxPhotoSize` and `fileLabels` from this configuration. Per-request `maxSize` and `labels` override them for a single call. A resize requires `createImageEditor`; a native picker requires `loadCamera`. Missing adapters throw `PhotoLoadError` with `code: 'unavailable'`. diff --git a/projects/photo-editor/editor/src/lib/photo-editor.page.html b/projects/photo-editor/editor/src/lib/photo-editor.page.html index 63f4fc7..fa51405 100644 --- a/projects/photo-editor/editor/src/lib/photo-editor.page.html +++ b/projects/photo-editor/editor/src/lib/photo-editor.page.html @@ -87,7 +87,13 @@ } @if (['brightness'].includes(this.footerMenu())) {
- +
} diff --git a/projects/photo-editor/file/src/lib/photo-file.service.ts b/projects/photo-editor/file/src/lib/photo-file.service.ts index 5c3ee4e..259e168 100644 --- a/projects/photo-editor/file/src/lib/photo-file.service.ts +++ b/projects/photo-editor/file/src/lib/photo-file.service.ts @@ -71,9 +71,11 @@ export class PhotoFileService { width: maxSize, source: 'camera', }) - .catch(() => undefined); + .catch((error: unknown) => { + throw this.#nativePickerError(error); + }); if (!image?.dataUrl) { - throw new PhotoLoadError('cancelled'); + throw new PhotoLoadError('unavailable'); } if (!image.dataUrl.includes('capacitor://localhost')) { return [image.dataUrl]; @@ -83,10 +85,9 @@ export class PhotoFileService { async #getPicturesFromAlbum(limit: number, maxSize: number): Promise { const camera = await this.#config.loadCamera(); - const images = await camera.pickImages({ quality: 100, width: maxSize, limit }).catch(() => undefined); - if (!images) { - throw new PhotoLoadError('cancelled'); - } + const images = await camera.pickImages({ quality: 100, width: maxSize, limit }).catch((error: unknown) => { + throw this.#nativePickerError(error); + }); return Promise.all( images.photos.slice(0, limit).map(({ webPath }) => { if (!webPath) { @@ -174,6 +175,14 @@ export class PhotoFileService { .finally(() => editor.destroy()); } + #nativePickerError(error: unknown): PhotoLoadError { + if (error instanceof PhotoLoadError) { + return error; + } + const message = error instanceof Error ? error.message : String(error); + return new PhotoLoadError(/cancelled|canceled/i.test(message) ? 'cancelled' : 'unavailable', { cause: error }); + } + async #resizePhoto(editor: Awaited>, filePath: string, maxPhotoSize: number): Promise { const response = await fetch(filePath); if (!response.ok) { diff --git a/projects/photo-editor/src/tests/photo-editor.page.spec.ts b/projects/photo-editor/src/tests/photo-editor.page.spec.ts index d81d27d..46ccc5c 100644 --- a/projects/photo-editor/src/tests/photo-editor.page.spec.ts +++ b/projects/photo-editor/src/tests/photo-editor.page.spec.ts @@ -102,13 +102,17 @@ describe('PhotoEditorPage', () => { }); it('merges custom labels without replacing the remaining dictionary', async () => { - componentRef.setInput('labels', { save: 'Upload', close: 'Close editor' }); + componentRef.setInput('labels', { save: 'Upload', close: 'Close editor', brightness: 'Exposure' }); fixture.detectChanges(); await fixture.whenStable(); expect(fixture.nativeElement.textContent).toContain('Upload'); expect(fixture.nativeElement.textContent).toContain('切り抜き・回転'); expect(fixture.nativeElement.querySelector('ion-button').getAttribute('aria-label')).toBe('Close editor'); + + state.footerMenu.set('brightness'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('ion-range').getAttribute('aria-label')).toBe('Exposure'); }); it.each([ diff --git a/projects/photo-editor/src/tests/photo-file.service.spec.ts b/projects/photo-editor/src/tests/photo-file.service.spec.ts index 4c72d4e..2540d82 100644 --- a/projects/photo-editor/src/tests/photo-file.service.spec.ts +++ b/projects/photo-editor/src/tests/photo-file.service.spec.ts @@ -191,13 +191,20 @@ describe('PhotoFileService', () => { }); }); - it('reports unavailable camera capture as cancelled', async () => { + it('reports an explicit native camera cancellation as cancelled', async () => { dismissData = 'camera'; - getPhoto.mockRejectedValue(new Error('camera unavailable')); + getPhoto.mockRejectedValue(new Error('User cancelled photos app')); await expect(service.loadPhoto()).rejects.toMatchObject({ code: 'cancelled' }); }); + it('reports native permission and plugin failures as unavailable', async () => { + dismissData = 'camera'; + getPhoto.mockRejectedValue(new Error('Camera permission denied')); + + await expect(service.loadPhoto()).rejects.toMatchObject({ code: 'unavailable' }); + }); + it('loads album paths in order with the configured maximum size', async () => { dismissData = 'album'; pickImages.mockResolvedValue({ From 4267183c28082dc2f8e2ca76fbbe91b934b133f4 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 22 Aug 2026 20:28:07 +0900 Subject: [PATCH 3/3] refactor!: align photo file option names --- docs/migration.md | 6 +++-- projects/photo-editor/README.md | 2 +- projects/photo-editor/docs/photo-file.md | 24 +++++++++---------- .../file/src/lib/photo-file.service.ts | 16 ++++++------- .../src/lib/photo-editor.config.ts | 16 ++++++------- .../src/tests/photo-file.service.spec.ts | 4 ++-- scripts/test-package-consumer.mjs | 3 ++- 7 files changed, 37 insertions(+), 34 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 3d86f8a..7d5f43c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -138,8 +138,8 @@ const files = await photoFileService.loadPhoto(2); export const appConfig = { providers: [ providePhotoEditor({ - maxPhotoSize: 1000, - fileLabels: { camera: 'Camera', album: 'Album', cancel: 'Cancel' }, + maxSize: 1000, + labels: { camera: 'Camera', album: 'Album', cancel: 'Cancel' }, createImageEditor: createTuiImageEditor, loadCamera: loadCapacitorPhotoCamera, }), @@ -150,6 +150,8 @@ export const appConfig = { const files = await photoFileService.loadPhoto({ limit: 2, maxSize: 1000, labels: { camera: 'Camera' } }); ``` +The global defaults and per-call overrides intentionally use the same `maxSize` and `labels` keys. Values passed to `loadPhoto()` take precedence for that request. + Cancellation and validation failures now throw `PhotoLoadError` with `code: 'cancelled' | 'invalid-type' | 'unavailable'` instead of returning empty arrays or generic errors. Remove any static `` from `index.html`. On web, the service creates and removes a hidden file input synchronously when `loadPhoto()` runs; there is no fixed input id. diff --git a/projects/photo-editor/README.md b/projects/photo-editor/README.md index 06ebbf2..178a20c 100644 --- a/projects/photo-editor/README.md +++ b/projects/photo-editor/README.md @@ -30,7 +30,7 @@ import { loadCapacitorPhotoCamera } from '@rdlabo/ionic-angular-photo-editor/fil export const appConfig = { providers: [ providePhotoEditor({ - maxPhotoSize: 1000, + maxSize: 1000, createImageEditor: createTuiImageEditor, loadCamera: loadCapacitorPhotoCamera, }), diff --git a/projects/photo-editor/docs/photo-file.md b/projects/photo-editor/docs/photo-file.md index 0d9b9cb..51f0b75 100644 --- a/projects/photo-editor/docs/photo-file.md +++ b/projects/photo-editor/docs/photo-file.md @@ -10,8 +10,8 @@ import { loadCapacitorPhotoCamera } from '@rdlabo/ionic-angular-photo-editor/fil export const appConfig = { providers: [ providePhotoEditor({ - maxPhotoSize: 1000, - fileLabels: { + maxSize: 1000, + labels: { camera: 'Camera', album: 'Album', cancel: 'Cancel', @@ -50,11 +50,11 @@ export class AppComponent { Opens the platform photo picker and returns normalized data URLs. -| Option | Default | Description | -| --------- | ----------------------------------- | ---------------------------------------------- | -| `limit` | `1` | Maximum number of images (album and web only). | -| `maxSize` | configured `maxPhotoSize` or `1000` | Longest edge in pixels after resize. | -| `labels` | configured `fileLabels` | Action sheet button text (Capacitor only). | +| Option | Default | Description | +| --------- | ------------------------------ | ---------------------------------------------- | +| `limit` | `1` | Maximum number of images (album and web only). | +| `maxSize` | configured `maxSize` or `1000` | Longest edge in pixels after resize. | +| `labels` | configured `labels` | Action sheet button text (Capacitor only). | ### Browser behavior @@ -64,7 +64,7 @@ Do not add a static file input to `index.html`. ### Capacitor behavior -On native platforms, an action sheet asks the user to choose camera or album. Per-request `labels` merge over values from `providePhotoEditor({ fileLabels })`. +On native platforms, an action sheet asks the user to choose camera or album. Per-request `labels` merge over values from `providePhotoEditor({ labels })`. Register `loadCapacitorPhotoCamera` from `/file/capacitor`; the base `/file` entry point stays usable by browser-only applications without `@capacitor/camera`. ### Errors @@ -79,7 +79,7 @@ Expected failures throw `PhotoLoadError`: ## Default labels (ja) -When no `fileLabels` or per-request `labels` are supplied: +When no global or per-request `labels` are supplied: | Key | Default (ja) | | ------ | ---------------- | @@ -95,8 +95,8 @@ Register application-wide defaults in `app.config.ts`: export const appConfig = { providers: [ providePhotoEditor({ - maxPhotoSize: 1200, - fileLabels: { camera: '…', album: '…', cancel: '…' }, + maxSize: 1200, + labels: { camera: '…', album: '…', cancel: '…' }, createImageEditor: createTuiImageEditor, loadCamera: loadCapacitorPhotoCamera, // omit in browser-only applications }), @@ -104,4 +104,4 @@ export const appConfig = { }; ``` -`PhotoFileService` reads these adapters plus `maxPhotoSize` and `fileLabels` from this configuration. Per-request `maxSize` and `labels` override them for a single call. A resize requires `createImageEditor`; a native picker requires `loadCamera`. Missing adapters throw `PhotoLoadError` with `code: 'unavailable'`. +`PhotoFileService` reads these adapters plus the global `maxSize` and `labels` defaults from this configuration. Per-request values with the same names override them for a single call. A resize requires `createImageEditor`; a native picker requires `loadCamera`. Missing adapters throw `PhotoLoadError` with `code: 'unavailable'`. diff --git a/projects/photo-editor/file/src/lib/photo-file.service.ts b/projects/photo-editor/file/src/lib/photo-file.service.ts index 259e168..2f59668 100644 --- a/projects/photo-editor/file/src/lib/photo-file.service.ts +++ b/projects/photo-editor/file/src/lib/photo-file.service.ts @@ -20,8 +20,8 @@ export class PhotoFileService { /** Opens the platform photo picker and returns normalized data URLs. */ async loadPhoto(options: PhotoLoadOptions = {}): Promise { const limit = Math.max(1, Math.trunc(options.limit ?? 1)); - const maxSize = Math.max(1, Math.trunc(options.maxSize ?? this.#config.maxPhotoSize)); - const labels = { ...this.#config.fileLabels, ...options.labels } satisfies PhotoFileLabels; + const maxSize = Math.max(1, Math.trunc(options.maxSize ?? this.#config.maxSize)); + const labels = { ...this.#config.labels, ...options.labels } satisfies PhotoFileLabels; if (!this.#platform.is('capacitor')) { return this.#getPictureFromBrowser(limit, maxSize); @@ -163,12 +163,12 @@ export class PhotoFileService { }); } - async #loadPhotoFromFilePath(filePath: string, maxPhotoSize: number): Promise { + async #loadPhotoFromFilePath(filePath: string, maxSize: number): Promise { const editor = await this.#config.createImageEditor(document.createElement('div'), { - cssMaxWidth: maxPhotoSize, - cssMaxHeight: maxPhotoSize, + cssMaxWidth: maxSize, + cssMaxHeight: maxSize, }); - return this.#resizePhoto(editor, filePath, maxPhotoSize) + return this.#resizePhoto(editor, filePath, maxSize) .catch((error: unknown) => { throw error instanceof PhotoLoadError ? error : new PhotoLoadError('unavailable', { cause: error }); }) @@ -183,7 +183,7 @@ export class PhotoFileService { return new PhotoLoadError(/cancelled|canceled/i.test(message) ? 'cancelled' : 'unavailable', { cause: error }); } - async #resizePhoto(editor: Awaited>, filePath: string, maxPhotoSize: number): Promise { + async #resizePhoto(editor: Awaited>, filePath: string, maxSize: number): Promise { const response = await fetch(filePath); if (!response.ok) { throw new Error(`Unable to load photo: ${response.status}`); @@ -192,7 +192,7 @@ export class PhotoFileService { const loaded = await editor.loadImageFromFile(new File([blob], 'data.png', { type: blob.type })); const longestEdge = Math.max(loaded.newWidth, loaded.newHeight); return editor.toDataURL({ - multiplier: longestEdge > 0 ? Math.min(1, maxPhotoSize / longestEdge) : 1, + multiplier: longestEdge > 0 ? Math.min(1, maxSize / longestEdge) : 1, }); } } diff --git a/projects/photo-editor/src/lib/photo-editor.config.ts b/projects/photo-editor/src/lib/photo-editor.config.ts index 95e6109..0b3c6d7 100644 --- a/projects/photo-editor/src/lib/photo-editor.config.ts +++ b/projects/photo-editor/src/lib/photo-editor.config.ts @@ -60,15 +60,15 @@ export type PhotoCameraLoader = () => Promise; /** Global defaults for photo selection and image editor construction. */ export interface PhotoEditorConfig { - maxPhotoSize?: number; - fileLabels?: Partial; + maxSize?: number; + labels?: Partial; createImageEditor?: PhotoImageEditorFactory; loadCamera?: PhotoCameraLoader; } interface ResolvedPhotoEditorConfig { - maxPhotoSize: number; - fileLabels: PhotoFileLabels; + maxSize: number; + labels: PhotoFileLabels; createImageEditor: PhotoImageEditorFactory; loadCamera: PhotoCameraLoader; } @@ -82,8 +82,8 @@ const missingCamera: PhotoCameraLoader = () => export const PHOTO_EDITOR_CONFIG = new InjectionToken('PHOTO_EDITOR_CONFIG', { providedIn: 'root', factory: () => ({ - maxPhotoSize: 1000, - fileLabels: dictionaryForService(), + maxSize: 1000, + labels: dictionaryForService(), createImageEditor: missingImageEditor, loadCamera: missingCamera, }), @@ -95,8 +95,8 @@ export function providePhotoEditor(config: PhotoEditorConfig = {}): EnvironmentP { provide: PHOTO_EDITOR_CONFIG, useValue: { - maxPhotoSize: config.maxPhotoSize ?? 1000, - fileLabels: { ...dictionaryForService(), ...config.fileLabels }, + maxSize: config.maxSize ?? 1000, + labels: { ...dictionaryForService(), ...config.labels }, createImageEditor: config.createImageEditor ?? missingImageEditor, loadCamera: config.loadCamera ?? missingCamera, } satisfies ResolvedPhotoEditorConfig, diff --git a/projects/photo-editor/src/tests/photo-file.service.spec.ts b/projects/photo-editor/src/tests/photo-file.service.spec.ts index 2540d82..d6c964e 100644 --- a/projects/photo-editor/src/tests/photo-file.service.spec.ts +++ b/projects/photo-editor/src/tests/photo-file.service.spec.ts @@ -64,8 +64,8 @@ describe('PhotoFileService', () => { { provide: Platform, useValue: { is: isPlatform } }, { provide: ActionSheetController, useValue: { create: createActionSheet } }, providePhotoEditor({ - maxPhotoSize: 1200, - fileLabels: { camera: 'Take photo', album: 'Choose photo', cancel: 'Close' }, + maxSize: 1200, + labels: { camera: 'Take photo', album: 'Choose photo', cancel: 'Close' }, createImageEditor: createImageEditor as never, loadCamera: loadCapacitorPhotoCamera, }), diff --git a/scripts/test-package-consumer.mjs b/scripts/test-package-consumer.mjs index 3b56bfb..8967f76 100644 --- a/scripts/test-package-consumer.mjs +++ b/scripts/test-package-consumer.mjs @@ -110,8 +110,9 @@ const editorProps: PhotoEditorProps = { value: 'data:image/png;base64,', toolbar const viewerProps: PhotoViewerProps = { imageUrls: [], toolbarColorScheme: 'light', imageAlt: (_, index) => String(index) }; const editorResult: PhotoEditorResult = { action: 'save', value: editorProps.value }; const viewerResult: PhotoViewerResult = { action: 'delete', index: 0, value: '' }; +const photoProviders = providePhotoEditor({ maxSize: 1000, labels: { camera: 'Camera' } }); const symbols = [providePhotoEditor, PhotoEditorPage, createTuiImageEditor, PhotoFileService, loadCapacitorPhotoCamera, PhotoViewerPage, ScrollHeaderDirective, CdkDynamicSizeVirtualScroll]; -void [mode, viewerProps, editorResult, viewerResult, symbols, calculateItemCountForPixelDistance([{ itemSize: 10 }], 5)]; +void [mode, viewerProps, editorResult, viewerResult, photoProviders, symbols, calculateItemCountForPixelDistance([{ itemSize: 10 }], 5)]; `, ); writeFileSync(