sagadeck 1.2.0 → 1.3.1

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.
Files changed (46) hide show
  1. package/README.md +11 -2
  2. package/bin/sagadeck.js +36 -3
  3. package/docs/ANDAMENTO.md +37 -0
  4. package/docs/REFERENCIA.md +4 -3
  5. package/package.json +1 -1
  6. package/src/ai/deck-ai.js +54 -12
  7. package/src/ai/document-visuals.js +230 -24
  8. package/src/ai/quality.js +1 -1
  9. package/src/ai/transform.js +51 -2
  10. package/src/diff.js +44 -0
  11. package/src/elements.js +37 -2
  12. package/src/export/pptx.js +8 -6
  13. package/src/figures/charts.js +11 -3
  14. package/src/figures/diagrams.js +37 -9
  15. package/src/fiscal/autofix.js +1 -1
  16. package/src/fiscal/enxugar.js +22 -0
  17. package/src/fiscal/normalize.js +19 -2
  18. package/src/import/crop.js +10 -3
  19. package/src/layouts.js +5 -3
  20. package/src/library.js +74 -2
  21. package/src/motion.js +1 -1
  22. package/src/package.js +16 -1
  23. package/src/research/brave.js +12 -0
  24. package/src/research/chat-research.js +29 -0
  25. package/src/research/links.js +37 -0
  26. package/src/research/research.js +42 -2
  27. package/src/research/wikipedia.js +15 -0
  28. package/src/runtime/base.css +6 -6
  29. package/src/runtime/motion.js +1 -1
  30. package/src/runtime/runtime.js +15 -2
  31. package/src/studio/ai-chat-route.js +158 -0
  32. package/src/studio/exporting.js +2 -2
  33. package/src/studio/library-routes.js +158 -0
  34. package/src/studio/public/app.js +44 -508
  35. package/src/studio/public/chat.js +461 -0
  36. package/src/studio/public/index.html +17 -12
  37. package/src/studio/public/library.js +13 -2
  38. package/src/studio/public/slide-clipboard.js +20 -7
  39. package/src/studio/public/slide-select.js +73 -0
  40. package/src/studio/public/studio-next.css +28 -1
  41. package/src/studio/public/style.css +7 -1
  42. package/src/studio/public-files.js +1 -1
  43. package/src/studio/server.js +14 -285
  44. package/src/studio/slide-copy-routes.js +7 -4
  45. package/src/studio/snapshot.js +3 -0
  46. package/src/themes.js +32 -2
@@ -6,12 +6,12 @@
6
6
  "use strict";
7
7
 
8
8
  const scopedURL = window.SagaWorkspace();
9
- let chatJob = null;
10
9
 
11
10
  // Estado da Aplicação
12
11
  const state = {
13
12
  deck: null,
14
13
  currentSlideIndex: 0,
14
+ selectedSlides: [],
15
15
  zoomScale: 0.6,
16
16
  autoFit: true,
17
17
  showGuides: true,
@@ -920,452 +920,6 @@
920
920
 
921
921
  // ==========================================================================
922
922
  // CHAT LATERAL COM IA
923
- // ==========================================================================
924
- // --------------------------------------------------------------------------
925
- // CONVERSA (brainstorm) sem escolher modo: o servidor percebe se é pedido de mudança ou conversa
926
- // (src/ai/intent.js). Resposta de conversa não mexe no deck, traz opções clicáveis e libera
927
- // "Transformar em slides".
928
- // --------------------------------------------------------------------------
929
- function updateBrainstormApply() {
930
- const recent = state.chatHistory.slice(-4);
931
- document.getElementById("btn-brainstorm-apply").classList.toggle("hidden", !recent.some((m) => m.talk));
932
- }
933
-
934
- function renderChatOptions(msg, options) {
935
- if (!options?.length) return;
936
- const row = document.createElement("div");
937
- row.className = "bs-options";
938
- for (const o of options) {
939
- const b = document.createElement("button");
940
- b.type = "button";
941
- b.className = "bs-option";
942
- b.textContent = o;
943
- b.onclick = () => {
944
- row.querySelectorAll("button").forEach((x) => (x.disabled = true));
945
- dom.chatInput.value = o;
946
- handleChatSubmit();
947
- };
948
- row.append(b);
949
- }
950
- // nenhuma das opções serve: a pessoa escreve a sua (a IA entende texto livre)
951
- const other = document.createElement("button");
952
- other.type = "button";
953
- other.className = "bs-option bs-option-other";
954
- other.innerHTML = '<i class="ic" data-ic="pencil"></i><span>Outra resposta…</span>';
955
- other.onclick = () => {
956
- dom.chatInput.value = "";
957
- dom.chatInput.placeholder = "Escreva a sua resposta…";
958
- dom.chatInput.focus();
959
- };
960
- row.append(other);
961
- hydrateIcons(row);
962
- msg.querySelector(".ai-content").append(row);
963
- }
964
-
965
- // atalho depois de uma conversa: autoriza a IA a aplicar o que foi combinado
966
- function applyBrainstorm() {
967
- dom.chatInput.value = "Pode fazer: aplique nos slides o que combinamos nesta conversa.";
968
- handleChatSubmit();
969
- }
970
-
971
- // Versões de um slide lado a lado; nada muda até escolher uma
972
- async function renderVariants(msg, variants) {
973
- const box = document.createElement("div");
974
- box.className = "variants";
975
- msg.querySelector(".ai-content").append(box);
976
- for (const [k, v] of variants.options.entries()) {
977
- const card = document.createElement("div");
978
- card.className = "variant";
979
- card.innerHTML = `<div class="variant-prev"><div class="thumb-render"></div></div><div class="variant-foot"><b></b><button type="button" class="btn btn-secondary btn-sm">Usar esta</button></div>`;
980
- card.querySelector("b").textContent = v.label;
981
- if (v.direction) { card.querySelector('b').title = v.direction.rationale; card.querySelector('button').textContent = 'Usar direção no deck'; }
982
- const btn = card.querySelector("button");
983
- btn.dataset.variant = k;
984
- btn.onclick = () => {
985
- const s = JSON.parse(JSON.stringify(v.slide));
986
- if (v.direction) { state.deck.theme = v.direction.theme; delete state.deck.palette; delete s.theme; }
987
- if (variants.insert) state.deck.slides.splice(variants.index, 0, s);
988
- else state.deck.slides[variants.index] = s;
989
- state.currentSlideIndex = variants.index;
990
- box.querySelectorAll(".variant").forEach((c) => c.classList.toggle("chosen", c === card));
991
- box.querySelectorAll("button").forEach((b) => (b.disabled = true));
992
- btn.textContent = "Escolhida";
993
- state.chatHistory.push({ role: "user", text: `Escolhi a versão "${v.label}" (já apliquei no slide ${variants.index + 1}).` });
994
- saveChatHistory();
995
- syncDeckToServer();
996
- renderThumbnails();
997
- renderCurrentSlide();
998
- showToast(`Versão "${v.label}" aplicada no slide ${variants.index + 1}`, 2200);
999
- };
1000
- box.append(card);
1001
- try {
1002
- const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
1003
- body: JSON.stringify({ slide: v.slide, index: variants.index }) })).json();
1004
- const prev = card.querySelector(".variant-prev");
1005
- prev.querySelector(".thumb-render").innerHTML = r.html;
1006
- requestAnimationFrame(() => fitRendered(prev));
1007
- prev.style.setProperty("--thumb-scale", String(prev.clientWidth / 1920));
1008
- if (v.direction) window.SagaArtPreview(prev, r, v.label);
1009
- } catch {}
1010
- }
1011
- box.scrollIntoView({ block: "nearest" });
1012
- }
1013
-
1014
- async function handleChatSubmit(e) {
1015
- if (e) e.preventDefault();
1016
- if (chatJob) { if(e?.type === "submit") chatJob.stop(); return; }
1017
- const message = dom.chatInput.value.trim();
1018
- if (state.chatAttachments.some(a => a.t === "doc" && !a.id)) { showToast("Aguarde a leitura do anexo antes de enviar.", 4000); return; }
1019
- if (!message) return;
1020
-
1021
- // Adicionar bolha do usuário (com os anexos: imagens e documentos)
1022
- const bubble = appendChatMessage("user", message);
1023
- if (state.chatAttachments.length) {
1024
- const row = document.createElement("div");
1025
- row.className = "msg-atts";
1026
- state.chatAttachments.forEach((a) => {
1027
- if (a.t === "img") { const img = document.createElement("img"); img.src = a.url; row.appendChild(img); }
1028
- else { const chip = document.createElement("span"); chip.className = "msg-doc"; chip.innerHTML = `<i class="ic" data-ic="file-text"></i> `; hydrateIcons(chip); chip.appendChild(document.createTextNode(a.name)); row.appendChild(chip); }
1029
- });
1030
- bubble.querySelector(".user-content")?.appendChild(row);
1031
- }
1032
- dom.chatInput.value = "";
1033
- autoGrowChat();
1034
-
1035
- // a IA sempre sabe qual slide está na tela; outros slides (ou o deck todo) a pessoa diz no pedido
1036
- const targetIdx = state.currentSlideIndex;
1037
-
1038
- // Indicador de progresso ao vivo (etapa, segundos, texto chegando)
1039
- const job = chatJob = { file: state.file, id: crypto.randomUUID(), controller: new AbortController() };
1040
- job.stop = async () => {
1041
- job.controller.abort();
1042
- await fetch("api/ai/chat/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requestId: job.id }) }).catch(() => {});
1043
- };
1044
- const work = createProgressBubble(state.ai.available
1045
- ? `Enviando para o LLM (${state.ai.textModel})…`
1046
- : "Verificando se a IA está no ar…");
1047
- dom.chatSend.disabled = false;
1048
- dom.chatSend.title = 'Parar processamento'; dom.chatSend.setAttribute('aria-label','Parar');
1049
- dom.chatSend.innerHTML = '<i class="ic" data-ic="square"></i>'; hydrateIcons(dom.chatSend);
1050
- dom.chatInput.disabled = false;
1051
- // a conversa inteira deste deck (o servidor compacta as mensagens antigas; nada é esquecido)
1052
- const history = state.chatHistory.slice();
1053
- const baseDeck = JSON.parse(JSON.stringify(state.deck));
1054
- if (state.chatAttachments.some((a) => a.t === "doc" && !a.id)) {
1055
- showToast("Aguarde a leitura do anexo antes de enviar.", 4000);
1056
- return;
1057
- }
1058
- state.chatHistory.push({ role: "user", text: message });
1059
- // imagens vão embutidas (como antes); documentos vão por id (o texto já está no servidor)
1060
- const attachments = state.chatAttachments.map((a) => (a.t === "img" ? a.url : { type: "doc", id: a.id }));
1061
- clearChatAttachments();
1062
-
1063
- try {
1064
- const data = await streamAI("api/ai/chat", {
1065
- message, requestId: job.id,
1066
- targetSlide: targetIdx,
1067
- spec: state.deck,
1068
- issues: state.issues,
1069
- history,
1070
- attachments,
1071
- renderNotes: state.renderNotes || [],
1072
- autoRun: !!state.autoRunCommands, // a pessoa liberou os comandos desta conversa (só enquanto a página está aberta)
1073
- expectFile: state.file,
1074
- }, (ev) => { if (!commandEvent(ev)) work.update(ev); }, job.controller.signal);
1075
- if (state.file !== job.file) { work.done(); showToast("O pedido terminou na apresentação em que começou; a apresentação atual foi preservada.", 6000); return; }
1076
- if (!data.spec) throw new Error(data.error || "resposta sem deck");
1077
- // conversa salva antes de dispensar o indicador: quem espera o fim da resposta já encontra o arquivo gravado
1078
- const finishWork = async () => { await saveChatHistory(); work.done(); };
1079
- if (data.variants) {
1080
- state.chatHistory.push({ role: "assistant", text: `${data.reply}\n(versões: ${data.variants.options.map((o) => o.label).join(" | ")})`, talk: true });
1081
- await finishWork();
1082
- const msg = appendChatMessage("ai", data.reply, data.actions);
1083
- await renderVariants(msg, data.variants);
1084
- updateBrainstormApply();
1085
- return;
1086
- }
1087
- if (data.talk) {
1088
- // conversa: nada muda nos slides (com a IA desligada o aviso não entra na conversa com o modelo)
1089
- if (data.mode === "off") state.chatHistory.pop();
1090
- else state.chatHistory.push({ role: "assistant", text: data.reply + (data.options?.length ? `\n(opções: ${data.options.join(" | ")})` : ""), talk: true });
1091
- await finishWork();
1092
- const msg = appendChatMessage("ai", data.reply, data.actions);
1093
- msg.classList.add("bs");
1094
- const tag = document.createElement("div");
1095
- tag.className = "bs-tag";
1096
- tag.innerHTML = '<i class="ic" data-ic="message-square-text"></i> Conversa — nada mudou nos slides'; hydrateIcons(tag);
1097
- msg.querySelector(".ai-content").prepend(tag);
1098
- renderChatOptions(msg, data.options);
1099
- // a IA criou uma apresentação nova (recriar): um clique para abrir
1100
- if (data.createdDeck?.id) {
1101
- tag.innerHTML = '<i class="ic" data-ic="presentation"></i> Apresentação nova na biblioteca'; hydrateIcons(tag);
1102
- const open = document.createElement("button");
1103
- open.type = "button"; open.className = "btn btn-primary btn-sm chat-open-deck";
1104
- open.innerHTML = '<i class="ic" data-ic="folder-open"></i> Abrir a apresentação nova'; hydrateIcons(open);
1105
- open.onclick = () => { location.href = `editor?deck=${encodeURIComponent(data.createdDeck.id)}`; };
1106
- msg.querySelector(".ai-content").append(open);
1107
- }
1108
- updateBrainstormApply();
1109
- if (data.mode === "off") refreshAIStatus(); // o selo "IA ligada/desligada" acompanha
1110
- return;
1111
- }
1112
- state.chatHistory.push({ role: "assistant", text: data.reply });
1113
-
1114
- // Atualizar o deck com as modificações da IA sem perder o que a pessoa mexeu enquanto ela pensava
1115
- // (junção a três: base = o deck quando o pedido saiu; ver merge-decks.js)
1116
- const merged = window.SagadeckMerge.mergeDecks(baseDeck, state.deck, data.spec);
1117
- state.deck = merged.deck;
1118
- trackDeck("Assistente de IA");
1119
- if (JSON.stringify(merged.deck) !== JSON.stringify(data.spec)) syncDeckToServer();
1120
- const conflicts = [...(data.conflicts || []), ...merged.conflicts];
1121
- if (merged.kept || data.kept) showToast("Você reorganizou os slides enquanto a IA trabalhava: mantive a sua versão. Peça de novo se quiser a mudança dela.", 7000);
1122
- else if (conflicts.length) showToast(`O slide ${[...new Set(conflicts)].map((i) => i + 1).join(", ")} mudou dos dois lados enquanto a IA trabalhava: ficou a sua versão.`, 7000);
1123
- if (typeof data.targetSlide === "number" && data.targetSlide < state.deck.slides.length) {
1124
- state.currentSlideIndex = data.targetSlide;
1125
- }
1126
-
1127
- // Re-renderizar
1128
- renderThumbnails();
1129
- await renderCurrentSlide();
1130
-
1131
- // Substituir o indicador pela resposta completa
1132
- await finishWork();
1133
- const reply = appendChatMessage("ai", data.reply, data.actions);
1134
- // depois de melhorar seguindo o estilo do original: salvar esse estilo para usar em outras apresentações
1135
- if (data.offerStyle && reply) {
1136
- const b = document.createElement("button");
1137
- b.type = "button"; b.className = "btn btn-sm chat-save-style";
1138
- b.innerHTML = '<i class="ic" data-ic="swatch-book"></i> Salvar este estilo'; hydrateIcons(b);
1139
- b.title = "Guarda a moldura, as cores e as fontes do original para usar em outras apresentações (Design › Estilo)";
1140
- b.onclick = async () => {
1141
- const name = window.prompt("Nome do estilo", data.offerStyle.name);
1142
- if (!name) return;
1143
- try { const r = await fetch("api/styles/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); const j = await r.json(); if (!r.ok || j.error) throw new Error(j.error || r.status); b.disabled = true; showToast(`Estilo "${j.name}" salvo: Design › Estilo aplica em outra apresentação ou usa em toda nova.`, 6000); } catch (e) { showToast(e.message); }
1144
- };
1145
- reply.querySelector(".ai-content")?.append(b);
1146
- }
1147
- updateBrainstormApply();
1148
- } catch (err) {
1149
- if (job.controller.signal.aborted) { work.done(); appendChatMessage("ai", "Parado a pedido. Nenhuma resposta pendente foi aplicada."); await saveChatHistory(); }
1150
- else work.fail(err.message);
1151
- } finally {
1152
- chatJob = null;
1153
- dom.chatSend.title = "Enviar (Enter)"; dom.chatSend.setAttribute("aria-label","Enviar");
1154
- dom.chatSend.innerHTML = '<i class="ic" data-ic="send"></i>'; hydrateIcons(dom.chatSend);
1155
- dom.chatSend.disabled = false;
1156
- dom.chatInput.disabled = false;
1157
- dom.chatInput.focus();
1158
- await saveChatHistory();
1159
- }
1160
- }
1161
-
1162
- // A conversa é desta apresentação e fica ao lado dela (<deck>.conversa.json): volta ao reabrir o deck.
1163
- // Devolve a promise: quem chama espera antes de liberar a tela, senão recarregar na mesma hora perde a troca.
1164
- function saveChatHistory() {
1165
- return fetch("api/chat/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ history: state.chatHistory }) }).catch(() => {});
1166
- }
1167
- // ---- comandos da IA: a pessoa vê o código e clica para rodar (a liberação vale só nesta página e neste deck).
1168
- // Ações gratuitas de vídeo (plan/status/frame/download) rodam direto, sem clique; só o que pode cobrar pede.
1169
- state.autoRunCommands = false;
1170
- function commandCard(cmd) {
1171
- const msg = appendChatMessage("ai", "");
1172
- msg.classList.add("cmd-msg");
1173
- const box = msg.querySelector(".ai-content");
1174
- box.innerHTML = `<div class="cmd-head"><i class="ic" data-ic="terminal"></i><b></b></div><div class="cmd-why"></div><pre class="cmd-code"></pre><div class="cmd-actions"></div>`;
1175
- box.querySelector("b").textContent = `A IA quer rodar um comando (${cmd.language})`;
1176
- box.querySelector(".cmd-why").textContent = cmd.why || "";
1177
- box.querySelector(".cmd-code").textContent = cmd.code && typeof cmd.code === "object" ? JSON.stringify(cmd.code, null, 1) : cmd.code;
1178
- hydrateIcons(box);
1179
- dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
1180
- return box;
1181
- }
1182
- function commandEvent(ev) {
1183
- if (ev.type !== "progress") return false;
1184
- if (ev.phase === "approve" && ev.command) {
1185
- const box = commandCard(ev.command), acts = box.querySelector(".cmd-actions");
1186
- if (ev.cost) {
1187
- const tag = document.createElement("div");
1188
- tag.className = "cmd-cost";
1189
- tag.textContent = `Custo estimado: ${ev.cost} (só cobra se executar)`;
1190
- acts.before(tag);
1191
- }
1192
- acts.innerHTML = `<button type="button" class="btn btn-primary" data-d="run"><i class="ic" data-ic="play"></i> Executar</button><button type="button" class="btn" data-d="always">Executar e liberar os próximos</button><button type="button" class="btn" data-d="deny"><i class="ic" data-ic="x"></i> Não executar</button>`;
1193
- hydrateIcons(acts);
1194
- acts.querySelectorAll("[data-d]").forEach((b) => b.onclick = async () => {
1195
- const decision = b.dataset.d;
1196
- if (decision === "always") state.autoRunCommands = true;
1197
- acts.querySelectorAll("button").forEach((x) => { x.disabled = true; });
1198
- try { await fetch("api/ai/approve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: ev.id, decision }) }); } catch {}
1199
- acts.innerHTML = `<span class="cmd-state">${decision === "deny" ? "Não executado" : decision === "always" ? "Executando (próximos liberados nesta conversa)" : "Executando…"}</span>`;
1200
- if (decision !== "deny") box.dataset.waiting = "1"; // o resultado vem para este cartão
1201
- });
1202
- return false; // a linha de progresso também mostra "Esperando você autorizar"
1203
- }
1204
- if (ev.phase === "command" && ev.command) {
1205
- // aprovado agora: o cartão já está na tela; liberado antes: aparece um cartão novo, já rodando
1206
- if (!document.querySelector(".cmd-msg [data-waiting]")) {
1207
- const box = commandCard(ev.command);
1208
- box.dataset.waiting = "1";
1209
- box.querySelector(".cmd-actions").innerHTML = `<span class="cmd-state">Executando (liberado nesta conversa)…</span>`;
1210
- }
1211
- return false;
1212
- }
1213
- if (ev.phase === "command-result") {
1214
- const box = document.querySelector(".cmd-msg [data-waiting]");
1215
- if (box) {
1216
- delete box.dataset.waiting;
1217
- const r = ev.result || {}, ok = r.exitCode === 0 && !r.timedOut;
1218
- const st = box.querySelector(".cmd-state") || box.querySelector(".cmd-actions");
1219
- st.textContent = r.timedOut ? "Tempo esgotado" : ok ? "Rodou (saída 0)" : `Rodou com erro (saída ${r.exitCode ?? "?"})`;
1220
- st.classList.toggle("cmd-bad", !ok);
1221
- const out = document.createElement("details");
1222
- out.className = "cmd-out";
1223
- out.innerHTML = "<summary>Ver a saída</summary><pre></pre>";
1224
- out.querySelector("pre").textContent = [r.stdout, r.stderr].filter(Boolean).join("\n").slice(0, 6000) || "(sem saída)";
1225
- box.appendChild(out);
1226
- }
1227
- return false;
1228
- }
1229
- return false;
1230
- }
1231
-
1232
- async function loadChatHistory() {
1233
- state.autoRunCommands = false; // outra apresentação: nada fica liberado
1234
- try {
1235
- const { history } = await (await fetch("api/chat/history")).json();
1236
- state.chatHistory = Array.isArray(history) ? history : [];
1237
- state.loadingHistory = true; // conversa guardada não é resposta nova (o atalho do chat não acende)
1238
- setTimeout(() => { state.loadingHistory = false; });
1239
- // o que ficou entre parênteses no fim ("opções: …") é para o modelo; na tela, só a mensagem
1240
- for (const m of state.chatHistory) appendChatMessage(m.role === "user" ? "user" : "ai", String(m.text).replace(/\n\((opções|versões): [^\n]*\)$/, ""));
1241
- } catch { state.chatHistory = []; }
1242
- }
1243
-
1244
- // ---- anexos do chat (imagem: colar/arrastar/escolher · documento: escolher → o servidor lê) ----
1245
- state.chatAttachments = [];
1246
- function clearChatAttachments() {
1247
- state.chatAttachments = [];
1248
- renderChatAttachments();
1249
- }
1250
- function renderChatAttachments() {
1251
- const box = dom.chatAttachments;
1252
- box.hidden = !state.chatAttachments.length;
1253
- box.innerHTML = "";
1254
- state.chatAttachments.forEach((a, i) => {
1255
- const it = document.createElement("div");
1256
- it.className = "chat-att";
1257
- if (a.t === "img") {
1258
- it.innerHTML = `<img alt=""><button type="button" title="Remover"><i class="ic" data-ic="x"></i></button>`; hydrateIcons(it);
1259
- it.querySelector("img").src = a.url;
1260
- } else {
1261
- it.innerHTML = `<span class="chat-doc" title="${escHtml(a.name)}"><i class="ic" data-ic="file-text"></i><b>${escHtml(a.name)}</b><small>${a.chars} caracteres</small></span><button type="button" title="Remover"><i class="ic" data-ic="x"></i></button>`; hydrateIcons(it);
1262
- }
1263
- it.querySelector("button").onclick = () => { state.chatAttachments.splice(i, 1); renderChatAttachments(); };
1264
- box.appendChild(it);
1265
- });
1266
- }
1267
- // reduz para no máximo 1280 px (a IA não precisa de mais, e a requisição fica leve)
1268
- function addChatImage(file) {
1269
- if (!file || !file.type.startsWith("image/")) return;
1270
- if (state.chatAttachments.length >= 4) { showToast("Até 4 imagens por mensagem."); return; }
1271
- const img = new Image();
1272
- img.onload = () => {
1273
- const k = Math.min(1, 1280 / Math.max(img.width, img.height));
1274
- const c = document.createElement("canvas");
1275
- c.width = Math.round(img.width * k); c.height = Math.round(img.height * k);
1276
- c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
1277
- state.chatAttachments.push({ t: "img", url: c.toDataURL("image/jpeg", 0.85) });
1278
- URL.revokeObjectURL(img.src);
1279
- renderChatAttachments();
1280
- openPane("chat");
1281
- };
1282
- img.src = URL.createObjectURL(file);
1283
- }
1284
- // documento (pdf, docx, xlsx, pptx, txt…): sobe na hora; o servidor extrai o texto e devolve um id.
1285
- // O binário nunca vai para a IA — só o texto extraído, na hora de enviar a mensagem.
1286
- function addChatDoc(file) {
1287
- if (!file) return;
1288
- if (file.size > 15 * 1024 * 1024) { showToast("Arquivo grande demais (limite 15 MB)."); return; }
1289
- const provisional = { t: "doc", id: null, name: file.name, chars: "lendo…" };
1290
- state.chatAttachments.push(provisional);
1291
- renderChatAttachments();
1292
- openPane("chat");
1293
- const rd = new FileReader();
1294
- rd.onload = async () => {
1295
- try {
1296
- const res = await fetch("api/ai/context", {
1297
- method: "POST", headers: { "Content-Type": "application/json" },
1298
- body: JSON.stringify({ name: file.name, dataUrl: rd.result }),
1299
- });
1300
- const data = await res.json();
1301
- if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
1302
- Object.assign(provisional, { id: data.id, chars: data.chars });
1303
- } catch (e) {
1304
- state.chatAttachments.splice(state.chatAttachments.indexOf(provisional), 1);
1305
- showToast(`Não deu para ler "${file.name}": ${e.message}`, 6000);
1306
- }
1307
- renderChatAttachments();
1308
- };
1309
- rd.readAsDataURL(file);
1310
- }
1311
- function addChatFile(file) {
1312
- if (!file) return;
1313
- window.SagaProject?.uploadFile(file, "contexto").then(() => window.SagaProject.refreshTree()).catch(() => {}); // sem projeto: só na conversa
1314
- if (file.type.startsWith("image/")) addChatImage(file);
1315
- else addChatDoc(file);
1316
- }
1317
- // Altura da caixa do chat = o conteúdo (ou o placeholder, se vazia) + a borda; barra de rolagem só no limite.
1318
- function autoGrowChat() {
1319
- const el = dom.chatInput;
1320
- const empty = !el.value;
1321
- if (empty) el.value = el.placeholder; // scrollHeight ignora o placeholder: mede com ele no lugar
1322
- el.style.height = "auto";
1323
- const need = el.scrollHeight + (el.offsetHeight - el.clientHeight);
1324
- if (empty) el.value = "";
1325
- el.style.height = `${Math.min(180, need)}px`;
1326
- el.style.overflowY = need > 180 ? "auto" : "hidden";
1327
- }
1328
-
1329
- function appendChatMessage(sender, text, actions = []) {
1330
- dom.chatEmpty?.remove();
1331
- // resposta da IA com o chat fora da vista: o atalho flutuante acende (some quando o chat abre)
1332
- if (sender !== "user" && !state.loadingHistory && currentPane() !== "chat") document.getElementById("chat-badge")?.classList.add("show");
1333
- const msgDiv = document.createElement("div");
1334
- msgDiv.className = sender === "user" ? "user-msg" : "ai-msg";
1335
-
1336
- const avatar = document.createElement("div");
1337
- avatar.className = sender === "user" ? "user-avatar" : "ai-avatar";
1338
- if (sender === "user") avatar.textContent = "EU"; else { avatar.innerHTML = '<i class="ic" data-ic="sparkles"></i>'; hydrateIcons(avatar); }
1339
- msgDiv.appendChild(avatar);
1340
-
1341
- const content = document.createElement("div");
1342
- content.className = sender === "user" ? "user-content" : "ai-content";
1343
-
1344
- // Formatar quebras de linha e negritos
1345
- const formatted = String(text)
1346
- .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
1347
- .replace(/\*\*(.*?)\*\*/g, "<b>$1</b>")
1348
- .replace(/\n/g, "<br>");
1349
- content.innerHTML = formatted;
1350
-
1351
- // Exibir badges de ações realizadas pela IA
1352
- if (actions && actions.length > 0) {
1353
- const actionsContainer = document.createElement("div");
1354
- actionsContainer.style.marginTop = "8px";
1355
- actions.forEach((a) => {
1356
- const pill = document.createElement("span");
1357
- pill.className = "action-badge-pill";
1358
- pill.innerHTML = `<i class="ic" data-ic="${/falhou|falha|erro|não enxerga/i.test(a) ? "circle-alert" : "check"}"></i> ${escHtml(a)}`; hydrateIcons(pill);
1359
- actionsContainer.appendChild(pill);
1360
- });
1361
- content.appendChild(actionsContainer);
1362
- }
1363
-
1364
- msgDiv.appendChild(content);
1365
- dom.chatMessages.appendChild(msgDiv);
1366
- dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
1367
- return msgDiv;
1368
- }
1369
923
 
1370
924
  // ==========================================================================
1371
925
  // SÍNTESE DE ÁUDIO WEB AUDIO API (MICRO-INTERAÇÕES HÁPTICAS)
@@ -2319,7 +1873,8 @@
2319
1873
 
2320
1874
  state.deck.slides.forEach((slide, idx) => {
2321
1875
  const card = document.createElement("div");
2322
- card.className = `thumb-card ${idx === state.currentSlideIndex ? "active" : ""}`;
1876
+ const selNow = new Set(selectedSlides());
1877
+ card.className = `thumb-card ${idx === state.currentSlideIndex ? "active" : ""}${selNow.has(idx) ? " selected" : ""}`;
2323
1878
  card.dataset.idx = idx;
2324
1879
  card.title = `${idx + 1}. ${plainTitle(slide, idx)} — ${layoutLabel(slide.layout)}`;
2325
1880
 
@@ -2372,7 +1927,7 @@
2372
1927
  });
2373
1928
  card.appendChild(actions);
2374
1929
 
2375
- card.addEventListener("click", () => { selectSlide(idx); dom.thumbnailsList.focus({ preventScroll: true }); });
1930
+ card.addEventListener("click", (e) => { selectSlide(idx, { add: e.ctrlKey || e.metaKey, range: e.shiftKey }); dom.thumbnailsList.focus({ preventScroll: true }); });
2376
1931
  // arrastar para reordenar
2377
1932
  card.draggable = true;
2378
1933
  card.addEventListener("dragstart", (e) => {
@@ -2452,20 +2007,15 @@
2452
2007
  }
2453
2008
 
2454
2009
  function markActiveThumb() {
2010
+ const sel = new Set(selectedSlides());
2455
2011
  dom.thumbnailsList.querySelectorAll(".thumb-card").forEach((c) => {
2456
2012
  const on = +c.dataset.idx === state.currentSlideIndex;
2457
2013
  c.classList.toggle("active", on);
2014
+ c.classList.toggle("selected", sel.has(+c.dataset.idx));
2458
2015
  if (on) c.scrollIntoView({ block: "nearest" });
2459
2016
  });
2460
2017
  }
2461
2018
 
2462
- function selectSlide(idx) {
2463
- if (idx < 0 || idx >= state.deck.slides.length) return;
2464
- if (idx !== state.currentSlideIndex) state.editorStep = "all"; // outro slide: volta a mostrar tudo
2465
- state.currentSlideIndex = idx;
2466
- markActiveThumb();
2467
- renderCurrentSlide();
2468
- }
2469
2019
 
2470
2020
  function moveSlide(idx, dir) {
2471
2021
  moveSlideTo(idx, idx + dir);
@@ -2479,6 +2029,8 @@
2479
2029
  const item = state.deck.slides.splice(from, 1)[0];
2480
2030
  state.deck.slides.splice(to, 0, item);
2481
2031
  state.currentSlideIndex = state.deck.slides.indexOf(current);
2032
+ // a seleção acompanha os slides (o movido vai para `to`, os do meio andam um)
2033
+ state.selectedSlides = selectedSlides().map((i) => i === from ? to : from < to && i > from && i <= to ? i - 1 : from > to && i >= to && i < from ? i + 1 : i);
2482
2034
  syncDeckToServer();
2483
2035
  renderThumbnails();
2484
2036
  renderCurrentSlide();
@@ -2667,13 +2219,15 @@
2667
2219
  const at = state.currentSlideIndex + 1;
2668
2220
  state.deck.slides.splice(at, 0, newSlide);
2669
2221
  state.currentSlideIndex = at;
2222
+ state.selectedSlides = [at];
2670
2223
  syncDeckToServer();
2671
2224
  renderThumbnails();
2672
2225
  renderCurrentSlide();
2673
2226
  showToast(`Slide adicionado na posição ${at + 1}`);
2674
2227
  }
2675
2228
 
2676
- const { copyCurrentSlide, pasteCopiedSlide } = window.SagaSlideClipboard({ state, showToast, trackDeck, syncDeckToServer, renderThumbnails, renderCurrentSlide });
2229
+ const { copySelectedSlides, pasteCopiedSlide } = window.SagaSlideClipboard({ state, showToast, trackDeck, syncDeckToServer, renderThumbnails, renderCurrentSlide });
2230
+ const { selectedSlides, selectSlide, bindSlideListKeys } = window.SagaSlideSelect({ state, dom, markActiveThumb, renderCurrentSlide, copySelectedSlides, pasteCopiedSlide, deleteCurrentSlide });
2677
2231
  function duplicateCurrentSlide() {
2678
2232
  const cur = state.deck.slides[state.currentSlideIndex];
2679
2233
  if (!cur) return;
@@ -2681,66 +2235,36 @@
2681
2235
  const at = state.currentSlideIndex + 1;
2682
2236
  state.deck.slides.splice(at, 0, clone);
2683
2237
  state.currentSlideIndex = at;
2238
+ state.selectedSlides = [at];
2684
2239
  syncDeckToServer();
2685
2240
  renderThumbnails();
2686
2241
  renderCurrentSlide();
2687
2242
  showToast(`Slide duplicado na posição ${at + 1}`);
2688
2243
  }
2689
2244
 
2690
- // Delete/Backspace com o foco na lista de slides (à esquerda) exclui o slide selecionado. Só ali: digitando
2691
- // num campo ou mexendo num elemento do slide, as teclas continuam fazendo o que já faziam.
2692
- function bindSlideListKeys() {
2693
- document.getElementById("btn-copy-slide").onclick = copyCurrentSlide;
2694
- document.getElementById("btn-paste-slide").onclick = pasteCopiedSlide;
2695
- if (dom.thumbnailsList._keys) return;
2696
- dom.thumbnailsList._keys = true;
2697
- dom.thumbnailsList.tabIndex = 0;
2698
- dom.thumbnailsList.setAttribute("aria-label", "Slides (Delete exclui o selecionado)");
2699
- dom.thumbnailsList.addEventListener("keydown", (e) => {
2700
- if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest("input, textarea, select, [contenteditable]")) return;
2701
- // ↑/↓ (e Home/End) trocam de slide, como no PowerPoint; nunca rolam a visualização
2702
- if ((e.ctrlKey || e.metaKey) && ["c","v"].includes(e.key.toLowerCase())) { e.preventDefault(); e.key.toLowerCase()==="c" ? copyCurrentSlide() : pasteCopiedSlide(); return; }
2703
- const go = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -1, ArrowRight: 1 }[e.key];
2704
- if (go || e.key === "Home" || e.key === "End") {
2705
- e.preventDefault();
2706
- const last = state.deck.slides.length - 1;
2707
- const to = e.key === "Home" ? 0 : e.key === "End" ? last : Math.max(0, Math.min(last, state.currentSlideIndex + go));
2708
- if (to !== state.currentSlideIndex) selectSlide(to);
2709
- // rola só a lista (scrollIntoView rolaria também a tela em volta)
2710
- const card = dom.thumbnailsList.querySelector(`.thumb-card[data-idx="${to}"]`), list = dom.thumbnailsList;
2711
- if (card) {
2712
- const c = card.getBoundingClientRect(), l = list.getBoundingClientRect();
2713
- if (c.top < l.top) list.scrollTop -= l.top - c.top + 8;
2714
- else if (c.bottom > l.bottom) list.scrollTop += c.bottom - l.bottom + 8;
2715
- }
2716
- return;
2717
- }
2718
- if (e.key !== "Delete" && e.key !== "Backspace") return;
2719
- e.preventDefault();
2720
- deleteCurrentSlide();
2721
- dom.thumbnailsList.focus({ preventScroll: true });
2722
- });
2723
- }
2724
-
2725
2245
  function deleteCurrentSlide() {
2726
- if (state.deck.slides.length <= 1) {
2727
- showToast("Não é possível excluir o único slide da apresentação.");
2246
+ const sel = selectedSlides();
2247
+ if (state.deck.slides.length - sel.length < 1) {
2248
+ showToast(sel.length > 1 ? "Não é possível excluir todos os slides da apresentação." : "Não é possível excluir o único slide da apresentação.");
2728
2249
  return;
2729
2250
  }
2730
- const at = state.currentSlideIndex;
2731
- const [removed] = state.deck.slides.splice(at, 1);
2732
- state.currentSlideIndex = Math.min(at, state.deck.slides.length - 1);
2251
+ const removed = sel.map((i) => ({ idx: i, slide: state.deck.slides[i] }));
2252
+ state.deck.slides = state.deck.slides.filter((_, i) => !sel.includes(i));
2253
+ state.currentSlideIndex = Math.min(sel[0], state.deck.slides.length - 1);
2254
+ state.selectedSlides = [state.currentSlideIndex];
2733
2255
  syncDeckToServer();
2734
2256
  renderThumbnails();
2735
2257
  renderCurrentSlide();
2736
- // sem desfazer geral no editor: o aviso devolve o slide no mesmo lugar
2737
- showToast(`Slide ${at + 1} excluído.`, 8000, { label: "Desfazer", fn: () => {
2738
- state.deck.slides.splice(Math.min(at, state.deck.slides.length), 0, removed);
2739
- state.currentSlideIndex = at;
2258
+ // sem desfazer geral no editor: o aviso devolve os slides nos mesmos lugares
2259
+ const nome = sel.length > 1 ? `${sel.length} slides excluídos.` : `Slide ${sel[0] + 1} excluído.`;
2260
+ showToast(nome, 8000, { label: "Desfazer", fn: () => {
2261
+ for (const { idx, slide } of removed) state.deck.slides.splice(Math.min(idx, state.deck.slides.length), 0, slide);
2262
+ state.currentSlideIndex = sel[0];
2263
+ state.selectedSlides = [...sel];
2740
2264
  syncDeckToServer();
2741
2265
  renderThumbnails();
2742
2266
  renderCurrentSlide();
2743
- showToast(`Slide ${at + 1} de volta.`);
2267
+ showToast(sel.length > 1 ? `${sel.length} slides de volta.` : `Slide ${sel[0] + 1} de volta.`);
2744
2268
  } });
2745
2269
  }
2746
2270
 
@@ -3554,6 +3078,7 @@
3554
3078
  dom.aiDeckStyle.value = "";
3555
3079
  state.deckMaterials = [];
3556
3080
  renderDeckMaterials();
3081
+ dom.modalAiDeck.querySelector("details").open = false; // simples por padrão, sempre
3557
3082
  dom.modalAiDeck.classList.remove("hidden");
3558
3083
  dom.aiDeckBriefing.focus();
3559
3084
  }
@@ -3755,7 +3280,6 @@
3755
3280
  tick();
3756
3281
  }
3757
3282
 
3758
- const createProgressBubble = window.SagaChatProgress({ dom, appendChatMessage, hydrateIcons, getChatJob: () => chatJob });
3759
3283
 
3760
3284
  // ==========================================================================
3761
3285
  // ESTRUTURA DA TELA: faixa de opções, popovers, painel lateral, anotações
@@ -4407,6 +3931,11 @@ ${ta.value}`;
4407
3931
  requestAnimationFrame(() => state.autoFit && updateCanvasScale());
4408
3932
  }
4409
3933
 
3934
+ // Chat lateral com IA (chat.js): mesma API, agora em módulo próprio.
3935
+ const chatApi = window.SagaChat({ state, dom, escHtml, hydrateIcons, showToast, openPane, currentPane,
3936
+ streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus });
3937
+ const { handleChatSubmit, loadChatHistory, appendChatMessage, createProgressBubble, autoGrowChat, applyBrainstorm, addChatFile } = chatApi;
3938
+
4410
3939
  function setNotesVisible(on) {
4411
3940
  dom.notesBar.classList.toggle("hidden", !on);
4412
3941
  dom.btnNotesToggle.classList.toggle("active", on);
@@ -4533,16 +4062,23 @@ ${ta.value}`;
4533
4062
  const card = e.target.closest(".thumb-card"); if (!card) return;
4534
4063
  e.preventDefault();
4535
4064
  const idx = +card.dataset.idx;
4536
- selectSlide(idx);
4065
+ // na seleção múltipla, o clique direito foca sem desmarcar; fora dela, seleciona só este
4066
+ if (selectedSlides().includes(idx)) { state.currentSlideIndex = idx; markActiveThumb(); renderCurrentSlide(); }
4067
+ else selectSlide(idx);
4068
+ const sel = selectedSlides(), n = sel.length, varios = n > 1;
4069
+ const nome = (um, muitos) => (varios ? muitos.replace("{}", String(n)) : um);
4537
4070
  contextMenu(e, [
4071
+ { label: nome("Copiar slide", "Copiar {} slides"), ic: "copy", fn: () => copySelectedSlides() },
4072
+ { label: "Colar slide", ic: "clipboard-paste", fn: () => pasteCopiedSlide() },
4073
+ { sep: true },
4538
4074
  { label: "Novo slide depois deste", ic: "plus", fn: () => openSceneLibrary() },
4539
- { label: "Duplicar slide", ic: "copy", fn: () => duplicateCurrentSlide() },
4540
- { label: "Mover para cima", ic: "arrow-up", disabled: idx === 0, fn: () => moveSlide(idx, -1) },
4541
- { label: "Mover para baixo", ic: "arrow-down", disabled: idx === state.deck.slides.length - 1, fn: () => moveSlide(idx, 1) },
4075
+ { label: "Duplicar slide", ic: "copy", fn: () => duplicateCurrentSlide(), disabled: varios },
4076
+ { label: "Mover para cima", ic: "arrow-up", disabled: varios || idx === 0, fn: () => moveSlide(idx, -1) },
4077
+ { label: "Mover para baixo", ic: "arrow-down", disabled: varios || idx === state.deck.slides.length - 1, fn: () => moveSlide(idx, 1) },
4542
4078
  { label: "Pedir à IA para melhorar este slide", ic: "sparkles", fn: () => askAI(`Melhore o slide ${idx + 1}: `) },
4543
4079
  { label: "Apresentar a partir daqui", ic: "play", fn: () => startPresentation() },
4544
4080
  { sep: true },
4545
- { label: "Excluir slide", ic: "trash-2", danger: true, fn: () => deleteCurrentSlide() },
4081
+ { label: nome("Excluir slide", "Excluir {} slides"), ic: "trash-2", danger: true, fn: () => deleteCurrentSlide() },
4546
4082
  ]);
4547
4083
  });
4548
4084