@rdlabo/ionic-angular-photo-editor 21.0.0 → 21.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ ion-button.viewer-close-button {
2
+ --color: var(--editor-color) !important;
3
+ }
4
+
5
+ img.circle {
6
+ border-radius: 50%;
7
+ display: block;
8
+ padding: 16px;
9
+ }
10
+
11
+ swiper-container {
12
+ height: 100%;
13
+ max-height: 100%;
14
+ }
15
+
16
+ swiper-slide {
17
+ height: 100%;
18
+ max-height: 100%;
19
+ display: flex;
20
+ justify-content: center;
21
+ align-items: center;
22
+ }
23
+
24
+ swiper-slide img {
25
+ display: block;
26
+ object-fit: contain;
27
+ max-width: 100%;
28
+ max-height: 100%;
29
+ }
@@ -0,0 +1,25 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { ComponentRef } from '@angular/core';
3
+ import { PhotoViewerPage } from './photo-viewer.page';
4
+ import { testConfig } from '../../../../../util/test.config';
5
+
6
+ describe('PhotoViewerPage', () => {
7
+ let component: PhotoViewerPage;
8
+ let fixture: ComponentFixture<PhotoViewerPage>;
9
+ let componentRef: ComponentRef<PhotoViewerPage>;
10
+
11
+ beforeEach(() => {
12
+ TestBed.configureTestingModule({
13
+ providers: testConfig.providers,
14
+ });
15
+ fixture = TestBed.createComponent(PhotoViewerPage);
16
+ component = fixture.componentInstance;
17
+ componentRef = fixture.componentRef;
18
+ componentRef.setInput('imageUrls', []);
19
+ fixture.detectChanges();
20
+ });
21
+
22
+ it('should create', () => {
23
+ expect(component).toBeTruthy();
24
+ });
25
+ });
@@ -0,0 +1,134 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ CUSTOM_ELEMENTS_SCHEMA,
5
+ effect,
6
+ ElementRef,
7
+ inject,
8
+ input,
9
+ OnDestroy,
10
+ OnInit,
11
+ signal,
12
+ viewChild,
13
+ } from '@angular/core';
14
+ import { IonicSlides, ModalController } from '@ionic/angular/standalone';
15
+ import { Navigation, Zoom } from 'swiper/modules';
16
+ import { fromEvent, Subscription, throttleTime, withLatestFrom, zipWith } from 'rxjs';
17
+ import { SwiperContainer } from 'swiper/element';
18
+ import { ionComponents } from '../../ion-components';
19
+ import { IDictionaryForViewer, IPhotoViewerDismiss } from '../../types';
20
+ import { register } from 'swiper/element/bundle';
21
+ import { dictionaryForViewer } from '../../dictionaries';
22
+ import { BooleanInput, coerceBooleanProperty, coerceNumberProperty, NumberInput } from '@angular/cdk/coercion';
23
+ import { initializeViewerIcons, waitToFindDom } from '../util';
24
+
25
+ @Component({
26
+ selector: 'app-photo-image',
27
+ templateUrl: './photo-viewer.page.html',
28
+ styleUrls: ['../core.scss', './photo-viewer.page.scss'],
29
+ imports: [...ionComponents],
30
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
31
+ changeDetection: ChangeDetectionStrategy.OnPush,
32
+ })
33
+ export class PhotoViewerPage implements OnInit, OnDestroy {
34
+ readonly imageUrls = input.required<string[]>();
35
+ readonly index = input<number, NumberInput>(0, {
36
+ transform: coerceNumberProperty,
37
+ });
38
+ readonly isCircle = input<boolean, BooleanInput>(false, {
39
+ transform: coerceBooleanProperty,
40
+ });
41
+ readonly enableDelete = input<boolean, BooleanInput>(false, {
42
+ transform: coerceBooleanProperty,
43
+ });
44
+ readonly enableFooterSafeArea = input<boolean, BooleanInput>(false, {
45
+ transform: coerceBooleanProperty,
46
+ });
47
+ readonly labels = input<Partial<IDictionaryForViewer>>();
48
+ readonly setLabels = effect(() => {
49
+ if (this.labels()) {
50
+ this.dictionary.update((value) => ({ ...value, ...this.labels() }));
51
+ }
52
+ });
53
+
54
+ readonly swiper = viewChild.required<ElementRef<SwiperContainer>>('swiper');
55
+ protected readonly dictionary = signal<IDictionaryForViewer>(dictionaryForViewer());
56
+
57
+ readonly watchSwipe$ = new Subscription();
58
+ readonly modalCtrl = inject(ModalController);
59
+ private readonly el = inject(ElementRef);
60
+
61
+ constructor() {
62
+ register();
63
+ initializeViewerIcons();
64
+ }
65
+
66
+ async ngOnInit() {
67
+ waitToFindDom(this.el.nativeElement, 'swiper-container').then(() => {
68
+ const index = this.index();
69
+ const swiper = this.swiper();
70
+ Object.assign(swiper.nativeElement, {
71
+ modules: [Navigation, Zoom, IonicSlides],
72
+ initialSlide: index,
73
+ slidesPerView: 1,
74
+ pagination: {
75
+ enabled: true,
76
+ clickable: true,
77
+ },
78
+ zoom: true,
79
+ });
80
+ swiper.nativeElement.initialize();
81
+ swiper.nativeElement.swiper.zoom.enable();
82
+
83
+ swiper.nativeElement.swiper.activeIndex = index;
84
+ swiper.nativeElement.swiper.update();
85
+ });
86
+
87
+ this.watchSwipe$.add(
88
+ fromEvent<TouchEvent>(this.el.nativeElement, 'touchstart')
89
+ .pipe(
90
+ zipWith(
91
+ fromEvent<TouchEvent>(this.el.nativeElement, 'touchend').pipe(
92
+ withLatestFrom(fromEvent<TouchEvent>(this.el.nativeElement, 'touchmove')),
93
+ ),
94
+ ),
95
+ throttleTime(1),
96
+ )
97
+ .subscribe(([touchstart, [_, touchmove]]) => {
98
+ const touchstartClientX = touchstart.touches ? touchstart.touches[0].clientX : (touchstart as any).detail[1].clientX;
99
+ const touchmoveClientX = touchmove.touches ? touchmove.touches[0].clientX : (touchmove as any).detail[1].clientX;
100
+
101
+ const touchstartClientY = touchstart.touches ? touchstart.touches[0].clientY : (touchstart as any).detail[1].clientY;
102
+ const touchmoveClientY = touchmove.touches ? touchmove.touches[0].clientY : (touchmove as any).detail[1].clientY;
103
+
104
+ const xDiff = touchstartClientX - touchmoveClientX;
105
+ const yDiff = touchstartClientY - touchmoveClientY;
106
+
107
+ const slides = (this.swiper() as any).nativeElement.querySelectorAll('swiper-slide') as HTMLElement[];
108
+ const isZoomed = Array.from(slides).find((slide: HTMLElement) => {
109
+ return ['swiper-slide-zoomed', 'swiper-slide-active'].every((c) => slide.classList.contains(c));
110
+ });
111
+
112
+ const threshold = touchmove.touches ? -50 : -5;
113
+
114
+ if (!isZoomed && Math.abs(xDiff) < Math.abs(threshold) && yDiff < threshold && touchstart.timeStamp <= touchmove.timeStamp) {
115
+ this.watchSwipe$.unsubscribe();
116
+ this.modalCtrl.dismiss();
117
+ }
118
+ }),
119
+ );
120
+ }
121
+
122
+ ngOnDestroy() {
123
+ this.watchSwipe$.unsubscribe();
124
+ }
125
+
126
+ remove() {
127
+ this.modalCtrl.dismiss({
128
+ delete: {
129
+ index: this.swiper().nativeElement.swiper.activeIndex,
130
+ value: this.imageUrls()[this.swiper().nativeElement.swiper.activeIndex],
131
+ },
132
+ } as IPhotoViewerDismiss);
133
+ }
134
+ }
@@ -0,0 +1,48 @@
1
+ import { addIcons } from 'ionicons';
2
+ import {
3
+ checkmarkOutline,
4
+ closeOutline,
5
+ colorFilterOutline,
6
+ cropOutline,
7
+ expandOutline,
8
+ refreshOutline,
9
+ removeOutline,
10
+ send,
11
+ squareOutline,
12
+ sunnyOutline,
13
+ tabletLandscapeOutline,
14
+ } from 'ionicons/icons';
15
+
16
+ export const initializeViewerIcons = (): void => {
17
+ addIcons({
18
+ closeOutline,
19
+ removeOutline,
20
+ });
21
+ };
22
+
23
+ export const initializeEditorIcons = (): void => {
24
+ addIcons({
25
+ closeOutline,
26
+ send,
27
+ cropOutline,
28
+ colorFilterOutline,
29
+ sunnyOutline,
30
+ expandOutline,
31
+ tabletLandscapeOutline,
32
+ squareOutline,
33
+ refreshOutline,
34
+ checkmarkOutline,
35
+ });
36
+ };
37
+
38
+ export const waitToFindDom = (nativeElement: HTMLElement, selector: string): Promise<void> => {
39
+ return new Promise<void>((resolve) => {
40
+ const interval = setInterval(() => {
41
+ const find = nativeElement.querySelector(selector);
42
+ if (find) {
43
+ clearInterval(interval);
44
+ resolve();
45
+ }
46
+ });
47
+ });
48
+ };
@@ -0,0 +1,5 @@
1
+ export enum PhotoEditorErrors {
2
+ initialize = 'input dom is not found',
3
+ cancel = 'user canceled',
4
+ type = 'upload file is not image.',
5
+ }
@@ -0,0 +1,19 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+
3
+ import { PhotoFileService } from './photo-file.service';
4
+ import { testConfig } from '../../../../util/test.config';
5
+
6
+ describe('PhotoService', () => {
7
+ let service: PhotoFileService;
8
+
9
+ beforeEach(() => {
10
+ TestBed.configureTestingModule({
11
+ providers: [...testConfig.providers],
12
+ });
13
+ service = TestBed.inject(PhotoFileService);
14
+ });
15
+
16
+ it('should be created', () => {
17
+ expect(service).toBeTruthy();
18
+ });
19
+ });
@@ -0,0 +1,161 @@
1
+ import { inject, Injectable, signal } from '@angular/core';
2
+ import { ActionSheetController, Platform } from '@ionic/angular/standalone';
3
+ import { Camera, CameraResultType, CameraSource, ImageOptions } from '@capacitor/camera';
4
+ import ImageEditor from 'tui-image-editor';
5
+ import { PhotoEditorErrors } from '../photoEditorErrors';
6
+ import { dictionaryForService } from '../dictionaries';
7
+ import { IDictionaryForService } from '../types';
8
+
9
+ @Injectable({
10
+ providedIn: 'root',
11
+ })
12
+ export class PhotoFileService {
13
+ private readonly $photoMaxSize = signal<number>(1000);
14
+ private readonly actionSheetCtrl = inject(ActionSheetController);
15
+ private readonly platform = inject(Platform);
16
+ private readonly dictionary = signal<IDictionaryForService>(dictionaryForService());
17
+
18
+ set photoMaxSize(value: number) {
19
+ this.$photoMaxSize.set(value);
20
+ }
21
+
22
+ set labels(d: IDictionaryForService) {
23
+ this.dictionary.update((value) => ({ ...value, ...d }));
24
+ }
25
+
26
+ async loadPhoto(limit: number): Promise<string[]> {
27
+ /**
28
+ * Using Input for browser
29
+ */
30
+ if (!this.platform.is('capacitor')) {
31
+ return this.getPictureFromBrowser();
32
+ }
33
+
34
+ const actionSheet = await this.actionSheetCtrl.create({
35
+ buttons: [
36
+ {
37
+ text: this.dictionary().camera,
38
+ handler: () => {
39
+ actionSheet.dismiss('camera');
40
+ },
41
+ },
42
+ {
43
+ text: this.dictionary().album,
44
+ handler: () => {
45
+ actionSheet.dismiss('album');
46
+ },
47
+ },
48
+ {
49
+ text: this.dictionary().cancel,
50
+ role: 'cancel',
51
+ },
52
+ ],
53
+ });
54
+ await actionSheet.present();
55
+ const { data } = await actionSheet.onDidDismiss<'camera' | 'album'>();
56
+ if (!data) {
57
+ return Promise.reject(PhotoEditorErrors.cancel);
58
+ }
59
+
60
+ if (data === 'camera') {
61
+ const defaultCamera: ImageOptions = {
62
+ quality: 100,
63
+ width: this.$photoMaxSize(),
64
+ allowEditing: false,
65
+ resultType: CameraResultType.DataUrl,
66
+ source: CameraSource.Camera,
67
+ presentationStyle: 'popover',
68
+ };
69
+ const image = await Camera.getPhoto(defaultCamera).catch(() => undefined);
70
+ if (!image?.dataUrl) {
71
+ return Promise.reject(PhotoEditorErrors.cancel);
72
+ }
73
+
74
+ if (!image.dataUrl.includes('capacitor://localhost')) {
75
+ return [image.dataUrl];
76
+ }
77
+ return Promise.all([image.dataUrl].map(async (image) => await this.loadPhotoFromFilePath(image)));
78
+ }
79
+
80
+ if (data === 'album') {
81
+ const images = await Camera.pickImages({
82
+ quality: 100,
83
+ width: this.$photoMaxSize(),
84
+ limit,
85
+ presentationStyle: 'popover',
86
+ }).catch(() => undefined);
87
+
88
+ if (!images) {
89
+ return Promise.reject(PhotoEditorErrors.cancel);
90
+ }
91
+
92
+ return Promise.all(images.photos.map(async (image) => await this.loadPhotoFromFilePath(image.webPath)));
93
+ }
94
+
95
+ // Not run on this line. This is for lint
96
+ return [];
97
+ }
98
+
99
+ private getPictureFromBrowser(): Promise<string[]> {
100
+ const inputFile: HTMLInputElement | null = document.querySelector('input#browserPhotoUploader');
101
+
102
+ if (!inputFile) {
103
+ return Promise.reject(PhotoEditorErrors.initialize);
104
+ }
105
+
106
+ return new Promise((resolve, reject) => {
107
+ const cancelMethod = () => {
108
+ inputFile!.removeEventListener('cancel', cancelMethod, false);
109
+ inputFile!.removeEventListener('change', changeMethod, false);
110
+
111
+ reject(PhotoEditorErrors.cancel);
112
+ };
113
+ const changeMethod = (e: Event) => {
114
+ inputFile!.removeEventListener('cancel', cancelMethod, false);
115
+ inputFile!.removeEventListener('change', changeMethod, false);
116
+
117
+ if (!(e.target as HTMLInputElement).files || !(e.target as HTMLInputElement).files![0]) {
118
+ reject(PhotoEditorErrors.cancel);
119
+ }
120
+ const file = (e.target as HTMLInputElement).files![0];
121
+ const reader = new FileReader();
122
+
123
+ reader.onload = (() => {
124
+ if (file.type.indexOf('image') < 0) {
125
+ reject(PhotoEditorErrors.type);
126
+ }
127
+
128
+ return async (event) => {
129
+ inputFile.value = '';
130
+ const result = event.target!.result as string;
131
+ const data = await this.loadPhotoFromFilePath(result);
132
+ resolve([data]);
133
+ };
134
+ })();
135
+
136
+ reader.readAsDataURL(file);
137
+ };
138
+
139
+ inputFile!.addEventListener('cancel', cancelMethod, false);
140
+ inputFile!.addEventListener('change', changeMethod, false);
141
+ inputFile.click();
142
+ });
143
+ }
144
+
145
+ private async loadPhotoFromFilePath(filePath: string): Promise<string> {
146
+ const defaultInstance = new ImageEditor(document.createElement('div'), {
147
+ cssMaxWidth: this.$photoMaxSize(),
148
+ cssMaxHeight: this.$photoMaxSize(),
149
+ });
150
+ const blob = await fetch(filePath).then((res) => res.blob());
151
+ const loaded = await defaultInstance.loadImageFromFile(new File([blob], 'data.png', { type: blob.type }));
152
+
153
+ const maxSize = Math.max(loaded.newWidth, loaded.newHeight);
154
+ const dataUrl = defaultInstance.toDataURL({
155
+ multiplier: this.$photoMaxSize() / maxSize,
156
+ });
157
+ defaultInstance.destroy();
158
+
159
+ return dataUrl;
160
+ }
161
+ }
@@ -0,0 +1,55 @@
1
+ export interface IFilter {
2
+ name: string;
3
+ type: string;
4
+ option: any;
5
+ data: string;
6
+ width: number;
7
+ height: number;
8
+ }
9
+
10
+ export interface ISize {
11
+ width: number;
12
+ height: number;
13
+ }
14
+
15
+ export interface IDictionaryForEditor {
16
+ save: string;
17
+ crop: string;
18
+ filter: string;
19
+ brightness: string;
20
+ original: string;
21
+ invert: string;
22
+ sepia: string;
23
+ vintage: string;
24
+ blur: string;
25
+ grayscale: string;
26
+ sharpen: string;
27
+ emboss: string;
28
+ }
29
+
30
+ export interface IFilterPreset {
31
+ name: string;
32
+ type: string;
33
+ option: any;
34
+ }
35
+
36
+ export interface IPhotoEditorDismiss {
37
+ value: string;
38
+ }
39
+
40
+ export interface IPhotoViewerDismiss {
41
+ delete: {
42
+ index: number;
43
+ value: string;
44
+ };
45
+ }
46
+
47
+ export interface IDictionaryForViewer {
48
+ delete: string;
49
+ }
50
+
51
+ export interface IDictionaryForService {
52
+ camera: string;
53
+ album: string;
54
+ cancel: string;
55
+ }
@@ -0,0 +1,8 @@
1
+ /*
2
+ * Public API Surface of photo-editor
3
+ */
4
+
5
+ export * from './lib/pages/photo-editor/photo-editor.page';
6
+ export * from './lib/pages/photo-viewer/photo-viewer.page';
7
+ export * from './lib/services/photo-file.service';
8
+ export * from './lib/types';
@@ -0,0 +1,14 @@
1
+ /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
+ {
3
+ "extends": "../../tsconfig.json",
4
+ "compilerOptions": {
5
+ "outDir": "../../out-tsc/lib",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "inlineSources": true,
9
+ "types": []
10
+ },
11
+ "exclude": [
12
+ "**/*.spec.ts"
13
+ ]
14
+ }
@@ -0,0 +1,10 @@
1
+ /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
+ {
3
+ "extends": "./tsconfig.lib.json",
4
+ "compilerOptions": {
5
+ "declarationMap": false
6
+ },
7
+ "angularCompilerOptions": {
8
+ "compilationMode": "partial"
9
+ }
10
+ }
@@ -0,0 +1,14 @@
1
+ /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
+ {
3
+ "extends": "../../tsconfig.json",
4
+ "compilerOptions": {
5
+ "outDir": "../../out-tsc/spec",
6
+ "types": [
7
+ "jasmine"
8
+ ]
9
+ },
10
+ "include": [
11
+ "**/*.spec.ts",
12
+ "**/*.d.ts"
13
+ ]
14
+ }