Skip to content

Instantly share code, notes, and snippets.

@celsowm
Created May 1, 2025 18:49
Show Gist options
  • Save celsowm/4f61fc93ffda38e32cf4812ad5ac68ec to your computer and use it in GitHub Desktop.
Save celsowm/4f61fc93ffda38e32cf4812ad5ac68ec to your computer and use it in GitHub Desktop.
Levi CKEDITOR 5
import { Plugin, ContextualBalloon, ButtonView, InputTextView, View } from 'ckeditor5';
export default class LeviPlugin extends Plugin {
static get requires() {
return [ContextualBalloon];
}
init() {
const editor = this.editor;
this._balloon = editor.plugins.get(ContextualBalloon);
this._selectedText = '';
this._userPrompt = '';
this._lastPosition = null;
this._createOverlay();
this._createInitialView();
this._createMainView();
// Sempre que a seleção mudar, atualiza o balão
this.listenTo(
editor.model.document.selection,
'change:range',
() => this._updateBalloonVisibility()
);
}
// ————— Overlay para destacar a seleção —————
_createOverlay() {
this._overlay = document.createElement('div');
Object.assign(this._overlay.style, {
position: 'absolute',
pointerEvents: 'none',
background: 'rgba(128,189,254,0.3)',
zIndex: '9999',
display: 'none'
});
document.body.appendChild(this._overlay);
}
_showOverlay(domRange) {
const rects = domRange.getClientRects();
if (!rects.length) return this._hideOverlay();
let minTop = Infinity, minLeft = Infinity, maxBottom = -Infinity, maxRight = -Infinity;
for (const r of rects) {
minTop = Math.min(minTop, r.top);
minLeft = Math.min(minLeft, r.left);
maxBottom = Math.max(maxBottom, r.top + r.height);
maxRight = Math.max(maxRight, r.left + r.width);
}
Object.assign(this._overlay.style, {
display: 'block',
left: `${minLeft + window.scrollX}px`,
top: `${minTop + window.scrollY}px`,
width: `${maxRight - minLeft}px`,
height: `${maxBottom - minTop}px`
});
}
_hideOverlay() {
this._overlay.style.display = 'none';
}
// ————— View inicial: só o botão 🤖 —————
_createInitialView() {
const locale = this.editor.locale;
this._robotButton = new ButtonView(locale);
this._robotButton.set({
label: '🤖',
withText: true,
tooltip: 'Clique para inserir prompt'
});
this.listenTo(this._robotButton, 'execute', () => this._showMainView());
this._initialView = new View(locale);
this._initialView.setTemplate({
tag: 'div',
attributes: { class: ['ck-hello-init-balloon'] },
children: [this._robotButton]
});
}
// ————— View principal: InputTextView + botão ⬆️ —————
_createMainView() {
const editor = this.editor;
const locale = editor.locale;
// InputTextView
this._inputView = new InputTextView(locale);
this._inputView.set({
placeholder: 'Digite o prompt…',
class: 'ck-hello-input'
});
// Renderiza o input e registra o Enter
this._inputView.render();
//editor.keystrokes.listenTo(this._inputView.element);
this.listenTo(this._inputView, 'input', (evt, domEvt) => {
this._userPrompt = domEvt.target.value;
// Optional: Enable/disable send button based on input content
this._sendButton.isEnabled = this._userPrompt.trim().length > 0;
});
// Listen for the 'enter' event emitted by InputTextView
this.listenTo(this._inputView, 'enter', (evt) => {
// Prevent potential default form submission or other side effects if needed
// evt.stop(); // Usually not necessary unless it's inside a <form>
// Trigger the send action
this._sendPromptAndReplace();
});
// Botão de envio
this._sendButton = new ButtonView(locale);
this._sendButton.set({
label: '⬆️',
withText: true,
tooltip: 'Enviar prompt + texto selecionado'
});
this.listenTo(this._sendButton, 'execute', () => this._sendPromptAndReplace());
// Container flexível
this._mainView = new View(locale);
this._mainView.setTemplate({
tag: 'div',
attributes: {
class: ['ck-hello-world-balloon'],
style: 'display:flex; gap:4px; align-items:center;'
},
children: [this._inputView, this._sendButton]
});
this._mainView.render();
}
// ————— Mostrar/esconder balão conforme seleção —————
_updateBalloonVisibility() {
const editor = this.editor;
const selection = editor.model.document.selection;
if (selection.isCollapsed) {
this._hideOverlay();
this._removeCurrentView();
return;
}
// Converte seleção → DOM range
const viewRange = editor.editing.mapper.toViewRange(selection.getFirstRange());
const domRange = editor.editing.view.domConverter.viewRangeToDom(viewRange);
this._selectedText = domRange.toString();
// Overlay e posição
this._showOverlay(domRange);
const position = { target: domRange };
this._lastPosition = position;
// Se não houver view ativa, mostra só o robozinho
if (!this._balloon.hasView(this._initialView) &&
!this._balloon.hasView(this._mainView)) {
this._balloon.add({ view: this._initialView, position });
} else {
this._balloon.updatePosition(position);
}
}
_removeCurrentView() {
if (this._balloon.hasView(this._initialView)) {
this._balloon.remove(this._initialView);
}
if (this._balloon.hasView(this._mainView)) {
this._balloon.remove(this._mainView);
}
}
// ————— Transição: robozinho → input + botão —————
_showMainView() {
this._balloon.remove(this._initialView);
this._balloon.add({ view: this._mainView, position: this._lastPosition });
// Foca o input já renderizado
setTimeout(() => {
const inputEl = this._inputView.element.querySelector('input');
if (inputEl) {
inputEl.focus();
}
}, 0);
}
// ————— Envia ao LLM, substitui o texto e limpa tudo —————
// ————— Envia prompt + seleção para o LLM e substitui pelo HTML gerado —————
async _sendPromptAndReplace() {
const editor = this.editor;
const selection = editor.model.document.selection;
const userPrompt = (this._userPrompt || '').trim();
// aborta se não houver texto selecionado ou prompt digitado
if (selection.isCollapsed || !userPrompt) {
console.warn('Faltou texto selecionado ou prompt.');
return;
}
/* ------------------------------------------------------------------
* 1) Extrai o HTML da seleção atual
* ------------------------------------------------------------------ */
const viewRange = editor.editing.mapper.toViewRange(selection.getFirstRange());
const domRange = editor.editing.view.domConverter.viewRangeToDom(viewRange);
const htmlContent = (() => {
const frag = domRange.cloneContents();
const div = document.createElement('div');
div.appendChild(frag);
return div.innerHTML;
})();
/* ------------------------------------------------------------------
* 2) Monta mensagem e envia para o endpoint LLM
* ------------------------------------------------------------------ */
const messages = [
{
role: 'system',
content: 'Your task is to execute the instruction using the provided HTML content. '
+ 'Return only a valid HTML snippet, no extra commentary.'
},
{
role: 'user',
content: `Instruction:\n${userPrompt}\n\nContent:\n${htmlContent}`
}
];
try {
const res = await fetch('http://localhost:8081/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-4o', messages })
});
const data = await res.json();
const resultHtml = data.choices?.[0]?.message?.content?.trim();
if (!resultHtml) {
console.warn('LLM retornou vazio ou mal-formado.');
return;
}
/* --------------------------------------------------------------
* 3) Converte a string HTML para ModelFragment e insere
* -------------------------------------------------------------- */
editor.model.change(writer => {
// HTML string -> ViewFragment
const viewFragment = editor.data.processor.toView(resultHtml);
// ViewFragment -> ModelFragment
const modelFragment = editor.data.toModel(viewFragment);
// Insere substituindo a seleção
editor.model.insertContent(modelFragment, selection.getFirstRange());
});
} catch (err) {
console.error('Erro na requisição LLM:', err);
} finally {
/* --------------------------------------------------------------
* 4) Limpa overlay, balão e campos de estado/UI
* -------------------------------------------------------------- */
this._hideOverlay();
this._removeCurrentView();
this._userPrompt = '';
this._inputView.value = ''; // limpa o InputTextView
this._sendButton.isEnabled = false;
}
}
destroy() {
super.destroy();
this._hideOverlay();
this._removeCurrentView();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment