diff --git a/src/app/editor/data-access/color-editor.service.ts b/src/app/editor/data-access/color-editor.service.ts index fe751b5..6fbd616 100644 --- a/src/app/editor/data-access/color-editor.service.ts +++ b/src/app/editor/data-access/color-editor.service.ts @@ -3,6 +3,7 @@ import { Injectable, inject, signal } from '@angular/core'; import { firstValueFrom, tap } from 'rxjs'; import { Color } from '../../shared/model'; import { sleep } from '../../shared/utils/sleep'; +import { EditorData } from '../editor.component'; @Injectable({ providedIn: 'root' @@ -18,7 +19,7 @@ export class ColorEditorService { this._isModalOpen.set(true); const editor = await import('../editor.component').then((c) => c.EditorComponent); - const dialogRef = this._dialog.open(editor, { + const dialogRef = this._dialog.open(editor, { backdropClass: 'rp-modal-backdrop', data: { color, diff --git a/src/app/editor/editor.component.html b/src/app/editor/editor.component.html index 782a23b..9ce0246 100644 --- a/src/app/editor/editor.component.html +++ b/src/app/editor/editor.component.html @@ -1,5 +1,4 @@

{{ color().name }}

@@ -13,7 +12,7 @@

{{ color().name }}

/>
- {{ color().name }} min="0" /> - {{ color().name }} key="saturation" /> - (DIALOG_DATA); - private readonly _dialogRef = inject(DialogRef); - private readonly _colorService = inject(ColorService); - private readonly _translateService = inject(TranslateService); + readonly #data = inject(DIALOG_DATA); + readonly #dialogRef = inject(DialogRef); + readonly #colorService = inject(ColorService); + readonly #translateService = inject(TranslateService); - protected readonly color = signal(this._data.color.copy()); - protected readonly shadeIndex = signal(this._data.shadeIndex ?? 0); + protected readonly color = signal(this.#data.color.copy()); + protected readonly shadeIndex = signal(this.#data.shadeIndex ?? 0); protected readonly shade = computed(() => { const selectedShade = this.color().shades.find((shade) => shade.index === this.shadeIndex()); @@ -48,20 +67,12 @@ export class EditorComponent { }); protected readonly hasUnsavedChanges = computed( - () => this._data.color.toString() !== this.color().toString() + () => this.#data.color.toString() !== this.color().toString() ); - private readonly _editor = viewChild.required>('editor'); - public constructor() { effect(() => { - this._editor().nativeElement.style.setProperty('--editor-hue', `${this.shade().hsl.H}`); - this._editor().nativeElement.style.setProperty('--editor-saturation', `${this.shade().hsl.S}%`); - this._editor().nativeElement.style.setProperty('--editor-lightness', `${this.shade().hsl.L}%`); - }); - - effect(() => { - this._dialogRef.disableClose = this.hasUnsavedChanges(); + this.#dialogRef.disableClose = this.hasUnsavedChanges(); }); } @@ -86,7 +97,7 @@ export class EditorComponent { } shade.fixed = false; - this._updateColor(); + this.updateColor(); } public update(type: UpdateType, value: string | number): void { @@ -108,24 +119,24 @@ export class EditorComponent { break; } - this._updateColor(); + this.updateColor(); const editedShade = this.color().shades.find((s) => s.hex === shade.hex); this.shadeIndex.set(editedShade?.index ?? -1); } protected cancel(): void { - this._dialogRef.close(undefined); - this.color.set(this._data.color.copy()); + this.#dialogRef.close(undefined); + this.color.set(this.#data.color.copy()); } protected save(): void { - this._dialogRef.close(this.color()); + this.#dialogRef.close(this.color()); } - private _updateColor(): void { + private updateColor(): void { const updatedColor = this.color().copy(); - this._colorService.regenerateShades(updatedColor); + this.#colorService.regenerateShades(updatedColor); this.color.set(updatedColor); } @@ -133,11 +144,11 @@ export class EditorComponent { const tooltips: Array = []; if (!selected) { - tooltips.push(this._translateService.instant('editor.shades')); + tooltips.push(this.#translateService.instant('editor.shades')); } if (shade.fixed) { - tooltips.push(this._translateService.instant('editor.unfix')); + tooltips.push(this.#translateService.instant('editor.unfix')); } return tooltips.join('\n'); diff --git a/src/app/editor/ui/editor-range/editor-range.component.css b/src/app/editor/ui/editor-range/editor-range.component.css deleted file mode 100644 index d1ed4b9..0000000 --- a/src/app/editor/ui/editor-range/editor-range.component.css +++ /dev/null @@ -1,39 +0,0 @@ -:host { - display: block; -} - -input[type='range']::-webkit-slider-thumb { - background-color: hsl(var(--editor-hue), var(--editor-saturation), var(--editor-lightness)); -} - -input[type='range']::-moz-range-thumb { - background-color: hsl(var(--editor-hue), var(--editor-saturation), var(--editor-lightness)); -} - -.hue input[type='range'] { - background: linear-gradient( - 90deg, - hsl(0, var(--editor-saturation), var(--editor-lightness)) 0%, - hsl(60, var(--editor-saturation), var(--editor-lightness)) 33.3%, - hsl(120, var(--editor-saturation), var(--editor-lightness)) 50%, - hsl(240, var(--editor-saturation), var(--editor-lightness)) 66.6%, - hsl(360, var(--editor-saturation), var(--editor-lightness)) 100% - ); -} - -.saturation input[type='range'] { - background: linear-gradient( - 90deg, - hsl(var(--editor-hue), 0%, var(--editor-lightness)) 0%, - hsl(var(--editor-hue), 100%, var(--editor-lightness)) 100% - ); -} - -.lightness input[type='range'] { - background: linear-gradient( - 90deg, - hsl(var(--editor-hue), var(--editor-saturation), 100%) 0%, - hsl(var(--editor-hue), var(--editor-saturation), 50%) 50%, - hsl(var(--editor-hue), var(--editor-saturation), 0%) 100% - ); -} diff --git a/src/app/editor/ui/editor-range/editor-range.component.html b/src/app/editor/ui/editor-range/editor-range.component.html deleted file mode 100644 index c333306..0000000 --- a/src/app/editor/ui/editor-range/editor-range.component.html +++ /dev/null @@ -1,26 +0,0 @@ -
- - - -
diff --git a/src/app/export/data-access/export-modal.service.ts b/src/app/export/data-access/export-modal.service.ts index 12d5479..51ecd4c 100644 --- a/src/app/export/data-access/export-modal.service.ts +++ b/src/app/export/data-access/export-modal.service.ts @@ -2,6 +2,7 @@ import { Dialog } from '@angular/cdk/dialog'; import { Injectable, inject, signal } from '@angular/core'; import { firstValueFrom, tap } from 'rxjs'; import { Palette } from '../../shared/model'; +import { ExportModalData } from '../export-modal.component'; @Injectable({ providedIn: 'root' @@ -18,7 +19,7 @@ export class ExportModalService { this._isModalOpen.set(true); const exportModal = await import('../export-modal.component').then((c) => c.ExportModalComponent); - const dialogRef = this._dialog.open(exportModal, { + const dialogRef = this._dialog.open(exportModal, { backdropClass: 'rp-modal-backdrop', data: { palette diff --git a/src/app/export/export-modal.component.ts b/src/app/export/export-modal.component.ts index 3169a2b..bbd695a 100644 --- a/src/app/export/export-modal.component.ts +++ b/src/app/export/export-modal.component.ts @@ -16,6 +16,16 @@ import { ExportFormatComponent } from './ui/export-format/export-format.componen import { ExportSuccessComponent } from './ui/export-success/export-success.component'; import { RequestFormatComponent } from './ui/request-format/request-format.component'; +/** + * Data for the export modal. + */ +export type ExportModalData = { + /** + * Palette to export. + */ + palette: Palette; +}; + enum ExportModalState { FORMAT = 'format', DOWNLOAD = 'download', @@ -39,7 +49,7 @@ export class ExportModalComponent { protected readonly ExportModalState = ExportModalState; protected readonly ExportFormat = ExportFormat; - private readonly _data = inject<{ palette: Palette }>(DIALOG_DATA); + private readonly _data = inject(DIALOG_DATA); private readonly _dialogRef = inject(DialogRef); private readonly _toastService = inject(ToastService); private readonly _exportService = inject(ExportService); diff --git a/src/app/home/home.component.spec.ts b/src/app/home/home.component.spec.ts index da32cf9..7593615 100644 --- a/src/app/home/home.component.spec.ts +++ b/src/app/home/home.component.spec.ts @@ -2,7 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { AnalyticsService, AnalyticsServiceMock } from '../shared/data-access/analytics.service'; -import { PaletteService, PaletteServiceMock } from '../shared/data-access/palette.service'; +import { PaletteService } from '../shared/data-access/palette.service'; +import { PaletteServiceMock } from '../shared/data-access/palette.service-mock'; import { HomeService, HomeServiceMock } from './data-access/home.service'; import HomeComponent from './home.component'; diff --git a/src/app/list/list.component.spec.ts b/src/app/list/list.component.spec.ts index b388790..b2d1ab3 100644 --- a/src/app/list/list.component.spec.ts +++ b/src/app/list/list.component.spec.ts @@ -3,7 +3,8 @@ import { ActivatedRoute } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { DialogService, DialogServiceMock } from '../shared/data-access/dialog.service'; import { ListService, ListServiceMock } from '../shared/data-access/list.service'; -import { PaletteService, PaletteServiceMock } from '../shared/data-access/palette.service'; +import { PaletteService } from '../shared/data-access/palette.service'; +import { PaletteServiceMock } from '../shared/data-access/palette.service-mock'; import ListComponent from './list.component'; describe('ListComponent', () => { diff --git a/src/app/shared/constants/tailwind-colors.ts b/src/app/shared/constants/tailwind-colors.ts index b32045e..177f069 100644 --- a/src/app/shared/constants/tailwind-colors.ts +++ b/src/app/shared/constants/tailwind-colors.ts @@ -331,24 +331,28 @@ export const rose = new Color( 'Rose' ); -export const TailwindRainbow = new Palette('Tailwind Rainbow', [ - red, - orange, - amber, - yellow, - lime, - green, - emerald, - teal, - cyan, - sky, - blue, - indigo, - violet, - purple, - fuchsia, - pink, - rose -]); -export const TailwindGrays = new Palette('Tailwind Grays', [slate, gray, zinc, neutral, stone]); -export const Tailwind = new Palette('Tailwind', [...TailwindRainbow.colors, ...TailwindGrays.colors]); +export const TailwindRainbow = new Palette( + 'Tailwind Rainbow', + [ + red, + orange, + amber, + yellow, + lime, + green, + emerald, + teal, + cyan, + sky, + blue, + indigo, + violet, + purple, + fuchsia, + pink, + rose + ], + 'tailwind-rainbow' +); +export const TailwindGrays = new Palette('Tailwind Grays', [slate, gray, zinc, neutral, stone], 'tailwind-grays'); +export const Tailwind = new Palette('Tailwind', [...TailwindRainbow.colors, ...TailwindGrays.colors], 'tailwind'); diff --git a/src/app/shared/data-access/confetti.service.spec.ts b/src/app/shared/data-access/confetti.service.spec.ts index bb03198..4cdda1e 100644 --- a/src/app/shared/data-access/confetti.service.spec.ts +++ b/src/app/shared/data-access/confetti.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { ConfettiService } from './confetti.service'; -import { PaletteService, PaletteServiceMock } from './palette.service'; +import { PaletteService } from './palette.service'; +import { PaletteServiceMock } from './palette.service-mock'; describe('ConfettiService', () => { let service: ConfettiService; diff --git a/src/app/shared/data-access/list.service.ts b/src/app/shared/data-access/list.service.ts index 09addc5..7c8bcc1 100644 --- a/src/app/shared/data-access/list.service.ts +++ b/src/app/shared/data-access/list.service.ts @@ -72,7 +72,12 @@ export class ListService { } export class ListServiceMock { - public add(_palette: Palette): void {} - public remove(_id: string): void {} - public list$ = new BehaviorSubject>([{ id: 'test-id', name: 'Test palette' }]).asObservable(); + readonly #list$ = new BehaviorSubject>([{ id: 'test-id', name: 'Test palette' }]); + public add(palette: Palette): void { + this.#list$.next([...this.#list$.value, palette]); + } + public remove(_id: string): void { + this.#list$.next(this.#list$.value.filter((palette) => palette.id !== _id)); + } + public list$ = this.#list$.asObservable(); } diff --git a/src/app/shared/data-access/palette.service-mock.ts b/src/app/shared/data-access/palette.service-mock.ts new file mode 100644 index 0000000..449205e --- /dev/null +++ b/src/app/shared/data-access/palette.service-mock.ts @@ -0,0 +1,39 @@ +import { signal } from '@angular/core'; +import { PaletteScheme } from '../constants/palette-scheme'; +import { Tailwind, TailwindGrays, TailwindRainbow } from '../constants/tailwind-colors'; +import { Color } from '../model/color.model'; +import { Palette } from '../model/palette.model'; +import { Shade } from '../model/shade.model'; + +/** + * The original service is located at ./palette.service.ts + * This mock in in this separate file to not include the Tailwind example palettes in the production bundle. + */ + +export class PaletteServiceMock { + public readonly palette = signal( + new Palette('Mock', [new Color([Shade.random()], 'MockColor')], 'test-id') + ); + public readonly isGenerating = signal(false); + public loadPaletteFromLocalStorage(id: string, _onlyReturn: boolean): Palette | undefined { + switch (id) { + case 'test-id': + return this.palette(); + case TailwindRainbow.id: + return TailwindRainbow; + case TailwindGrays.id: + return TailwindGrays; + case Tailwind.id: + return Tailwind; + default: + return undefined; + } + } + public savePaletteToLocalStorage(_upgrade: boolean): void {} + public duplicatePalette(_name?: string): string { + return 'test-id'; + } + public async generatePalette(_hex: string, _scheme: PaletteScheme): Promise { + return 'test-id'; + } +} diff --git a/src/app/shared/data-access/palette.service.spec.ts b/src/app/shared/data-access/palette.service.spec.ts index aa234c7..8e7510b 100644 --- a/src/app/shared/data-access/palette.service.spec.ts +++ b/src/app/shared/data-access/palette.service.spec.ts @@ -89,6 +89,16 @@ describe('PaletteService', () => { expect(palette!.id).toBe('test'); }); + it('should load palette from local storage (onlyReturn)', () => { + localStorage.setItem(`${LocalStorageKey.PALETTE}_test`, JSON.stringify({ name: 'Test', id: 'test', colors: [] })); + const palette = service.loadPaletteFromLocalStorage('test', true); + + expect(palette).toBeTruthy(); + expect(palette!.name).toBe('Test'); + expect(palette!.id).toBe('test'); + expect(service.palette()).toBeFalsy(); + }); + it('should show error toast when loading invalid palette', () => { localStorage.setItem(`${LocalStorageKey.PALETTE}_test`, 'invalid'); service.loadPaletteFromLocalStorage('test'); diff --git a/src/app/shared/data-access/palette.service.ts b/src/app/shared/data-access/palette.service.ts index 895b607..5bfcbfc 100644 --- a/src/app/shared/data-access/palette.service.ts +++ b/src/app/shared/data-access/palette.service.ts @@ -53,25 +53,41 @@ export class PaletteService { } } - public loadPaletteFromLocalStorage(id: string): void { - if (this.palette()?.id === id) { - return; + /** + * Load a palette from local storage. + * + * If `onlyReturn` is true, the palette will not be set as the current palette. + */ + public loadPaletteFromLocalStorage(id: string, onlyReturn = false): Palette | undefined { + // Check if the palette is already loaded + const currentPalette = this._palette(); + if (currentPalette && currentPalette.id === id) { + return currentPalette; } // Check if there was a palette stored for an app update - let palette = localStorage.getItem(LocalStorageKey.PALETTE_TMP); + let paletteString = localStorage.getItem(LocalStorageKey.PALETTE_TMP); - if (palette) { + if (paletteString) { // Palette was stored for an update, remove it now localStorage.removeItem(LocalStorageKey.PALETTE_TMP); } else { // Load the palette saved by the user - palette = localStorage.getItem(`${LocalStorageKey.PALETTE}_${id}`); + paletteString = localStorage.getItem(`${LocalStorageKey.PALETTE}_${id}`); } - if (palette) { + if (paletteString) { try { - this._palette.set(Palette.parse(palette)); + // Parse the palette + const palette = Palette.parse(paletteString); + + // Set the palette if it should not only be returned + if (!onlyReturn) { + this._palette.set(palette); + } + + // Return the palette + return palette; } catch (e) { this._toastService.showToast({ type: 'error', @@ -79,6 +95,9 @@ export class PaletteService { }); } } + + // No valid palette found + return undefined; } public savePaletteToLocalStorage(upgrade = false): void { @@ -545,14 +564,8 @@ export class PaletteService { } } -export class PaletteServiceMock { - public palette = signal( - new Palette('Mock', [new Color([Shade.random()], 'MockColor')], 'test-id') - ); - public isGenerating = signal(false); - public loadPaletteFromLocalStorage(): void {} - public savePaletteToLocalStorage(): void {} - public generatePalette(_hex: string, _scheme: PaletteScheme): string { - return 'test-id'; - } -} +/** + * The corresponding mock for this service is located in ./palette.service-mock.ts + * This is because the mock uses the Tailwind example palettes under the hood. + * We don't want to include these in the production bundle, but only in the testing environment keeping the bundle size smaller. + */ diff --git a/src/app/shared/data-access/pwa.service.spec.ts b/src/app/shared/data-access/pwa.service.spec.ts index 8f24e74..9b99f6e 100644 --- a/src/app/shared/data-access/pwa.service.spec.ts +++ b/src/app/shared/data-access/pwa.service.spec.ts @@ -7,7 +7,8 @@ import { SwUpdateMock } from '../utils/sw-update-mock'; import { AnalyticsService, AnalyticsServiceMock } from './analytics.service'; import { ConfettiService, ConfettiServiceMock } from './confetti.service'; import { DialogService, DialogServiceMock } from './dialog.service'; -import { PaletteService, PaletteServiceMock } from './palette.service'; +import { PaletteService } from './palette.service'; +import { PaletteServiceMock } from './palette.service-mock'; import { PwaService } from './pwa.service'; import { ToastService, ToastServiceMock } from './toast.service'; import { VersionService, VersionServiceMock } from './version.service'; @@ -106,7 +107,6 @@ describe('PwaService', () => { expect(dialogService.confirm).toHaveBeenCalledTimes(1); expect(paletteService.savePaletteToLocalStorage).toHaveBeenCalledTimes(1); - // @ts-expect-error Function has an optional parameter which does not get detected here expect(paletteService.savePaletteToLocalStorage).toHaveBeenCalledWith(true); expect(analyticsService.trackEvent).toHaveBeenCalledWith( TrackingEventCategory.PWA, diff --git a/src/app/shared/enums/tracking-event.ts b/src/app/shared/enums/tracking-event.ts index bcee2dc..3459d82 100644 --- a/src/app/shared/enums/tracking-event.ts +++ b/src/app/shared/enums/tracking-event.ts @@ -18,6 +18,10 @@ export enum TrackingEventCategory { * User generates a palette. */ GENERATE_PALETTE = 'GENERATE_PALETTE', + /** + * Import color. + */ + IMPORT_COLOR = 'IMPORT_COLOR', /** * PWA events */ @@ -61,6 +65,10 @@ export enum TrackingEventAction { * User generates a palette. */ GENERATE_PALETTE = 'GENERATE_PALETTE', + /** + * User imports a color. + */ + IMPORT_COLOR = 'IMPORT_COLOR', /** * User installs the website as a PWA. */ diff --git a/src/app/shared/ui/color-range-slider/color-range-slider.component.css b/src/app/shared/ui/color-range-slider/color-range-slider.component.css new file mode 100644 index 0000000..e898e80 --- /dev/null +++ b/src/app/shared/ui/color-range-slider/color-range-slider.component.css @@ -0,0 +1,43 @@ +:host { + display: block; +} + +input[type='range']::-webkit-slider-thumb { + background-color: hsl(var(--editor-hue), var(--editor-saturation), var(--editor-lightness)); +} + +input[type='range']::-moz-range-thumb { + background-color: hsl(var(--editor-hue), var(--editor-saturation), var(--editor-lightness)); +} + +input[type='range'] { + &.hue { + background: linear-gradient( + 90deg, + hsl(0, var(--editor-saturation), var(--editor-lightness)) 0%, + hsl(60, var(--editor-saturation), var(--editor-lightness)) 33.3%, + hsl(120, var(--editor-saturation), var(--editor-lightness)) 50%, + hsl(180, var(--editor-saturation), var(--editor-lightness)) 58.3%, + hsl(240, var(--editor-saturation), var(--editor-lightness)) 66.6%, + hsl(300, var(--editor-saturation), var(--editor-lightness)) 83.4%, + hsl(360, var(--editor-saturation), var(--editor-lightness)) 100% + ); + } + + &.saturation { + background: linear-gradient( + 90deg, + hsl(var(--editor-hue), 0%, var(--editor-lightness)) 0%, + hsl(var(--editor-hue), 100%, var(--editor-lightness)) 100% + ); + } + + &.lightness { + background: linear-gradient( + 90deg, + hsl(var(--editor-hue), var(--editor-saturation), 100%) 0%, + hsl(var(--editor-hue), var(--editor-saturation), 50%) 50%, + hsl(var(--editor-hue), var(--editor-saturation), 0%) 100% + ); + } +} diff --git a/src/app/shared/ui/color-range-slider/color-range-slider.component.html b/src/app/shared/ui/color-range-slider/color-range-slider.component.html new file mode 100644 index 0000000..e48dbd1 --- /dev/null +++ b/src/app/shared/ui/color-range-slider/color-range-slider.component.html @@ -0,0 +1,25 @@ + + + diff --git a/src/app/editor/ui/editor-range/editor-range.component.spec.ts b/src/app/shared/ui/color-range-slider/color-range-slider.component.spec.ts similarity index 68% rename from src/app/editor/ui/editor-range/editor-range.component.spec.ts rename to src/app/shared/ui/color-range-slider/color-range-slider.component.spec.ts index 310be63..5fcf6c1 100644 --- a/src/app/editor/ui/editor-range/editor-range.component.spec.ts +++ b/src/app/shared/ui/color-range-slider/color-range-slider.component.spec.ts @@ -1,17 +1,17 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { EditorRangeComponent } from './editor-range.component'; +import { ColorRangeSliderComponent } from './color-range-slider.component'; -describe('EditorRangeComponent', () => { - let component: EditorRangeComponent; - let fixture: ComponentFixture; +describe('ColorRangeSliderComponent', () => { + let component: ColorRangeSliderComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [EditorRangeComponent] + imports: [ColorRangeSliderComponent] }).compileComponents(); - fixture = TestBed.createComponent(EditorRangeComponent); + fixture = TestBed.createComponent(ColorRangeSliderComponent); component = fixture.componentInstance; // @ts-expect-error - Bind required input signal diff --git a/src/app/editor/ui/editor-range/editor-range.component.stories.ts b/src/app/shared/ui/color-range-slider/color-range-slider.component.stories.ts similarity index 59% rename from src/app/editor/ui/editor-range/editor-range.component.stories.ts rename to src/app/shared/ui/color-range-slider/color-range-slider.component.stories.ts index 3501859..23ae48f 100644 --- a/src/app/editor/ui/editor-range/editor-range.component.stories.ts +++ b/src/app/shared/ui/color-range-slider/color-range-slider.component.stories.ts @@ -1,10 +1,10 @@ import { Meta, argsToTemplate } from '@storybook/angular'; -import { createStory } from '../../../shared/utils/storybook'; -import { EditorRangeComponent } from './editor-range.component'; +import { createStory } from '../../utils/storybook'; +import { ColorRangeSliderComponent } from './color-range-slider.component'; -const meta: Meta = { - title: 'Editor/Range', - component: EditorRangeComponent, +const meta: Meta = { + title: 'Shared/Color Range Slider', + component: ColorRangeSliderComponent, tags: ['autodocs'], argTypes: { key: { @@ -24,7 +24,7 @@ const meta: Meta = { }; export default meta; -export const Hue = createStory({ +export const Hue = createStory({ args: { label: 'Hue', tooltip: 'Adjust the hue', @@ -35,11 +35,11 @@ export const Hue = createStory({ render: (args) => ({ template: `
- +
` }) }); -export const Saturation = createStory({ +export const Saturation = createStory({ args: { label: 'Saturation', tooltip: 'Adjust the saturation', @@ -49,11 +49,11 @@ export const Saturation = createStory({ render: (args) => ({ template: `
- +
` }) }); -export const Lightness = createStory({ +export const Lightness = createStory({ args: { label: 'Lightness', tooltip: 'Adjust the lightness', @@ -63,7 +63,7 @@ export const Lightness = createStory({ render: (args) => ({ template: `
- +
` }) }); diff --git a/src/app/editor/ui/editor-range/editor-range.component.ts b/src/app/shared/ui/color-range-slider/color-range-slider.component.ts similarity index 89% rename from src/app/editor/ui/editor-range/editor-range.component.ts rename to src/app/shared/ui/color-range-slider/color-range-slider.component.ts index b6aa2c9..90ac64c 100644 --- a/src/app/editor/ui/editor-range/editor-range.component.ts +++ b/src/app/shared/ui/color-range-slider/color-range-slider.component.ts @@ -3,13 +3,13 @@ import { Component, computed, input, model, numberAttribute } from '@angular/cor import { hueToWheel, wheelToHue } from '../../utils/color-wheel'; @Component({ - selector: 'rp-editor-range', + selector: 'rp-color-range-slider', standalone: true, imports: [DecimalPipe], - templateUrl: './editor-range.component.html', - styleUrl: './editor-range.component.css' + templateUrl: './color-range-slider.component.html', + styleUrl: './color-range-slider.component.css' }) -export class EditorRangeComponent { +export class ColorRangeSliderComponent { /** * Label to display above the range input */ diff --git a/src/app/editor/utils/color-wheel.ts b/src/app/shared/utils/color-wheel.ts similarity index 100% rename from src/app/editor/utils/color-wheel.ts rename to src/app/shared/utils/color-wheel.ts diff --git a/src/app/shared/utils/filter-array.ts b/src/app/shared/utils/filter-array.ts new file mode 100644 index 0000000..ef64d95 --- /dev/null +++ b/src/app/shared/utils/filter-array.ts @@ -0,0 +1,8 @@ +import { map, OperatorFunction } from 'rxjs'; + +/** + * Filters an array based on a filter function. + */ +export function filterArray(filterFn: (item: T, index: number) => boolean): OperatorFunction, Array> { + return map((array: Array) => array.filter((item, index) => filterFn(item, index))); +} diff --git a/src/app/shared/utils/map-array.ts b/src/app/shared/utils/map-array.ts new file mode 100644 index 0000000..39c5a6d --- /dev/null +++ b/src/app/shared/utils/map-array.ts @@ -0,0 +1,8 @@ +import { map, OperatorFunction } from 'rxjs'; + +/** + * Maps an array using the provided function. + */ +export function mapArray(mapFn: (item: T, index: number) => R): OperatorFunction, Array> { + return map((array: Array) => array.map((item, index) => mapFn(item, index))); +} diff --git a/src/app/view/ui/import-color/import-color.component.html b/src/app/view/ui/import-color/import-color.component.html new file mode 100644 index 0000000..8027c06 --- /dev/null +++ b/src/app/view/ui/import-color/import-color.component.html @@ -0,0 +1,139 @@ +
+
+
+

+ {{ 'view.import.title' | translate }} +

+ + +
+ +

+ {{ 'view.import.description' | translate }} +

+
+ +
+
+ @if (mode() === 'text') { + + } @else { + + } + + +
+
+ +
+ @let palettes = filteredPalettes(); + @if (initialized() && palettes) { + @if (palettes.length > 0) { + +
+ @if (item.type === 'palette') { + + {{ item.name }} + + } @else { + + } +
+
+ } @else if (searchTerm() || mode() === 'hue') { +
+

+ {{ 'view.import.not-found' | translate }} +

+ + @if (mode() === 'text') { + + } +
+ } @else { +
+

+ {{ 'view.import.no-colors' | translate }} +

+
+ } + } +
+
diff --git a/src/app/view/ui/import-color/import-color.component.spec.ts b/src/app/view/ui/import-color/import-color.component.spec.ts new file mode 100644 index 0000000..697dbce --- /dev/null +++ b/src/app/view/ui/import-color/import-color.component.spec.ts @@ -0,0 +1,44 @@ +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ListService, ListServiceMock } from '../../../shared/data-access/list.service'; +import { PaletteService } from '../../../shared/data-access/palette.service'; +import { PaletteServiceMock } from '../../../shared/data-access/palette.service-mock'; +import { ImportColorComponent } from './import-color.component'; + +describe('ImportColorComponent', () => { + let component: ImportColorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ImportColorComponent, TranslateModule.forRoot()], + providers: [ + { + provide: DIALOG_DATA, + useValue: { paletteId: 'test' } + }, + { + provide: DialogRef, + useValue: {} + }, + { + provide: ListService, + useClass: ListServiceMock + }, + { + provide: PaletteService, + useClass: PaletteServiceMock + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ImportColorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/view/ui/import-color/import-color.component.stories.ts b/src/app/view/ui/import-color/import-color.component.stories.ts new file mode 100644 index 0000000..26a7ee0 --- /dev/null +++ b/src/app/view/ui/import-color/import-color.component.stories.ts @@ -0,0 +1,45 @@ +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { Meta, applicationConfig } from '@storybook/angular'; +import { Tailwind, TailwindGrays, TailwindRainbow } from '../../../shared/constants/tailwind-colors'; +import { ListService, ListServiceMock } from '../../../shared/data-access/list.service'; +import { PaletteService } from '../../../shared/data-access/palette.service'; +import { PaletteServiceMock } from '../../../shared/data-access/palette.service-mock'; +import { createStory } from '../../../shared/utils/storybook'; +import { ImportColorComponent } from './import-color.component'; + +const listService = new ListServiceMock(); +listService.remove('test-id'); +listService.add(TailwindRainbow); +listService.add(TailwindGrays); +listService.add(Tailwind); + +const meta: Meta = { + title: 'View/Import Color', + component: ImportColorComponent, + tags: ['autodocs'], + decorators: [ + applicationConfig({ + providers: [ + { + provide: DIALOG_DATA, + useValue: { paletteId: 'test' } + }, + { + provide: DialogRef, + useValue: {} + }, + { + provide: ListService, + useValue: listService + }, + { + provide: PaletteService, + useClass: PaletteServiceMock + } + ] + }) + ] +}; +export default meta; + +export const Import = createStory({}); diff --git a/src/app/view/ui/import-color/import-color.component.ts b/src/app/view/ui/import-color/import-color.component.ts new file mode 100644 index 0000000..6799337 --- /dev/null +++ b/src/app/view/ui/import-color/import-color.component.ts @@ -0,0 +1,304 @@ +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { NgIconComponent } from '@ng-icons/core'; +import { + heroArrowLeftMini, + heroEyeDropperMini, + heroMagnifyingGlassMini, + heroXMarkMini +} from '@ng-icons/heroicons/mini'; +import { TranslateModule } from '@ngx-translate/core'; +import { debounce, debounceTime, of, timer } from 'rxjs'; +import { ListService } from '../../../shared/data-access/list.service'; +import { PaletteService } from '../../../shared/data-access/palette.service'; +import { Color, Palette } from '../../../shared/model'; +import { ColorRangeSliderComponent } from '../../../shared/ui/color-range-slider/color-range-slider.component'; +import { filterArray } from '../../../shared/utils/filter-array'; +import { mapArray } from '../../../shared/utils/map-array'; + +/** + * Data for the import color dialog. + */ +export type ImportColorData = { + /** + * The ID of the palette to import the color to. + */ + paletteId: string; +}; + +/** + * Palette with only the necessary properties for the import color dialog. + */ +type FilteredPalette = Pick; + +@Component({ + selector: 'rp-import-color', + standalone: true, + imports: [TranslateModule, NgIconComponent, ReactiveFormsModule, ScrollingModule, ColorRangeSliderComponent], + templateUrl: './import-color.component.html', + host: { + '[style.--editor-saturation]': '"75%"', + '[style.--editor-lightness]': '"50%"', + '[style.--editor-hue]': 'hueControl.value' + } +}) +export class ImportColorComponent implements OnInit { + readonly #data = inject(DIALOG_DATA); + readonly #dialogRef = inject(DialogRef); + readonly #listService = inject(ListService); + readonly #paletteService = inject(PaletteService); + + protected readonly ICONS = { + back: heroArrowLeftMini, + close: heroXMarkMini, + color: heroEyeDropperMini, + search: heroMagnifyingGlassMini + } as const; + + /** + * Search term form control. + */ + protected readonly searchControl = new FormControl('', { nonNullable: true }); + + /** + * Hue form control. + */ + protected readonly hueControl = new FormControl(0, { nonNullable: true }); + + /** + * List of all palettes except the current palette. + */ + readonly #palettes = toSignal( + this.#listService.list$.pipe( + filterArray((palette) => palette.id !== this.#data.paletteId), + mapArray((palette) => this.#paletteService.loadPaletteFromLocalStorage(palette.id, true)), + filterArray((palette) => palette !== undefined), + mapArray((palette) => palette!), + filterArray((palette) => palette.colors.length > 0) + ) + ); + + /** + * Current search term. + */ + protected readonly searchTerm = toSignal( + this.searchControl.valueChanges.pipe( + // Only debounce when a value is present, now when resetting to the empty search + debounce((value) => (value ? timer(300) : of({}))) + ), + { + initialValue: this.searchControl.value + } + ); + + /** + * Current hue value. + */ + protected readonly hue = toSignal(this.hueControl.valueChanges.pipe(debounceTime(300)), { + initialValue: this.hueControl.value + }); + + /** + * Flag indicating if the component is initialized. + */ + protected readonly initialized = signal(false); + + /** + * Mode determining the search algorithm. + * + * If 'text', the search term is matched against the palette and color names. + * If 'hue', the search term is matched against the hue values of the colors. + */ + protected readonly mode = signal<'text' | 'hue'>('text'); + + /** + * Filtered palettes based on the current search term. + */ + readonly #filteredPalettes = computed(() => { + // Check if palettes are loaded + const palettes = this.#palettes(); + if (!palettes) { + return undefined; + } + + // Filter the palettes based on the mode + if (this.mode() === 'text') { + return this.filterByName(palettes); + } else { + return this.filterByHue(palettes); + } + }); + + /** + * List of palettes and colors to display. + */ + protected readonly filteredPalettes = computed(() => { + // Check if the palettes are loaded + const filteredPalettes = this.#filteredPalettes(); + if (!filteredPalettes) { + return []; + } + + // Transform the palettes and colors + return filteredPalettes + .map((palette) => { + return [ + // Palette + { + type: 'palette' as const, + name: palette.name + }, + // Colors + ...palette.colors.map((color, index) => ({ + type: 'color' as const, + paletteId: palette.id, + colorIndex: index, + name: color.name, + // Pick the light, mid, and dark shades to display + light: color.shades.find((shade) => shade.index === 200)?.hex, + mid: color.shades.find((shade) => shade.index === 500)?.hex, + dark: color.shades.find((shade) => shade.index === 800)?.hex + })) + ]; + }) + .flat(); + }); + + /** + * Text for the mode switch button. + */ + protected readonly modeSwitchText = computed(() => { + if (this.mode() === 'text') { + return 'view.import.search.hue'; + } else { + return 'view.import.search.name'; + } + }); + + public ngOnInit(): void { + // Set the initialized signal + this.initialized.set(true); + } + + /** + * Emit the selected color. + */ + protected colorClicked(paletteId: string, colorIndex: number): void { + // Check if the palettes are loaded + const palettes = this.#filteredPalettes(); + if (!palettes) { + return; + } + + // Find the palette + const palette = palettes.find((p) => p.id === paletteId); + if (!palette) { + return; + } + + // Find the color + const color = palette.colors[colorIndex]; + if (!color) { + return; + } + + // Emit a copy of the color + this.#dialogRef.close(color.copy()); + } + + /** + * Close the dialog without emitting a color. + */ + protected close(): void { + this.#dialogRef.close(); + } + + /** + * Reset the search term. + */ + protected resetSearch(): void { + this.searchControl.setValue(''); + } + + /** + * Toggle the search mode. + */ + protected toggleMode(): void { + this.mode.update((mode) => (mode === 'text' ? 'hue' : 'text')); + } + + /** + * Filter the palettes by their name / color names. + */ + private filterByName(palettes: Array): Array { + const searchTerm = this.searchTerm().toLowerCase(); + // Check if the search term is empty + if (!searchTerm) { + return palettes; + } + + // Filter the palettes + return ( + palettes + .map((palette) => { + // Check if the palette name matches the search term + if (palette.name.toLowerCase().includes(searchTerm)) { + return { + id: palette.id, + name: palette.name, + colors: palette.colors + }; + } + + // Check if any color name matches the search term and return only the matching colors + const colors = palette.colors.filter((color) => color.name.toLowerCase().includes(searchTerm)); + if (colors.length > 0) { + return { + id: palette.id, + name: palette.name, + colors + }; + } + + // No match + return undefined; + }) + // Remove empty palettes + .filter((palette) => palette !== undefined) + ); + } + + /** + * Filter the palettes by their color hues. + */ + private filterByHue(palettes: Array): Array { + return palettes + .map((palette) => { + // Check if any color has a shade with a similar hue and return only the matching colors + const colors = palette.colors.filter((color) => + // Check the shades of the color + color.shades.some((shade) => { + // Calculate the hue difference + const hueDiff = Math.abs(shade.hsl.H - this.hue()); + const hueDiffWrapped = Math.min(hueDiff, 360 - hueDiff); + // Check if the hue difference is below the threshold + return Math.min(hueDiff, hueDiffWrapped) < 30; + }) + ); + if (colors.length > 0) { + return { + id: palette.id, + name: palette.name, + colors + }; + } + + // No match + return undefined; + }) + .filter((palette) => palette !== undefined); + } +} diff --git a/src/app/view/ui/view-palette/view-palette.component.html b/src/app/view/ui/view-palette/view-palette.component.html index e6d1d59..84ed828 100644 --- a/src/app/view/ui/view-palette/view-palette.component.html +++ b/src/app/view/ui/view-palette/view-palette.component.html @@ -5,6 +5,7 @@ > @for (color of palette().colors; track $index) {
{ beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ViewPaletteComponent] + imports: [ViewPaletteComponent], + providers: [{ provide: MobileService, useClass: MobileServiceMock }] }).compileComponents(); fixture = TestBed.createComponent(ViewPaletteComponent); diff --git a/src/app/view/ui/view-palette/view-palette.component.stories.ts b/src/app/view/ui/view-palette/view-palette.component.stories.ts index 9063d6e..b2bd555 100644 --- a/src/app/view/ui/view-palette/view-palette.component.stories.ts +++ b/src/app/view/ui/view-palette/view-palette.component.stories.ts @@ -1,5 +1,6 @@ -import { Meta } from '@storybook/angular'; +import { Meta, moduleMetadata } from '@storybook/angular'; import { TailwindGrays, TailwindRainbow } from '../../../shared/constants/tailwind-colors'; +import { MobileService, MobileServiceMock } from '../../../shared/data-access/mobile.service'; import { createStory } from '../../../shared/utils/storybook'; import { ViewPaletteComponent } from './view-palette.component'; @@ -40,7 +41,12 @@ const meta: Meta = { disable: true } } - } + }, + decorators: [ + moduleMetadata({ + providers: [{ provide: MobileService, useClass: MobileServiceMock }] + }) + ] }; export default meta; diff --git a/src/app/view/ui/view-palette/view-palette.component.ts b/src/app/view/ui/view-palette/view-palette.component.ts index 0b50ac1..82a2620 100644 --- a/src/app/view/ui/view-palette/view-palette.component.ts +++ b/src/app/view/ui/view-palette/view-palette.component.ts @@ -1,8 +1,9 @@ import { CdkDrag, CdkDragDrop, CdkDragPlaceholder, CdkDropList } from '@angular/cdk/drag-drop'; -import { Component, model, output } from '@angular/core'; +import { Component, inject, model, output } from '@angular/core'; import { NgIconComponent } from '@ng-icons/core'; import { heroAdjustmentsHorizontalMini, heroPencilSquareMini, heroTrashMini } from '@ng-icons/heroicons/mini'; import { TranslateModule } from '@ngx-translate/core'; +import { MobileService } from '../../../shared/data-access/mobile.service'; import { Color, Palette, Shade } from '../../../shared/model'; import { textColor } from '../../../shared/utils/text-color'; @@ -14,6 +15,10 @@ import { textColor } from '../../../shared/utils/text-color'; styleUrl: './view-palette.component.css' }) export class ViewPaletteComponent { + readonly #mobileService = inject(MobileService); + + protected readonly isMobile = this.#mobileService.isMobile; + protected readonly textColor = textColor; protected readonly heroPencilSquareMini = heroPencilSquareMini; diff --git a/src/app/view/view.component.html b/src/app/view/view.component.html index c6e87d0..6759be5 100644 --- a/src/app/view/view.component.html +++ b/src/app/view/view.component.html @@ -99,6 +99,23 @@

{{ 'view.color.add' | translate }} + + @if (hasOtherPalettes()) { + + }

} @else { diff --git a/src/app/view/view.component.spec.ts b/src/app/view/view.component.spec.ts index 70bded8..4eb0a35 100644 --- a/src/app/view/view.component.spec.ts +++ b/src/app/view/view.component.spec.ts @@ -1,3 +1,4 @@ +import { Dialog } from '@angular/cdk/dialog'; import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; @@ -8,11 +9,14 @@ import { TailwindGrays } from '../shared/constants/tailwind-colors'; import { AnalyticsService, AnalyticsServiceMock } from '../shared/data-access/analytics.service'; import { ColorService, ColorServiceMock } from '../shared/data-access/color.service'; import { DialogService, DialogServiceMock } from '../shared/data-access/dialog.service'; -import { PaletteService, PaletteServiceMock } from '../shared/data-access/palette.service'; +import { ListService, ListServiceMock } from '../shared/data-access/list.service'; +import { PaletteService } from '../shared/data-access/palette.service'; +import { PaletteServiceMock } from '../shared/data-access/palette.service-mock'; import { PwaService, PwaServiceMock } from '../shared/data-access/pwa.service'; import { ToastService, ToastServiceMock } from '../shared/data-access/toast.service'; import { TrackingEventAction, TrackingEventCategory } from '../shared/enums/tracking-event'; import { Color, Shade } from '../shared/model'; +import { DialogMock } from '../shared/utils/dialog-mock'; import { IS_RUNNING_TEST } from '../shared/utils/is-running-test'; import ViewComponent from './view.component'; @@ -24,6 +28,7 @@ describe('ViewComponent', () => { let paletteService: PaletteServiceMock; let toastService: ToastServiceMock; let analyticsService: AnalyticsServiceMock; + let dialog: DialogMock; let component: ViewComponent; let fixture: ComponentFixture; @@ -35,6 +40,7 @@ describe('ViewComponent', () => { paletteService = new PaletteServiceMock(); toastService = new ToastServiceMock(); analyticsService = new AnalyticsServiceMock(); + dialog = new DialogMock(new Color([], 'Imported')); await TestBed.configureTestingModule({ imports: [ViewComponent, TranslateModule.forRoot()], @@ -48,7 +54,9 @@ describe('ViewComponent', () => { { provide: ToastService, useValue: toastService }, { provide: AnalyticsService, useValue: analyticsService }, { provide: PwaService, useClass: PwaServiceMock }, - { provide: IS_RUNNING_TEST, useValue: true } + { provide: IS_RUNNING_TEST, useValue: true }, + { provide: Dialog, useValue: dialog }, + { provide: ListService, useClass: ListServiceMock } ] }).compileComponents(); @@ -137,6 +145,19 @@ describe('ViewComponent', () => { expect(component.hasUnsavedChanges()).toBeTrue(); }); + it('should import color', async () => { + spyOn(dialog, 'open').and.callThrough(); + + await component.importColor(); + + const colors = paletteService.palette()?.colors; + + expect(dialog.open).toHaveBeenCalledTimes(1); + expect(colors).toBeTruthy(); + expect(colors![colors!.length - 1].name).toBe('Imported'); + expect(component.hasUnsavedChanges()).toBeTrue(); + }); + it('should open export modal', async () => { await component.exportPalette(); diff --git a/src/app/view/view.component.ts b/src/app/view/view.component.ts index cca1e31..2b1b7d8 100644 --- a/src/app/view/view.component.ts +++ b/src/app/view/view.component.ts @@ -1,4 +1,6 @@ +import { Dialog } from '@angular/cdk/dialog'; import { Component, HostListener, OnInit, computed, inject, input, signal } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { Validators } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; import { NgIconComponent } from '@ng-icons/core'; @@ -11,12 +13,14 @@ import { heroPlusMini } from '@ng-icons/heroicons/mini'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { combineLatestWith, firstValueFrom, map } from 'rxjs'; import { string_to_unicode_variant as toUnicodeVariant } from 'string-to-unicode-variant'; import { ColorEditorService } from '../editor/data-access/color-editor.service'; import { ExportModalService } from '../export/data-access/export-modal.service'; import { AnalyticsService } from '../shared/data-access/analytics.service'; import { ColorService } from '../shared/data-access/color.service'; import { DialogService } from '../shared/data-access/dialog.service'; +import { ListService } from '../shared/data-access/list.service'; import { PaletteService } from '../shared/data-access/palette.service'; import { PwaService } from '../shared/data-access/pwa.service'; import { ToastService } from '../shared/data-access/toast.service'; @@ -26,6 +30,7 @@ import { NoPaletteComponent } from '../shared/ui/no-palette/no-palette.component import { deduplicateName } from '../shared/utils/deduplicate-name'; import { IS_RUNNING_TEST } from '../shared/utils/is-running-test'; import { sleep } from '../shared/utils/sleep'; +import { ImportColorData } from './ui/import-color/import-color.component'; import { ViewPaletteComponent } from './ui/view-palette/view-palette.component'; import { duplicateValidator } from './utils/duplicate.validator'; import { UnsavedChangesComponent } from './utils/unsaved-changes.guard'; @@ -48,6 +53,8 @@ export default class ViewComponent implements OnInit, UnsavedChangesComponent { private readonly _analyticsService = inject(AnalyticsService); private readonly _router = inject(Router); private readonly _pwaService = inject(PwaService); + private readonly _dialog = inject(Dialog); + private readonly _listService = inject(ListService); protected readonly heroPencilSquareMini = heroPencilSquareMini; protected readonly heroPlusMini = heroPlusMini; @@ -59,6 +66,25 @@ export default class ViewComponent implements OnInit, UnsavedChangesComponent { protected readonly palette = this._paletteService.palette; protected readonly saving = signal(false); + /** + * Flag indicating if there are other palettes to import colors from + */ + protected readonly hasOtherPalettes = toSignal( + toObservable(this.palette).pipe( + combineLatestWith(this._listService.list$), + map(([palette, list]) => { + // Check if palette and list is loaded + if (!palette || list.length === 0) { + return false; + } + + // Filter out current palette + return list.some((p) => p.id !== palette.id); + }) + ), + { initialValue: false } + ); + private readonly _hasUnsavedChanges = signal(false); public ngOnInit(): void { @@ -252,6 +278,47 @@ export default class ViewComponent implements OnInit, UnsavedChangesComponent { this._hasUnsavedChanges.set(true); } + /** + * Import a color from another palette + */ + public async importColor(): Promise { + // Check if palette is loaded + const palette = this.palette(); + if (!palette) { + return; + } + + // Open the import selector + const importer = await import('./ui/import-color/import-color.component').then((c) => c.ImportColorComponent); + const dialogRef = this._dialog.open(importer, { + backdropClass: 'rp-modal-backdrop', + data: { + paletteId: palette.id + }, + panelClass: 'rp-modal-panel', + width: 'inherit' + }); + + // Check if color was selected + const color = await firstValueFrom(dialogRef.closed); + if (!color) { + return; + } + + // Track import event + this._analyticsService.trackEvent(TrackingEventCategory.IMPORT_COLOR, TrackingEventAction.IMPORT_COLOR); + + // Check if color name already exists + const colorNames = palette.colors.map((c) => c.name); + color.name = deduplicateName(color.name, colorNames); + + // Import color to current palette + palette.addColor(color); + + // Set unsaved changes + this._hasUnsavedChanges.set(true); + } + public async copyToClipboard(shade: Shade): Promise { try { await navigator.clipboard.writeText(shade.hex); diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index b3f9995..4a21ea1 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -18,6 +18,7 @@ "remove": "Entfernen", "rename": "Umbenennen", "required": "Dieses Feld kann nicht leer sein.", + "reset-search": "Suche zurücksetzen", "saturation": "Sättigung", "save": "Speichern", "saving": "Speichern...", @@ -295,6 +296,19 @@ "renamed": "Deine Farbe wurde in \"{{ name }}\" umbenannt.", "tune": "Passe die Farbe nach deinem Geschmack an" }, + "import": { + "description": "Wähle eine Farbe aus der Liste, um sie in deine aktuelle Palette zu importieren.", + "no-colors": "Du hast keine anderen Paletten, aus denen Farben importiert werden können", + "not-found": "Es wurde keine Palette / Farbe zu deiner Suche gefunden", + "search": { + "hue": "Suche nach einem Farbton", + "name": "Suche nach dem Namen einer Palette / Farbe", + "placeholder": "Suche nach einer Palette / Farbe" + }, + "select": "Importiere diese Farbe", + "title": "Farbe importieren", + "tooltip": "Importiere eine Farbe aus einer anderen Palette" + }, "palette": { "no-changes": "Es wurden noch keine Änderungen an deiner Palette vorgenommen.", "no-export": "Deine Palette kann nicht exportiert werden, da sie noch keine Farben enthält.", diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 67ddf24..69e6e0b 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -18,6 +18,7 @@ "remove": "Remove", "rename": "Rename", "required": "This field is cannot be empty.", + "reset-search": "Reset search", "saturation": "Saturation", "save": "Save", "saving": "Saving...", @@ -296,6 +297,19 @@ "renamed": "Your color has been renamed to \"{{ name }}\".", "tune": "Tune the color to your liking" }, + "import": { + "description": "Select a color from the list below to import it into your palette.", + "no-colors": "You have no other palettes to import a color from", + "not-found": "No palette / color matches your search", + "search": { + "hue": "Search by hue", + "name": "Search by name", + "placeholder": "Search for a palette / color" + }, + "select": "Import this color", + "title": "Import color", + "tooltip": "Import a color from another palette." + }, "palette": { "no-changes": "No changes have been made to your palette yet.", "no-export": "Your palette needs at least one color to be exported.",