feedback
This commit is contained in:
parent
cb30fdb5f6
commit
d00fdecb37
18 changed files with 5680 additions and 4286 deletions
8846
dist/eli-vue.es.js
vendored
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
76
dist/eli-vue.umd.js
vendored
File diff suppressed because one or more lines are too long
65
dist/types/componentes/FeedbackWidget/EliFeedbackWidget.vue.d.ts
vendored
Normal file
65
dist/types/componentes/FeedbackWidget/EliFeedbackWidget.vue.d.ts
vendored
Normal 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>;
|
||||
7
dist/types/componentes/FeedbackWidget/formatadores.d.ts
vendored
Normal file
7
dist/types/componentes/FeedbackWidget/formatadores.d.ts
vendored
Normal 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;
|
||||
2
dist/types/componentes/FeedbackWidget/index.d.ts
vendored
Normal file
2
dist/types/componentes/FeedbackWidget/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as EliFeedbackWidget } from "./EliFeedbackWidget.vue";
|
||||
export type { ElementoCapturado, ItemFeedback, ModoSelecao } from "./tiposFeedback";
|
||||
34
dist/types/componentes/FeedbackWidget/tiposFeedback.d.ts
vendored
Normal file
34
dist/types/componentes/FeedbackWidget/tiposFeedback.d.ts
vendored
Normal 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";
|
||||
61
dist/types/componentes/FeedbackWidget/useFeedback.d.ts
vendored
Normal file
61
dist/types/componentes/FeedbackWidget/useFeedback.d.ts
vendored
Normal 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;
|
||||
};
|
||||
4
dist/types/index.d.ts
vendored
4
dist/types/index.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
154
src/componentes/FeedbackWidget/EliFeedbackPainel.css
Normal file
154
src/componentes/FeedbackWidget/EliFeedbackPainel.css
Normal 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;
|
||||
}
|
||||
152
src/componentes/FeedbackWidget/EliFeedbackWidget.css
Normal file
152
src/componentes/FeedbackWidget/EliFeedbackWidget.css
Normal 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); }
|
||||
}
|
||||
185
src/componentes/FeedbackWidget/EliFeedbackWidget.vue
Normal file
185
src/componentes/FeedbackWidget/EliFeedbackWidget.vue
Normal 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><{{ elementoSelecionado.tag }}></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>
|
||||
116
src/componentes/FeedbackWidget/formatadores.ts
Normal file
116
src/componentes/FeedbackWidget/formatadores.ts
Normal 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(" > "),
|
||||
}
|
||||
}
|
||||
2
src/componentes/FeedbackWidget/index.ts
Normal file
2
src/componentes/FeedbackWidget/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as EliFeedbackWidget } from "./EliFeedbackWidget.vue"
|
||||
export type { ElementoCapturado, ItemFeedback, ModoSelecao } from "./tiposFeedback"
|
||||
37
src/componentes/FeedbackWidget/tiposFeedback.ts
Normal file
37
src/componentes/FeedbackWidget/tiposFeedback.ts
Normal 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"
|
||||
213
src/componentes/FeedbackWidget/useFeedback.ts
Normal file
213
src/componentes/FeedbackWidget/useFeedback.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue