sagadeck 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/SKILL.md +148 -0
- package/bin/sagadeck.js +511 -0
- package/docs/ANDAMENTO.md +1255 -0
- package/docs/EXPERIENCIAS.md +39 -0
- package/docs/PLANO-EXPERIENCIAS.md +39 -0
- package/docs/PLANO-TESTE-AULAS-TECNICAS.md +44 -0
- package/docs/PLANO-TESTE-DESINFORMACAO.md +38 -0
- package/docs/REFERENCIA.md +1131 -0
- package/package.json +59 -0
- package/src/adaptive-layouts.js +18 -0
- package/src/ai/art-direction.js +8 -0
- package/src/ai/commands.js +130 -0
- package/src/ai/context.js +245 -0
- package/src/ai/deck-ai.js +1135 -0
- package/src/ai/directions.js +74 -0
- package/src/ai/document-materials.js +49 -0
- package/src/ai/document-visuals.js +215 -0
- package/src/ai/evaluation.js +31 -0
- package/src/ai/ground.js +55 -0
- package/src/ai/llm.js +292 -0
- package/src/ai/quality.js +51 -0
- package/src/ai/relay.js +45 -0
- package/src/ai/staged-generation.js +24 -0
- package/src/ai/transform.js +858 -0
- package/src/ai/usage.js +17 -0
- package/src/ai/variety.js +80 -0
- package/src/ai/video-frames.js +24 -0
- package/src/ai/video-generation.js +126 -0
- package/src/ai/web-operation.js +31 -0
- package/src/algo-catalog.js +153 -0
- package/src/algo-trace.js +139 -0
- package/src/api-client.js +514 -0
- package/src/api-collections.js +14 -0
- package/src/api-demo.js +222 -0
- package/src/aspect.js +40 -0
- package/src/build.js +323 -0
- package/src/carousel.js +56 -0
- package/src/chrome.js +65 -0
- package/src/code-highlight.js +68 -0
- package/src/code-lab-browser.js +22 -0
- package/src/code-lab.js +3 -0
- package/src/code-language.js +52 -0
- package/src/csv.js +74 -0
- package/src/decision-lab.js +20 -0
- package/src/deck-file.js +119 -0
- package/src/diagram/napkin-examples.js +13 -0
- package/src/diagram/napkin.js +539 -0
- package/src/docx.js +128 -0
- package/src/dynamics/git-sim.js +200 -0
- package/src/dynamics/layouts.js +112 -0
- package/src/elements.js +554 -0
- package/src/experiences.js +124 -0
- package/src/exploration.js +31 -0
- package/src/export/browser.js +53 -0
- package/src/export/estudo.js +67 -0
- package/src/export/pptx.js +475 -0
- package/src/export/roteiro.js +40 -0
- package/src/export/shots.js +182 -0
- package/src/figures/charts.js +383 -0
- package/src/figures/diagrams.js +117 -0
- package/src/figures/icons.js +166 -0
- package/src/figures/material-pictos.js +20 -0
- package/src/figures/pictos.js +255 -0
- package/src/figures/points.js +106 -0
- package/src/figures/qr.js +29 -0
- package/src/figures/ufmap.js +48 -0
- package/src/fiscal/autofix.js +377 -0
- package/src/fiscal/normalize.js +115 -0
- package/src/graph-lab.js +24 -0
- package/src/hologram.js +30 -0
- package/src/identity.js +86 -0
- package/src/import/crop.js +59 -0
- package/src/import/index.js +132 -0
- package/src/import/legacy-word.js +42 -0
- package/src/import/merge.js +45 -0
- package/src/import/office.js +123 -0
- package/src/import/omml.js +69 -0
- package/src/import/pdf-render.js +56 -0
- package/src/import/pptx.js +701 -0
- package/src/import/xml.js +42 -0
- package/src/infographic.js +401 -0
- package/src/layouts.js +723 -0
- package/src/lessons.js +171 -0
- package/src/library.js +399 -0
- package/src/markup.js +116 -0
- package/src/master.js +214 -0
- package/src/mcp/server.js +371 -0
- package/src/meeting.js +42 -0
- package/src/motion.js +56 -0
- package/src/package.js +116 -0
- package/src/playground.js +19 -0
- package/src/portal-scene.js +20 -0
- package/src/preferences.js +61 -0
- package/src/protect.js +51 -0
- package/src/purpose.js +24 -0
- package/src/pytrace.js +1092 -0
- package/src/research/duckduckgo.js +73 -0
- package/src/research/research.js +202 -0
- package/src/research/svg-image.js +17 -0
- package/src/research/visuals.js +52 -0
- package/src/runtime/api-collection-ui.js +5 -0
- package/src/runtime/api-collections.js +28 -0
- package/src/runtime/api-core.js +530 -0
- package/src/runtime/api-ui.js +966 -0
- package/src/runtime/base.css +1774 -0
- package/src/runtime/calc.js +139 -0
- package/src/runtime/code-lab.js +371 -0
- package/src/runtime/decision-lab.js +49 -0
- package/src/runtime/diagram.js +341 -0
- package/src/runtime/fit.js +268 -0
- package/src/runtime/fonts/caveat.css +2 -0
- package/src/runtime/fonts/comic-neue.css +3 -0
- package/src/runtime/fonts/fredoka.css +2 -0
- package/src/runtime/fonts/index.json +10 -0
- package/src/runtime/fonts/inter.css +2 -0
- package/src/runtime/fonts/patrick-hand.css +2 -0
- package/src/runtime/fonts/plus-jakarta-sans.css +2 -0
- package/src/runtime/fonts/press-start-2p.css +2 -0
- package/src/runtime/fonts/vt323.css +2 -0
- package/src/runtime/formula.js +148 -0
- package/src/runtime/graph-lab.js +72 -0
- package/src/runtime/meeting.css +7 -0
- package/src/runtime/meeting.js +10 -0
- package/src/runtime/motion.js +15 -0
- package/src/runtime/playground.js +11 -0
- package/src/runtime/points.js +19 -0
- package/src/runtime/portal-scene.css +6 -0
- package/src/runtime/portal-scene.js +22 -0
- package/src/runtime/runtime.js +827 -0
- package/src/runtime/science.js +89 -0
- package/src/runtime/skins/aluminio.css +31 -0
- package/src/runtime/skins/arcade.css +25 -0
- package/src/runtime/skins/aurora.css +17 -0
- package/src/runtime/skins/bauhaus.css +19 -0
- package/src/runtime/skins/editorial.css +19 -0
- package/src/runtime/skins/jornal.css +17 -0
- package/src/runtime/skins/manual-noite.css +14 -0
- package/src/runtime/skins/manual.css +14 -0
- package/src/runtime/skins/noite.css +21 -0
- package/src/runtime/skins/oceano.css +17 -0
- package/src/runtime/skins/pop.css +23 -0
- package/src/runtime/skins/prata.css +18 -0
- package/src/runtime/skins/prisma.css +28 -0
- package/src/runtime/skins/rabisco.css +18 -0
- package/src/runtime/skins/relevo.css +33 -0
- package/src/runtime/skins/sinal.css +16 -0
- package/src/runtime/skins/terminal.css +23 -0
- package/src/runtime/vendor/KATEX-LICENSE.txt +21 -0
- package/src/runtime/vendor/MERMAID-LICENSE.txt +21 -0
- package/src/runtime/vendor/PLOTLY-LICENSE.txt +21 -0
- package/src/runtime/vendor/katex.css +1 -0
- package/src/runtime/vendor/mermaid.min.js +3636 -0
- package/src/runtime/vendor/plotly.min.js +3620 -0
- package/src/runtime/video-player.js +35 -0
- package/src/science.js +162 -0
- package/src/studio/ai-response.js +36 -0
- package/src/studio/api-routes.js +152 -0
- package/src/studio/assets/contexto/leia-me.md +12 -0
- package/src/studio/assets/contexto/vendas.csv +7 -0
- package/src/studio/assets/imagens/colecao-arquitetura.png +0 -0
- package/src/studio/assets/imagens/colecao-cor.png +0 -0
- package/src/studio/assets/imagens/colecao-equipe.png +0 -0
- package/src/studio/assets/imagens/lavanda-cover.jpg +0 -0
- package/src/studio/code-routes.js +32 -0
- package/src/studio/command-approval.js +48 -0
- package/src/studio/demo-decks.js +98 -0
- package/src/studio/direction-routes.js +29 -0
- package/src/studio/errors.js +65 -0
- package/src/studio/exploration-demo.js +17 -0
- package/src/studio/exporting.js +105 -0
- package/src/studio/generation-report.js +10 -0
- package/src/studio/instance.js +48 -0
- package/src/studio/layout-samples.js +152 -0
- package/src/studio/project.js +371 -0
- package/src/studio/public/api-collections.js +28 -0
- package/src/studio/public/app.js +5265 -0
- package/src/studio/public/art-preview.js +10 -0
- package/src/studio/public/calc-fields.js +11 -0
- package/src/studio/public/chat-progress.js +47 -0
- package/src/studio/public/developer-fields.js +22 -0
- package/src/studio/public/explorer.js +443 -0
- package/src/studio/public/history.js +58 -0
- package/src/studio/public/index.html +867 -0
- package/src/studio/public/inspector.js +270 -0
- package/src/studio/public/lab-fields.js +5 -0
- package/src/studio/public/library.css +275 -0
- package/src/studio/public/library.html +55 -0
- package/src/studio/public/library.js +471 -0
- package/src/studio/public/merge-decks.js +155 -0
- package/src/studio/public/review-ui.js +100 -0
- package/src/studio/public/screenshot-editor.js +140 -0
- package/src/studio/public/share-ui.js +72 -0
- package/src/studio/public/slide-clipboard.js +23 -0
- package/src/studio/public/slide-form.js +1263 -0
- package/src/studio/public/studio-awareness.js +154 -0
- package/src/studio/public/studio-next.css +303 -0
- package/src/studio/public/style.css +1262 -0
- package/src/studio/public/ui-icons.js +2 -0
- package/src/studio/public/viewers.js +372 -0
- package/src/studio/public/visual-editor.js +326 -0
- package/src/studio/public/workspace.js +18 -0
- package/src/studio/public-files.js +1 -0
- package/src/studio/request-helpers.js +41 -0
- package/src/studio/server.js +1583 -0
- package/src/studio/share-routes.js +146 -0
- package/src/studio/slide-copy-routes.js +26 -0
- package/src/studio/slide-copy.js +30 -0
- package/src/studio/snapshot.js +160 -0
- package/src/studio/style-routes.js +103 -0
- package/src/studio/template-collections.js +97 -0
- package/src/studio/versions.js +95 -0
- package/src/studio/video-download.js +29 -0
- package/src/studio/visual-keys.js +52 -0
- package/src/studio/workspaces.js +39 -0
- package/src/table.js +93 -0
- package/src/templates/scaffold.js +372 -0
- package/src/themes.js +604 -0
- package/src/trace-view.js +450 -0
- package/src/uid.js +19 -0
- package/src/video-player.js +40 -0
- package/src/visual-edits.js +99 -0
- package/src/ws.js +134 -0
- package/templates/cenario/Texto no cen/303/241rio.yaml" +171 -0
- package/templates/cenario/imagens/cidade.svg +30 -0
- package/templates/cenario/imagens/pessoa.svg +15 -0
- package/templates/cenario-e-ciencia.yaml +47 -0
- package/templates/ensaio-api.yaml +160 -0
- package/templates/exemplo-alegre.yaml +75 -0
- package/templates/exemplo-keynote.yaml +90 -0
- package/templates/exemplo-napkin.yaml +139 -0
- package/templates/exemplo.yaml +228 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Cada direção tem seu próprio CSS: a prévia não muda o tema do editor nem das outras opções.
|
|
2
|
+
window.SagaArtPreview = (container, rendered, label) => {
|
|
3
|
+
const frame = document.createElement('iframe');
|
|
4
|
+
frame.title = `Prévia: ${label}`;
|
|
5
|
+
frame.setAttribute('sandbox', '');
|
|
6
|
+
frame.style.cssText = 'width:1920px;height:1080px;border:0;transform-origin:0 0;pointer-events:none;position:absolute;left:0;top:0';
|
|
7
|
+
frame.srcdoc = `<!doctype html><html class="export"><meta charset="utf-8"><style>${rendered.baseCSS}\n${rendered.themeCSS}\nhtml,body{margin:0;width:1920px;height:1080px}.slide{opacity:1!important;visibility:visible!important;transform:none!important}</style><body>${rendered.html}</body></html>`;
|
|
8
|
+
container.replaceChildren(frame);
|
|
9
|
+
frame.style.transform = `scale(${container.clientWidth / 1920})`;
|
|
10
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Formulário das experiências; carregado antes de slide-form.js.
|
|
2
|
+
window.SagaCalcFields = (f, obj) => [f.text("kicker", "Chapéu"), f.text("title", "Título"), f.json("inputs", "Entradas", { rows: 8, hint: "{ V: { label, value, min, max, step, unit, latex }, nu: { value, fixed: true } } — cada entrada vira um controle deslizante (fixed: constante)." }),
|
|
3
|
+
f.list("outputs", "Resultados (na ordem: um pode usar os anteriores)", obj([f.text("name", "Nome (para usar nas fórmulas)", { mono: true }), f.text("label", "Rótulo"), f.text("fn", "Fórmula", { mono: true, placeholder: "V*D/nu" }), f.text("latex", "Fórmula para mostrar (LaTeX)", { mono: true }), f.text("unit", "Unidade"), f.num("decimals", "Casas decimais"),
|
|
4
|
+
f.more([f.text("of", "Faixas de qual valor?", { mono: true }), f.json("cases", "Faixas", { hint: "[{ below: 2300, text: Laminar, color: s3 }, { text: Turbulento, color: hi }] — ou fn por faixa" }), f.json("scale", "Régua", { hint: "{ min: 100, max: 1000000, log: true }" })])]),
|
|
5
|
+
{ addLabel: "Adicionar resultado", newItem: () => ({ name: "r", label: "Resultado", fn: "" }) }),
|
|
6
|
+
f.text("prediction", "Pergunta antes de revelar"),
|
|
7
|
+
f.text("sweep", "Entrada no eixo da curva", { hint: "Nome da entrada; as outras ficam nos valores atuais." }),
|
|
8
|
+
f.text("explanation", "O que observar ao explorar"),
|
|
9
|
+
f.bool("illustrative", "Simulação ilustrativa"),
|
|
10
|
+
f.list("scenarios", "Cenários", obj([f.text("label", "Nome"), f.json("values", "Valores das entradas", { hint: '{ "V": 0.02 }' }), f.text("explanation", "O que este cenário mostra")]), { addLabel: "Adicionar cenário", newItem: () => ({ label: "Novo cenário", values: {} }) }),
|
|
11
|
+
f.text("note", "Nota embaixo")];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
window.SagaChatProgress = function ({ dom, appendChatMessage, hydrateIcons, getChatJob }) {
|
|
2
|
+
function createProgressBubble(initial) {
|
|
3
|
+
const el = appendChatMessage("ai", "");
|
|
4
|
+
el.classList.add("ai-working");
|
|
5
|
+
const content = el.querySelector(".ai-content");
|
|
6
|
+
content.innerHTML = `<div class="work-line"><span class="work-dots"><i></i><i></i><i></i></span><span class="work-text"></span><span class="work-time">0s</span></div><div class="work-preview"></div>`;
|
|
7
|
+
const text = content.querySelector(".work-text");
|
|
8
|
+
const time = content.querySelector(".work-time");
|
|
9
|
+
const preview = content.querySelector(".work-preview");
|
|
10
|
+
const started = Date.now();
|
|
11
|
+
text.textContent = initial;
|
|
12
|
+
const timer = setInterval(() => {
|
|
13
|
+
const s = Math.round((Date.now() - started) / 1000);
|
|
14
|
+
time.textContent = `${s}s`;
|
|
15
|
+
}, 500);
|
|
16
|
+
return {
|
|
17
|
+
el,
|
|
18
|
+
update(ev) {
|
|
19
|
+
if (ev.type !== "progress") return;
|
|
20
|
+
text.textContent = ev.chars ? `${ev.text} (${(ev.chars / 1000).toFixed(1)} mil caracteres)` : ev.text;
|
|
21
|
+
// transformação da apresentação inteira: pode levar muitos minutos, então dá para parar (o feito fica guardado)
|
|
22
|
+
if (String(ev.phase || "").startsWith("transform") && !getChatJob() && !content.querySelector(".work-stop")) {
|
|
23
|
+
const stop = document.createElement("button");
|
|
24
|
+
stop.type = "button"; stop.className = "btn btn-ghost btn-sm work-stop";
|
|
25
|
+
stop.title = "Parar; o que já ficou pronto fica guardado e pedir de novo continua de onde parou";
|
|
26
|
+
stop.innerHTML = '<i class="ic" data-ic="square"></i> Parar'; hydrateIcons(stop);
|
|
27
|
+
stop.onclick = async () => { stop.disabled = true; await fetch("api/ai/transform/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: ev.mode }) }).catch(() => {}); };
|
|
28
|
+
content.querySelector(".work-line").append(stop);
|
|
29
|
+
}
|
|
30
|
+
if (ev.preview) preview.textContent = ev.preview;
|
|
31
|
+
dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
|
|
32
|
+
},
|
|
33
|
+
done() { clearInterval(timer); el.remove(); },
|
|
34
|
+
fail(msg) {
|
|
35
|
+
clearInterval(timer);
|
|
36
|
+
el.classList.remove("ai-working");
|
|
37
|
+
content.innerHTML = "";
|
|
38
|
+
const span = document.createElement("span");
|
|
39
|
+
span.style.color = "var(--danger)";
|
|
40
|
+
span.textContent = `Erro: ${msg}`;
|
|
41
|
+
content.appendChild(span);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return createProgressBubble;
|
|
47
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
window.SagaDeveloperFields = {
|
|
2
|
+
poll: (f,T)=>[f.text('kicker','Chapéu'),f.area('question','Pergunta'),f.text('context','Contexto'),f.list('options','Opções',T,{addLabel:'Adicionar opção'}),f.text('hint','Dica'),f.bool('manual','Decisão da reunião (contagens e aprovação salvas)'),f.json('values','Valores numéricos por opção (opcional)'),f.text('participantTitle','Título para divulgação'),f.select('audience','Visibilidade',[['organization','Só organização'],['all','Organização e participantes']],{empty:'Ambos'}),f.more([f.text('id','Identificador da enquete'),f.text('compare','Comparar com a enquete (identificador)'),f.num('titleSize','Tamanho da pergunta (px)')])],
|
|
3
|
+
portal: (f,obj) => [f.text('title','Título'),f.select('skin','Aparência',[['aurora','Aurora — claro e colorido'],['neon','Neon'],['editorial','Editorial'],['industrial','Industrial']],{empty:false,default:'neon'}),f.text('label','Convite no cenário'),f.text('caption','Legenda inicial'),f.list('items','Elementos para explorar',obj([f.text('title','Nome'),f.text('icon','Ícone Lucide'),f.photo('image','Sua imagem ou screenshot'),f.area('text','Descrição'),f.text('beforeWord','Texto integrado antes'),f.text('afterWord','Texto integrado depois'),f.area('before','Explicação antes'),f.area('after','Explicação depois'),f.list('objects','Objetos da transformação',obj([f.text('label','Nome'),f.text('icon','Ícone'),f.select('shape','Forma',[['panel','Painel'],['sheet','Documento'],['orb','Esfera'],['column','Coluna']],{empty:'Painel'})]),{newItem:()=>({label:'Nova etapa'})})]),{newItem:()=>({title:'Novo mundo',beforeWord:'ANTES',afterWord:'DEPOIS',objects:[{label:'Uma ideia'},{label:'Uma solução'}]})})],
|
|
4
|
+
playground: f => [f.text('title','Título'),f.area('html','HTML',{mono:true,rows:8}),f.area('css','CSS',{mono:true,rows:8}),f.area('javascript','JavaScript',{mono:true,rows:12}),f.area('explanation','Convite para explorar')],
|
|
5
|
+
collection: (f,obj,getCtx) => [
|
|
6
|
+
f.action('Importar coleção Postman', (s,button) => {
|
|
7
|
+
const ctx=getCtx(),input=document.createElement('input');input.type='file';input.accept='.json,application/json';input.hidden=true;input.dataset.collectionFile='';document.body.append(input);
|
|
8
|
+
input.onchange=async()=>{try{const file=input.files[0];if(!file)return;s.services=window.SagaApiCollections.importCollection(await file.text());if(button.isConnected)ctx.commit(true);}catch(e){button.textContent=e.message;}finally{input.remove();}};input.click();
|
|
9
|
+
}),
|
|
10
|
+
f.list('services','Serviços da coleção',obj([f.text('name','Nome no seletor'),f.text('id','Identificador'),f.select('mode','Modo',[['sync','Síncrono'],['polling','Polling'],['stream','Streaming']],{empty:'Síncrono'}),f.obj('request','Pedido',[f.select('method','Método',[['GET','GET'],['POST','POST'],['PUT','PUT'],['PATCH','PATCH'],['DELETE','DELETE']],{empty:'GET'}),f.text('url','Endpoint'),f.json('body','Corpo JSON'),f.json('headers','Cabeçalhos JSON')]),f.json('polling','Configuração de polling'),f.json('stream','Configuração de streaming')]),{addLabel:'Adicionar serviço',newItem:()=>({name:'Novo serviço',request:{method:'GET',url:'{{base}}/'}})})
|
|
11
|
+
],
|
|
12
|
+
video: (f,getCtx) => [f.text('kicker','Chapéu'),f.text('title','Título'),f.text('url','YouTube ou arquivo local'),f.text('label','Descrição do vídeo'),f.select('start','Início da reprodução',[['manual','Clique para iniciar'],['normal','Controles de vídeo']],{empty:'Controles de vídeo'}),f.select('finish','Ao terminar',[['next','Ir para o próximo slide'],['reveal','Revelar a capa por baixo'],['stay','Permanecer neste slide']],{empty:'Permanecer neste slide'}),f.num('handoffAt','Passar ao próximo no tempo (segundos)',{min:0,step:0.01}),f.select('controls','Controles da abertura',[['stage','Palco: clique ou Espaço, sem botões'],['visible','Botões visíveis']],{empty:'Botões visíveis'}),f.photo('poster','Imagem inicial'),f.bool('loop','Loop silencioso automático'),
|
|
13
|
+
f.action('Inserir meu MP4',(s,button)=>{
|
|
14
|
+
const ctx=getCtx(),input=document.createElement('input');input.type='file';input.accept='video/mp4,.mp4';input.hidden=true;input.dataset.videoFile='';document.body.append(input);
|
|
15
|
+
input.onchange=async()=>{try{const file=input.files[0];if(!file)return;const url=await window.SagaProject.uploadFile(file,'videos');if(button.isConnected){s.url=url;ctx.commit(true);}}catch(e){button.textContent=e.message;}finally{input.remove();}};input.click();
|
|
16
|
+
}),
|
|
17
|
+
f.action('Baixar YouTube para MP4',async(s,button)=>{
|
|
18
|
+
const ctx=getCtx(),label=button.textContent;button.disabled=true;button.textContent='Baixando vídeo…';
|
|
19
|
+
try{const r=await fetch('api/video/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:s.url})}),data=await r.json();if(!r.ok)throw new Error(data.error);if(button.isConnected){s.youtubeSource=data.source;s.url=data.url;ctx.commit(true);}}
|
|
20
|
+
catch(e){button.textContent=e.message;return;}finally{button.disabled=false;}button.textContent=label;
|
|
21
|
+
}),f.text('caption','Legenda'),f.el('figure','Figura')]
|
|
22
|
+
};
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/* Projeto no estilo VS Code: a pasta da apresentação vira uma árvore de arquivos (à esquerda, no lugar dos slides)
|
|
2
|
+
e cada arquivo abre numa aba no centro.
|
|
3
|
+
- Apresentação (sempre a primeira aba): o slide, como sempre.
|
|
4
|
+
- texto da apresentação (.yaml): editor à esquerda e o slide à direita, atualizando enquanto digita.
|
|
5
|
+
- texto e código (.md, .txt, .json, .yaml, .py, .js, .css, .html…): editor com números de linha e realce
|
|
6
|
+
(viewers.js); o .md com a prévia ao lado.
|
|
7
|
+
- planilha (.csv, .tsv, .xlsx): grade como no Excel (separador descoberto; csv/tsv editáveis, gravam no mesmo
|
|
8
|
+
formato), o tipo de cada coluna e sugestões de gráfico com prévia (regras ou IA); "Inserir no slide" cria o
|
|
9
|
+
gráfico ligado à planilha (from:, com Atualizar). "Texto" mostra o arquivo cru, cada coluna de uma cor.
|
|
10
|
+
- PDF: as páginas (pdf.js do próprio Studio). DOCX: o documento para ler (edição fica no Word).
|
|
11
|
+
- imagem: a prévia e "Usar num slide".
|
|
12
|
+
Ctrl+V de um print (fora de um campo de texto) guarda em contexto/. Tudo o que está em contexto/ vai para a IA. */
|
|
13
|
+
(function () {
|
|
14
|
+
let ctx = null;
|
|
15
|
+
let treeData = null;
|
|
16
|
+
const openTabs = []; // { path, kind, name, dirty, text, sheet }
|
|
17
|
+
let active = "__deck__";
|
|
18
|
+
const expanded = new Set(["contexto"]);
|
|
19
|
+
const $ = (s, r = document) => r.querySelector(s);
|
|
20
|
+
const esc = (s) => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
21
|
+
const api = async (url, body) => {
|
|
22
|
+
const r = await fetch(url, body === undefined ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
23
|
+
const j = await r.json().catch(() => ({}));
|
|
24
|
+
if (!r.ok || j.error) throw new Error(j.error || `erro ${r.status}`);
|
|
25
|
+
return j;
|
|
26
|
+
};
|
|
27
|
+
const KIND_ICON = { deck: "presentation", md: "file-text", text: "file-code", sheet: "file-spreadsheet", image: "file-image", pdf: "file-text", docx: "file-text", pptx: "presentation", other: "file" };
|
|
28
|
+
const LANG_LABEL = { json: "JSON", yaml: "YAML", md: "Markdown", js: "JavaScript", py: "Python", css: "CSS", html: "HTML", xml: "XML", sql: "SQL", sh: "Shell", ini: "Config", csv: "CSV", tsv: "TSV", c: "C / Java", m: "MATLAB", tex: "LaTeX", plain: "Texto" };
|
|
29
|
+
const fileUrl = (p) => `api/project/file?path=${encodeURIComponent(p)}&t=${Date.now()}`;
|
|
30
|
+
const readAsDataUrl = (file) => new Promise((ok, bad) => { const fr = new FileReader(); fr.onload = () => ok(fr.result); fr.onerror = bad; fr.readAsDataURL(file); });
|
|
31
|
+
|
|
32
|
+
// ------------------------------------------------------------------ árvore
|
|
33
|
+
async function refreshTree() {
|
|
34
|
+
const host = $("#project-tree");
|
|
35
|
+
try { treeData = await api("api/project/tree"); }
|
|
36
|
+
catch (e) { treeData = null; host.innerHTML = `<div class="ex-empty">${esc(e.message)}</div>`; return; }
|
|
37
|
+
host.innerHTML = `<div class="ex-root" title="${esc(treeData.name)}"><i class="ic" data-ic="folder-open"></i><b>${esc(treeData.name)}</b></div>${renderEntries(treeData.entries, 0)}`;
|
|
38
|
+
ctx.hydrate(host);
|
|
39
|
+
}
|
|
40
|
+
const rowActions = (e) => e.protected ? "" : `<span class="ex-acts"><i class="ic" data-ic="pencil" data-act="rename" role="button" aria-label="Renomear ${esc(e.name)}" title="Renomear (F2)"></i><i class="ic" data-ic="trash-2" data-act="delete" role="button" aria-label="Apagar ${esc(e.name)}" title="Apagar (Delete)"></i></span>`;
|
|
41
|
+
function renderEntries(list, depth) {
|
|
42
|
+
return list.map((e) => {
|
|
43
|
+
const pad = `style="padding-left:${10 + depth * 14}px"`;
|
|
44
|
+
const sel = selected === e.path ? " sel" : "";
|
|
45
|
+
if (e.type === "dir") {
|
|
46
|
+
const open = expanded.has(e.path);
|
|
47
|
+
return `<div class="ex-row ex-dir${e.protected ? " ex-protected" : ""}${sel}" data-path="${esc(e.path)}" data-type="dir" draggable="${!e.protected}" ${pad} title="${e.protected ? "Pasta do Studio (conversa, cache e lixeira do projeto): só some junto com o projeto" : esc(e.path)}"><i class="ic ex-caret" data-ic="${open ? "chevron-down" : "chevron-right"}"></i><i class="ic" data-ic="folder"></i><span class="ex-name">${esc(e.name)}</span>${rowActions(e)}</div>${open ? renderEntries(e.children || [], depth + 1) : ""}`;
|
|
48
|
+
}
|
|
49
|
+
return `<div class="ex-row ex-file${e.protected ? " ex-protected" : ""}${active === e.path ? " active" : ""}${sel}" data-path="${esc(e.path)}" data-type="file" data-kind="${e.kind}" draggable="${!e.protected}" ${pad} title="${esc(e.path)}"><i class="ic" data-ic="${KIND_ICON[e.kind] || "file"}"></i><span class="ex-name">${esc(e.name)}</span>${rowActions(e)}</div>`;
|
|
50
|
+
}).join("");
|
|
51
|
+
}
|
|
52
|
+
const findEntry = (p, list = treeData?.entries || []) => { for (const e of list) { if (e.path === p) return e; if (e.children) { const f = findEntry(p, e.children); if (f) return f; } } return null; };
|
|
53
|
+
const dirOf = (p) => { const e = findEntry(p); return !p ? "" : e?.type === "dir" ? p : p.split("/").slice(0, -1).join("/"); };
|
|
54
|
+
let selected = "";
|
|
55
|
+
const rowOf = (p) => $(`#project-tree .ex-row[data-path="${CSS.escape(p)}"]`);
|
|
56
|
+
function select(p, { focus = true } = {}) {
|
|
57
|
+
selected = p;
|
|
58
|
+
$("#project-tree").querySelectorAll(".ex-row.sel").forEach((r) => r.classList.remove("sel"));
|
|
59
|
+
const row = rowOf(p);
|
|
60
|
+
row?.classList.add("sel");
|
|
61
|
+
row?.scrollIntoView({ block: "nearest" });
|
|
62
|
+
if (focus) $("#project-tree").focus({ preventScroll: true });
|
|
63
|
+
}
|
|
64
|
+
function openRow(row) {
|
|
65
|
+
if (row.dataset.type === "dir") { expanded.has(row.dataset.path) ? expanded.delete(row.dataset.path) : expanded.add(row.dataset.path); refreshTree(); }
|
|
66
|
+
else openFile(row.dataset.path, row.dataset.kind);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function bindTree() {
|
|
70
|
+
const host = $("#project-tree");
|
|
71
|
+
host.addEventListener("click", (ev) => {
|
|
72
|
+
if (ev.target.closest(".ex-edit")) return;
|
|
73
|
+
const row = ev.target.closest(".ex-row"); if (!row) return;
|
|
74
|
+
const act = ev.target.closest("[data-act]")?.dataset.act;
|
|
75
|
+
select(row.dataset.path);
|
|
76
|
+
if (act === "rename") return renameEntry(row.dataset.path);
|
|
77
|
+
if (act === "delete") return deleteEntry(row.dataset.path);
|
|
78
|
+
openRow(row);
|
|
79
|
+
});
|
|
80
|
+
// teclado como no VS Code: setas andam, Enter abre, F2 renomeia, Delete apaga
|
|
81
|
+
host.addEventListener("keydown", (ev) => {
|
|
82
|
+
if (ev.target.closest(".ex-edit")) return;
|
|
83
|
+
const rows = [...host.querySelectorAll(".ex-row")], i = rows.findIndex((r) => r.dataset.path === selected);
|
|
84
|
+
const row = rows[i];
|
|
85
|
+
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); const n = rows[Math.max(0, Math.min(rows.length - 1, i + (ev.key === "ArrowDown" ? 1 : -1)))]; if (n) select(n.dataset.path); return; }
|
|
86
|
+
if (!row) return;
|
|
87
|
+
if (ev.key === "ArrowRight" && row.dataset.type === "dir" && !expanded.has(selected)) { ev.preventDefault(); expanded.add(selected); refreshTree(); }
|
|
88
|
+
else if (ev.key === "ArrowLeft" && row.dataset.type === "dir" && expanded.has(selected)) { ev.preventDefault(); expanded.delete(selected); refreshTree(); }
|
|
89
|
+
else if (ev.key === "Enter") { ev.preventDefault(); openRow(row); }
|
|
90
|
+
else if (ev.key === "F2") { ev.preventDefault(); renameEntry(selected); }
|
|
91
|
+
else if (ev.key === "Delete") { ev.preventDefault(); deleteEntry(selected); }
|
|
92
|
+
});
|
|
93
|
+
host.addEventListener("contextmenu", (ev) => {
|
|
94
|
+
ev.preventDefault();
|
|
95
|
+
const row = ev.target.closest(".ex-row");
|
|
96
|
+
if (row) select(row.dataset.path, { focus: false });
|
|
97
|
+
menuAt(ev, row);
|
|
98
|
+
});
|
|
99
|
+
host.addEventListener("dblclick", (ev) => { const row = ev.target.closest(".ex-row.ex-file"); if (row && !row.classList.contains("ex-protected") && ev.target.closest(".ex-name")) renameEntry(row.dataset.path); });
|
|
100
|
+
// arrastar: arquivo do computador para uma pasta (envia) ou item da árvore para outra pasta (move)
|
|
101
|
+
host.addEventListener("dragstart", (ev) => { const row = ev.target.closest(".ex-row"); if (row) ev.dataTransfer.setData("text/x-saga-path", row.dataset.path); });
|
|
102
|
+
host.addEventListener("dragover", (ev) => { ev.preventDefault(); host.querySelectorAll(".drop").forEach((r) => r.classList.remove("drop")); ev.target.closest(".ex-dir")?.classList.add("drop"); });
|
|
103
|
+
host.addEventListener("dragleave", () => host.querySelectorAll(".drop").forEach((r) => r.classList.remove("drop")));
|
|
104
|
+
host.addEventListener("drop", async (ev) => {
|
|
105
|
+
ev.preventDefault();
|
|
106
|
+
host.querySelectorAll(".drop").forEach((r) => r.classList.remove("drop"));
|
|
107
|
+
const dir = ev.target.closest(".ex-dir")?.dataset.path ?? "contexto";
|
|
108
|
+
const moving = ev.dataTransfer.getData("text/x-saga-path");
|
|
109
|
+
try {
|
|
110
|
+
if (moving) { if (dirOf(moving) !== dir) { await api("api/project/move", { path: moving, dir }); expanded.add(dir); } }
|
|
111
|
+
else for (const f of ev.dataTransfer.files) await uploadFile(f, dir);
|
|
112
|
+
refreshTree();
|
|
113
|
+
} catch (e) { ctx.toast(e.message); }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// botão direito: o mesmo menu do resto do Studio
|
|
117
|
+
function menuAt(ev, row) {
|
|
118
|
+
const p = row?.dataset.path || "", prot = !!row?.classList.contains("ex-protected"), isDir = row ? row.dataset.type === "dir" : true;
|
|
119
|
+
const here = row ? (isDir ? p : dirOf(p)) : "";
|
|
120
|
+
const items = [
|
|
121
|
+
row && !isDir && { label: "Abrir", ic: "folder-open", fn: () => openFile(p, row.dataset.kind) },
|
|
122
|
+
!prot && { label: "Novo arquivo", ic: "file-plus", fn: () => newFile(here || "contexto") },
|
|
123
|
+
!prot && { label: "Nova pasta", ic: "folder-plus", fn: () => newFolder(here) },
|
|
124
|
+
!prot && { label: "Enviar arquivos para cá", ic: "upload", fn: () => { uploadTarget = here || "contexto"; $("#ex-upload-input").click(); } },
|
|
125
|
+
row && { sep: true },
|
|
126
|
+
row && !prot && { label: "Renomear", ic: "pencil", key: "F2", fn: () => renameEntry(p) },
|
|
127
|
+
row && { label: "Copiar caminho", ic: "copy", fn: () => navigator.clipboard?.writeText(p).then(() => ctx.toast(`Copiado: ${p}`)) },
|
|
128
|
+
row && !isDir && row.dataset.kind === "image" && { label: "Usar num slide", ic: "plus", fn: () => ctx.insertSlide({ layout: "image", image: p, title: p.split("/").pop().replace(/\.[a-z0-9]+$/i, "") }) },
|
|
129
|
+
row && !prot && { sep: true },
|
|
130
|
+
row && !prot && { label: "Apagar", ic: "trash-2", key: "Delete", danger: true, fn: () => deleteEntry(p) },
|
|
131
|
+
].filter(Boolean);
|
|
132
|
+
ctx.contextMenu(ev, items);
|
|
133
|
+
}
|
|
134
|
+
// campo de nome dentro da árvore (renomear e criar), como no VS Code: Enter confirma, Esc desiste
|
|
135
|
+
function inlineName(anchorRow, initial, { depth = 0, icon = "file", create = false } = {}) {
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const input = Object.assign(document.createElement("input"), { className: "ex-edit", value: initial, spellcheck: false });
|
|
138
|
+
input.setAttribute("aria-label", create ? "Nome do novo item" : "Novo nome");
|
|
139
|
+
let row = anchorRow, holder;
|
|
140
|
+
if (create || !row) {
|
|
141
|
+
holder = document.createElement("div");
|
|
142
|
+
holder.className = "ex-row ex-new";
|
|
143
|
+
holder.style.paddingLeft = `${10 + depth * 14}px`;
|
|
144
|
+
holder.innerHTML = `<i class="ic" data-ic="${icon}"></i>`;
|
|
145
|
+
holder.append(input);
|
|
146
|
+
(anchorRow ? anchorRow.after(holder) : $("#project-tree").append(holder));
|
|
147
|
+
ctx.hydrate(holder);
|
|
148
|
+
} else {
|
|
149
|
+
row.classList.add("editing");
|
|
150
|
+
row.querySelector(".ex-name").replaceWith(input);
|
|
151
|
+
}
|
|
152
|
+
input.focus();
|
|
153
|
+
const dot = initial.lastIndexOf(".");
|
|
154
|
+
input.setSelectionRange(0, dot > 0 ? dot : initial.length); // como no VS Code: seleciona o nome sem a extensão
|
|
155
|
+
let done = false;
|
|
156
|
+
const finish = (value) => { if (done) return; done = true; holder?.remove(); resolve(value); };
|
|
157
|
+
input.addEventListener("keydown", (ev) => {
|
|
158
|
+
ev.stopPropagation();
|
|
159
|
+
if (ev.key === "Enter") { ev.preventDefault(); finish(input.value.trim() || null); }
|
|
160
|
+
if (ev.key === "Escape") { ev.preventDefault(); finish(null); refreshTree(); }
|
|
161
|
+
});
|
|
162
|
+
input.addEventListener("blur", () => finish(input.value.trim() || null));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const depthOf = (p) => (p ? p.split("/").length : 0);
|
|
166
|
+
async function newFile(dir = dirOf(selected) || "contexto") {
|
|
167
|
+
expanded.add(dir); await refreshTree();
|
|
168
|
+
const name = await inlineName(dir ? rowOf(dir) : null, "anotações.md", { depth: depthOf(dir), icon: "file-text", create: true });
|
|
169
|
+
if (!name) return refreshTree();
|
|
170
|
+
try { const r = await api("api/project/create", { dir, name, text: "" }); await refreshTree(); select(r.path); openFile(r.path); } catch (e) { ctx.toast(e.message); refreshTree(); }
|
|
171
|
+
}
|
|
172
|
+
async function newFolder(dir = dirOf(selected)) {
|
|
173
|
+
if (dir) { expanded.add(dir); await refreshTree(); }
|
|
174
|
+
const name = await inlineName(dir ? rowOf(dir) : null, "nova pasta", { depth: depthOf(dir), icon: "folder", create: true });
|
|
175
|
+
if (!name) return refreshTree();
|
|
176
|
+
try { const r = await api("api/project/mkdir", { dir, name }); expanded.add(r.path); await refreshTree(); select(r.path); } catch (e) { ctx.toast(e.message); refreshTree(); }
|
|
177
|
+
}
|
|
178
|
+
async function renameEntry(p) {
|
|
179
|
+
const row = rowOf(p); if (!row || row.classList.contains("ex-protected")) return;
|
|
180
|
+
const cur = p.split("/").pop(), name = await inlineName(row, cur);
|
|
181
|
+
if (!name || name === cur) return refreshTree();
|
|
182
|
+
try {
|
|
183
|
+
const r = await api("api/project/rename", { path: p, name });
|
|
184
|
+
const t = openTabs.find((x) => x.path === p);
|
|
185
|
+
if (t) { t.path = r.path; t.name = r.path.split("/").pop(); if (active === p) active = r.path; renderTabs(); }
|
|
186
|
+
if (expanded.has(p)) { expanded.delete(p); expanded.add(r.path); }
|
|
187
|
+
await refreshTree(); select(r.path);
|
|
188
|
+
} catch (e) { ctx.toast(e.message); refreshTree(); }
|
|
189
|
+
}
|
|
190
|
+
// apagar: sem pergunta (vai para a lixeira do projeto) e com Desfazer
|
|
191
|
+
async function deleteEntry(p) {
|
|
192
|
+
const row = rowOf(p); if (!row || row.classList.contains("ex-protected")) return;
|
|
193
|
+
const rows = [...$("#project-tree").querySelectorAll(".ex-row")], i = rows.indexOf(row);
|
|
194
|
+
try {
|
|
195
|
+
const r = await api("api/project/delete", { path: p });
|
|
196
|
+
closeTab(p, true);
|
|
197
|
+
await refreshTree();
|
|
198
|
+
const next = rows[i + 1] || rows[i - 1];
|
|
199
|
+
if (next && rowOf(next.dataset.path)) select(next.dataset.path);
|
|
200
|
+
ctx.toast(`"${p.split("/").pop()}" foi para a lixeira do projeto.`, 8000, { label: "Desfazer", fn: async () => { await api("api/project/restore", { path: r.trashed }); await refreshTree(); select(p); } });
|
|
201
|
+
} catch (e) { ctx.toast(e.message); }
|
|
202
|
+
}
|
|
203
|
+
let uploadTarget = null;
|
|
204
|
+
async function uploadFile(file, dir = "contexto") {
|
|
205
|
+
const r = await api("api/project/upload", { dir, name: file.name, dataUrl: await readAsDataUrl(file) });
|
|
206
|
+
expanded.add(dir);
|
|
207
|
+
return r.path;
|
|
208
|
+
}
|
|
209
|
+
// print colado: vai para contexto/ com data e hora no nome (e a IA passa a ver o texto dos documentos de lá)
|
|
210
|
+
async function savePastedImage(blob) {
|
|
211
|
+
const stamp = new Date().toISOString().slice(0, 19).replace("T", " ").replace(/:/g, "-");
|
|
212
|
+
const ext = (blob.type.split("/")[1] || "png").replace("jpeg", "jpg");
|
|
213
|
+
const path = await uploadFile(new File([blob], `print ${stamp}.${ext}`, { type: blob.type }), "contexto");
|
|
214
|
+
await refreshTree();
|
|
215
|
+
return path;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ------------------------------------------------------------------ abas
|
|
219
|
+
function renderTabs() {
|
|
220
|
+
const bar = $("#doc-tabs");
|
|
221
|
+
bar.innerHTML = `<button type="button" class="doc-tab${active === "__deck__" ? " active" : ""}" data-doc="__deck__"><i class="ic" data-ic="presentation"></i><span>Apresentação</span></button>` +
|
|
222
|
+
openTabs.map((t) => `<button type="button" class="doc-tab${active === t.path ? " active" : ""}" data-doc="${esc(t.path)}" title="${esc(t.path)}"><i class="ic" data-ic="${KIND_ICON[t.kind] || "file"}"></i><span>${esc(t.name)}</span>${t.dirty ? '<span class="doc-dirty" aria-label="não gravado"></span>' : ""}<i class="ic doc-close" data-ic="x" data-close="${esc(t.path)}" role="button" aria-label="Fechar ${esc(t.name)}"></i></button>`).join("");
|
|
223
|
+
ctx.hydrate(bar);
|
|
224
|
+
}
|
|
225
|
+
function bindTabs() {
|
|
226
|
+
$("#doc-tabs").addEventListener("click", (ev) => {
|
|
227
|
+
const c = ev.target.closest("[data-close]"); if (c) { ev.stopPropagation(); closeTab(c.dataset.close); return; }
|
|
228
|
+
const t = ev.target.closest("[data-doc]"); if (t) activate(t.dataset.doc);
|
|
229
|
+
});
|
|
230
|
+
$("#doc-tabs").addEventListener("auxclick", (ev) => { const t = ev.target.closest("[data-doc]"); if (ev.button === 1 && t && t.dataset.doc !== "__deck__") closeTab(t.dataset.doc); });
|
|
231
|
+
}
|
|
232
|
+
function closeTab(p, force = false) {
|
|
233
|
+
const i = openTabs.findIndex((t) => t.path === p); if (i < 0) return;
|
|
234
|
+
if (!force && openTabs[i].dirty && !confirm("Fechar sem gravar as mudanças?")) return;
|
|
235
|
+
openTabs.splice(i, 1);
|
|
236
|
+
if (active === p) activate(openTabs[i - 1]?.path || openTabs[i]?.path || "__deck__");
|
|
237
|
+
else renderTabs();
|
|
238
|
+
}
|
|
239
|
+
async function openFile(p, kind) {
|
|
240
|
+
kind = kind || findEntry(p)?.kind || "other";
|
|
241
|
+
if (!openTabs.some((t) => t.path === p)) openTabs.push({ path: p, kind, name: p.split("/").pop() });
|
|
242
|
+
await activate(p);
|
|
243
|
+
}
|
|
244
|
+
async function activate(p) {
|
|
245
|
+
active = p;
|
|
246
|
+
renderTabs();
|
|
247
|
+
$("#project-tree")?.querySelectorAll(".ex-file").forEach((r) => r.classList.toggle("active", r.dataset.path === p));
|
|
248
|
+
const editor = $("#canvas-stage-wrapper"), view = $("#doc-view");
|
|
249
|
+
editor.classList.remove("doc-side", "doc-full");
|
|
250
|
+
if (p === "__deck__") { view.innerHTML = ""; view.classList.add("hidden"); ctx.relayout(); return; }
|
|
251
|
+
const t = openTabs.find((x) => x.path === p);
|
|
252
|
+
view.classList.remove("hidden");
|
|
253
|
+
editor.classList.add(t.kind === "deck" ? "doc-side" : "doc-full");
|
|
254
|
+
try {
|
|
255
|
+
if (t.kind === "deck") await showDeckText(view, t);
|
|
256
|
+
else if (t.kind === "md" || t.kind === "text" || (t.kind === "sheet" && t.asText)) await showText(view, t);
|
|
257
|
+
else if (t.kind === "sheet") await showSheet(view, t);
|
|
258
|
+
else if (t.kind === "image") showImage(view, t);
|
|
259
|
+
else if (t.kind === "pdf") await showPdf(view, t);
|
|
260
|
+
else if (t.kind === "docx") await showDocx(view, t);
|
|
261
|
+
else view.innerHTML = `<div class="dv-info"><i class="ic" data-ic="file"></i><b>${esc(t.name)}</b><p>O texto deste arquivo vai como material para a IA.</p><a class="btn btn-small" href="${fileUrl(t.path)}" download="${esc(t.name)}">Baixar</a></div>`;
|
|
262
|
+
} catch (e) { view.innerHTML = `<div class="dv-info">${esc(e.message)}</div>`; }
|
|
263
|
+
ctx.hydrate(view);
|
|
264
|
+
ctx.relayout();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ------------------------------------------------------------------ editores de texto
|
|
268
|
+
let deckTimer = 0;
|
|
269
|
+
async function showDeckText(view, t) {
|
|
270
|
+
const r = await (await fetch("api/deck")).json();
|
|
271
|
+
view.innerHTML = `<div class="dv-head"><i class="ic" data-ic="presentation"></i><b>${esc(t.name)}</b><span class="dv-status" id="dv-status">o slide ao lado atualiza enquanto você digita</span></div>`;
|
|
272
|
+
const { wrap, ta, paint } = window.SagaViewers.codeEditor({ text: r.yaml || "", highlighter: (v) => ctx.highlightYaml(v).replace(/\n$/, "") });
|
|
273
|
+
ta.id = "deck-text-editor";
|
|
274
|
+
view.append(wrap);
|
|
275
|
+
ta.addEventListener("input", () => {
|
|
276
|
+
clearTimeout(deckTimer);
|
|
277
|
+
deckTimer = setTimeout(async () => {
|
|
278
|
+
const st = $("#dv-status");
|
|
279
|
+
try {
|
|
280
|
+
const res = await fetch("api/deck", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ yaml: ta.value }) });
|
|
281
|
+
const j = await res.json();
|
|
282
|
+
if (!res.ok || !j.ok) throw new Error(j.error || "YAML inválido");
|
|
283
|
+
st.textContent = "aplicado"; st.classList.remove("error");
|
|
284
|
+
await ctx.reloadDeck();
|
|
285
|
+
} catch (e) { st.textContent = e.message; st.classList.add("error"); }
|
|
286
|
+
}, 450);
|
|
287
|
+
});
|
|
288
|
+
t.refresh = async () => { if (document.activeElement === ta) return; const r2 = await (await fetch("api/deck")).json(); if (r2.yaml !== ta.value) { ta.value = r2.yaml; paint(); } };
|
|
289
|
+
}
|
|
290
|
+
// markdown simples para a prévia (títulos, listas, negrito, itálico, código, links)
|
|
291
|
+
function mdToHtml(src) {
|
|
292
|
+
const inline = (s) => esc(s).replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>").replace(/\*([^*]+)\*/g, "<i>$1</i>").replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
293
|
+
const out = []; let list = null, code = null;
|
|
294
|
+
for (const line of String(src).split("\n")) {
|
|
295
|
+
if (code) { if (/^```/.test(line)) { out.push(`<pre>${esc(code.join("\n"))}</pre>`); code = null; } else code.push(line); continue; }
|
|
296
|
+
if (/^```/.test(line)) { code = []; continue; }
|
|
297
|
+
const li = line.match(/^\s*[-*]\s+(.*)$/) || line.match(/^\s*\d+[.)]\s+(.*)$/);
|
|
298
|
+
if (li) { if (!list) { list = []; } list.push(`<li>${inline(li[1])}</li>`); continue; }
|
|
299
|
+
if (list) { out.push(`<ul>${list.join("")}</ul>`); list = null; }
|
|
300
|
+
const h = line.match(/^(#{1,4})\s+(.*)$/);
|
|
301
|
+
if (h) out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`);
|
|
302
|
+
else if (line.trim()) out.push(`<p>${inline(line)}</p>`);
|
|
303
|
+
}
|
|
304
|
+
if (list) out.push(`<ul>${list.join("")}</ul>`);
|
|
305
|
+
if (code) out.push(`<pre>${esc(code.join("\n"))}</pre>`);
|
|
306
|
+
return out.join("");
|
|
307
|
+
}
|
|
308
|
+
async function showText(view, t) {
|
|
309
|
+
const text = t.text ?? (await (await fetch(fileUrl(t.path))).text());
|
|
310
|
+
const md = t.kind === "md";
|
|
311
|
+
const lang = window.SagaViewers.langOf(t.name);
|
|
312
|
+
const asGrid = t.kind === "sheet" ? `<button type="button" class="btn btn-small" data-as-grid title="Ver como planilha"><i class="ic" data-ic="table"></i> Planilha</button>` : "";
|
|
313
|
+
view.innerHTML = `<div class="dv-head"><i class="ic" data-ic="${KIND_ICON[t.kind]}"></i><b>${esc(t.name)}</b><span class="dv-lang">${esc(LANG_LABEL[lang] || lang)}</span><span class="dv-status" id="dv-status">grava sozinho</span>${asGrid}</div><div class="dv-split${md ? "" : " one"}"></div>`;
|
|
314
|
+
view.querySelector("[data-as-grid]")?.addEventListener("click", () => { t.asText = false; t.text = undefined; activate(t.path); });
|
|
315
|
+
const split = view.querySelector(".dv-split");
|
|
316
|
+
const { wrap, ta } = window.SagaViewers.codeEditor({ text, lang });
|
|
317
|
+
ta.classList.add("dv-text");
|
|
318
|
+
ta.dataset.path = t.path;
|
|
319
|
+
split.append(wrap);
|
|
320
|
+
const prev = md ? Object.assign(document.createElement("div"), { className: "dv-md" }) : null;
|
|
321
|
+
if (prev) { prev.innerHTML = mdToHtml(text); split.append(prev); }
|
|
322
|
+
let timer = 0;
|
|
323
|
+
ta.addEventListener("input", () => {
|
|
324
|
+
t.text = ta.value; t.dirty = true; renderTabs();
|
|
325
|
+
if (prev) prev.innerHTML = mdToHtml(ta.value);
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
timer = setTimeout(async () => {
|
|
328
|
+
try { await api("api/project/write", { path: t.path, text: ta.value }); t.dirty = false; renderTabs(); $("#dv-status").textContent = "gravado"; }
|
|
329
|
+
catch (e) { $("#dv-status").textContent = e.message; }
|
|
330
|
+
}, 500);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
function showImage(view, t) {
|
|
334
|
+
view.innerHTML = `<div class="dv-head"><i class="ic" data-ic="file-image"></i><b>${esc(t.name)}</b><button type="button" class="btn btn-small" data-use-image><i class="ic" data-ic="plus"></i> Usar num slide</button></div><div class="dv-img"><img src="${fileUrl(t.path)}" alt="${esc(t.name)}"></div>`;
|
|
335
|
+
view.querySelector("[data-use-image]").onclick = () => ctx.insertSlide({ layout: "image", image: t.path, title: t.name.replace(/\.[a-z0-9]+$/i, "") });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ------------------------------------------------------------------ planilha + sugestões de gráfico
|
|
339
|
+
const TYPE_LABEL = { number: "número", percent: "porcentagem", date: "tempo", category: "categoria", text: "texto" };
|
|
340
|
+
async function showSheet(view, t) {
|
|
341
|
+
const data = await api(`api/project/sheet?path=${encodeURIComponent(t.path)}&all=1`);
|
|
342
|
+
const sheet = data.sheets.find((s) => s.name === t.sheet) || data.sheets[0];
|
|
343
|
+
t.sheet = sheet.name;
|
|
344
|
+
const complete = sheet.rows.length >= sheet.total;
|
|
345
|
+
const editable = data.editable && complete;
|
|
346
|
+
const tabs = data.sheets.length > 1 ? `<div class="sh-tabs">${data.sheets.map((s) => `<button type="button" class="sh-tab${s.name === sheet.name ? " active" : ""}" data-sheet="${esc(s.name)}">${esc(s.name)}</button>`).join("")}</div>` : "";
|
|
347
|
+
const cols = sheet.columns;
|
|
348
|
+
const DELIM = { ",": "vírgula", ";": "ponto e vírgula", "\t": "tab", "|": "barra" };
|
|
349
|
+
const sepInfo = data.delimiter ? ` · separado por ${DELIM[data.delimiter] || data.delimiter}` : "";
|
|
350
|
+
const asText = /\.(csv|tsv)$/i.test(t.name) ? `<button type="button" class="btn btn-small" data-as-text title="Ver o arquivo como texto"><i class="ic" data-ic="file-code"></i> Texto</button>` : "";
|
|
351
|
+
view.innerHTML = `<div class="dv-head"><i class="ic" data-ic="file-spreadsheet"></i><b>${esc(t.name)}</b><span class="dv-status" id="dv-status">${sheet.total} linhas · ${cols.length} colunas${sepInfo}${editable ? "" : " · só leitura"}</span>${asText}<button type="button" class="btn btn-small" data-toggle-suggest title="Gráficos que fazem sentido com estes dados"><i class="ic" data-ic="chart-column"></i> Gráficos</button></div>${tabs}
|
|
352
|
+
<div class="sh-body${t.hideSuggest ? " no-suggest" : ""}"><div class="sh-grid-wrap" id="sh-grid"></div>
|
|
353
|
+
<aside class="sh-suggest"><div class="sh-suggest-head"><b>Gráficos que fazem sentido</b><button type="button" class="btn btn-small" data-ai-suggest title="A IA confere o tipo das colunas e sugere gráficos, títulos e nomes dos eixos"><i class="ic" data-ic="sparkles"></i> Sugerir com IA</button></div><div class="sh-cards" id="sh-cards"><div class="ex-empty">Olhando as colunas…</div></div></aside></div>`;
|
|
354
|
+
const types = [];
|
|
355
|
+
for (const c of cols) types[c.index] = { type: c.type, label: TYPE_LABEL[c.type] || c.type };
|
|
356
|
+
let timer = 0;
|
|
357
|
+
window.SagaViewers.sheetGrid(view.querySelector("#sh-grid"), {
|
|
358
|
+
rows: sheet.rows, editable, hasHeader: sheet.hasHeader, types,
|
|
359
|
+
onChange: (rows) => {
|
|
360
|
+
const st = $("#dv-status"); st.textContent = "gravando…"; t.dirty = true; renderTabs();
|
|
361
|
+
clearTimeout(timer);
|
|
362
|
+
timer = setTimeout(async () => {
|
|
363
|
+
try { await api("api/project/sheet-write", { path: t.path, rows }); t.dirty = false; renderTabs(); st.textContent = `gravado${sepInfo}`; st.classList.remove("error"); }
|
|
364
|
+
catch (e) { st.textContent = e.message; st.classList.add("error"); }
|
|
365
|
+
}, 500);
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
view.querySelectorAll("[data-sheet]").forEach((b) => b.onclick = () => { t.sheet = b.dataset.sheet; activate(t.path); });
|
|
369
|
+
view.querySelector("[data-as-text]")?.addEventListener("click", () => { t.asText = true; t.text = undefined; activate(t.path); });
|
|
370
|
+
view.querySelector("[data-toggle-suggest]").onclick = () => { t.hideSuggest = !t.hideSuggest; view.querySelector(".sh-body").classList.toggle("no-suggest", t.hideSuggest); };
|
|
371
|
+
view.querySelector("[data-ai-suggest]").onclick = (ev) => loadSuggestions(t, true, ev.currentTarget);
|
|
372
|
+
ctx.hydrate(view);
|
|
373
|
+
loadSuggestions(t, false);
|
|
374
|
+
}
|
|
375
|
+
async function showPdf(view, t) {
|
|
376
|
+
view.innerHTML = `<div class="dv-head"><i class="ic" data-ic="file-text"></i><b>${esc(t.name)}</b><a class="btn btn-small" href="${fileUrl(t.path)}" download="${esc(t.name)}" title="Baixar o PDF"><i class="ic" data-ic="download"></i></a></div><div class="pv" id="pv-host"></div>`;
|
|
377
|
+
ctx.hydrate(view);
|
|
378
|
+
await window.SagaViewers.pdfView(view.querySelector("#pv-host"), fileUrl(t.path), { name: t.name });
|
|
379
|
+
ctx.hydrate(view);
|
|
380
|
+
}
|
|
381
|
+
async function showDocx(view, t) {
|
|
382
|
+
const { html } = await api(`api/project/docx?path=${encodeURIComponent(t.path)}`);
|
|
383
|
+
view.innerHTML = `<div class="dv-head"><i class="ic" data-ic="file-text"></i><b>${esc(t.name)}</b><span class="dv-status">só leitura (edite no Word)</span><a class="btn btn-small" href="${fileUrl(t.path)}" download="${esc(t.name)}" title="Baixar o documento"><i class="ic" data-ic="download"></i></a></div><div class="dx" id="dx-host"></div>`;
|
|
384
|
+
window.SagaViewers.docxView(view.querySelector("#dx-host"), html);
|
|
385
|
+
}
|
|
386
|
+
async function loadSuggestions(t, ai, btn) {
|
|
387
|
+
const host = $("#sh-cards"); if (!host) return;
|
|
388
|
+
if (btn) { btn.disabled = true; btn.classList.add("busy"); }
|
|
389
|
+
if (ai) host.insertAdjacentHTML("afterbegin", '<div class="ex-empty sh-thinking">A IA está olhando as colunas…</div>');
|
|
390
|
+
try {
|
|
391
|
+
const r = await api("api/project/chart-suggestions", { path: t.path, sheet: t.sheet, ai });
|
|
392
|
+
if (active !== t.path) return;
|
|
393
|
+
if (r.ai?.columns?.length) r.ai.columns.forEach((c) => { const chip = document.querySelector(`[data-col-type="${CSS.escape(c.nome)}"]`); if (chip && c.tipo) { chip.textContent = `${c.tipo} · IA`; chip.classList.add("by-ai"); } });
|
|
394
|
+
if (!r.suggestions.length) { host.innerHTML = '<div class="ex-empty">Não achei um gráfico que faça sentido com estas colunas. Precisa de ao menos uma coluna de números.</div>'; return; }
|
|
395
|
+
host.innerHTML = r.suggestions.map((s, i) => `<article class="sh-card${s.ai ? " by-ai" : ""}"><div class="sh-prev lc-prev"><div class="thumb-render">${s.html || ""}</div></div><div class="sh-card-body"><b>${esc(s.title)}</b>${s.ai ? '<span class="sh-ai f-label">IA</span>' : ""}<p>${esc(s.why)}</p><button type="button" class="btn btn-primary btn-sm" data-insert="${i}"><i class="ic" data-ic="plus"></i> Inserir no slide</button></div></article>`).join("");
|
|
396
|
+
host.querySelectorAll("[data-insert]").forEach((b) => b.onclick = () => { ctx.insertSlide(r.suggestions[+b.dataset.insert].slide); ctx.toast("Gráfico inserido depois do slide atual (ligado à planilha: Formatar › Atualizar da planilha)."); });
|
|
397
|
+
host.querySelectorAll(".sh-prev").forEach((el) => scaleObs.observe(el));
|
|
398
|
+
host.querySelectorAll(".pl, mark, .chart").forEach((e) => e.classList.add("play")); // a prévia já mostra o gráfico desenhado
|
|
399
|
+
ctx.hydrate(host);
|
|
400
|
+
} catch (e) { host.querySelector(".sh-thinking")?.remove(); ctx.toast(e.message); }
|
|
401
|
+
finally { if (btn) { btn.disabled = false; btn.classList.remove("busy"); } }
|
|
402
|
+
}
|
|
403
|
+
const scaleObs = new ResizeObserver((es) => es.forEach(({ target }) => { if (target.clientWidth) target.style.setProperty("--thumb-scale", String(target.clientWidth / 1920)); }));
|
|
404
|
+
|
|
405
|
+
// ------------------------------------------------------------------ montagem
|
|
406
|
+
function setView(which) {
|
|
407
|
+
const files = which === "files";
|
|
408
|
+
$("#rail-tab-slides").classList.toggle("active", !files);
|
|
409
|
+
$("#rail-tab-files").classList.toggle("active", files);
|
|
410
|
+
$("#thumbnails-list").classList.toggle("hidden", files);
|
|
411
|
+
$("#project-explorer").classList.toggle("hidden", !files);
|
|
412
|
+
$("#btn-add-slide-mini").classList.toggle("hidden", files);
|
|
413
|
+
try { localStorage.setItem("sagadeck.railView", which); } catch {}
|
|
414
|
+
if (files) refreshTree();
|
|
415
|
+
}
|
|
416
|
+
function setup(c) {
|
|
417
|
+
ctx = c;
|
|
418
|
+
$("#rail-tab-slides").onclick = () => setView("slides");
|
|
419
|
+
$("#rail-tab-files").onclick = () => setView("files");
|
|
420
|
+
$("#ex-new-file").onclick = () => newFile();
|
|
421
|
+
$("#ex-new-folder").onclick = () => newFolder();
|
|
422
|
+
$("#ex-refresh").onclick = () => refreshTree();
|
|
423
|
+
const up = $("#ex-upload-input");
|
|
424
|
+
$("#ex-upload").onclick = () => up.click();
|
|
425
|
+
up.onchange = async () => { try { const dir = uploadTarget ?? (dirOf(selected) || "contexto"); uploadTarget = null; for (const f of up.files) await uploadFile(f, dir); up.value = ""; refreshTree(); ctx.toast("Arquivo no projeto."); } catch (e) { ctx.toast(e.message); } };
|
|
426
|
+
window.SagaViewers.contextMenu = (ev, items) => ctx.contextMenu(ev, items);
|
|
427
|
+
bindTree(); bindTabs(); renderTabs();
|
|
428
|
+
// Ctrl+V de print em qualquer lugar do editor (fora de campo de texto): vai para contexto/
|
|
429
|
+
document.addEventListener("paste", async (ev) => {
|
|
430
|
+
if (ev.defaultPrevented || ev.target.closest?.("input, textarea, [contenteditable], #chat-form")) return;
|
|
431
|
+
const img = [...(ev.clipboardData?.items || [])].find((it) => it.kind === "file" && it.type.startsWith("image/"));
|
|
432
|
+
if (!img) return;
|
|
433
|
+
ev.preventDefault();
|
|
434
|
+
try { const p = await savePastedImage(img.getAsFile()); ctx.toast(`Print guardado em ${p}. A IA vê o que está em contexto/.`, 6000, { label: "Abrir", fn: () => { setView("files"); openFile(p, "image"); } }); }
|
|
435
|
+
catch (e) { ctx.toast(e.message); }
|
|
436
|
+
});
|
|
437
|
+
setView("slides"); // ao abrir, a barra da esquerda mostra os slides (Arquivos é um clique)
|
|
438
|
+
}
|
|
439
|
+
// o deck mudou por outro caminho (formulário, IA, arrastar): a aba do texto da apresentação acompanha
|
|
440
|
+
function deckChanged() { openTabs.find((t) => t.kind === "deck")?.refresh?.(); }
|
|
441
|
+
const wantsPaste = () => !$("#project-explorer").classList.contains("hidden") || active !== "__deck__";
|
|
442
|
+
window.SagaProject = { setup, refreshTree, openFile, deckChanged, savePastedImage, uploadFile, wantsPaste, activeTab: () => active };
|
|
443
|
+
})();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Desfazer / refazer único do Studio: TODA mudança do deck (formulário, arrastar na tela, YAML, IA, transformação,
|
|
2
|
+
// revisão, estilo) vira um passo, com um rótulo que diz de onde veio. O histórico é por apresentação (outra abre,
|
|
3
|
+
// ele recomeça). Digitação seguida no mesmo campo vira um passo só. Serve ao Studio e aos testes (Node).
|
|
4
|
+
(function (root) {
|
|
5
|
+
// onde duas versões diferem (caminhos até as folhas, poucos níveis): digitar no mesmo campo muda sempre o mesmo
|
|
6
|
+
// caminho; arrastar e depois apagar o objeto mudam caminhos diferentes (dx × hidden) e viram dois passos
|
|
7
|
+
function diffPaths(a, b, path = "", out = [], depth = 6) {
|
|
8
|
+
if (out.length > 24) return out;
|
|
9
|
+
if (a === b) return out;
|
|
10
|
+
const oa = a && typeof a === "object", ob = b && typeof b === "object";
|
|
11
|
+
if (!oa || !ob || depth === 0 || Array.isArray(a) !== Array.isArray(b) || (Array.isArray(a) && a.length !== b.length)) { out.push(path); return out; }
|
|
12
|
+
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) diffPaths(a[k], b[k], `${path}/${k}`, out, depth - 1);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
function createHistory({ limit = 100, coalesceMs = 1500, now = () => Date.now() } = {}) {
|
|
16
|
+
let undo = [], redo = [], current = null, key = undefined, lastAt = 0, lastLabel = null, lastWhere = null;
|
|
17
|
+
const api = {
|
|
18
|
+
// começa de novo (apresentação aberta)
|
|
19
|
+
reset(deck, file) { undo = []; redo = []; current = deck ? JSON.stringify(deck) : null; key = file; lastLabel = null; },
|
|
20
|
+
// registra o estado atual; outra apresentação recomeça o histórico. Devolve true se virou um passo novo.
|
|
21
|
+
note(deck, label = "Edição", file = key) {
|
|
22
|
+
if (file !== key) { api.reset(deck, file); return false; }
|
|
23
|
+
const json = JSON.stringify(deck);
|
|
24
|
+
if (current === null) { current = json; return false; }
|
|
25
|
+
if (json === current) return false;
|
|
26
|
+
const t = now();
|
|
27
|
+
const where = diffPaths(JSON.parse(current), deck).sort().join("|");
|
|
28
|
+
const merge = label === lastLabel && label === "Edição" && t - lastAt < coalesceMs && where === lastWhere && undo.length;
|
|
29
|
+
if (!merge) { undo.push({ json: current, label }); if (undo.length > limit) undo.shift(); }
|
|
30
|
+
redo = []; current = json; lastAt = t; lastLabel = label; lastWhere = where;
|
|
31
|
+
return !merge;
|
|
32
|
+
},
|
|
33
|
+
// o servidor completou o deck (uid de slide novo, ajuste levado junto): o estado atual é esse, sem passo novo
|
|
34
|
+
rebase(deck) { current = JSON.stringify(deck); },
|
|
35
|
+
undo() {
|
|
36
|
+
if (!undo.length) return null;
|
|
37
|
+
const e = undo.pop();
|
|
38
|
+
redo.push({ json: current, label: e.label });
|
|
39
|
+
current = e.json; lastLabel = null;
|
|
40
|
+
return { deck: JSON.parse(e.json), label: e.label };
|
|
41
|
+
},
|
|
42
|
+
redo() {
|
|
43
|
+
if (!redo.length) return null;
|
|
44
|
+
const e = redo.pop();
|
|
45
|
+
undo.push({ json: current, label: e.label });
|
|
46
|
+
current = e.json; lastLabel = null;
|
|
47
|
+
return { deck: JSON.parse(e.json), label: e.label };
|
|
48
|
+
},
|
|
49
|
+
get canUndo() { return undo.length > 0; },
|
|
50
|
+
get canRedo() { return redo.length > 0; },
|
|
51
|
+
get undoLabel() { return undo.at(-1)?.label || null; },
|
|
52
|
+
get redoLabel() { return redo.at(-1)?.label || null; },
|
|
53
|
+
get size() { return undo.length; },
|
|
54
|
+
};
|
|
55
|
+
return api;
|
|
56
|
+
}
|
|
57
|
+
root.SagaHistory = { createHistory };
|
|
58
|
+
})(typeof globalThis !== "undefined" ? globalThis : window);
|