Skip to content

Commit

Permalink
feat(ai): Create an "AI Image" custom block #25670
Browse files Browse the repository at this point in the history
* Created AI content node

* Created ai-content-service

* Created AI prompt content extension

* Update ai-content.service.ts

* Optimised and aligned extension code

* Resolved comments on PR

* Removed ai content node file

* Update ai-content.service.ts

* Remove import path for ai-content-node

* Updated the ai-prompt form

* align ai text prompt extension

* Resovled comments on pull

* Fix the aiTextPrompt form

* Updated extension related code

* Update ai-content-prompt.plugin.ts

* Remove some extra code

* Fixed outside click for aiContentPrompt extension

* Created ai content node

* Updated ai-content node

* Remove unused code, resolve comments on PR

* ai-content-prompt extension code optimisation

* Update ai-content-prompt.plugin.ts

* Integrated with AI api

* insert ai node on response

* textPrompt extension code optimsation

* Update ai-content.service.ts

* Resolve comments on pull req

* handle close extension on outside click and content flip fix

* Added focus field method and type for form

* Change name of ai node creation command

* Update the ai-content icon in the actions menu list

* Update ai-content-prompt.component.scss

* Resolved comments on pull

* Added pening status and update the name of destroy var

* Implement ai-content-actions extension

* ai-prompt extension code optimisation

* ai-text-prompt block optimisation

* Update main.ts

* feat: Created ai image prompt extension

* Resolve comments on pull

* Created loader node and handle loading image

* Resolve comments on PR

* Code optimisation for ai-content-actions extension

* Fix css for p-listbox

* Handle ai content actions to work in two diff context, code optimisation

* Code optimisation

* clean code from extra code

---------

Co-authored-by: Nikola Trajkovic <82508651+nikolatrajkovic24@users.noreply.github.com>
Co-authored-by: Nikola <nikola@physician.me>
Co-authored-by: Will Ezell <will@dotcms.com>
  • Loading branch information
4 people committed Oct 20, 2023
1 parent 486b58e commit 4b7b189
Show file tree
Hide file tree
Showing 30 changed files with 1,358 additions and 14 deletions.
10 changes: 8 additions & 2 deletions core-web/libs/block-editor/src/lib/block-editor.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { DotBlockEditorComponent } from './components/dot-block-editor/dot-block
import { DotEditorCountBarComponent } from './components/dot-editor-count-bar/dot-editor-count-bar.component';
import {
AIContentPromptComponent,
AIContentActionsComponent,
AIImagePromptComponent,
BubbleLinkFormComponent,
BubbleMenuButtonComponent,
BubbleMenuComponent,
Expand Down Expand Up @@ -52,7 +54,9 @@ import { SharedModule } from './shared/shared.module';
DotBlockEditorComponent,
DotEditorCountBarComponent,
FloatingButtonComponent,
AIContentPromptComponent
AIContentPromptComponent,
AIContentActionsComponent,
AIImagePromptComponent
],
providers: [DotUploadFileService, LoggerService, StringUtils, AiContentService],
exports: [
Expand All @@ -63,7 +67,9 @@ import { SharedModule } from './shared/shared.module';
SharedModule,
BubbleFormComponent,
DotBlockEditorComponent,
AIContentPromptComponent
AIContentPromptComponent,
AIContentActionsComponent,
AIImagePromptComponent
]
})
export class BlockEditorModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,45 @@
}
}
}

.ai-content-container {
&.ProseMirror-selectednode {
background-color: $color-palette-primary-op-20;
border: 1px solid $color-palette-primary-300;
}
}

.loader-style {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
min-height: 12.5rem;
min-width: 25rem;
width: 25.5625rem;
height: 17.375rem;
padding: 0.5rem;
border-radius: 0.5rem;
border: 1.5px solid $color-palette-gray-400;
}

.p-progress-spinner {
border: 5px solid $color-palette-gray-300;
border-radius: 50%;
border-top: 5px solid $color-palette-primary;
width: 2.4rem;
height: 2.4rem;
animation: spin 1s linear infinite;
}

@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}

tiptap-editor {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,20 @@ import {
FREEZE_SCROLL_KEY,
AssetUploader,
DotComands,
AIContentPromptExtension
AIContentPromptExtension,
AIImagePromptExtension,
AIContentActionsExtension
} from '../../extensions';
import { DotPlaceholder } from '../../extensions/dot-placeholder/dot-placeholder-plugin';
import { ContentletBlock, ImageNode, VideoNode } from '../../nodes';
import { ContentletBlock, ImageNode, VideoNode, AIContentNode, LoaderNode } from '../../nodes';
import {
formatHTML,
removeInvalidNodes,
SetDocAttrStep,
DotMarketingConfigService,
RestoreDefaultDOMAttrs
} from '../../shared';

@Component({
selector: 'dot-block-editor',
templateUrl: './dot-block-editor.component.html',
Expand Down Expand Up @@ -109,7 +112,9 @@ export class DotBlockEditorComponent implements OnInit, OnDestroy {
['dotContent', ContentletBlock(this.injector)],
['image', ImageNode],
['video', VideoNode],
['table', DotTableExtension()]
['table', DotTableExtension()],
['aiContent', AIContentNode],
['loader', LoaderNode]
]);

private destroy$: Subject<boolean> = new Subject<boolean>();
Expand Down Expand Up @@ -381,6 +386,8 @@ export class DotBlockEditorComponent implements OnInit, OnDestroy {
DotBubbleMenuExtension(this.viewContainerRef),
BubbleFormExtension(this.viewContainerRef),
AIContentPromptExtension(this.viewContainerRef),
AIImagePromptExtension(this.viewContainerRef),
AIContentActionsExtension(this.viewContainerRef),
DotFloatingButton(this.injector, this.viewContainerRef),
DotTableCellExtension(this.viewContainerRef),
BubbleAssetFormExtension(this.viewContainerRef),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@ function execCommand({
subscript: () => editor.chain().setSubscript().focus().run(),
superscript: () => editor.chain().setSuperscript().focus().run(),
video: () => editor.commands.openAssetForm({ type: 'video' }),
aiContentPrompt: () => editor.commands.openAIPrompt()
aiContentPrompt: () => editor.commands.openAIPrompt(),
aiContent: () => editor.commands.insertAINode(),
aiImagePrompt: () => editor.commands.openImagePrompt()
};

getCustomActions(customBlocks).forEach((option) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<p-listbox [options]="actionOptions" (onClick)="$event.value.callback()">
<ng-template let-actionOption pTemplate="item">
<div class="action-content">
<i [class]="actionOption.icon"></i>
{{ actionOption.label }}
</div>
</ng-template>
</p-listbox>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
@use "variables" as *;

:host ::ng-deep {
.p-listbox {
display: block;
width: 12.5rem;
padding: $spacing-1;
margin-left: $spacing-8;

li {
padding: $spacing-2 $spacing-3;

// accept option
&:first-child {
background-color: $color-palette-primary-200;
}

// regenerate option
&:nth-child(2) {
padding: $spacing-3;
background-color: $white !important;
}

// delete option
&:last-child {
border-top: 1px solid $color-palette-gray-300;
background-color: $white !important;
}

&:hover {
background-color: $color-palette-primary-100;
}
}
}
}

.action-content {
display: flex;
align-items: center;
color: $black;

// list option icon
i {
margin-right: $spacing-0;
color: $color-palette-gray-600;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';

import { AIContentActionsComponent } from './ai-content-actions.component';

import { AiContentService } from '../../shared';

describe('AIContentActionsComponent', () => {
let component: AIContentActionsComponent;
let fixture: ComponentFixture<AIContentActionsComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule, HttpClientTestingModule],
declarations: [AIContentActionsComponent],
providers: [AiContentService]
}).compileComponents();

fixture = TestBed.createComponent(AIContentActionsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Observable } from 'rxjs';

import { Component, EventEmitter, Output, OnInit } from '@angular/core';

import { AiContentService } from '../../shared/services/ai-content/ai-content.service';

interface ActionOption {
label: string;
icon: string;
selectedOption: boolean;
callback: () => void;
}

export enum ACTIONS {
ACCEPT = 'ACCEPT',
DELETE = 'DELETE',
REGENERATE = 'REGENERATE'
}

@Component({
selector: 'dot-ai-content-actions',
templateUrl: './ai-content-actions.component.html',
styleUrls: ['./ai-content-actions.component.scss']
})
export class AIContentActionsComponent implements OnInit {
@Output() actionEmitter = new EventEmitter<ACTIONS>();

actionOptions!: ActionOption[];
tooltipContent = 'Describe the size, color palette, style, mood, etc.';

constructor(private aiContentService: AiContentService) {}

ngOnInit() {
this.actionOptions = [
{
label: 'Accept',
icon: 'pi pi-check',
callback: () => this.emitAction(ACTIONS.ACCEPT),
selectedOption: true
},
{
label: 'Regenerate',
icon: 'pi pi-sync',
callback: () => this.emitAction(ACTIONS.REGENERATE),
selectedOption: false
},
{
label: 'Delete',
icon: 'pi pi-trash',
callback: () => this.emitAction(ACTIONS.DELETE),
selectedOption: false
}
];
}

private emitAction(action: ACTIONS) {
this.actionEmitter.emit(action);
}

getLatestContent(): string {
return this.aiContentService.getLatestContent();
}

getNewContent(contentType: string): Observable<string> {
return this.aiContentService.getNewContent(contentType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { PluginKey } from 'prosemirror-state';
import { Props } from 'tippy.js';

import { ViewContainerRef } from '@angular/core';

import { Extension } from '@tiptap/core';

import { AIContentActionsComponent } from './ai-content-actions.component';
import { aiContentActionsPlugin } from './plugins/ai-content-actions.plugin';

export interface AIContentActionsOptions {
pluginKey: PluginKey;
tippyOptions?: Partial<Props>;
element: HTMLElement | null;
}

declare module '@tiptap/core' {
interface Commands<ReturnType> {
AIContentActions: {
openAIContentActions: () => ReturnType;
closeAIContentActions: () => ReturnType;
};
}
}

export const AI_CONTENT_ACTIONS_PLUGIN_KEY = new PluginKey('aiContentActions-form');

export const AIContentActionsExtension = (viewContainerRef: ViewContainerRef) => {
return Extension.create<AIContentActionsOptions>({
name: 'aiContentActions',

addOptions() {
return {
element: null,
tippyOptions: {},
pluginKey: AI_CONTENT_ACTIONS_PLUGIN_KEY
};
},

addCommands() {
return {
openAIContentActions:
() =>
({ chain }) => {
return chain()
.command(({ tr }) => {
tr.setMeta(AI_CONTENT_ACTIONS_PLUGIN_KEY, { open: true });

return true;
})
.freezeScroll(true)
.run();
},
closeAIContentActions:
() =>
({ chain }) => {
return chain()
.command(({ tr }) => {
tr.setMeta(AI_CONTENT_ACTIONS_PLUGIN_KEY, { open: false });

return true;
})
.freezeScroll(false)
.run();
}
};
},

addProseMirrorPlugins() {
const component = viewContainerRef.createComponent(AIContentActionsComponent);
component.changeDetectorRef.detectChanges();

return [
aiContentActionsPlugin({
pluginKey: this.options.pluginKey,
editor: this.editor,
element: component.location.nativeElement,
tippyOptions: this.options.tippyOptions,
component: component
})
];
}
});
};
Loading

0 comments on commit 4b7b189

Please sign in to comment.