import {html} from "lit";
import {litToHTML} from "../../../../utilities/lit-to-html";
import AIPanelComponent from './panel/ai-panel.component';
import AITooltipComponent from "./tooltip/ai-tooltip.component";
import Quill from "quill";
import ZnTextarea from "../../../textarea";
import type {Range} from "quill";
class QuillAI {
private _quill: Quill;
private readonly _path: string = '';
private _component!: AITooltipComponent | AIPanelComponent;
private _selectedText: string = '';
private _prompt: string = '';
private _aiResponseContent: string = '';
constructor(quill: Quill, options: { path: string }) {
this._quill = quill;
this._path = options.path;
this._initComponent();
this._attachEvents();
}
private _initComponent() {
this._component = this._createTooltipComponent()!;
this._component.addEventListener('click', (e: Event) => this._replaceTooltip(e));
this._quill.container.ownerDocument.body.appendChild(this._component);
}
private _attachEvents() {
this._quill.on(Quill.events.SELECTION_CHANGE, (range) => this._updateFromEditor(range));
}
private _latestContent(panel: HTMLElement | null | undefined): string {
if (panel === null || panel === undefined) {
panel = this._component.shadowRoot?.querySelector('.ai-panel');
}
if (panel) {
const ele = panel.querySelector('[name="editor-response-text"]')
if (ele instanceof HTMLInputElement || ele instanceof ZnTextarea) {
return ele.value;
}
}
return this._aiResponseContent
}
async processAIRequest() {
const quotedSelectedText = this._selectedText ? this._selectedText : this._quill.getText();
const panel: HTMLElement | null | undefined = this._component.shadowRoot?.querySelector('.ai-panel');
const response = await fetch(this._path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-kx-fetch-style': 'zn-editor',
'x-kx-editor-id': this._quill?.container?.getAttribute('x-editor-id') || '',
},
body: JSON.stringify({text: quotedSelectedText, prompt: this._prompt})
});
if (response.ok) {
const result: unknown = await response.text();
if (panel) {
panel.style.width = '500px';
panel.innerHTML = result as string;
this._aiResponseContent = this._latestContent(panel);
}
} else {
const result: unknown = await response.json();
if (typeof result === 'string') {
const range = this._quill.getSelection();
if (range) {
this._quill.deleteText(range.index, range.length);
this._quill.insertText(range.index, result);
this._quill.setSelection(range.index + result.length, 0);
}
}
}
}
private _createTooltipComponent() {
const tpl = html`
`;
return litToHTML(tpl);
}
private _createPanelComponent() {
const tpl = html`
`;
return litToHTML(tpl);
}
private _replaceTooltip(e: Event) {
e.preventDefault();
e.stopPropagation();
this._component.remove();
const panel = this._createPanelComponent();
if (!panel) return;
this._component = panel;
this._component.requestUpdate();
// Attach panel button triggers
this._attachPanelEvents(panel);
// TODO: Finish overlay to prevent clicking away
/* // Create and append the overlay
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'none';
overlay.style.zIndex = '10000';
// Append the overlay and panel to the body
this._quill.container.ownerDocument.body.appendChild(overlay);
this._component.style.zIndex = '10001';*/
this._quill.container.ownerDocument.body.appendChild(this._component);
this._show();
this._positionComponent();
}
private _onDocumentClick(e: MouseEvent) {
const path = e.composedPath ? e.composedPath() : [e.target as Node];
const isInsideComponent = path.includes(this._component) || path.includes(this._component.shadowRoot!);
const isInsideQuillRoot = this._quill.root.contains(path[0] as Node);
const isInsideAIPanel = path.some((node) => {
if (node instanceof HTMLElement) {
return node.tagName === 'ZN-AI-PANEL';
}
return false;
});
// If this._aiResponseContent is not empty, it means user is interacting with the panel
if (this._latestContent(null)) {
if (!isInsideAIPanel) {
e.preventDefault();
e.stopPropagation();
return;
}
}
if (!isInsideComponent && !isInsideQuillRoot && !isInsideAIPanel) {
this.resetComponent();
}
}
private _updateFromEditor(range: Range) {
// Update if component isn't AI panel
if (this._component instanceof AIPanelComponent) return;
// Display 'refine' option for selections longer than 25 characters
if (range?.length > 25) {
// Keep selected text in memory to be passed to AI later
this._selectedText = this._quill.getText(range.index, range.length);
this._show();
this._positionComponent();
} else {
this.resetComponent();
}
}
private _positionComponent() {
if (!this._component || !this._component.open) return;
const range = this._quill.getSelection();
if (!range) return;
if (this._component instanceof AITooltipComponent) {
const editorBounds = this._quill.container.getBoundingClientRect();
const endIndex = range.index + range.length;
const bounds = this._quill.getBounds(endIndex);
if (!bounds) return;
const left = editorBounds.left + bounds.left - 10; // Slight offset to the left
const top = editorBounds.top + bounds.bottom + 4;
this._component.style.left = `${Math.max(0, left)}px`;
this._component.style.top = `${top}px`;
return;
}
const positionPanel = () => {
const editorBounds = this._quill.container.getBoundingClientRect();
let bounds;
if (range.length === this._quill.getLength() - 1) {
bounds = this._quill.getBounds(range.index, range.length);
} else {
const endIndex = range.index + range.length;
bounds = this._quill.getBounds(endIndex);
}
if (!bounds) return;
const right = editorBounds.left + bounds.right; // Align right side
const bottom = editorBounds.top + bounds.top - 8; // Position above the selection
if (right > window.innerWidth) {
this._setPanelPosition(window.innerWidth - 10, bottom);
return;
}
this._setPanelPosition(right, bottom);
};
// Defer positioning to ensure the panel is fully rendered
requestAnimationFrame(positionPanel);
}
private _setPanelPosition(right: number, bottom: number) {
this._component.style.right = `${Math.max(0, window.innerWidth - right)}px`;
this._component.style.bottom = `${Math.max(0, window.innerHeight - bottom)}px`;
}
public resetComponent() {
this._hide();
this._component.remove();
this._initComponent();
}
private _show() {
if (!this._component.open) {
this._component.show();
this._quill.container.ownerDocument.addEventListener('click', (e: MouseEvent) => this._onDocumentClick(e));
this._quill.container.ownerDocument.addEventListener('keydown', this._onEscapeKey);
}
}
private _hide() {
this._component.hide();
this._quill.container.ownerDocument.removeEventListener('click', (e: MouseEvent) => this._onDocumentClick(e));
this._quill.container.ownerDocument.removeEventListener('keydown', this._onEscapeKey);
}
private _onEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
this._hide();
this._component.remove();
this._initComponent();
}
}
private _attachPanelEvents(panel: AIPanelComponent) {
panel.refine = this._enterCustomPrompt.bind(this);
panel.refineBuiltIn = this._clickPreDefinedEvent.bind(this);
}
private _clickPreDefinedEvent(e: Event) {
if (e instanceof PointerEvent) {
const target = e.target as HTMLElement;
this._prompt = target.getAttribute('data-ai-option') || '';
if (this._prompt === "") {
for (const targetElement of e.composedPath()) {
if (!(targetElement instanceof HTMLElement)) continue;
this._prompt = targetElement.getAttribute('data-ai-option') || ''
if (this._prompt) break;
}
}
this.processAIRequest().then(r => r);
}
}
private _enterCustomPrompt(prompt: string) {
this._prompt = prompt;
this.processAIRequest().then(r => r);
}
}
export default QuillAI;