diff --git a/core-web/apps/dotcms-ui/src/stories/primeng/panel/Accordion.stories.ts b/core-web/apps/dotcms-ui/src/stories/primeng/panel/Accordion.stories.ts new file mode 100644 index 000000000000..35d0a3d6ab00 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/stories/primeng/panel/Accordion.stories.ts @@ -0,0 +1,50 @@ +import { Meta, Story } from '@storybook/angular'; + +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { Accordion, AccordionModule } from 'primeng/accordion'; + +export default { + title: 'PrimeNG/Panel/Accordion', + component: Accordion, + args: { activeIndex: 0, expandIcon: 'pi pi-angle-down', collapseIcon: 'pi pi-angle-up' }, + parameters: { + docs: { + description: { + component: + 'Accordion groups a collection of contents in tabs.: https://www.primefaces.org/primeng-v15-lts/accordion' + } + } + } +} as Meta; + +const BasicTemplate = ` + + +

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
+ +

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi + architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione + voluptatem sequi nesciunt. Consectetur, adipisci velit, sed quia non numquam eius modi.

+
+ +

At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati + cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. + Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus.

+
+
`; + +export const Main: Story = (args) => { + return { + props: { + activeIndex: args.activeIndex, + expandIcon: args.expandIcon, + collapseIcon: args.collapseIcon + }, + moduleMetadata: { imports: [AccordionModule, BrowserAnimationsModule] }, + template: BasicTemplate + }; +}; diff --git a/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts b/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts index 77d773ebe481..2a5cd943fc3d 100644 --- a/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts +++ b/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts @@ -68,7 +68,6 @@ import { DotMarketingConfigService, formatHTML, removeInvalidNodes, - removeLoadingNodes, RestoreDefaultDOMAttrs, SetDocAttrStep } from '../../shared'; @@ -485,19 +484,10 @@ export class DotBlockEditorComponent implements OnInit, OnDestroy, ControlValueA } private setEditorJSONContent(content: Content) { - //TODO: remove this when the AI content is generated exclusively in popups and not in the editor directly. - const filterContent = removeLoadingNodes( - content - ? Array.isArray(content) - ? [...content] - : [...(content as JSONContent).content] - : [] - ); - this.content = this.allowedBlocks?.length > 1 - ? removeInvalidNodes(filterContent, this.allowedBlocks) - : filterContent; + ? removeInvalidNodes(content, this.allowedBlocks) + : content; } private setEditorContent(content: Content) { diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-content-actions/plugins/ai-content-actions.plugin.ts b/core-web/libs/block-editor/src/lib/extensions/ai-content-actions/plugins/ai-content-actions.plugin.ts index 24c78c4fd282..29e7cab84ca1 100644 --- a/core-web/libs/block-editor/src/lib/extensions/ai-content-actions/plugins/ai-content-actions.plugin.ts +++ b/core-web/libs/block-editor/src/lib/extensions/ai-content-actions/plugins/ai-content-actions.plugin.ts @@ -10,12 +10,9 @@ import { takeUntil } from 'rxjs/operators'; import { Editor } from '@tiptap/core'; -import { getAIPlaceholderImage } from '../../../shared'; import { DOT_AI_TEXT_CONTENT_KEY } from '../../ai-content-prompt/ai-content-prompt.extension'; import { AiContentPromptStore } from '../../ai-content-prompt/store/ai-content-prompt.store'; -import { DOT_AI_IMAGE_CONTENT_KEY } from '../../ai-image-prompt/ai-image-prompt.extension'; import { DotAiImagePromptStore } from '../../ai-image-prompt/ai-image-prompt.store'; -import { AI_IMAGE_PLACEHOLDER_PROPERTY } from '../../ai-image-prompt/plugins/ai-image-prompt.plugin'; import { ACTIONS, AIContentActionsComponent } from '../ai-content-actions.component'; import { AI_CONTENT_ACTIONS_PLUGIN_KEY } from '../ai-content-actions.extension'; import { TIPPY_OPTIONS } from '../utils'; @@ -152,20 +149,6 @@ export class AIContentActionsView { case DOT_AI_TEXT_CONTENT_KEY: this.aiContentPromptStore.setAcceptContent(true); break; - - case DOT_AI_IMAGE_CONTENT_KEY: - // eslint-disable-next-line no-case-declarations - const placeholder = getAIPlaceholderImage(this.editor); - - delete placeholder.node.attrs.data[AI_IMAGE_PLACEHOLDER_PROPERTY]; - - this.view.dispatch( - this.view.state.tr.setNodeMarkup(placeholder.from, undefined, { - data: placeholder.node.attrs.data - }) - ); - - break; } } @@ -178,10 +161,6 @@ export class AIContentActionsView { case DOT_AI_TEXT_CONTENT_KEY: this.aiContentPromptStore.reGenerateContent(); break; - - case DOT_AI_IMAGE_CONTENT_KEY: - this.dotAiImagePromptStore.reGenerateContent(); - break; } } @@ -191,16 +170,6 @@ export class AIContentActionsView { case DOT_AI_TEXT_CONTENT_KEY: this.aiContentPromptStore.setDeleteContent(true); break; - - case DOT_AI_IMAGE_CONTENT_KEY: - // eslint-disable-next-line no-case-declarations - const placeholder = getAIPlaceholderImage(this.editor); - this.editor.commands.deleteRange({ - from: placeholder.from, - to: placeholder.to - }); - - break; } this.editor.commands.closeAIContentActions(); diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.html b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.html index 8461c709cc7b..2e4ebe599609 100644 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.html +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.html @@ -4,28 +4,35 @@ [dismissableMask]="true" [draggable]="false" [resizable]="false" - [style]="{ width: '800px' }" - (onHide)="hideDialog()" + [style]="{ width: '1040px' }" + (onHide)="store.hideDialog()" appendTo="body" header="{{ 'block-editor.extension.ai-image.dialog-title' | dm }}"> -
- - - +
+ +
+ + +
diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.scss b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.scss index 2c1e61c03419..fe6088cbb4fe 100644 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.scss +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.scss @@ -1,5 +1,20 @@ @use "variables" as *; -.dialog-prompts__wrapper { +.dialog-prompt__wrapper { gap: $spacing-5; + display: grid; + grid-template-columns: 1fr 1fr; + + > * { + display: flex; + flex-direction: column; + flex: 1; + justify-content: center; + } +} + +.dialog-prompt_gallery { + > * { + margin-top: auto; + } } diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.spec.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.spec.ts index 751022633036..0108e11524c9 100644 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.spec.ts +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.spec.ts @@ -1,28 +1,85 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ReactiveFormsModule } from '@angular/forms'; +import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator'; +import { of } from 'rxjs'; + +import { Dialog } from 'primeng/dialog'; import { AIImagePromptComponent } from './ai-image-prompt.component'; +import { DotAiImagePromptStore } from './ai-image-prompt.store'; +import { AiImagePromptFormComponent } from './components/ai-image-prompt-form/ai-image-prompt-form.component'; -import { DotAiService } from '../../shared'; +import { + AIImagePrompt, + DotAIImageOrientation, + DotGeneratedAIImage +} from '../../shared/services/dot-ai/dot-ai.models'; describe('AIImagePromptComponent', () => { - let component: AIImagePromptComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ReactiveFormsModule, HttpClientTestingModule], - declarations: [AIImagePromptComponent], - providers: [DotAiService] - }).compileComponents(); - - fixture = TestBed.createComponent(AIImagePromptComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + let spectator: Spectator; + let store: DotAiImagePromptStore; + + const imagesMock: DotGeneratedAIImage[] = [ + { name: 'image1', url: 'image_url' }, + { name: 'image2', url: 'image_url_2' } + ] as unknown as DotGeneratedAIImage[]; + + const createComponent = createComponentFactory({ + component: AIImagePromptComponent, + providers: [ + { + provide: DotAiImagePromptStore, + useValue: { + vm$: of({ + showDialog: true, + isLoading: false, + images: imagesMock, + galleryActiveIndex: 0, + orientation: DotAIImageOrientation.VERTICAL + }), + generateImage: jasmine.createSpy('generateImage'), + hideDialog: jasmine.createSpy('hideDialog'), + patchState: jasmine.createSpy('patchState'), + cleanError: jasmine.createSpy('cleanError') + } + } + ] + }); + + beforeEach(() => { + spectator = createComponent(); + store = spectator.inject(DotAiImagePromptStore); + }); + + it('should hide dialog', () => { + const dialog = spectator.query(Dialog); + dialog.onHide.emit('true'); + expect(store.hideDialog).toHaveBeenCalled(); + }); + + it('should generate image', () => { + const promptForm = spectator.query(AiImagePromptFormComponent); + const formMock: AIImagePrompt = { + text: 'Test', + type: 'input', + size: DotAIImageOrientation.VERTICAL + }; + + promptForm.value.emit(formMock); + + expect(store.generateImage).toHaveBeenCalledWith(formMock); + }); + + it('should inset image', () => { + const submitBtn = spectator.query(byTestId('submit-btn')); + + spectator.click(submitBtn); + expect(store.patchState).toHaveBeenCalledWith({ + selectedImage: imagesMock[0] + }); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should clear error on hide confirm', () => { + const dialog = spectator.query(Dialog); + dialog.onHide.emit('true'); + expect(store.cleanError).toHaveBeenCalled(); }); }); diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.ts index 142dc10ecd17..8c978f730759 100644 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.ts +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.component.ts @@ -2,22 +2,19 @@ import { Observable } from 'rxjs'; import { AsyncPipe, NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ReactiveFormsModule } from '@angular/forms'; +import { FormGroupDirective } from '@angular/forms'; import { ConfirmationService } from 'primeng/api'; -import { ButtonModule } from 'primeng/button'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogModule } from 'primeng/dialog'; -import { OverlayPanelModule } from 'primeng/overlaypanel'; -import { TooltipModule } from 'primeng/tooltip'; import { DotMessageService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; -import { PromptType } from './ai-image-prompt.models'; import { DotAiImagePromptStore, VmAiImagePrompt } from './ai-image-prompt.store'; -import { AiImagePromptInputComponent } from './components/ai-image-prompt-input/ai-image-prompt-input.component'; +import { AiImagePromptFormComponent } from './components/ai-image-prompt-form/ai-image-prompt-form.component'; +import { AiImagePromptGalleryComponent } from './components/ai-image-prompt-gallery/ai-image-prompt-gallery.component'; @Component({ selector: 'dot-ai-image-prompt', @@ -25,17 +22,15 @@ import { AiImagePromptInputComponent } from './components/ai-image-prompt-input/ templateUrl: './ai-image-prompt.component.html', styleUrls: ['./ai-image-prompt.component.scss'], imports: [ - ButtonModule, - TooltipModule, - ReactiveFormsModule, - OverlayPanelModule, NgIf, DialogModule, - AiImagePromptInputComponent, AsyncPipe, DotMessagePipe, - ConfirmDialogModule + ConfirmDialogModule, + AiImagePromptFormComponent, + AiImagePromptGalleryComponent ], + providers: [FormGroupDirective], changeDetection: ChangeDetectionStrategy.OnPush }) @@ -44,36 +39,7 @@ export class AIImagePromptComponent { protected readonly ComponentStatus = ComponentStatus; private confirmationService = inject(ConfirmationService); private dotMessageService = inject(DotMessageService); - private store: DotAiImagePromptStore = inject(DotAiImagePromptStore); - - /** - * Hides the dialog. - * @return {void} - */ - hideDialog(): void { - this.store.hideDialog(); - } - - /** - * Selects the prompt type - * - * @return {void} - */ - selectType(promptType: PromptType, current: PromptType): void { - if (current != promptType) { - this.store.setPromptType(promptType); - } - } - - /** - * Generates an image based on the provided prompt. - * - * @param {string} prompt - The text prompt used to generate the image. - * @return {void} - This method does not return any value. - */ - generateImage(prompt: string): void { - this.store.generateImage(prompt); - } + store: DotAiImagePromptStore = inject(DotAiImagePromptStore); /** * Clears the error at the store on hiding the confirmation dialog. diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.models.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.models.ts index 6ccd86b30821..a6c0d4cbda7d 100644 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.models.ts +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.models.ts @@ -1 +1,4 @@ -export type PromptType = 'input' | 'auto'; +export const enum PromptType { + INPUT = 'input', + AUTO = 'auto' +} diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.store.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.store.ts index 22fa3ed391d2..a89eb964f40a 100644 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.store.ts +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.store.ts @@ -5,38 +5,50 @@ import { Injectable } from '@angular/core'; import { switchMap, tap, withLatestFrom } from 'rxjs/operators'; -import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; +import { ComponentStatus } from '@dotcms/dotcms-models'; import { PromptType } from './ai-image-prompt.models'; import { DotAiService } from '../../shared'; +import { + AIImagePrompt, + DotAIImageOrientation, + DotGeneratedAIImage +} from '../../shared/services/dot-ai/dot-ai.models'; -const DEFAULT_INPUT_PROMPT: PromptType = 'input'; +const DEFAULT_INPUT_PROMPT = PromptType.INPUT; export interface DotAiImagePromptComponentState { showDialog: boolean; - selectedPromptType: PromptType | null; prompt: string | null; // we always have the final prompt here editorContent: string | null; - contentlets: DotCMSContentlet[] | []; + images: DotGeneratedAIImage[]; status: ComponentStatus; error: string; + selectedImage: DotGeneratedAIImage | null; + galleryActiveIndex: number; + orientation: DotAIImageOrientation; } export interface VmAiImagePrompt { - selectedPromptType: PromptType | null; showDialog: boolean; status: ComponentStatus; + images: DotGeneratedAIImage[]; + galleryActiveIndex: number; + orientation: DotAIImageOrientation; + isLoading: boolean; } const initialState: DotAiImagePromptComponentState = { - selectedPromptType: null, showDialog: false, status: ComponentStatus.INIT, - contentlets: [], + images: [], prompt: null, editorContent: null, - error: '' + error: '', + selectedImage: null, + galleryActiveIndex: 0, + orientation: DotAIImageOrientation.HORIZONTAL }; @Injectable({ providedIn: 'root' }) @@ -47,14 +59,13 @@ export class DotAiImagePromptStore extends ComponentStore status === ComponentStatus.LOADING ); + + readonly selectedImage$ = this.select(this.state$, ({ selectedImage }) => selectedImage); + readonly errorMsg$ = this.select(this.state$, ({ error }) => error); - readonly getContentlets$ = this.select(this.state$, ({ contentlets }) => contentlets); + readonly getImages$ = this.select(this.state$, ({ images }) => images); //Updaters - readonly setPromptType = this.updater((state, selectedPromptType: PromptType) => ({ - ...state, - selectedPromptType - })); readonly showDialog = this.updater((state, editorContent: string) => ({ ...state, @@ -68,18 +79,26 @@ export class DotAiImagePromptStore extends ComponentStore ({ - ...state, - showDialog: false, - selectedPromptType: null + readonly setSelectedImage = this.updater( + (state: DotAiImagePromptComponentState, selectedImage: DotGeneratedAIImage) => { + return { ...state, selectedImage }; + } + ); + + readonly hideDialog = this.updater(() => ({ + ...initialState })); readonly vm$: Observable = this.select( this.state$, - ({ selectedPromptType, showDialog, status }) => ({ - selectedPromptType, + this.isLoading$, + ({ showDialog, status, images, galleryActiveIndex, orientation }, isLoading) => ({ showDialog, - status + status, + images, + galleryActiveIndex, + orientation, + isLoading }) ); @@ -91,14 +110,14 @@ export class DotAiImagePromptStore extends ComponentStore} prompt$ - An observable representing the prompt. * @returns {Observable} - An observable that emits the generated image. */ - readonly generateImage = this.effect((prompt$: Observable) => { + readonly generateImage = this.effect((prompt$: Observable) => { return prompt$.pipe( withLatestFrom(this.state$), - switchMap(([prompt, { selectedPromptType, editorContent }]) => { - const cleanPrompt = prompt?.trim() ?? ''; + switchMap(([prompt, { editorContent }]) => { + const cleanPrompt = prompt.text?.trim() ?? ''; const finalPrompt = - selectedPromptType === 'auto' && editorContent + prompt.type === 'auto' && editorContent ? `${cleanPrompt} to illustrate the following content: ${editorContent}` : cleanPrompt; @@ -108,13 +127,14 @@ export class DotAiImagePromptStore extends ComponentStore { - this.patchState({ + (response) => { + this.patchState((state) => ({ status: ComponentStatus.IDLE, - contentlets: contentLets - }); + images: [...state.images, { request: prompt, response: response }], + galleryActiveIndex: state.images.length + })); }, (error: string) => { this.patchState({ status: ComponentStatus.IDLE, error }); diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.html b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.html new file mode 100644 index 000000000000..c50328625c37 --- /dev/null +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.html @@ -0,0 +1,106 @@ +
+ + +
+ +
+ + +
+ {{ 'block-editor.extension.ai-image.custom.prompt.desc' | dm }} +
+ + +
+ {{ 'block-editor.extension.ai-image.existing.content.desc' | dm }} +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ +
diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.scss b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.scss new file mode 100644 index 000000000000..3e41eff71989 --- /dev/null +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.scss @@ -0,0 +1,53 @@ +@use "variables" as *; + +form { + display: flex; + flex-direction: column; + flex: 1; + justify-content: center; +} + +.ai-image-prompt__input { + display: flex; + flex-direction: column; + + > div { + display: flex; + align-items: center; + } + + > span { + display: flex; + margin: $spacing-0 0 $spacing-1 $spacing-5; + font-size: $font-size-sm; + color: $color-palette-gray-700; + } +} + +.ai-image-prompt__text { + .field:last-child { + margin-bottom: 0; + } + + .ai-image-prompt__textarea { + height: 23rem; + } + + &.ai-image-prompt__text--generated { + .ai-image-prompt__textarea { + height: 6rem; + } + + .ai-image-prompt__rewritten-textarea { + height: 14rem; + } + } +} + +.ai-image-prompt__orientation { + margin-bottom: 9rem; +} + +p-accordion { + min-height: 37rem; +} diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.spec.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.spec.ts new file mode 100644 index 000000000000..0d2a12203754 --- /dev/null +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.spec.ts @@ -0,0 +1,119 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { AiImagePromptFormComponent } from './ai-image-prompt-form.component'; + +import { + DotAIImageOrientation, + DotGeneratedAIImage +} from '../../../../shared/services/dot-ai/dot-ai.models'; +import { PromptType } from '../../ai-image-prompt.models'; + +describe('AiImagePromptFormComponent', () => { + let spectator: Spectator; + let generateButton; + const formValue = { + text: 'Test', + type: PromptType.INPUT, + size: DotAIImageOrientation.HORIZONTAL + }; + const createComponent = createComponentFactory({ + component: AiImagePromptFormComponent, + imports: [HttpClientTestingModule, ButtonModule, ReactiveFormsModule], + providers: [DotMessageService], + mocks: [DotMessagePipe] + }); + + beforeEach(() => { + spectator = createComponent(); + generateButton = spectator.query('button'); + }); + + it('should initialize the form properly', () => { + expect(spectator.component.form.get('text').value).toEqual(''); + expect(spectator.component.form.get('type').value).toEqual(PromptType.INPUT); + expect(spectator.component.form.get('size').value).toEqual( + DotAIImageOrientation.HORIZONTAL + ); + }); + + it('should emit value on form submission', () => { + const emitSpy = jest.spyOn(spectator.component.value, 'emit'); + spectator.component.form.setValue(formValue); + + spectator.detectChanges(); + + spectator.click(generateButton); + + expect(emitSpy).toHaveBeenCalledWith(formValue); + }); + + it('should emit orientation on size control value change', () => { + const emitSpy = jest.spyOn(spectator.component.orientation, 'emit'); + spectator.component.form.get('size').setValue(DotAIImageOrientation.SQUARE); + expect(emitSpy).toHaveBeenCalledWith(DotAIImageOrientation.SQUARE); + }); + + it('should clear validators for text control when type is auto', () => { + spectator.component.form.get('type').setValue('auto'); + expect(spectator.component.form.get('text').validator).toBeNull(); + }); + + it('should update form when changes come', () => { + const newGeneratedValue = { + request: formValue, + response: { revised_prompt: 'New Prompt' } + } as DotGeneratedAIImage; + + spectator.setInput('generatedValue', newGeneratedValue); + spectator.setInput('isLoading', false); + + expect(spectator.component.form.value).toEqual(newGeneratedValue.request); + expect(spectator.component.aiProcessedPrompt).toBe( + newGeneratedValue.response.revised_prompt + ); + }); + + it('should disable form controls when isLoading is true', () => { + spectator.setInput('isLoading', true); + expect(spectator.query('form').getAttribute('disabled')).toBeDefined(); + }); + + it('should enable form controls when isLoading is false', () => { + spectator.setInput('isLoading', false); + expect(spectator.query('form').getAttribute('disabled')).toBeNull(); + }); + + it('should disable button when form is invalid or isLoading is true', () => { + spectator.setInput('isLoading', false); + spectator.component.form.setErrors({ invalid: true }); + spectator.detectChanges(); + + expect(generateButton.disabled).toEqual(true); + }); + + it('should enable button when form is valid and isLoading is false', () => { + spectator.setInput({ isLoading: false }); + spectator.component.form.setValue(formValue); + spectator.detectChanges(); + + expect(generateButton.disabled).toEqual(false); + }); + + it('should call submitForm method on button click', () => { + const valueSpy = jest.spyOn(spectator.component.value, 'emit'); + spectator.setInput({ isLoading: false }); + spectator.component.form.setValue(formValue); + spectator.detectChanges(); + + spectator.click(generateButton); + expect(valueSpy).toHaveBeenCalled(); + }); +}); diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.ts new file mode 100644 index 000000000000..e95a6f440e01 --- /dev/null +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-form/ai-image-prompt-form.component.ts @@ -0,0 +1,180 @@ +import { NgIf } from '@angular/common'; +import { + Component, + DestroyRef, + EventEmitter, + inject, + Input, + OnChanges, + OnInit, + Output, + SimpleChange, + SimpleChanges +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators +} from '@angular/forms'; + +import { AccordionModule } from 'primeng/accordion'; +import { SelectItem } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { DropdownModule } from 'primeng/dropdown'; +import { InputTextareaModule } from 'primeng/inputtextarea'; +import { RadioButtonModule } from 'primeng/radiobutton'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotFieldRequiredDirective, DotMessagePipe } from '@dotcms/ui'; + +import { + AIImagePrompt, + DotAIImageOrientation, + DotGeneratedAIImage +} from '../../../../shared/services/dot-ai/dot-ai.models'; +import { PromptType } from '../../ai-image-prompt.models'; + +@Component({ + selector: 'dot-ai-image-prompt-form', + standalone: true, + templateUrl: './ai-image-prompt-form.component.html', + imports: [ + ButtonModule, + AccordionModule, + RadioButtonModule, + ReactiveFormsModule, + FormsModule, + DropdownModule, + NgIf, + InputTextareaModule, + DotFieldRequiredDirective, + DotMessagePipe + ], + styleUrls: ['./ai-image-prompt-form.component.scss'] +}) +export class AiImagePromptFormComponent implements OnChanges, OnInit { + @Input() + generatedValue: DotGeneratedAIImage; + + @Input() + isLoading = false; + + @Output() + value = new EventEmitter(); + + @Output() + orientation = new EventEmitter(); + + form: FormGroup; + aiProcessedPrompt: string; + dotMessageService = inject(DotMessageService); + destroyRef = inject(DestroyRef); + promptTextAreaPlaceholder = 'block-editor.extension.ai-image.custom.placeholder'; + promptLabel = 'block-editor.extension.ai-image.prompt'; + submitButtonLabel = 'block-editor.extension.ai-image.generate'; + requiredPrompt = true; + + orientationOptions: SelectItem[] = [ + { + value: DotAIImageOrientation.HORIZONTAL, + label: this.dotMessageService.get( + 'block-editor.extension.ai-image.orientation.horizontal' + ) + }, + { + value: DotAIImageOrientation.SQUARE, + label: this.dotMessageService.get('block-editor.extension.ai-image.orientation.square') + }, + { + value: DotAIImageOrientation.VERTICAL, + label: this.dotMessageService.get( + 'block-editor.extension.ai-image.orientation.vertical' + ) + } + ]; + + ngOnInit(): void { + this.initForm(); + } + + ngOnChanges(changes: SimpleChanges): void { + const { generatedValue, isLoading } = changes; + + this.updatedFormValues(generatedValue, isLoading?.currentValue); + + this.setSubmitButtonLabel(isLoading?.currentValue); + + this.toggleFormState(isLoading?.currentValue && !isLoading.firstChange); + } + + private initForm(): void { + this.form = new FormGroup({ + text: new FormControl('', Validators.required), + type: new FormControl(PromptType.INPUT, Validators.required), + size: new FormControl(DotAIImageOrientation.HORIZONTAL, Validators.required) + }); + + const sizeControl = this.form.get('size'); + const typeControl = this.form.get('type'); + + sizeControl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((size) => { + this.orientation.emit(size); + }); + + typeControl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((type) => { + const promptControl = this.form.get('text'); + type === PromptType.AUTO + ? promptControl.clearValidators() + : promptControl.setValidators(Validators.required); + + promptControl.updateValueAndValidity(); + + this.setTypeLabels(type); + this.requiredPrompt = type === PromptType.INPUT; + }); + } + + private setTypeLabels(type: PromptType): void { + if (type === PromptType.INPUT) { + this.promptLabel = 'block-editor.extension.ai-image.prompt'; + this.promptTextAreaPlaceholder = 'block-editor.extension.ai-image.custom.placeholder'; + } else { + this.promptLabel = 'block-editor.extension.ai-image.custom.props'; + this.promptTextAreaPlaceholder = 'block-editor.extension.ai-image.placeholder'; + } + } + + private toggleFormState(isLoading: boolean): void { + isLoading ? this.form?.disable() : this.form?.enable(); + } + + /** + * Updates the form values based on the generated content that comes from + * the endpoint. + * + * @param {SimpleChange} generatedValue - The generated value. + * @param {boolean} isLoading - The loading status. + * @return {void} + */ + private updatedFormValues(generatedValue: SimpleChange, isLoading: boolean): void { + if (generatedValue?.currentValue && !generatedValue.firstChange && !isLoading) { + const updatedValue: DotGeneratedAIImage = generatedValue.currentValue; + this.form.patchValue(updatedValue.request); + this.form.clearValidators(); + this.form.updateValueAndValidity(); + + this.aiProcessedPrompt = updatedValue.response.revised_prompt; + } + } + + private setSubmitButtonLabel(isLoading: boolean): void { + this.submitButtonLabel = isLoading + ? 'block-editor.extension.ai-image.generating' + : this.aiProcessedPrompt + ? 'block-editor.extension.ai-image.regenerate' + : 'block-editor.extension.ai-image.generate'; + } +} diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.html b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.html new file mode 100644 index 000000000000..6e86797f9913 --- /dev/null +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.html @@ -0,0 +1,55 @@ +
+ + + + + + +
+ + + + + + + + + + + {{ activeImageIndex + 1 }} {{ 'of' | dm }} {{ images.length }} + diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.scss b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.scss new file mode 100644 index 000000000000..d0fe8ace19e8 --- /dev/null +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.scss @@ -0,0 +1,60 @@ +@use "variables" as *; + +:host { + display: flex; + flex-direction: column; +} + +.ai-image-gallery__placeholder { + background-color: $color-palette-gray-200; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + border-radius: $border-radius-md; + + &.s1024x1792 { + aspect-ratio: 1/1.25; + } + + &.s1792x1024 { + aspect-ratio: 1/0.5; + } + + &.s1024x1024 { + aspect-ratio: 1/1; + } +} + +p-galleria { + height: 400px; + background-color: $color-palette-gray-200; + display: flex; + align-items: center; +} + +.ai-image-gallery__count { + margin-top: $spacing-1; +} + +:host ::ng-deep { + .ai-image-gallery__img { + width: 100%; + max-height: 400px; + } + + p-galleriacontent { + width: 100%; + } + + .ai-image-gallery_skeleton { + width: 100%; + height: 400px; + display: flex; + } + + .ai-image-gallery__count-skeleton { + margin-top: $spacing-1; + align-self: end; + } +} diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.spec.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.spec.ts new file mode 100644 index 000000000000..4c3a504b8a81 --- /dev/null +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.spec.ts @@ -0,0 +1,82 @@ +import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +import { Galleria, GalleriaModule } from 'primeng/galleria'; + +import { AiImagePromptGalleryComponent } from './ai-image-prompt-gallery.component'; + +import { DotGeneratedAIImage } from '../../../../shared/services/dot-ai/dot-ai.models'; + +describe('AiImagePromptGalleryComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: AiImagePromptGalleryComponent, + imports: [HttpClientTestingModule, GalleriaModule] + }); + + const imagesMock: DotGeneratedAIImage[] = [ + { + response: { + contentlet: { assetVersion: 'image_url' } + } + } as unknown as DotGeneratedAIImage, + { + response: { assetVersion: 'image_url_2' } + } as unknown as DotGeneratedAIImage + ]; + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should display placeholder when isLoading is false and images is empty', () => { + spectator.setInput({ + isLoading: false, + images: [] + }); + spectator.detectChanges(); + const placeholderElement = spectator.query(byTestId('ai-image-gallery__placeholder')); + expect(placeholderElement).toBeTruthy(); + }); + + it('should display skeleton when isLoading is true', () => { + spectator.setInput({ + isLoading: true, + images: [] + }); + spectator.detectChanges(); + + const skeletonElement = spectator.query(byTestId('ai-image-gallery_skeleton')); + const skeletonElementCount = spectator.query(byTestId('ai-image-gallery__count-skeleton')); + + expect(skeletonElement).toBeTruthy(); + expect(skeletonElementCount).toBeTruthy(); + }); + + it('should display Galleria when isLoading is false and images is not empty', () => { + spectator.setInput({ + isLoading: false, + images: imagesMock + }); + spectator.detectChanges(); + const galleriaElement = spectator.query('p-galleria'); + expect(galleriaElement).toBeTruthy(); + }); + + it('should emit activeIndexChange event when galleria active index changes', () => { + const emitterSpy = jest.spyOn(spectator.component.activeIndexChange, 'emit'); + + spectator.setInput({ + isLoading: false, + images: imagesMock + }); + spectator.detectChanges(); + const galleria = spectator.query(Galleria); + + galleria.activeIndexChange.emit(1); + + expect(emitterSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.ts new file mode 100644 index 000000000000..d441cff84368 --- /dev/null +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-gallery/ai-image-prompt-gallery.component.ts @@ -0,0 +1,41 @@ +import { NgIf } from '@angular/common'; +import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; + +import { SharedModule } from 'primeng/api'; +import { GalleriaModule } from 'primeng/galleria'; +import { ImageModule } from 'primeng/image'; +import { SkeletonModule } from 'primeng/skeleton'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { + DotAIImageOrientation, + DotGeneratedAIImage +} from '../../../../shared/services/dot-ai/dot-ai.models'; + +@Component({ + selector: 'dot-ai-image-prompt-gallery', + standalone: true, + templateUrl: './ai-image-prompt-gallery.component.html', + imports: [GalleriaModule, ImageModule, NgIf, SharedModule, SkeletonModule, DotMessagePipe], + styleUrls: ['./ai-image-prompt-gallery.component.scss'] +}) +export class AiImagePromptGalleryComponent { + @Input() + isLoading = false; + + @Input() + images: DotGeneratedAIImage[] = []; + + @Input() + activeImageIndex = 0; + + @Input() + orientation: DotAIImageOrientation; + + @Output() + activeIndexChange = new EventEmitter(); + + dotMessageService = inject(DotMessageService); +} diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.html b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.html deleted file mode 100644 index ff4a224f46d2..000000000000 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.html +++ /dev/null @@ -1,100 +0,0 @@ -
- -
- - - - - - -
- -
-

- - -

-
-
- -
-
- -
- -
- -
- - - -
- - - - - -
- -
-

- - -

-
-
-
diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.scss b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.scss deleted file mode 100644 index d9f8d166f71c..000000000000 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.scss +++ /dev/null @@ -1,44 +0,0 @@ -@use "variables" as *; - -:host { - form { - position: relative; - .field { - margin-bottom: $spacing-5; - } - dot-field-validation-message { - position: absolute; - left: 0; - bottom: 50px; - } - } - - .prompt-input__wrapper { - border-radius: $border-radius-lg; - border: 2px solid $color-palette-gray-300; - cursor: pointer; - padding: $spacing-4; - gap: $spacing-4; - transition: all $basic-speed ease; - min-height: 540px; - - .pi { - display: inline-flex; - color: $color-palette-primary-500; - } - - p { - margin: 0; - } - - &:hover { - border-color: $color-palette-secondary-200; - } - - &.selected { - border-color: $color-palette-secondary-300; - box-shadow: $shadow-l; - cursor: default; - } - } -} diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.spec.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.spec.ts deleted file mode 100644 index df327369766e..000000000000 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Spectator, createComponentFactory } from '@ngneat/spectator'; - -import { AiImagePromptInputComponent } from './ai-image-prompt-input.component'; - -describe('AiImagePromptInputComponent', () => { - let spectator: Spectator; - const createComponent = createComponentFactory(AiImagePromptInputComponent); - - it('should create', () => { - spectator = createComponent(); - - expect(spectator.component).toBeTruthy(); - }); -}); diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.ts deleted file mode 100644 index 8923c3161b10..000000000000 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/components/ai-image-prompt-input/ai-image-prompt-input.component.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { AsyncPipe, NgIf, NgTemplateOutlet } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - inject, - Input, - OnChanges, - Output, - SimpleChanges -} from '@angular/core'; -import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; - -import { AutoFocusModule } from 'primeng/autofocus'; -import { ButtonModule } from 'primeng/button'; -import { InputTextareaModule } from 'primeng/inputtextarea'; -import { TooltipModule } from 'primeng/tooltip'; - -import { DotMessageService } from '@dotcms/data-access'; -import { - DotAutofocusDirective, - DotFieldValidationMessageComponent, - DotMessagePipe -} from '@dotcms/ui'; - -import { PromptType } from '../../ai-image-prompt.models'; - -//TODO: make this component more flexible is we need more PromptType -//TODO: disable in auto if you dont have a prompt and content in BlockEditor -@Component({ - selector: 'dot-ai-image-prompt-input', - standalone: true, - templateUrl: './ai-image-prompt-input.component.html', - styleUrls: ['./ai-image-prompt-input.component.scss'], - imports: [ - ButtonModule, - NgIf, - ReactiveFormsModule, - TooltipModule, - NgTemplateOutlet, - InputTextareaModule, - DotMessagePipe, - AsyncPipe, - DotAutofocusDirective, - AutoFocusModule, - DotFieldValidationMessageComponent - ], - - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class AiImagePromptInputComponent implements OnChanges { - isSelected = false; - - @Input() - placeholder: string; - - @Input() - isLoading: boolean; - - @Input({ required: true }) - type: PromptType; - - @Output() - promptChanged = new EventEmitter(); - - form = inject(FormBuilder).group({ - prompt: ['', Validators.required] - }); - - dotMessageService = inject(DotMessageService); - - @Input() - set selected(isSelected: boolean) { - this.isSelected = isSelected; - this.resetForm(); - } - - get promptControl(): FormControl { - return this.form.get('prompt') as FormControl; - } - - /** - * Emit the prompt - * - * @return {void} - */ - generateImage(): void { - if (this.form.valid) { - this.promptChanged.emit(this.promptControl.value); - this.disableForm(); - } - } - - ngOnChanges(changes: SimpleChanges): void { - const { type } = changes; - if (type && type.currentValue === 'auto') { - this.promptControl.clearValidators(); - this.form.updateValueAndValidity(); - } - } - - private resetForm() { - this.form.enable(); - this.form.reset(); - } - - private disableForm() { - this.form.disable(); - } -} diff --git a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/plugins/ai-image-prompt.plugin.ts b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/plugins/ai-image-prompt.plugin.ts index 315dcaece01e..a9b84201991b 100644 --- a/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/plugins/ai-image-prompt.plugin.ts +++ b/core-web/libs/block-editor/src/lib/extensions/ai-image-prompt/plugins/ai-image-prompt.plugin.ts @@ -6,18 +6,12 @@ import { Instance, Props } from 'tippy.js'; import { ComponentRef } from '@angular/core'; -import { ConfirmationService } from 'primeng/api'; - import { filter, skip, takeUntil } from 'rxjs/operators'; import { Editor } from '@tiptap/core'; -import { DotMessageService } from '@dotcms/data-access'; - -import { findNodeByType, getAIPlaceholderImage } from '../../../shared'; -import { NodeTypes } from '../../bubble-menu/models'; import { AIImagePromptComponent } from '../ai-image-prompt.component'; -import { AI_IMAGE_PROMPT_PLUGIN_KEY, DOT_AI_IMAGE_CONTENT_KEY } from '../ai-image-prompt.extension'; +import { AI_IMAGE_PROMPT_PLUGIN_KEY } from '../ai-image-prompt.extension'; import { DotAiImagePromptStore } from '../ai-image-prompt.store'; interface AIImagePromptProps { @@ -35,8 +29,6 @@ export type AIImagePromptViewProps = AIImagePromptProps & { view: EditorView; }; -export const AI_IMAGE_PLACEHOLDER_PROPERTY = 'isAIPlaceholder'; - export class AIImagePromptView { public editor: Editor; @@ -58,6 +50,10 @@ export class AIImagePromptView { private store: DotAiImagePromptStore; + /** + * Creates a new instance of the AIImagePromptView class. + * @param {AIImagePromptViewProps} props - The properties for the component. + */ constructor(props: AIImagePromptViewProps) { const { editor, element, view, pluginKey, component } = props; @@ -86,83 +82,19 @@ export class AIImagePromptView { }); /** - * Subscription fired by the store when the dialog change of the state - * Handle the click of Generate button - */ - this.store.isLoading$ - .pipe( - filter((isLoading) => isLoading === true), - takeUntil(this.destroy$) - ) - .subscribe(() => { - const placeholder = getAIPlaceholderImage(this.editor); - - if (placeholder) { - // A regenerate has been requested, so we need to delete the placeholder image - this.editor - .chain() - .deleteRange({ - from: placeholder.from, - to: placeholder.to - }) - .insertLoaderNode(true, placeholder.from) - .run(); - } else { - // A new image is being inserted - this.store.hideDialog(); - this.editor.chain().insertLoaderNode().closeImagePrompt().run(); - } - }); - - /** - * Subscription fired by an error and remove the loader node - */ - this.store.errorMsg$ - .pipe( - filter((hasError) => !!hasError), - takeUntil(this.destroy$) - ) - .subscribe((error) => { - const loaderNodes = findNodeByType(this.editor, NodeTypes.LOADER); - this.editor.commands.deleteRange({ - from: loaderNodes[0].from, - to: loaderNodes[0].to - }); - // call the confirmation service - - this.component.injector.get(ConfirmationService).confirm({ - key: 'ai-image-prompt-msg', - message: this.component.injector.get(DotMessageService).get(error), - header: 'Error', - rejectVisible: false, - acceptVisible: false - }); - }); - - /** - * Subscription fired by the store when the prompt get a new contentlet to show + * Subscription fired by the store when image is seleted + * from the gallery to be inserted it into the editor */ - this.store.getContentlets$ + this.store.selectedImage$ .pipe( - filter((contentlets) => contentlets.length > 0), + filter((selectedImage) => !!selectedImage), takeUntil(this.destroy$) ) - .subscribe((contentlets) => { - const data = Object.values(contentlets[0])[0]; - - const loaderNodes = findNodeByType(this.editor, NodeTypes.LOADER); - - //Trust in this property to identify the image as a placeholder, until the user accept the content. - data[AI_IMAGE_PLACEHOLDER_PROPERTY] = true; - - if (loaderNodes) { - this.editor - .chain() - .deleteRange({ from: loaderNodes[0].from, to: loaderNodes[0].to }) - .insertImage(data, loaderNodes[0].from) - .openAIContentActions(DOT_AI_IMAGE_CONTENT_KEY) - .run(); - } + .subscribe((selectedImage) => { + this.editor.chain().insertImage(selectedImage.response.contentlet).run(); + // A new image is being inserted + this.store.hideDialog(); + this.editor.chain().closeImagePrompt().run(); }); } diff --git a/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.models.ts b/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.models.ts index 46cb392684d3..06d0a7f631a1 100644 --- a/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.models.ts +++ b/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.models.ts @@ -1,3 +1,7 @@ +import { DotCMSContentlet } from '@dotcms/dotcms-models'; + +import { PromptType } from '../../../extensions/ai-image-prompt/ai-image-prompt.models'; + export interface AiPluginResponse { id: string; object: string; @@ -25,6 +29,9 @@ interface Usage { total_tokens: number; } +/** + * Represents the response received from the DotAI Image API. + */ export interface DotAIImageResponse { originalPrompt: string; response: string; @@ -33,6 +40,41 @@ export interface DotAIImageResponse { url: string; } +/** + * Represents an AI image prompt and the possible config options. + */ +export interface AIImagePrompt { + text: string; + type: PromptType; + size: DotAIImageOrientation; +} + +/** + Represents the response received from the DotAI Image API plus + the contentle generated by DotCMS with the response data + */ +export interface DotAIImageContent extends DotAIImageResponse { + contentlet: DotCMSContentlet; +} + +/** + * Represents the response and request of a generated AI image, + * to keep sync when the user change between the gallery and the form + */ +export interface DotGeneratedAIImage { + request: AIImagePrompt; + response: DotAIImageContent; +} + +/** + * Represents the possible orientations of a Dot AI image. + */ +export const enum DotAIImageOrientation { + HORIZONTAL = '1792x1024', + SQUARE = '1024x1024', + VERTICAL = '1024x1792' +} + export interface DotAICompletionsConfig { apiImageUrl: string; apiKey: string; diff --git a/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.service.spec.ts b/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.service.spec.ts index 7dae8a9419b5..7df1fb6476ac 100644 --- a/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.service.spec.ts +++ b/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.service.spec.ts @@ -4,6 +4,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { DotCMSContentlet } from '@dotcms/dotcms-models'; +import { DotAIImageContent, DotAIImageOrientation, DotAIImageResponse } from './dot-ai.models'; import { DotAiService } from './dot-ai.service'; describe('DotAiService', () => { @@ -51,26 +52,50 @@ describe('DotAiService', () => { req.flush(null, { status: 500, statusText: 'Server Error' }); }); - it('should generate image', () => { + it('should generate and publish an image', () => { const mockPrompt = 'Test prompt'; - const mockResponse = 'Test response'; - - spectator.service.generateImage(mockPrompt).subscribe((response) => { - expect(response).toEqual(mockResponse); + const mockGenerateResponse: DotAIImageResponse = { + response: 'temp_file123', + tempFileName: 'Test Imagae' + } as unknown as DotAIImageContent; + const mockPublishResponse = [{ '123': 'testContent' }] as unknown as DotCMSContentlet[]; + const mockPublishRequest = [ + { + baseType: 'dotAsset', + asset: mockGenerateResponse.response, + title: mockGenerateResponse.tempFileName, + hostFolder: '', + indexPolicy: 'WAIT_FOR' + } + ]; + const size = DotAIImageOrientation.SQUARE; + + spectator.service.generateAndPublishImage(mockPrompt, size).subscribe((response) => { + expect(response).toEqual({ + contentlet: Object.values(mockPublishResponse[0])[0], + ...mockGenerateResponse + }); }); - const req = httpTestingController.expectOne('/api/v1/ai/image/generate'); + const generateRequest = httpTestingController.expectOne('/api/v1/ai/image/generate'); + const publishRequest = httpTestingController.expectOne( + '/api/v1/workflow/actions/default/fire/PUBLISH' + ); - expect(req.request.method).toEqual('POST'); - expect(JSON.parse(req.request.body)).toEqual({ prompt: mockPrompt }); + expect(generateRequest.request.method).toEqual('POST'); + expect(JSON.parse(generateRequest.request.body)).toEqual({ prompt: mockPrompt, size }); - req.flush({ response: mockResponse }); + expect(publishRequest.request.method).toEqual('POST'); + expect(JSON.parse(generateRequest.request.body)).toEqual({ mockPublishRequest }); + + generateRequest.flush({ response: mockGenerateResponse }); + publishRequest.flush({ response: mockPublishResponse }); }); it('should handle errors while generating image', () => { const mockPrompt = 'Test prompt'; - spectator.service.generateImage(mockPrompt).subscribe( + spectator.service.generateAndPublishImage(mockPrompt).subscribe( () => fail('Expected an error, but received a response'), (error) => { expect(error).toBe('Error fetching AI content'); @@ -82,36 +107,10 @@ describe('DotAiService', () => { req.flush(null, { status: 500, statusText: 'Server Error' }); }); - it('should create and publish contentlet', () => { - const mockFileId = '123'; - const mockResponse = ['contentlet'] as unknown as DotCMSContentlet[]; - - spectator.service.createAndPublishContentlet(mockFileId).subscribe((response) => { - expect(response).toEqual(mockResponse); - }); - - const req = httpTestingController.expectOne( - '/api/v1/workflow/actions/default/fire/PUBLISH' - ); - - expect(req.request.method).toEqual('POST'); - expect(JSON.parse(req.request.body)).toEqual({ - contentlets: [ - { - contentType: 'dotAsset', - asset: mockFileId, - hostFolder: '', - indexPolicy: 'WAIT_FOR' - } - ] - }); - req.flush({ entity: { results: mockResponse } }); - }); - it('should handle errors while creating and publishing contentlet', () => { - const mockFileId = '123'; + const mockPrompt = 'Test prompt' as unknown as DotAIImageResponse; - spectator.service.createAndPublishContentlet(mockFileId).subscribe( + spectator.service.createAndPublishContentlet(mockPrompt).subscribe( () => fail('Expected an error, but received a response'), (error) => { expect(error).toBe('Test Error'); diff --git a/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.service.ts b/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.service.ts index 98ac8a48c66d..769aa25a66b3 100644 --- a/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.service.ts +++ b/core-web/libs/block-editor/src/lib/shared/services/dot-ai/dot-ai.service.ts @@ -7,7 +7,13 @@ import { catchError, map, pluck, switchMap } from 'rxjs/operators'; import { DotCMSContentlet } from '@dotcms/dotcms-models'; -import { AiPluginResponse, DotAICompletionsConfig, DotAIImageResponse } from './dot-ai.models'; +import { + AiPluginResponse, + DotAICompletionsConfig, + DotAIImageContent, + DotAIImageOrientation, + DotAIImageResponse +} from './dot-ai.models'; import { AI_PLUGIN_KEY } from '../../utils'; @@ -17,8 +23,6 @@ const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); -type ImageSize = '1024x1024' | '1024x1792' | '1792x1024'; - @Injectable() export class DotAiService { private http: HttpClient = inject(HttpClient); @@ -65,12 +69,12 @@ export class DotAiService { * * @param {string} prompt - The prompt for generating the image. * @param {string} size - The size of the image to be generated (default: '1024x1024'). - * @returns {Observable} - An observable that emits an array of DotCMSContentlet objects. + * @returns {Observable} - An observable that emits an array of DotCMSContentlet objects. */ public generateAndPublishImage( prompt: string, - size: ImageSize = '1024x1024' - ): Observable { + size = DotAIImageOrientation.HORIZONTAL + ): Observable { return this.http .post( `${API_ENDPOINT}/image/generate`, @@ -107,8 +111,8 @@ export class DotAiService { ); } - private createAndPublishContentlet(image: DotAIImageResponse): Observable { - const { response, tempFileName } = image; + createAndPublishContentlet(aiResponse: DotAIImageResponse): Observable { + const { response, tempFileName } = aiResponse; const contentlets: Partial[] = [ { baseType: 'dotAsset', @@ -125,11 +129,15 @@ export class DotAiService { }) .pipe( pluck('entity', 'results'), + map((contentlets: DotCMSContentlet[]) => ({ + contentlet: Object.values(contentlets[0])[0], + ...aiResponse + })), catchError(() => throwError( 'block-editor.extension.ai-image.api-error.error-publishing-ai-image' ) ) - ) as Observable; + ) as Observable; } } diff --git a/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts b/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts index 474c59b84ca8..05b9447fa645 100644 --- a/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts +++ b/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts @@ -1,7 +1,5 @@ import { Content, JSONContent } from '@tiptap/core'; -import { AI_IMAGE_PLACEHOLDER_PROPERTY, NodeTypes } from '../../extensions'; - interface BlockMap { [key: string]: boolean; } @@ -96,44 +94,6 @@ export const purifyNodeTree = (content: JSONContent[], blocksMap: BlockMap): JSO return allowedContent; }; -/** - * Removes the loading nodes from the provided JSONContent array recursively. - * This is needed because the AI placeholder content are part of the editor, - * but they are not really valid content. - * - * @param {JSONContent[]} content - An array of JSONContent objects representing the content with loading nodes. - * @return {JSONContent[]} The content array with loading nodes removed. - */ -export const removeLoadingNodes = (content: JSONContent[]): JSONContent[] => { - if (!content?.length) { - return content; - } - - const nodesToRemove = [NodeTypes.AI_CONTENT, NodeTypes.LOADER]; - const allowedContent = []; - - for (const i in content) { - const node = content[i]; - - if ( - node && - !nodesToRemove.includes(node?.type as NodeTypes) && - !isAIPlaceholderImage(node) - ) { - allowedContent.push({ - ...node, - content: removeLoadingNodes(node.content) - }); - } - } - - return allowedContent; -}; - -const isAIPlaceholderImage = (node: JSONContent): boolean => { - return node.type === NodeTypes.DOT_IMAGE && node.attrs?.data?.[AI_IMAGE_PLACEHOLDER_PROPERTY]; -}; - /** * Convert the allowBlock array to an object. * Since we are going to traverse a tree of nodes (using recursion), diff --git a/core-web/libs/block-editor/src/lib/shared/utils/prosemirror.utils.ts b/core-web/libs/block-editor/src/lib/shared/utils/prosemirror.utils.ts index 0f1b801263cd..2635f3c582f4 100644 --- a/core-web/libs/block-editor/src/lib/shared/utils/prosemirror.utils.ts +++ b/core-web/libs/block-editor/src/lib/shared/utils/prosemirror.utils.ts @@ -4,7 +4,7 @@ import { EditorView } from 'prosemirror-view'; import { Editor } from '@tiptap/core'; -import { AI_IMAGE_PLACEHOLDER_PROPERTY, CustomNodeTypes, NodeTypes } from '../../extensions'; +import { CustomNodeTypes, NodeTypes } from '../../extensions'; const aTagRex = new RegExp(/]*)>(\s|\n|]*src="[^"]*"[^>]*>)*?<\/a>/gm); const imgTagRex = new RegExp(/]*src="[^"]*"[^>]*>/gm); @@ -274,21 +274,6 @@ export const findNodeByType = ( return nodes.length ? nodes : null; }; -/** - * Get the information about the first occurrence of an AI placeholder image node in the TipTap editor. - * - * @param {Editor} editor - The TipTap editor instance. - * @returns {DotTiptapNodeInformation | null} - */ -export const getAIPlaceholderImage = (editor: Editor): DotTiptapNodeInformation => { - const nodes = findNodeByType(editor, NodeTypes.DOT_IMAGE); - const aIPlaceholderImages = nodes - ? nodes.filter((nodeInfo) => nodeInfo.node.attrs.data[AI_IMAGE_PLACEHOLDER_PROPERTY]) - : null; - - return aIPlaceholderImages?.[0]; -}; - /** * Check if the text is an image URL. * diff --git a/core-web/libs/block-editor/src/test-setup.ts b/core-web/libs/block-editor/src/test-setup.ts index 3d6428dcb9d8..d69ec33e305c 100644 --- a/core-web/libs/block-editor/src/test-setup.ts +++ b/core-web/libs/block-editor/src/test-setup.ts @@ -1,2 +1,13 @@ // Configure Jest for Angular [https://medium.com/@kyjungok/setup-jest-in-angular-application-22b22609cbcd] import 'jest-preset-angular/setup-jest'; + +import { NgModule } from '@angular/core'; + +// This is needed to mock the PrimeNG SplitButton component to avoid errors while running tests. +// https://github.com/primefaces/primeng/issues/12945 +@NgModule() +export class SplitButtonMockModule {} + +jest.mock('primeng/splitbutton', () => ({ + SplitButtonModule: SplitButtonMockModule +})); diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_accordion.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_accordion.scss new file mode 100644 index 000000000000..418a55184ccf --- /dev/null +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_accordion.scss @@ -0,0 +1,85 @@ +@use "variables" as *; + +.p-accordion .p-accordion-header .p-accordion-header-link { + padding: $spacing-3; + border: 1px solid $color-palette-gray-400; + color: $black; + font-weight: $font-weight-semi-bold; + border-radius: $border-radius-md; + transition: box-shadow 0.2s; + font-size: $font-size-lg; +} +.p-accordion .p-accordion-header .p-accordion-header-link .p-accordion-toggle-icon { + margin-right: $spacing-1; +} +.p-accordion .p-accordion-header .p-accordion-header-link:focus { + outline: 0 none; + outline-offset: 0; +} + +.p-accordion .p-accordion-header.p-highlight .p-accordion-header-link { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + border-bottom: none; +} + +.p-accordion .p-accordion-content { + padding: $spacing-3; + border: 1px solid $color-palette-gray-400; + border-top: 0; + border-top-right-radius: 0; + border-top-left-radius: 0; + border-bottom-right-radius: $border-radius-md; + border-bottom-left-radius: $border-radius-md; +} +.p-accordion p-accordiontab .p-accordion-tab { + margin-bottom: $spacing-3; +} + +.p-accordion p-accordiontab:first-child .p-accordion-header .p-accordion-header-link { + border-top-right-radius: $border-radius-md; + border-top-left-radius: $border-radius-md; +} +.p-accordion + p-accordiontab:last-child + .p-accordion-header:not(.p-highlight) + .p-accordion-header-link { + border-bottom-right-radius: $border-radius-md; + border-bottom-left-radius: $border-radius-md; +} +.p-accordion p-accordiontab:last-child .p-accordion-content { + border-bottom-right-radius: $border-radius-md; + border-bottom-left-radius: $border-radius-md; +} + +//Custom +.p-accordion { + .p-accordion-header { + .p-accordion-header-link .p-accordion-toggle-icon-end { + &.pi { + border: 1px solid; + border-radius: 100px; + height: $spacing-5; + width: $spacing-5; + display: flex; + align-items: center; + justify-content: center; + color: $color-palette-primary; + } + + &.pi:before { + font-size: $spacing-2; + } + } + + &.p-disabled { + .p-accordion-header-link { + color: $color-palette-gray-400; + + .p-accordion-toggle-icon-end.pi { + color: $color-palette-gray-400; + } + } + } + } +} diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_galleria.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_galleria.scss new file mode 100644 index 000000000000..b92cfbc49ca5 --- /dev/null +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_galleria.scss @@ -0,0 +1 @@ +//TODO: Implement Galleria styles. diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_image.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_image.scss new file mode 100644 index 000000000000..fd316c982c6f --- /dev/null +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_image.scss @@ -0,0 +1 @@ +//TODO: implement image styles diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss index 11096ab4830f..410e9647248b 100644 --- a/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss @@ -1,7 +1,7 @@ @charset "UTF-8"; @use "misc"; - +@use "components/accordion"; @use "components/avatar"; @use "components/badge"; @use "components/breadcrumb"; @@ -16,6 +16,8 @@ @use "components/divider"; @use "components/dynamicdialog"; @use "components/form"; +@use "components/galleria"; +@use "components/image"; @use "components/inplace"; @use "components/listbox"; @use "components/menu"; diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 7354e94894e9..eb890f31dabf 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -5616,6 +5616,29 @@ block-editor.common.input-prompt-required-error=The information provided is insu block-editor.extension.ai-content.ask-ai-to-write-something=Ask AI to write something block-editor.extension.ai-image.dialog-title=Generate AI Image +block-editor.extension.ai-image.settings=Settings +block-editor.extension.ai-image.input=Input +block-editor.extension.ai-image.custom.prompt=Create with Custom Prompt +block-editor.extension.ai-image.custom.prompt.desc=Write your own prompt and have full creative control over the image generation. +block-editor.extension.ai-image.existing.content=Create based on Existing Content +block-editor.extension.ai-image.existing.content.desc=Generate and image based on provided content. You may Customize with mood, colors and size. +block-editor.extension.ai-image.orientation=Orientation + +block-editor.extension.ai-image.prompt=Prompt +block-editor.extension.ai-image.custom.props=Customize images Properties ( Optional ) + +block-editor.extension.ai-image.placeholder=Enhance the given content. Detail your desired mood, color preferences. +block-editor.extension.ai-image.custom.placeholder=Describe the image you want to create, including details like subjects, colors, mood, and style. The more specific, the better! + +block-editor.extension.ai-image.rewritten=AI Prompt (Rewritten) +block-editor.extension.ai-image.generating=Generating +block-editor.extension.ai-image.generate=Generate +block-editor.extension.ai-image.regenerate=Regenerate +block-editor.extension.ai-image.insert=Insert + +block-editor.extension.ai-image.orientation.horizontal=Horizontal (1792 x 1024) +block-editor.extension.ai-image.orientation.square=Square (1024 x 1024) +block-editor.extension.ai-image.orientation.vertical=Vertical (1024 x 1792) block-editor.extension.ai-image.input-text.placeholder=Create a realistic image of a cow in the snow block-editor.extension.ai-image.input-text.tooltip=Describe the type of image you want to generate.