This commit is contained in:
Luiz Silva 2026-06-08 09:19:15 -03:00
parent cb30fdb5f6
commit d00fdecb37
18 changed files with 5680 additions and 4286 deletions

8846
dist/eli-vue.es.js vendored

File diff suppressed because one or more lines are too long

76
dist/eli-vue.umd.js vendored

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,65 @@
import "./EliFeedbackPainel.css";
import "./EliFeedbackWidget.css";
declare const _default: typeof __VLS_export;
export default _default;
declare const __VLS_export: import("vue").DefineComponent<{}, {
textareaRef: import("vue").Ref<HTMLTextAreaElement | null, HTMLTextAreaElement | null>;
expandido: import("vue").Ref<boolean, boolean>;
modo: import("vue").Ref<import("./tiposFeedback").ModoSelecao, import("./tiposFeedback").ModoSelecao>;
elementoSelecionado: import("vue").Ref<{
seletor: string;
id: string | null;
classes: string[];
tag: string;
textoVisivel: string;
dataAtributos: Record<string, string>;
caminhoDom: string;
} | null, import("./tiposFeedback").ElementoCapturado | {
seletor: string;
id: string | null;
classes: string[];
tag: string;
textoVisivel: string;
dataAtributos: Record<string, string>;
caminhoDom: string;
} | null>;
comentario: import("vue").Ref<string, string>;
copiouElemento: import("vue").Ref<boolean, boolean>;
itens: import("vue").Ref<{
id: string;
criadoEm: string;
elemento: {
seletor: string;
id: string | null;
classes: string[];
tag: string;
textoVisivel: string;
dataAtributos: Record<string, string>;
caminhoDom: string;
};
comentario: string;
urlPagina: string;
}[], import("./tiposFeedback").ItemFeedback[] | {
id: string;
criadoEm: string;
elemento: {
seletor: string;
id: string | null;
classes: string[];
tag: string;
textoVisivel: string;
dataAtributos: Record<string, string>;
caminhoDom: string;
};
comentario: string;
urlPagina: string;
}[]>;
totalItens: import("vue").ComputedRef<number>;
aoClicarFab: () => void;
aoIniciarFeedback: () => void;
aoIniciarCopia: () => void;
cancelarSelecao: () => void;
salvarFeedback: () => void;
exportarMarkdown: () => void;
confirmarLimpeza: () => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;

View file

@ -0,0 +1,7 @@
import type { ElementoCapturado, ItemFeedback } from "./tiposFeedback";
/** Formata os dados de um elemento capturado como texto para clipboard */
export declare function formatarElementoComoTexto(el: ElementoCapturado, url: string): string;
/** Formata todos os feedbacks como Markdown para exportação */
export declare function formatarComoMarkdown(itens: ItemFeedback[]): string;
/** Captura informações identificadoras de um elemento DOM */
export declare function capturarElemento(el: HTMLElement): ElementoCapturado;

View file

@ -0,0 +1,2 @@
export { default as EliFeedbackWidget } from "./EliFeedbackWidget.vue";
export type { ElementoCapturado, ItemFeedback, ModoSelecao } from "./tiposFeedback";

View file

@ -0,0 +1,34 @@
/** Informações capturadas do elemento DOM selecionado */
export interface ElementoCapturado {
/** Seletor CSS que identifica o elemento */
seletor: string;
/** ID do elemento (se houver) */
id: string | null;
/** Lista de classes do elemento */
classes: string[];
/** Tag HTML do elemento */
tag: string;
/** Texto visível truncado */
textoVisivel: string;
/** Atributos data-* relevantes */
dataAtributos: Record<string, string>;
/** Caminho hierárquico simplificado no DOM */
caminhoDom: string;
}
/** Um item de feedback salvo */
export interface ItemFeedback {
/** Identificador único do feedback */
id: string;
/** Timestamp ISO da criação */
criadoEm: string;
/** Elemento capturado */
elemento: ElementoCapturado;
/** Comentário livre do usuário */
comentario: string;
/** URL da página onde o feedback foi criado */
urlPagina: string;
}
/** Estado do modo de seleção */
export type ModoSelecao = "inativo" | "selecionando" | "selecionando-copiar" | "comentando";
/** Chave usada no localStorage */
export declare const CHAVE_STORAGE = "eli-feedback-items";

View file

@ -0,0 +1,61 @@
import { type ElementoCapturado, type ItemFeedback, type ModoSelecao } from "./tiposFeedback";
export declare function useFeedback(): {
modo: import("vue").Ref<ModoSelecao, ModoSelecao>;
elementoSelecionado: import("vue").Ref<{
seletor: string;
id: string | null;
classes: string[];
tag: string;
textoVisivel: string;
dataAtributos: Record<string, string>;
caminhoDom: string;
} | null, ElementoCapturado | {
seletor: string;
id: string | null;
classes: string[];
tag: string;
textoVisivel: string;
dataAtributos: Record<string, string>;
caminhoDom: string;
} | null>;
comentario: import("vue").Ref<string, string>;
painelAberto: import("vue").Ref<boolean, boolean>;
copiouElemento: import("vue").Ref<boolean, boolean>;
itens: import("vue").Ref<{
id: string;
criadoEm: string;
elemento: {
seletor: string;
id: string | null;
classes: string[];
tag: string;
textoVisivel: string;
dataAtributos: Record<string, string>;
caminhoDom: string;
};
comentario: string;
urlPagina: string;
}[], ItemFeedback[] | {
id: string;
criadoEm: string;
elemento: {
seletor: string;
id: string | null;
classes: string[];
tag: string;
textoVisivel: string;
dataAtributos: Record<string, string>;
caminhoDom: string;
};
comentario: string;
urlPagina: string;
}[]>;
totalItens: import("vue").ComputedRef<number>;
iniciarSelecao: () => void;
iniciarSelecaoParaCopiar: () => void;
cancelarSelecao: () => void;
salvarFeedback: () => void;
exportarMarkdown: () => void;
limparTodos: () => void;
removerItem: (id: string) => void;
};

View file

@ -3,12 +3,14 @@ import "./styles/eli-vue-fonts.css";
import "./design-tokens.css";
import { EliBotao } from "./componentes/botao";
import { EliCartao } from "./componentes/cartao";
import { EliFeedbackWidget } from "./componentes/FeedbackWidget";
import { EliBadge } from "./componentes/indicador";
import { EliOlaMundo } from "./componentes/ola_mundo";
export { iniciarFiltroDelta } from "p-comuns";
export * from "./componentes/EliEntrada";
export * from "./componentes/EliTabela";
export * from "./componentes/FeedbackWidget";
export * from "./tipos";
export { EliBadge, EliBotao, EliCartao, EliOlaMundo };
export { EliBadge, EliBotao, EliCartao, EliFeedbackWidget, EliOlaMundo };
declare const EliVue: Plugin;
export default EliVue;

View file

@ -1,6 +1,6 @@
{
"name": "eli-vue",
"version": "0.1.210",
"version": "0.1.215",
"private": false,
"main": "./dist/eli-vue.umd.js",
"module": "./dist/eli-vue.es.js",

View file

@ -0,0 +1,154 @@
/* ── EliFeedbackWidget — Painel de Comentário ── */
.eli-fb-painel {
position: fixed;
bottom: 68px;
right: 24px;
z-index: 99999;
width: 360px;
background: #fff;
border-radius: 14px;
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.15),
0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
animation: eliFbSlideUp 0.25s ease;
font-family: "Inter", "Segoe UI", system-ui, sans-serif;
}
.eli-fb-painel-header {
background: linear-gradient(135deg, #C62828, #E53935);
color: #fff;
padding: 14px 18px;
display: flex;
align-items: center;
justify-content: space-between;
}
.eli-fb-painel-header h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
}
.eli-fb-painel-fechar {
background: rgba(255, 255, 255, 0.15);
border: none;
color: #fff;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: background 0.15s;
}
.eli-fb-painel-fechar:hover {
background: rgba(255, 255, 255, 0.3);
}
.eli-fb-painel-corpo {
padding: 16px 18px;
}
/* Info do elemento capturado */
.eli-fb-el-info {
background: #F5F5F5;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 14px;
font-size: 11px;
color: #616161;
line-height: 1.6;
}
.eli-fb-el-info code {
background: #E0E0E0;
padding: 1px 5px;
border-radius: 3px;
font-size: 10.5px;
font-family: "SFMono-Regular", Consolas, monospace;
color: #333;
}
.eli-fb-el-info strong {
color: #424242;
}
/* Textarea */
.eli-fb-textarea {
width: 100%;
min-height: 80px;
max-height: 160px;
border: 1.5px solid #E0E0E0;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
font-family: "Inter", "Segoe UI", system-ui, sans-serif;
resize: vertical;
outline: none;
transition: border-color 0.15s;
color: #333;
box-sizing: border-box;
}
.eli-fb-textarea:focus {
border-color: #C62828;
box-shadow: 0 0 0 3px rgba(198, 40, 40, 0.08);
}
.eli-fb-textarea::placeholder {
color: #BDBDBD;
}
/* Botões do painel */
.eli-fb-painel-acoes {
display: flex;
gap: 8px;
margin-top: 12px;
justify-content: flex-end;
}
.eli-fb-btn-salvar {
background: linear-gradient(135deg, #C62828, #E53935);
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.eli-fb-btn-salvar:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(198, 40, 40, 0.3);
}
.eli-fb-btn-salvar:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.eli-fb-btn-cancelar {
background: #F5F5F5;
color: #616161;
border: 1px solid #E0E0E0;
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.eli-fb-btn-cancelar:hover {
background: #EEEEEE;
}

View file

@ -0,0 +1,152 @@
/* ── EliFeedbackWidget — Estilos ── */
.eli-feedback-fab {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 99999;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
font-family: "Inter", "Segoe UI", system-ui, sans-serif;
}
/* ── Botão principal ── */
.eli-fb-botao-principal {
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #C62828, #E53935);
color: #fff;
font-size: 14px;
cursor: pointer;
box-shadow:
0 2px 8px rgba(198, 40, 40, 0.35),
0 1px 3px rgba(0, 0, 0, 0.12);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.eli-fb-botao-principal:hover {
transform: scale(1.08);
box-shadow:
0 6px 20px rgba(198, 40, 40, 0.45),
0 2px 6px rgba(0, 0, 0, 0.15);
}
.eli-fb-botao-principal:active { transform: scale(0.95); }
/* Badge de contagem */
.eli-fb-badge {
position: absolute;
top: -5px;
right: -5px;
background: #1565C0;
color: #fff;
font-size: 9px;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
line-height: 1;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
/* ── Botões secundários (ícones) ── */
.eli-fb-acoes {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
animation: eliFbSlideUp 0.2s ease;
}
.eli-fb-botao-ico {
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.eli-fb-botao-ico:hover {
transform: scale(1.1);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22);
}
.eli-fb-botao-exportar { background: #1565C0; color: #fff; }
.eli-fb-botao-feedback { background: linear-gradient(135deg, #C62828, #E53935); color: #fff; }
.eli-fb-botao-copiar { background: #2E7D32; color: #fff; }
.eli-fb-botao-limpar { background: #424242; color: #fff; }
/* ── Tooltip de seleção (junto ao FAB) ── */
.eli-fb-tooltip-selecao {
position: absolute;
bottom: 14px;
right: 64px;
background: linear-gradient(135deg, #C62828, #B71C1C);
color: #fff;
padding: 8px 14px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
display: flex;
flex-direction: column;
gap: 2px;
box-shadow: 0 4px 16px rgba(198, 40, 40, 0.35);
animation: eliFbSlideUp 0.2s ease;
pointer-events: none;
}
.eli-fb-tooltip-selecao::after {
content: "";
position: absolute;
right: -6px;
top: 50%;
transform: translateY(-50%);
border: 6px solid transparent;
border-left-color: #B71C1C;
border-right: none;
}
.eli-fb-tooltip-esc { font-size: 10px; opacity: 0.7; }
.eli-fb-tooltip-esc kbd {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
font-weight: 700;
font-family: inherit;
}
/* ── Animações ── */
@keyframes eliFbSlideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes eliFbSlideDown {
from { opacity: 0; transform: translateY(-100%); }
to { opacity: 1; transform: translateY(0); }
}

View file

@ -0,0 +1,185 @@
<template>
<div id="eli-feedback-widget" class="eli-feedback-fab">
<!-- Botões expandidos -->
<div v-if="expandido && modo === 'inativo'" class="eli-fb-acoes">
<button
class="eli-fb-botao-ico eli-fb-botao-feedback"
title="Adicionar feedback com comentário"
@click="aoIniciarFeedback"
>
🐛
</button>
<button
class="eli-fb-botao-ico eli-fb-botao-copiar"
title="Selecionar elemento e copiar dados"
@click="aoIniciarCopia"
>
{{ copiouElemento ? '✅' : '📋' }}
</button>
<button
v-if="totalItens > 0"
class="eli-fb-botao-ico eli-fb-botao-exportar"
:title="`Visualizar ${totalItens} feedback(s) em nova aba`"
@click="exportarMarkdown"
>
📄
</button>
<button
v-if="totalItens > 0"
class="eli-fb-botao-ico eli-fb-botao-limpar"
title="Limpar todos os feedbacks"
@click="confirmarLimpeza"
>
🗑️
</button>
</div>
<!-- Botão FAB principal (toggle) -->
<button
class="eli-fb-botao-principal"
title="Ferramentas de feedback"
@click="aoClicarFab"
>
<span v-if="modo === 'inativo' && !expandido">🐛</span>
<span v-else>✕</span>
<span v-if="totalItens > 0 && modo === 'inativo' && !expandido" class="eli-fb-badge">
{{ totalItens }}
</span>
</button>
<!-- Tooltip de instrução -->
<div v-if="modo === 'selecionando' || modo === 'selecionando-copiar'" class="eli-fb-tooltip-selecao">
<span v-if="modo === 'selecionando'">🎯 Clique no elemento</span>
<span v-else>📋 Clique para copiar</span>
<span class="eli-fb-tooltip-esc"><kbd>ESC</kbd> cancela</span>
</div>
<!-- Painel de comentário -->
<div v-if="modo === 'comentando' && elementoSelecionado" class="eli-fb-painel">
<div class="eli-fb-painel-header">
<h3>💬 Adicionar Feedback</h3>
<button class="eli-fb-painel-fechar" @click="cancelarSelecao">✕</button>
</div>
<div class="eli-fb-painel-corpo">
<div class="eli-fb-el-info">
<div><strong>Seletor:</strong> <code>{{ elementoSelecionado.seletor }}</code></div>
<div><strong>Tag:</strong> <code>&lt;{{ elementoSelecionado.tag }}&gt;</code></div>
<div v-if="elementoSelecionado.id">
<strong>ID:</strong> <code>{{ elementoSelecionado.id }}</code>
</div>
<div v-if="elementoSelecionado.classes.length">
<strong>Classes:</strong>
<code>{{ elementoSelecionado.classes.join(', ') }}</code>
</div>
<div><strong>Caminho:</strong> <code>{{ elementoSelecionado.caminhoDom }}</code></div>
<div v-if="elementoSelecionado.textoVisivel">
<strong>Texto:</strong> "{{ elementoSelecionado.textoVisivel }}"
</div>
</div>
<textarea
ref="textareaRef"
v-model="comentario"
class="eli-fb-textarea"
placeholder="Descreva o problema, sugestão ou observação..."
@keydown.ctrl.enter="salvarFeedback"
/>
<div class="eli-fb-painel-acoes">
<button class="eli-fb-btn-cancelar" @click="cancelarSelecao">
Cancelar
</button>
<button
class="eli-fb-btn-salvar"
:disabled="!comentario.trim()"
@click="salvarFeedback"
>
Salvar ✓
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, nextTick, ref, watch } from "vue"
import "./EliFeedbackPainel.css"
import "./EliFeedbackWidget.css"
import { useFeedback } from "./useFeedback"
export default defineComponent({
name: "EliFeedbackWidget",
setup() {
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const expandido = ref(false)
const {
modo,
elementoSelecionado,
comentario,
copiouElemento,
itens,
totalItens,
iniciarSelecao,
iniciarSelecaoParaCopiar,
cancelarSelecao,
salvarFeedback,
exportarMarkdown,
limparTodos,
} = useFeedback()
function aoClicarFab() {
if (modo.value !== "inativo") {
cancelarSelecao()
expandido.value = false
} else {
expandido.value = !expandido.value
}
}
function aoIniciarFeedback() {
expandido.value = false
iniciarSelecao()
}
function aoIniciarCopia() {
expandido.value = false
iniciarSelecaoParaCopiar()
}
function confirmarLimpeza() {
limparTodos()
expandido.value = false
}
// Auto-focus no textarea ao entrar no modo comentando
watch(modo, async (novoModo) => {
if (novoModo === "comentando") {
await nextTick()
textareaRef.value?.focus()
}
})
return {
textareaRef,
expandido,
modo,
elementoSelecionado,
comentario,
copiouElemento,
itens,
totalItens,
aoClicarFab,
aoIniciarFeedback,
aoIniciarCopia,
cancelarSelecao,
salvarFeedback,
exportarMarkdown,
confirmarLimpeza,
}
},
})
</script>

View file

@ -0,0 +1,116 @@
import type { ElementoCapturado, ItemFeedback } from "./tiposFeedback"
/** Formata os dados de um elemento capturado como texto para clipboard */
export function formatarElementoComoTexto(el: ElementoCapturado, url: string): string {
const linhas: string[] = [
`## Elemento: ${el.seletor}`,
"",
`- **Página:** \`${url}\``,
`- **Tag:** \`<${el.tag}>\``,
]
if (el.id) linhas.push(`- **ID:** \`${el.id}\``)
if (el.classes.length) linhas.push(`- **Classes:** \`${el.classes.join(", ")}\``)
linhas.push(`- **Caminho DOM:** \`${el.caminhoDom}\``)
const chaves = Object.keys(el.dataAtributos)
if (chaves.length) {
linhas.push("- **data-* atributos:**")
for (const [k, v] of Object.entries(el.dataAtributos)) {
linhas.push(` - \`${k}\` = \`${v}\``)
}
}
if (el.textoVisivel) {
linhas.push(`- **Texto visível:** "${el.textoVisivel}"`)
}
return linhas.join("\n")
}
/** Formata todos os feedbacks como Markdown para exportação */
export function formatarComoMarkdown(itens: ItemFeedback[]): string {
const linhas: string[] = [
"# Relatório de Feedback de Interface",
"",
`> Gerado em: ${new Date().toLocaleString("pt-BR")}`,
`> Total de itens: ${itens.length}`,
"",
"---",
"",
]
for (const item of itens) {
const el = item.elemento
linhas.push(`## 🔹 ${el.seletor}`)
linhas.push("")
linhas.push(`- **Data:** ${new Date(item.criadoEm).toLocaleString("pt-BR")}`)
linhas.push(`- **Página:** \`${item.urlPagina}\``)
linhas.push(`- **Tag:** \`<${el.tag}>\``)
if (el.id) linhas.push(`- **ID:** \`${el.id}\``)
if (el.classes.length) linhas.push(`- **Classes:** \`${el.classes.join(", ")}\``)
linhas.push(`- **Caminho DOM:** \`${el.caminhoDom}\``)
const chaves = Object.keys(el.dataAtributos)
if (chaves.length) {
linhas.push("- **data-* atributos:**")
for (const [k, v] of Object.entries(el.dataAtributos)) {
linhas.push(` - \`${k}\` = \`${v}\``)
}
}
if (el.textoVisivel) {
linhas.push(`- **Texto visível:** "${el.textoVisivel}"`)
}
linhas.push("")
linhas.push("### Comentário")
linhas.push("")
linhas.push(item.comentario)
linhas.push("")
linhas.push("---")
linhas.push("")
}
return linhas.join("\n")
}
/** Captura informações identificadoras de um elemento DOM */
export function capturarElemento(el: HTMLElement): ElementoCapturado {
const dataAtributos: Record<string, string> = {}
for (const attr of Array.from(el.attributes)) {
if (attr.name.startsWith("data-")) {
dataAtributos[attr.name] = attr.value
}
}
const textoVisivel = (el.textContent ?? "").trim().slice(0, 120)
// Constrói caminho DOM simplificado (últimos 4 níveis)
const partes: string[] = []
let atual: HTMLElement | null = el
for (let i = 0; i < 4 && atual && atual !== document.body; i++) {
const pedaco =
atual.tagName.toLowerCase() +
(atual.id ? `#${atual.id}` : "") +
(atual.classList.length ? `.${Array.from(atual.classList).join(".")}` : "")
partes.unshift(pedaco)
atual = atual.parentElement
}
// Gera seletor CSS mais específico possível
let seletor = el.tagName.toLowerCase()
if (el.id) {
seletor = `#${el.id}`
} else if (el.classList.length) {
seletor = `${el.tagName.toLowerCase()}.${Array.from(el.classList).join(".")}`
}
return {
seletor,
id: el.id || null,
classes: Array.from(el.classList),
tag: el.tagName.toLowerCase(),
textoVisivel,
dataAtributos,
caminhoDom: partes.join(" > "),
}
}

View file

@ -0,0 +1,2 @@
export { default as EliFeedbackWidget } from "./EliFeedbackWidget.vue"
export type { ElementoCapturado, ItemFeedback, ModoSelecao } from "./tiposFeedback"

View file

@ -0,0 +1,37 @@
/** Informações capturadas do elemento DOM selecionado */
export interface ElementoCapturado {
/** Seletor CSS que identifica o elemento */
seletor: string
/** ID do elemento (se houver) */
id: string | null
/** Lista de classes do elemento */
classes: string[]
/** Tag HTML do elemento */
tag: string
/** Texto visível truncado */
textoVisivel: string
/** Atributos data-* relevantes */
dataAtributos: Record<string, string>
/** Caminho hierárquico simplificado no DOM */
caminhoDom: string
}
/** Um item de feedback salvo */
export interface ItemFeedback {
/** Identificador único do feedback */
id: string
/** Timestamp ISO da criação */
criadoEm: string
/** Elemento capturado */
elemento: ElementoCapturado
/** Comentário livre do usuário */
comentario: string
/** URL da página onde o feedback foi criado */
urlPagina: string
}
/** Estado do modo de seleção */
export type ModoSelecao = "inativo" | "selecionando" | "selecionando-copiar" | "comentando"
/** Chave usada no localStorage */
export const CHAVE_STORAGE = "eli-feedback-items"

View file

@ -0,0 +1,213 @@
import { computed, onBeforeUnmount, ref } from "vue"
import { capturarElemento, formatarComoMarkdown, formatarElementoComoTexto } from "./formatadores"
import {
CHAVE_STORAGE,
type ElementoCapturado,
type ItemFeedback,
type ModoSelecao,
} from "./tiposFeedback"
/** Gera um ID curto e único */
function gerarId(): string {
return `fb-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
}
export function useFeedback() {
const modo = ref<ModoSelecao>("inativo")
const elementoSelecionado = ref<ElementoCapturado | null>(null)
const comentario = ref("")
const elementoHover = ref<HTMLElement | null>(null)
const painelAberto = ref(false)
const copiouElemento = ref(false)
// Carrega itens do localStorage
function carregarItens(): ItemFeedback[] {
try {
const raw = localStorage.getItem(CHAVE_STORAGE)
return raw ? (JSON.parse(raw) as ItemFeedback[]) : []
} catch {
return []
}
}
const itens = ref<ItemFeedback[]>(carregarItens())
const totalItens = computed(() => itens.value.length)
function salvarItens() {
localStorage.setItem(CHAVE_STORAGE, JSON.stringify(itens.value))
}
// ── Seleção de elemento ──
let overlayEl: HTMLDivElement | null = null
function criarOverlay() {
overlayEl = document.createElement("div")
overlayEl.id = "eli-feedback-overlay"
Object.assign(overlayEl.style, {
position: "fixed",
pointerEvents: "none",
border: "2px solid #C62828",
background: "rgba(198, 40, 40, 0.08)",
borderRadius: "4px",
zIndex: "99998",
transition: "all 0.1s ease",
display: "none",
})
document.body.appendChild(overlayEl)
}
function removerOverlay() {
if (overlayEl) {
overlayEl.remove()
overlayEl = null
}
}
function posicionarOverlay(el: HTMLElement) {
if (!overlayEl) return
const rect = el.getBoundingClientRect()
Object.assign(overlayEl.style, {
display: "block",
top: `${rect.top - 2}px`,
left: `${rect.left - 2}px`,
width: `${rect.width + 4}px`,
height: `${rect.height + 4}px`,
})
}
function aoMoverMouse(e: MouseEvent) {
const alvo = e.target as HTMLElement
if (alvo.closest("#eli-feedback-widget") || alvo.id === "eli-feedback-overlay") return
elementoHover.value = alvo
posicionarOverlay(alvo)
}
function pararListeners() {
document.removeEventListener("mousemove", aoMoverMouse, true)
document.removeEventListener("click", aoClicarElemento, true)
document.removeEventListener("keydown", aoEscSelecionando, true)
document.body.style.cursor = ""
if (overlayEl) overlayEl.style.display = "none"
}
function aoClicarElemento(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
const alvo = e.target as HTMLElement
if (alvo.closest("#eli-feedback-widget") || alvo.id === "eli-feedback-overlay") return
const elCapturado = capturarElemento(alvo)
const modoAtual = modo.value
pararListeners()
if (modoAtual === "selecionando-copiar") {
const texto = formatarElementoComoTexto(elCapturado, window.location.href)
navigator.clipboard
.writeText(texto)
.then(() => {
copiouElemento.value = true
setTimeout(() => {
copiouElemento.value = false
}, 1500)
})
.catch(() => {
/* silencioso */
})
modo.value = "inativo"
removerOverlay()
} else {
elementoSelecionado.value = elCapturado
modo.value = "comentando"
}
}
function aoEscSelecionando(e: KeyboardEvent) {
if (e.key === "Escape") cancelarSelecao()
}
function ativarListenersSelecao() {
elementoSelecionado.value = null
comentario.value = ""
painelAberto.value = false
criarOverlay()
document.body.style.cursor = "crosshair"
document.addEventListener("mousemove", aoMoverMouse, true)
document.addEventListener("click", aoClicarElemento, true)
document.addEventListener("keydown", aoEscSelecionando, true)
}
function iniciarSelecao() {
modo.value = "selecionando"
ativarListenersSelecao()
}
function iniciarSelecaoParaCopiar() {
modo.value = "selecionando-copiar"
ativarListenersSelecao()
}
function cancelarSelecao() {
modo.value = "inativo"
elementoSelecionado.value = null
elementoHover.value = null
comentario.value = ""
pararListeners()
removerOverlay()
}
function salvarFeedback() {
if (!elementoSelecionado.value || !comentario.value.trim()) return
const item: ItemFeedback = {
id: gerarId(),
criadoEm: new Date().toISOString(),
elemento: elementoSelecionado.value,
comentario: comentario.value.trim(),
urlPagina: window.location.href,
}
itens.value.unshift(item)
salvarItens()
modo.value = "inativo"
elementoSelecionado.value = null
comentario.value = ""
removerOverlay()
}
function exportarMarkdown() {
if (!itens.value.length) return
const md = formatarComoMarkdown(itens.value)
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" })
const url = URL.createObjectURL(blob)
window.open(url, "_blank")
}
function limparTodos() {
itens.value = []
localStorage.removeItem(CHAVE_STORAGE)
}
function removerItem(id: string) {
itens.value = itens.value.filter((i) => i.id !== id)
salvarItens()
}
onBeforeUnmount(() => {
cancelarSelecao()
})
return {
modo,
elementoSelecionado,
comentario,
painelAberto,
copiouElemento,
itens,
totalItens,
iniciarSelecao,
iniciarSelecaoParaCopiar,
cancelarSelecao,
salvarFeedback,
exportarMarkdown,
limparTodos,
removerItem,
}
}

View file

@ -12,6 +12,7 @@ import {
EliEntradaTexto,
} from "./componentes/EliEntrada"
import { EliTabela } from "./componentes/EliTabela"
import { EliFeedbackWidget } from "./componentes/FeedbackWidget"
import { EliBadge } from "./componentes/indicador"
import { EliOlaMundo } from "./componentes/ola_mundo"
@ -19,9 +20,10 @@ export { iniciarFiltroDelta } from "p-comuns"
export * from "./componentes/EliEntrada"
// Exportar tudo (componentes + types + helpers) de Tabela e Entrada
export * from "./componentes/EliTabela"
export * from "./componentes/FeedbackWidget"
// Exportar tipos compartilhados (ex: CartaoStatus)
export * from "./tipos"
export { EliBadge, EliBotao, EliCartao, EliOlaMundo }
export { EliBadge, EliBotao, EliCartao, EliFeedbackWidget, EliOlaMundo }
const EliVue: Plugin = {
install(app: App) {
@ -36,6 +38,7 @@ const EliVue: Plugin = {
app.component("EliEntradaParagrafo", EliEntradaParagrafo)
app.component("EliEntradaSelecao", EliEntradaSelecao)
app.component("EliEntradaCoordenadas", EliEntradaCoordenadas)
app.component("EliFeedbackWidget", EliFeedbackWidget)
},
}

View file

@ -1,5 +1,8 @@
<template>
<div style="display: flex; flex-direction: row; min-height: 100vh;">
<!-- Widget de Feedback flutuante -->
<EliFeedbackWidget />
<!-- Seção de Componentes -->
<div style="flex: 1; min-width: 0; overflow-y: auto;">
<v-container class="py-6">
@ -73,6 +76,7 @@
<script lang="ts">
import { defineComponent, provide, ref } from "vue"
import { EliFeedbackWidget } from "../componentes/FeedbackWidget"
import BotaoPlayground from "./botao.playground.vue"
import CartaoPlayground from "./cartao.playground.vue"
import DataHoraPlayground from "./data_hora.playground.vue"
@ -122,6 +126,7 @@ export interface PlaygroundConsole {
export default defineComponent({
name: "App",
components: {
EliFeedbackWidget,
BotaoPlayground,
IndicadorPlayground,
CartaoPlayground,