Created
May 1, 2025 18:49
-
-
Save celsowm/4f61fc93ffda38e32cf4812ad5ac68ec to your computer and use it in GitHub Desktop.
Levi CKEDITOR 5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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