sagadeck 1.4.0 → 1.5.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/README.md +4 -4
- package/bin/sagadeck.js +9 -1
- package/docs/ANDAMENTO.md +285 -0
- package/docs/REFERENCIA.md +2 -2
- package/package.json +1 -1
- package/src/ai/bench.js +132 -0
- package/src/ai/context.js +4 -2
- package/src/ai/critique.js +114 -0
- package/src/ai/deck-ai.js +141 -31
- package/src/ai/document-visuals.js +7 -3
- package/src/ai/progress.js +23 -0
- package/src/ai/quality.js +30 -2
- package/src/api-client.js +2 -1
- package/src/build.js +3 -0
- package/src/decision-lab.js +2 -1
- package/src/deck-file.js +2 -1
- package/src/elements.js +10 -3
- package/src/figures/charts.js +7 -4
- package/src/figures/diagrams.js +15 -1
- package/src/figures/ufmap.js +2 -1
- package/src/fs-retry.js +19 -0
- package/src/layouts.js +1 -1
- package/src/library.js +68 -16
- package/src/locale.js +18 -0
- package/src/mcp/server.js +2 -2
- package/src/motion.js +4 -2
- package/src/preferences.js +2 -1
- package/src/runtime/calc.js +3 -3
- package/src/runtime/decision-lab.js +1 -1
- package/src/runtime/runtime.js +2 -1
- package/src/studio/ai-chat-route.js +99 -3
- package/src/studio/code-version.js +35 -0
- package/src/studio/generate.js +53 -0
- package/src/studio/project.js +8 -7
- package/src/studio/public/app.js +7 -216
- package/src/studio/public/chat.js +71 -1
- package/src/studio/public/header-footer.js +140 -0
- package/src/studio/public/index.html +5 -0
- package/src/studio/public/library.html +1 -0
- package/src/studio/public/library.js +4 -3
- package/src/studio/public/preferences-ui.js +83 -0
- package/src/studio/public/provenance.js +13 -0
- package/src/studio/public/style.css +17 -0
- package/src/studio/public/ui-icons.js +1 -1
- package/src/studio/public/update-notice.js +25 -0
- package/src/studio/public-files.js +11 -1
- package/src/studio/server.js +20 -29
- package/src/studio/share-routes.js +2 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Impressão do código do sagadeck (caminho, data e tamanho de cada arquivo). O Studio guarda a de quando subiu e a
|
|
2
|
+
// página consulta de tempos em tempos: se o código mudou no disco (git pull, npm update, merge), o servidor que está
|
|
3
|
+
// rodando ainda é o velho e a página avisa "reinicie". Antes, um Studio aberto antes de uma atualização recebia
|
|
4
|
+
// arquivos novos do disco com o servidor velho e quebrava em silêncio (nenhum slide abria).
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
|
|
9
|
+
const CODE = /\.(m?js|html|css|json)$/i;
|
|
10
|
+
|
|
11
|
+
export function codeFingerprint(root) {
|
|
12
|
+
const h = crypto.createHash("sha1");
|
|
13
|
+
const walk = (dir) => {
|
|
14
|
+
let entries;
|
|
15
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
16
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
17
|
+
if (e.name === "node_modules" || e.name.startsWith(".")) continue;
|
|
18
|
+
const p = path.join(dir, e.name);
|
|
19
|
+
if (e.isDirectory()) walk(p);
|
|
20
|
+
else if (CODE.test(e.name)) { try { const st = fs.statSync(p); h.update(`${path.relative(root, p)}|${st.mtimeMs}|${st.size}\n`); } catch {} }
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
walk(root);
|
|
24
|
+
return h.digest("hex").slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// a que vale para o servidor: a de quando ele subiu × a de agora (recalculada no máximo a cada poucos segundos)
|
|
28
|
+
export function codeWatch(root, { every = 3000 } = {}) {
|
|
29
|
+
const started = codeFingerprint(root);
|
|
30
|
+
let now = started, at = Date.now();
|
|
31
|
+
return () => {
|
|
32
|
+
if (Date.now() - at > every) { now = codeFingerprint(root); at = Date.now(); }
|
|
33
|
+
return { started, now, changed: now !== started };
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Gerar uma apresentação pelo caminho do Studio ("Criar com IA" e chat de deck novo): Preferências, anexos guardados
|
|
2
|
+
// na pasta do deck (com o inventário visual), imagens, revisão pelos slides renderizados e pesquisa. Fica fora do
|
|
3
|
+
// servidor para a avaliação ao vivo (test/bench-live.test.js) medir exatamente o que a pessoa recebe.
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { generateDeck } from "../ai/deck-ai.js";
|
|
6
|
+
import { prepareDocumentMaterials } from "../ai/document-materials.js";
|
|
7
|
+
import { reviewExperience } from "../ai/quality.js";
|
|
8
|
+
import { stageBoard } from "../ai/progress.js";
|
|
9
|
+
import { loadPreferences } from "../preferences.js";
|
|
10
|
+
import { slideSnapshots, diagramCheck } from "./snapshot.js";
|
|
11
|
+
|
|
12
|
+
const DOCUMENT = /\.(pdf|docx?)$/i;
|
|
13
|
+
|
|
14
|
+
// b: o corpo do pedido (briefing, answer, theme, style, slides, duration, direction); materials: [{ name, text, detail, bytes }]
|
|
15
|
+
// O inventário do documento (a visão lendo página a página: minutos num paper) corre JUNTO com o resto: entender o
|
|
16
|
+
// pedido, pesquisar e a leitura crítica usam só o texto; a escrita espera o inventário. Parou antes (a IA perguntou,
|
|
17
|
+
// deu erro): o inventário é cancelado e a pasta fica livre.
|
|
18
|
+
export async function generateForStudio(b, { dir, materials = [], emit, prefs = loadPreferences().ia, prepare = prepareDocumentMaterials } = {}) {
|
|
19
|
+
const board = stageBoard((ev) => emit?.(ev));
|
|
20
|
+
const stop = new AbortController();
|
|
21
|
+
const withBytes = materials.some((m) => m.bytes);
|
|
22
|
+
const pending = withBytes ? board.run("Lendo o documento", (onProgress) => prepare(materials, dir, { onProgress, signal: stop.signal })) : null;
|
|
23
|
+
pending?.catch(() => {}); // quem espera é a geração; se ela parou antes, o erro do cancelamento não importa
|
|
24
|
+
// enquanto isso, o texto do documento (o binário fica com o inventário); `document` marca que é um documento
|
|
25
|
+
const text = materials.map(({ bytes, ...m }) => (bytes && DOCUMENT.test(m.name || "") ? { ...m, document: true } : m));
|
|
26
|
+
try {
|
|
27
|
+
const gen = await generateDeck(String(b.briefing || ""), {
|
|
28
|
+
ask: prefs.perguntar !== false && !b.answer, answer: b.answer ? String(b.answer).slice(0, 500) : "",
|
|
29
|
+
author: prefs.autor || "", language: prefs.idioma || "auto",
|
|
30
|
+
theme: b.theme || undefined,
|
|
31
|
+
style: b.style || undefined,
|
|
32
|
+
slides: Number(b.slides) || undefined,
|
|
33
|
+
duration: Number(b.duration) || undefined,
|
|
34
|
+
direction: b.direction || undefined,
|
|
35
|
+
materials: text,
|
|
36
|
+
pendingMaterials: pending,
|
|
37
|
+
board,
|
|
38
|
+
images: prefs.imagens !== false, // o briefing diz se quer imagens (e onde); Preferências podem desligar
|
|
39
|
+
imageOptions: { baseDir: dir, assetsDir: path.join(dir, "imagens") },
|
|
40
|
+
onEvent: emit,
|
|
41
|
+
drawCheck: diagramCheck,
|
|
42
|
+
reviewCheck: (deck, indices, o = {}) => reviewExperience({ ...deck, _dir: dir }, indices, { snapshot: slideSnapshots, onProgress: o.onProgress || emit, briefing: b.briefing }),
|
|
43
|
+
// pesquisa na web quando a IA decidir que precisa (Preferências › IA pode desligar; SAGADECK_WEB=0 no banco);
|
|
44
|
+
// as fontes lidas ficam em contexto/pesquisa/ do deck
|
|
45
|
+
research: prefs.pesquisa === false ? false : "auto",
|
|
46
|
+
researchDir: dir,
|
|
47
|
+
});
|
|
48
|
+
return gen;
|
|
49
|
+
} finally {
|
|
50
|
+
stop.abort();
|
|
51
|
+
await pending?.catch(() => {});
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/studio/project.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Protegida: só some junto com o projeto inteiro. Nada disso vai no pacote .sagadeck
|
|
7
7
|
// (src/package.js leva só o que o deck usa).
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
+
import { renameRetry } from "../fs-retry.js";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import crypto from "node:crypto";
|
|
11
12
|
import JSZip from "jszip";
|
|
@@ -82,7 +83,7 @@ export function ensureMeta(P) {
|
|
|
82
83
|
fs.mkdirSync(path.join(P.dir, CONTEXT), { recursive: true }); // à vista: é aqui que moram anexos, prints e planilhas
|
|
83
84
|
const conv = path.join(P.dir, META, "conversa.json"), legacy = P.file.replace(/\.ya?ml$/i, ".conversa.json");
|
|
84
85
|
// conversa antiga (ao lado do deck) passa para dentro do projeto
|
|
85
|
-
if (fs.existsSync(legacy) && !fs.existsSync(conv)) { try {
|
|
86
|
+
if (fs.existsSync(legacy) && !fs.existsSync(conv)) { try { renameRetry(legacy, conv); } catch {} }
|
|
86
87
|
return conv;
|
|
87
88
|
}
|
|
88
89
|
export const conversationFile = (P) => ensureMeta(P);
|
|
@@ -101,7 +102,7 @@ export function writeText(P, rel, text) {
|
|
|
101
102
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
102
103
|
const tmp = `${abs}.tmp-${process.pid}`;
|
|
103
104
|
fs.writeFileSync(tmp, String(text ?? ""), "utf8");
|
|
104
|
-
|
|
105
|
+
renameRetry(tmp, abs);
|
|
105
106
|
return { path: r };
|
|
106
107
|
}
|
|
107
108
|
export function createFile(P, dirRel, name, text = "") {
|
|
@@ -136,7 +137,7 @@ export function rename(P, fromRel, toName) {
|
|
|
136
137
|
const to = resolveIn(P, path.posix.join(path.posix.dirname(r), n));
|
|
137
138
|
if (fs.existsSync(to)) throw new Error(`Já existe "${n}" nessa pasta.`);
|
|
138
139
|
if (isMeta(relOf(P, to))) throw new Error(`A pasta ${META} é do Studio.`);
|
|
139
|
-
|
|
140
|
+
renameRetry(abs, to);
|
|
140
141
|
return { path: relOf(P, to) };
|
|
141
142
|
}
|
|
142
143
|
export function move(P, fromRel, toDirRel) {
|
|
@@ -146,7 +147,7 @@ export function move(P, fromRel, toDirRel) {
|
|
|
146
147
|
if (isMeta(d)) throw new Error(`A pasta ${META} é do Studio.`);
|
|
147
148
|
if (dest === abs || dest.startsWith(abs + path.sep)) throw new Error("Não dá para mover uma pasta para dentro dela mesma.");
|
|
148
149
|
const to = unique(path.join(dest, path.basename(abs)));
|
|
149
|
-
|
|
150
|
+
renameRetry(abs, to);
|
|
150
151
|
return { path: relOf(P, to) };
|
|
151
152
|
}
|
|
152
153
|
// apagar = mandar para a lixeira do projeto (.sagadeck/lixeira), de onde dá para restaurar
|
|
@@ -158,7 +159,7 @@ export function remove(P, rel) {
|
|
|
158
159
|
fs.mkdirSync(bin, { recursive: true });
|
|
159
160
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
160
161
|
const to = path.join(bin, `${stamp}__${r.replace(/\//g, "__")}`);
|
|
161
|
-
|
|
162
|
+
renameRetry(abs, to);
|
|
162
163
|
return { trashed: relOf(P, to) };
|
|
163
164
|
}
|
|
164
165
|
export function restore(P, trashedRel) {
|
|
@@ -167,7 +168,7 @@ export function restore(P, trashedRel) {
|
|
|
167
168
|
const orig = path.basename(abs).split("__").slice(1).join("/");
|
|
168
169
|
const to = unique(resolveIn(P, orig));
|
|
169
170
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
170
|
-
|
|
171
|
+
renameRetry(abs, to);
|
|
171
172
|
return { path: relOf(P, to) };
|
|
172
173
|
}
|
|
173
174
|
|
|
@@ -228,7 +229,7 @@ export function writeSheet(P, rel, rows) {
|
|
|
228
229
|
const clean = rows.map((r) => r.map((c) => String(c ?? "")));
|
|
229
230
|
while (clean.length > 1 && clean.at(-1).every((c) => c === "")) clean.pop(); // linha vazia no fim (o cursor passou dela) não vai para o arquivo
|
|
230
231
|
fs.writeFileSync(tmp, toCSV(clean, old));
|
|
231
|
-
|
|
232
|
+
renameRetry(tmp, abs);
|
|
232
233
|
return { path: relOf(P, abs), delimiter: old.delimiter };
|
|
233
234
|
}
|
|
234
235
|
|
package/src/studio/public/app.js
CHANGED
|
@@ -1907,6 +1907,8 @@
|
|
|
1907
1907
|
rv.title = slide.review.note || "";
|
|
1908
1908
|
card.appendChild(rv);
|
|
1909
1909
|
}
|
|
1910
|
+
const pv = window.SagaProvenance?.(slide, hydrateIcons); // origem do conteúdo (provenance.js)
|
|
1911
|
+
if (pv) card.appendChild(pv);
|
|
1910
1912
|
if (Array.isArray(slide.auto) && slide.auto.length) {
|
|
1911
1913
|
const badge = document.createElement("span");
|
|
1912
1914
|
badge.className = "thumb-auto";
|
|
@@ -3343,142 +3345,8 @@
|
|
|
3343
3345
|
}
|
|
3344
3346
|
}
|
|
3345
3347
|
|
|
3346
|
-
//
|
|
3347
|
-
|
|
3348
|
-
// --------------------------------------------------------------------------
|
|
3349
|
-
const HF_PRESETS = [
|
|
3350
|
-
{ id: "padrao", name: "Padrão", desc: "Título e número", footer: null, header: null },
|
|
3351
|
-
{ id: "autor", name: "Autor e evento", desc: "Autor · evento e 3 / 20", footer: { left: "{autor} · {evento}", right: "{n} / {total}" }, header: null },
|
|
3352
|
-
{ id: "evento", name: "Evento e data", desc: "Evento, data e número", footer: { left: "{evento}", center: "{data:DD MMM AAAA}", right: "{pagina}" }, header: null },
|
|
3353
|
-
{ id: "corp", name: "Corporativo", desc: "Área e Confidencial em cima; título, data e página embaixo",
|
|
3354
|
-
header: { left: "{depto}", right: "Confidencial" }, footer: { left: "{titulo}", center: "{data:MM/AAAA}", right: "{n} / {total}" } },
|
|
3355
|
-
{ id: "numero", name: "Só o número", desc: "Número discreto no canto", footer: { right: "{n}" }, header: null },
|
|
3356
|
-
{ id: "nenhum", name: "Nenhum", desc: "Slides limpos", footer: false, header: null },
|
|
3357
|
-
];
|
|
3358
|
-
const HF_TOKENS = [["{titulo}", "Título"], ["{autor}", "Autor"], ["{evento}", "Evento"], ["{depto}", "Departamento"], ["{data:DD/MM/AAAA}", "Data"],
|
|
3359
|
-
["{data:DD MMM AAAA}", "Data por extenso"], ["{pagina}", "Página (01)"], ["{n}", "Página (1)"], ["{total}", "Total"]];
|
|
3360
|
-
const HF_SLOTS = ["header.left", "header.center", "header.right", "footer.left", "footer.center", "footer.right"];
|
|
3361
|
-
const hfModal = () => document.getElementById("modal-hf");
|
|
3362
|
-
let hfLastInput = null;
|
|
3363
|
-
let hfTimer = null;
|
|
3364
|
-
|
|
3365
|
-
// footer/header do deck -> valores dos 6 campos (o padrão sem footer = título + número)
|
|
3366
|
-
function hfSlotsFrom(v, kind, deck) {
|
|
3367
|
-
if (v === false) return { left: "", center: "", right: "" };
|
|
3368
|
-
if (v == null) return kind === "footer" ? { left: "{titulo}", center: "", right: "{pagina}" } : { left: "", center: "", right: "" };
|
|
3369
|
-
if (typeof v === "string") return { left: v, center: "", right: "{pagina}" };
|
|
3370
|
-
return { left: v.left || "", center: v.center || "", right: v.right || "" };
|
|
3371
|
-
}
|
|
3372
|
-
|
|
3373
|
-
function hfFill(deckLike) {
|
|
3374
|
-
const m = hfModal();
|
|
3375
|
-
for (const k of ["author", "event", "department", "date"]) m.querySelector(`[data-deck="${k}"]`).value = deckLike[k] || "";
|
|
3376
|
-
for (const kind of ["header", "footer"]) {
|
|
3377
|
-
const v = hfSlotsFrom(deckLike[kind], kind, deckLike);
|
|
3378
|
-
for (const pos of ["left", "center", "right"]) m.querySelector(`[data-slot="${kind}.${pos}"]`).value = v[pos];
|
|
3379
|
-
}
|
|
3380
|
-
}
|
|
3381
|
-
|
|
3382
|
-
// o que os campos do modal significam como deck (footer/header "limpos": null = padrão, false = nenhum)
|
|
3383
|
-
function hfRead() {
|
|
3384
|
-
const m = hfModal();
|
|
3385
|
-
const out = {};
|
|
3386
|
-
for (const k of ["author", "event", "department", "date"]) out[k] = m.querySelector(`[data-deck="${k}"]`).value.trim();
|
|
3387
|
-
for (const kind of ["header", "footer"]) {
|
|
3388
|
-
const v = Object.fromEntries(["left", "center", "right"].map((p) => [p, m.querySelector(`[data-slot="${kind}.${p}"]`).value.trim()]));
|
|
3389
|
-
const empty = !v.left && !v.center && !v.right;
|
|
3390
|
-
if (kind === "footer" && v.left === "{titulo}" && !v.center && v.right === "{pagina}") out.footer = null;
|
|
3391
|
-
else if (empty) out[kind] = kind === "footer" ? false : null;
|
|
3392
|
-
else out[kind] = Object.fromEntries(Object.entries(v).filter(([, x]) => x));
|
|
3393
|
-
}
|
|
3394
|
-
return out;
|
|
3395
|
-
}
|
|
3396
|
-
|
|
3397
|
-
function hfApplyTo(deck, vals) {
|
|
3398
|
-
for (const [k, v] of Object.entries(vals)) {
|
|
3399
|
-
if (v == null || v === "") delete deck[k];
|
|
3400
|
-
else deck[k] = v;
|
|
3401
|
-
}
|
|
3402
|
-
return deck;
|
|
3403
|
-
}
|
|
3404
|
-
|
|
3405
|
-
function openHeaderFooter() {
|
|
3406
|
-
const m = hfModal();
|
|
3407
|
-
const presets = m.querySelector(".hf-presets");
|
|
3408
|
-
if (!presets.childElementCount) {
|
|
3409
|
-
for (const p of HF_PRESETS) {
|
|
3410
|
-
const b = document.createElement("button");
|
|
3411
|
-
b.type = "button";
|
|
3412
|
-
b.className = "hf-preset";
|
|
3413
|
-
b.dataset.preset = p.id;
|
|
3414
|
-
b.innerHTML = `<b></b><span></span>`;
|
|
3415
|
-
b.querySelector("b").textContent = p.name;
|
|
3416
|
-
b.querySelector("span").textContent = p.desc;
|
|
3417
|
-
b.onclick = () => {
|
|
3418
|
-
const cur = hfRead();
|
|
3419
|
-
hfFill({ ...cur, footer: p.footer, header: p.header });
|
|
3420
|
-
presets.querySelectorAll(".hf-preset").forEach((x) => x.classList.toggle("active", x === b));
|
|
3421
|
-
hfPreview();
|
|
3422
|
-
};
|
|
3423
|
-
presets.append(b);
|
|
3424
|
-
}
|
|
3425
|
-
const chips = m.querySelector(".hf-tokens");
|
|
3426
|
-
for (const [tok, label] of HF_TOKENS) {
|
|
3427
|
-
const c = document.createElement("button");
|
|
3428
|
-
c.type = "button";
|
|
3429
|
-
c.className = "hf-token";
|
|
3430
|
-
c.dataset.token = tok;
|
|
3431
|
-
c.textContent = label;
|
|
3432
|
-
c.title = tok;
|
|
3433
|
-
c.onmousedown = (e) => e.preventDefault(); // não rouba o foco do campo
|
|
3434
|
-
c.onclick = () => {
|
|
3435
|
-
const inp = hfLastInput || m.querySelector('[data-slot="footer.left"]');
|
|
3436
|
-
const a = inp.selectionStart ?? inp.value.length, z = inp.selectionEnd ?? a;
|
|
3437
|
-
inp.value = inp.value.slice(0, a) + tok + inp.value.slice(z);
|
|
3438
|
-
inp.focus();
|
|
3439
|
-
inp.setSelectionRange(a + tok.length, a + tok.length);
|
|
3440
|
-
hfPreview();
|
|
3441
|
-
};
|
|
3442
|
-
chips.append(c);
|
|
3443
|
-
}
|
|
3444
|
-
m.querySelectorAll("input").forEach((inp) => {
|
|
3445
|
-
inp.addEventListener("focus", () => { if (inp.dataset.slot) hfLastInput = inp; });
|
|
3446
|
-
inp.addEventListener("input", () => { clearTimeout(hfTimer); hfTimer = setTimeout(hfPreview, 250); });
|
|
3447
|
-
});
|
|
3448
|
-
}
|
|
3449
|
-
hfFill(state.deck);
|
|
3450
|
-
m.classList.remove("hidden");
|
|
3451
|
-
hfPreview();
|
|
3452
|
-
}
|
|
3453
|
-
|
|
3454
|
-
function closeHeaderFooter() {
|
|
3455
|
-
hfModal().classList.add("hidden");
|
|
3456
|
-
}
|
|
3457
|
-
|
|
3458
|
-
async function hfPreview() {
|
|
3459
|
-
const stage = hfModal().querySelector(".hf-stage");
|
|
3460
|
-
const spec = hfApplyTo(JSON.parse(JSON.stringify(state.deck)), hfRead());
|
|
3461
|
-
// na prévia, um slide que mostra rodapé (a capa não mostra)
|
|
3462
|
-
let i = state.currentSlideIndex;
|
|
3463
|
-
const NO_BARS = ["cover", "section", "end", "image", "canvas", "full", "headline"];
|
|
3464
|
-
if (NO_BARS.includes(spec.slides[i]?.layout)) i = Math.max(0, spec.slides.findIndex((s) => !NO_BARS.includes(s.layout)));
|
|
3465
|
-
try {
|
|
3466
|
-
const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
|
|
3467
|
-
body: JSON.stringify({ slide: spec.slides[i], index: i, spec }) })).json();
|
|
3468
|
-
stage.innerHTML = `<div class="thumb-render">${r.html}</div>`;
|
|
3469
|
-
requestAnimationFrame(() => fitRendered(stage));
|
|
3470
|
-
stage.style.setProperty("--thumb-scale", String(stage.clientWidth / 1920));
|
|
3471
|
-
} catch {}
|
|
3472
|
-
}
|
|
3473
|
-
|
|
3474
|
-
function applyHeaderFooter() {
|
|
3475
|
-
hfApplyTo(state.deck, hfRead());
|
|
3476
|
-
closeHeaderFooter();
|
|
3477
|
-
syncDeckToServer();
|
|
3478
|
-
renderCurrentSlide();
|
|
3479
|
-
renderThumbnails();
|
|
3480
|
-
showToast("Cabeçalho e rodapé aplicados em todos os slides", 2200);
|
|
3481
|
-
}
|
|
3348
|
+
// Cabeçalho e rodapé: modelos prontos, campos com variáveis e prévia (header-footer.js)
|
|
3349
|
+
const { openHeaderFooter, closeHeaderFooter, applyHeaderFooter } = window.SagaHeaderFooter({ state, fitRendered, syncDeckToServer, renderCurrentSlide, renderThumbnails, showToast });
|
|
3482
3350
|
|
|
3483
3351
|
// ---------------------------------------------------------------- slides de API
|
|
3484
3352
|
// Ambientes: o ~/.sagadeck/ambientes.yaml como texto, validado pelo Studio antes de gravar
|
|
@@ -3758,86 +3626,8 @@ ${ta.value}`;
|
|
|
3758
3626
|
document.querySelectorAll(".split-button.show, .file-menu-wrap.show").forEach((m) => { if (!m.contains(except)) m.classList.remove("show"); });
|
|
3759
3627
|
}
|
|
3760
3628
|
|
|
3761
|
-
//
|
|
3762
|
-
|
|
3763
|
-
// as do editor, neste navegador. Cada linha diz o que faz; gravar é automático.
|
|
3764
|
-
// ==========================================================================
|
|
3765
|
-
const PREFS_UI = [
|
|
3766
|
-
{ id: "texto", title: "Texto e código", items: [
|
|
3767
|
-
{ k: "texto.minCodePt", type: "num", unit: "pt", min: 6, max: 24, label: "Tamanho mínimo do código ao encolher", hint: "Código que não cabe encolhe até aqui (10 pt ≈ 20 px no slide). Se nem assim couber, ele rola na apresentação e o fiscal avisa." },
|
|
3768
|
-
{ k: "texto.minTextPt", type: "num", unit: "pt", min: 6, max: 30, label: "Tamanho mínimo do texto ao encolher", hint: "Títulos e textos que se ajustam para caber não ficam menores que isto." },
|
|
3769
|
-
{ k: "texto.wrapCode", type: "bool", label: "Quebrar linhas longas de código", hint: "Em vez de encolher o bloco todo por causa de uma linha comprida." },
|
|
3770
|
-
] },
|
|
3771
|
-
{ id: "ia", title: "Inteligência artificial", items: [
|
|
3772
|
-
{ k: "ia.pesquisa", type: "bool", label: "Pesquisar na web quando precisar", hint: "Ranking, lançamentos, preços, um artigo científico: a IA pesquisa, lê as fontes confiáveis e cita cada uma. Desligado (ou sem internet), ela usa só o que sabe e avisa." },
|
|
3773
|
-
{ k: "ia.perguntar", type: "bool", label: "Perguntar quando o pedido não disser para que serve o material", hint: "Ex.: um workshop sem dizer se o pessoal vai guardar o material. Desligado, a IA decide sozinha." },
|
|
3774
|
-
{ k: "ia.imagens", type: "bool", label: "Gerar imagens quando o pedido pedir", hint: "Desligado: só ícones, gráficos e diagramas do sagadeck (sem custo de imagem)." },
|
|
3775
|
-
{ k: "ia.autor", type: "text", label: "Seu nome", hint: "Vai na capa e no rodapé das apresentações novas (no lugar de \"Seu Nome\")." },
|
|
3776
|
-
{ k: "ia.idioma", type: "select", options: [["auto", "O do pedido"], ["português do Brasil", "Português (Brasil)"], ["inglês", "Inglês"], ["espanhol", "Espanhol"]], label: "Idioma do conteúdo gerado" },
|
|
3777
|
-
] },
|
|
3778
|
-
{ id: "exportacao", title: "Exportação", items: [
|
|
3779
|
-
{ k: "exportacao.pdfClaro", type: "bool", label: "PDF na versão clara do tema", hint: "Tema escuro com par claro (ex.: Manual noite) sai claro no PDF, melhor para imprimir." },
|
|
3780
|
-
] },
|
|
3781
|
-
{ id: "editor", title: "Editor (este navegador)", local: true, items: [
|
|
3782
|
-
{ k: "editor.theme", type: "select", options: [["system", "Automática (do sistema)"], ["light", "Clara"], ["dark", "Escura"]], label: "Tema da interface", hint: "Só a interface do Studio; o slide mantém o tema dele.",
|
|
3783
|
-
get: () => store.get("appTheme", "system"), set: (v) => setAppTheme(v) },
|
|
3784
|
-
{ k: "editor.guides", type: "bool", label: "Mostrar as guias da área segura", hint: "O retângulo pontilhado onde o conteúdo cabe sem cortar.",
|
|
3785
|
-
get: () => dom.chkGuides.checked, set: (v) => { dom.chkGuides.checked = v; dom.chkGuides.dispatchEvent(new Event("change", { bubbles: true })); } },
|
|
3786
|
-
{ k: "editor.inspect", type: "bool", label: "Marcar no slide os avisos do fiscal", hint: "Caixas em volta do texto fora da margem, código que não coube, sobreposição.",
|
|
3787
|
-
get: () => dom.chkInspectOverlay.checked, set: (v) => { dom.chkInspectOverlay.checked = v; dom.chkInspectOverlay.dispatchEvent(new Event("change", { bubbles: true })); } },
|
|
3788
|
-
] },
|
|
3789
|
-
];
|
|
3790
|
-
let prefsData = null, prefsTimer = null;
|
|
3791
|
-
const prefGet = (key) => { const [sec, k] = key.split("."); return prefsData?.[sec]?.[k]; };
|
|
3792
|
-
async function openPrefs() {
|
|
3793
|
-
const modal = document.getElementById("modal-prefs");
|
|
3794
|
-
modal.classList.remove("hidden");
|
|
3795
|
-
try {
|
|
3796
|
-
const r = await fetch("api/preferences"); const j = await r.json();
|
|
3797
|
-
prefsData = j.prefs; document.getElementById("prefs-file").textContent = j.file || "";
|
|
3798
|
-
} catch { prefsData = {}; }
|
|
3799
|
-
renderPrefs();
|
|
3800
|
-
const q = document.getElementById("prefs-search"); q.value = ""; q.focus();
|
|
3801
|
-
}
|
|
3802
|
-
function renderPrefs() {
|
|
3803
|
-
const nav = document.getElementById("prefs-nav"), list = document.getElementById("prefs-list");
|
|
3804
|
-
nav.innerHTML = PREFS_UI.map((sec) => `<button type="button" data-prefs-sec="${sec.id}">${escHtml(sec.title)}</button>`).join("");
|
|
3805
|
-
list.innerHTML = PREFS_UI.map((sec) => `<section class="prefs-sec" data-prefs-sec="${sec.id}"><h4>${escHtml(sec.title)}</h4>${sec.items.map((it) => {
|
|
3806
|
-
const v = it.get ? it.get() : prefGet(it.k);
|
|
3807
|
-
const ctl = it.type === "bool" ? `<input type="checkbox" data-pref="${it.k}"${v ? " checked" : ""} aria-label="${escAttr(it.label)}">`
|
|
3808
|
-
: it.type === "select" ? `<select class="form-control" data-pref="${it.k}" aria-label="${escAttr(it.label)}">${it.options.map(([ov, ol]) => `<option value="${ov}"${String(v) === ov ? " selected" : ""}>${escHtml(ol)}</option>`).join("")}</select>`
|
|
3809
|
-
: it.type === "num" ? `<span class="pref-num"><input type="number" class="form-control" data-pref="${it.k}" value="${escAttr(v ?? "")}" min="${it.min}" max="${it.max}" step="1" aria-label="${escAttr(it.label)}"><span>${it.unit || ""}</span></span>`
|
|
3810
|
-
: `<input type="text" class="form-control" data-pref="${it.k}" value="${escAttr(v ?? "")}" aria-label="${escAttr(it.label)}">`;
|
|
3811
|
-
return `<div class="pref-row" data-search="${escAttr(fold(`${sec.title} ${it.label} ${it.hint || ""}`))}"><div class="pref-label"><b>${escHtml(it.label)}</b>${it.hint ? `<small>${escHtml(it.hint)}</small>` : ""}</div><div class="pref-ctl">${ctl}</div></div>`;
|
|
3812
|
-
}).join("")}</section>`).join("");
|
|
3813
|
-
nav.querySelectorAll("[data-prefs-sec]").forEach((b) => b.onclick = () => list.querySelector(`section[data-prefs-sec="${b.dataset.prefsSec}"]`)?.scrollIntoView({ block: "start" }));
|
|
3814
|
-
list.querySelectorAll("[data-pref]").forEach((el) => el.addEventListener("change", () => changePref(el)));
|
|
3815
|
-
}
|
|
3816
|
-
function changePref(el) {
|
|
3817
|
-
const key = el.dataset.pref, it = PREFS_UI.flatMap((s) => s.items).find((x) => x.k === key);
|
|
3818
|
-
const value = el.type === "checkbox" ? el.checked : it.type === "num" ? Number(el.value) : el.value;
|
|
3819
|
-
const status = document.getElementById("prefs-status");
|
|
3820
|
-
if (it.set) { it.set(value); status.textContent = "Salvo neste navegador"; return; }
|
|
3821
|
-
const [sec, k] = key.split(".");
|
|
3822
|
-
(prefsData[sec] ||= {})[k] = value;
|
|
3823
|
-
clearTimeout(prefsTimer);
|
|
3824
|
-
status.textContent = "Salvando…";
|
|
3825
|
-
prefsTimer = setTimeout(async () => {
|
|
3826
|
-
try {
|
|
3827
|
-
const r = await fetch("api/preferences", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prefs: { [sec]: prefsData[sec] } }) });
|
|
3828
|
-
const j = await r.json(); if (!r.ok) throw new Error(j.error || r.status);
|
|
3829
|
-
prefsData = j.prefs;
|
|
3830
|
-
const shown = document.querySelector(`#prefs-list [data-pref="${key}"]`); if (shown && shown.type === "number") shown.value = prefGet(key); // voltou para a faixa aceita
|
|
3831
|
-
status.textContent = "Salvo";
|
|
3832
|
-
renderCurrentSlide(); renderThumbnails(); // os mínimos do ajuste para caber mudam o desenho
|
|
3833
|
-
} catch (e) { status.textContent = "Não salvou: " + e.message; }
|
|
3834
|
-
}, 250);
|
|
3835
|
-
}
|
|
3836
|
-
function filterPrefs() {
|
|
3837
|
-
const q = fold(document.getElementById("prefs-search").value.trim());
|
|
3838
|
-
document.querySelectorAll("#prefs-list .pref-row").forEach((r) => { r.hidden = !!q && !r.dataset.search.includes(q); });
|
|
3839
|
-
document.querySelectorAll("#prefs-list .prefs-sec").forEach((sec) => { sec.hidden = !sec.querySelector(".pref-row:not([hidden])"); });
|
|
3840
|
-
}
|
|
3629
|
+
// Preferências: tela única com busca (preferences-ui.js)
|
|
3630
|
+
const { openPrefs, filterPrefs } = window.SagaPrefs({ dom, store, escHtml, escAttr, fold, setAppTheme, renderCurrentSlide, renderThumbnails });
|
|
3841
3631
|
|
|
3842
3632
|
function syncMotionMenu() {
|
|
3843
3633
|
const cur = state.deck?.motion || "subtle";
|
|
@@ -4300,6 +4090,7 @@ ${ta.value}`;
|
|
|
4300
4090
|
if (files.length) { e.preventDefault(); files.forEach(addChatImage); }
|
|
4301
4091
|
});
|
|
4302
4092
|
dom.chatAttach.onclick = () => dom.chatAttachInput.click();
|
|
4093
|
+
document.getElementById("chat-critique").onclick = () => chatApi.runCritique();
|
|
4303
4094
|
dom.chatAttachInput.onchange = () => { [...dom.chatAttachInput.files].forEach(addChatFile); dom.chatAttachInput.value = ""; };
|
|
4304
4095
|
|
|
4305
4096
|
// Chips de Sugestões de Prompt do Chat
|
|
@@ -451,9 +451,79 @@ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, open
|
|
|
451
451
|
dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
|
|
452
452
|
return msgDiv;
|
|
453
453
|
}
|
|
454
|
+
// Leitura crítica do material anexado (botão do chat): o servidor lê o documento como um revisor da área e devolve
|
|
455
|
+
// inconsistências, achados escondidos, perguntas prováveis e slides que representam mal o material, cada item com
|
|
456
|
+
// trecho conferido. Nada muda nos slides; vira uma mensagem da conversa com opções para aplicar.
|
|
457
|
+
async function runCritique() {
|
|
458
|
+
if (chatJob) return;
|
|
459
|
+
appendChatMessage("user", "Leitura crítica do material");
|
|
460
|
+
const job = chatJob = { file: state.file, id: crypto.randomUUID(), controller: new AbortController() };
|
|
461
|
+
job.stop = () => job.controller.abort();
|
|
462
|
+
const work = createProgressBubble("Lendo o material com olho crítico…");
|
|
463
|
+
try {
|
|
464
|
+
const data = await streamAI("api/ai/critique", { spec: state.deck, requestId: job.id }, (ev) => work.update(ev), job.controller.signal);
|
|
465
|
+
state.chatHistory.push({ role: "user", text: "Leitura crítica do material" });
|
|
466
|
+
state.chatHistory.push({ role: "assistant", text: data.reply + (data.options?.length ? `\n(opções: ${data.options.join(" | ")})` : ""), talk: true });
|
|
467
|
+
await saveChatHistory();
|
|
468
|
+
work.done();
|
|
469
|
+
// com os pontos estruturados, a bolha traz o resumo e cada ponto vira um cartão (o texto inteiro fica no histórico)
|
|
470
|
+
const msg = appendChatMessage("ai", data.critique?.itens?.length ? data.critique.summary : data.reply, data.actions || []);
|
|
471
|
+
msg.classList.add("bs");
|
|
472
|
+
if (data.critique?.itens?.length) renderCritiqueItems(msg, data.critique);
|
|
473
|
+
const tag = document.createElement("div");
|
|
474
|
+
tag.className = "bs-tag";
|
|
475
|
+
tag.innerHTML = '<i class="ic" data-ic="scan-search"></i> Leitura crítica — nada mudou nos slides'; hydrateIcons(tag);
|
|
476
|
+
msg.querySelector(".ai-content").prepend(tag);
|
|
477
|
+
renderChatOptions(msg, data.options);
|
|
478
|
+
updateBrainstormApply();
|
|
479
|
+
} catch (e) {
|
|
480
|
+
work.done();
|
|
481
|
+
appendChatMessage("ai", `A leitura crítica não saiu: ${e.message}`);
|
|
482
|
+
} finally { chatJob = null; }
|
|
483
|
+
}
|
|
484
|
+
// Cada ponto da leitura crítica como cartão: o slide a que ele se liga (clique leva até lá), Aplicar (pede à IA só
|
|
485
|
+
// aquele ponto, mirando aquele slide; a autoria decide se vai ao slide ou às notes) e Ignorar (gravado: o ponto deixa
|
|
486
|
+
// de acompanhar os pedidos).
|
|
487
|
+
const CRIT_LABEL = { destaque: "Achado", inconsistencia: "Inconsistência", slide: "Slide a rever", pergunta: "Pergunta provável", limitacao: "Limitação", forte: "Ponto forte" };
|
|
488
|
+
function goToSlide(n) {
|
|
489
|
+
if (!(n >= 1 && n <= state.deck.slides.length)) return;
|
|
490
|
+
state.currentSlideIndex = n - 1;
|
|
491
|
+
renderThumbnails(); renderCurrentSlide();
|
|
492
|
+
}
|
|
493
|
+
async function markCritique(id, status) {
|
|
494
|
+
try { await fetch("api/ai/critique/item", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, status }) }); } catch { /* fica só na tela */ }
|
|
495
|
+
}
|
|
496
|
+
function renderCritiqueItems(msg, critique) {
|
|
497
|
+
const list = document.createElement("div");
|
|
498
|
+
list.className = "crit-list";
|
|
499
|
+
for (const it of critique.itens) {
|
|
500
|
+
const card = document.createElement("div");
|
|
501
|
+
card.className = `crit-item crit-${it.tipo}`;
|
|
502
|
+
card.dataset.id = it.id;
|
|
503
|
+
card.innerHTML = `<div class="crit-head"><span class="crit-kind"></span>${it.slide ? `<button type="button" class="crit-slide" title="Ir ao slide ${it.slide}">Slide ${it.slide}</button>` : ""}</div><b class="crit-title"></b><div class="crit-text"></div>${it.trecho ? '<blockquote class="crit-quote"></blockquote>' : ""}<div class="crit-actions"><button type="button" class="bs-option crit-apply"><i class="ic" data-ic="check"></i> Aplicar${it.slide ? ` no slide ${it.slide}` : ""}</button><button type="button" class="bs-option crit-ignore"><i class="ic" data-ic="x"></i> Ignorar</button></div>`;
|
|
504
|
+
card.querySelector(".crit-kind").textContent = CRIT_LABEL[it.tipo] || it.tipo;
|
|
505
|
+
card.querySelector(".crit-title").textContent = it.titulo;
|
|
506
|
+
card.querySelector(".crit-text").textContent = `${it.texto}${it.onde ? ` (${it.onde})` : ""}`;
|
|
507
|
+
if (it.trecho) card.querySelector(".crit-quote").textContent = `"${it.trecho}"`;
|
|
508
|
+
const done = (label) => { card.classList.add("crit-done"); card.querySelector(".crit-actions").innerHTML = `<span class="crit-status">${label}</span>`; };
|
|
509
|
+
card.querySelector(".crit-slide")?.addEventListener("click", () => goToSlide(it.slide));
|
|
510
|
+
card.querySelector(".crit-apply").onclick = () => {
|
|
511
|
+
if (chatJob) { showToast("Espere a IA terminar o pedido atual.", 3000); return; }
|
|
512
|
+
goToSlide(it.slide);
|
|
513
|
+
done(it.slide ? `Aplicado no slide ${it.slide}` : "Aplicado");
|
|
514
|
+
markCritique(it.id, "aplicado");
|
|
515
|
+
dom.chatInput.value = `Aplique este ponto da leitura crítica${it.slide ? ` no slide ${it.slide}` : ", no slide em que ele se encaixa"}: ${it.titulo}. ${it.texto}${it.trecho ? ` (trecho do material: "${it.trecho}")` : ""}. Siga a autoria da apresentação: achado do material pode ir para o slide; crítica, inconsistência e pergunta vão para as notes ("Prepare-se: …"), salvo se a apresentação for livre.`;
|
|
516
|
+
handleChatSubmit();
|
|
517
|
+
};
|
|
518
|
+
card.querySelector(".crit-ignore").onclick = () => { done("Ignorado"); markCritique(it.id, "ignorado"); };
|
|
519
|
+
list.append(card);
|
|
520
|
+
}
|
|
521
|
+
hydrateIcons(list);
|
|
522
|
+
msg.querySelector(".ai-content").append(list);
|
|
523
|
+
}
|
|
454
524
|
// Bolha de progresso do chat e de outros fluxos (transformação usa a mesma).
|
|
455
525
|
const createProgressBubble = window.SagaChatProgress({ dom, appendChatMessage, hydrateIcons, getChatJob });
|
|
456
|
-
const api = { getChatJob, handleChatSubmit, loadChatHistory, saveChatHistory, appendChatMessage,
|
|
526
|
+
const api = { getChatJob, handleChatSubmit, runCritique, loadChatHistory, saveChatHistory, appendChatMessage,
|
|
457
527
|
createProgressBubble, commandEvent, updateBrainstormApply, renderVariants, applyBrainstorm,
|
|
458
528
|
renderChatOptions, clearChatAttachments, renderChatAttachments, addChatImage, addChatDoc,
|
|
459
529
|
addChatFile, autoGrowChat, commandCard };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Cabeçalho e rodapé do deck: modelos prontos, campos com variáveis ({autor}, {data:…}, {n}) e prévia do slide atual.
|
|
2
|
+
// Saiu do app.js pela trava de tamanho dos monolitos; o comportamento é o mesmo.
|
|
3
|
+
// Fábrica no molde de slide-select.js: recebe o estado e os ajudantes, devolve a API.
|
|
4
|
+
window.SagaHeaderFooter = function ({ state, fitRendered, syncDeckToServer, renderCurrentSlide, renderThumbnails, showToast }) {
|
|
5
|
+
const HF_PRESETS = [
|
|
6
|
+
{ id: "padrao", name: "Padrão", desc: "Título e número", footer: null, header: null },
|
|
7
|
+
{ id: "autor", name: "Autor e evento", desc: "Autor · evento e 3 / 20", footer: { left: "{autor} · {evento}", right: "{n} / {total}" }, header: null },
|
|
8
|
+
{ id: "evento", name: "Evento e data", desc: "Evento, data e número", footer: { left: "{evento}", center: "{data:DD MMM AAAA}", right: "{pagina}" }, header: null },
|
|
9
|
+
{ id: "corp", name: "Corporativo", desc: "Área e Confidencial em cima; título, data e página embaixo",
|
|
10
|
+
header: { left: "{depto}", right: "Confidencial" }, footer: { left: "{titulo}", center: "{data:MM/AAAA}", right: "{n} / {total}" } },
|
|
11
|
+
{ id: "numero", name: "Só o número", desc: "Número discreto no canto", footer: { right: "{n}" }, header: null },
|
|
12
|
+
{ id: "nenhum", name: "Nenhum", desc: "Slides limpos", footer: false, header: null },
|
|
13
|
+
];
|
|
14
|
+
const HF_TOKENS = [["{titulo}", "Título"], ["{autor}", "Autor"], ["{evento}", "Evento"], ["{depto}", "Departamento"], ["{data:DD/MM/AAAA}", "Data"],
|
|
15
|
+
["{data:DD MMM AAAA}", "Data por extenso"], ["{pagina}", "Página (01)"], ["{n}", "Página (1)"], ["{total}", "Total"]];
|
|
16
|
+
const HF_SLOTS = ["header.left", "header.center", "header.right", "footer.left", "footer.center", "footer.right"];
|
|
17
|
+
const hfModal = () => document.getElementById("modal-hf");
|
|
18
|
+
let hfLastInput = null;
|
|
19
|
+
let hfTimer = null;
|
|
20
|
+
|
|
21
|
+
// footer/header do deck -> valores dos 6 campos (o padrão sem footer = título + número)
|
|
22
|
+
function hfSlotsFrom(v, kind, deck) {
|
|
23
|
+
if (v === false) return { left: "", center: "", right: "" };
|
|
24
|
+
if (v == null) return kind === "footer" ? { left: "{titulo}", center: "", right: "{pagina}" } : { left: "", center: "", right: "" };
|
|
25
|
+
if (typeof v === "string") return { left: v, center: "", right: "{pagina}" };
|
|
26
|
+
return { left: v.left || "", center: v.center || "", right: v.right || "" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hfFill(deckLike) {
|
|
30
|
+
const m = hfModal();
|
|
31
|
+
for (const k of ["author", "event", "department", "date"]) m.querySelector(`[data-deck="${k}"]`).value = deckLike[k] || "";
|
|
32
|
+
for (const kind of ["header", "footer"]) {
|
|
33
|
+
const v = hfSlotsFrom(deckLike[kind], kind, deckLike);
|
|
34
|
+
for (const pos of ["left", "center", "right"]) m.querySelector(`[data-slot="${kind}.${pos}"]`).value = v[pos];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// o que os campos do modal significam como deck (footer/header "limpos": null = padrão, false = nenhum)
|
|
39
|
+
function hfRead() {
|
|
40
|
+
const m = hfModal();
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const k of ["author", "event", "department", "date"]) out[k] = m.querySelector(`[data-deck="${k}"]`).value.trim();
|
|
43
|
+
for (const kind of ["header", "footer"]) {
|
|
44
|
+
const v = Object.fromEntries(["left", "center", "right"].map((p) => [p, m.querySelector(`[data-slot="${kind}.${p}"]`).value.trim()]));
|
|
45
|
+
const empty = !v.left && !v.center && !v.right;
|
|
46
|
+
if (kind === "footer" && v.left === "{titulo}" && !v.center && v.right === "{pagina}") out.footer = null;
|
|
47
|
+
else if (empty) out[kind] = kind === "footer" ? false : null;
|
|
48
|
+
else out[kind] = Object.fromEntries(Object.entries(v).filter(([, x]) => x));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hfApplyTo(deck, vals) {
|
|
54
|
+
for (const [k, v] of Object.entries(vals)) {
|
|
55
|
+
if (v == null || v === "") delete deck[k];
|
|
56
|
+
else deck[k] = v;
|
|
57
|
+
}
|
|
58
|
+
return deck;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function openHeaderFooter() {
|
|
62
|
+
const m = hfModal();
|
|
63
|
+
const presets = m.querySelector(".hf-presets");
|
|
64
|
+
if (!presets.childElementCount) {
|
|
65
|
+
for (const p of HF_PRESETS) {
|
|
66
|
+
const b = document.createElement("button");
|
|
67
|
+
b.type = "button";
|
|
68
|
+
b.className = "hf-preset";
|
|
69
|
+
b.dataset.preset = p.id;
|
|
70
|
+
b.innerHTML = `<b></b><span></span>`;
|
|
71
|
+
b.querySelector("b").textContent = p.name;
|
|
72
|
+
b.querySelector("span").textContent = p.desc;
|
|
73
|
+
b.onclick = () => {
|
|
74
|
+
const cur = hfRead();
|
|
75
|
+
hfFill({ ...cur, footer: p.footer, header: p.header });
|
|
76
|
+
presets.querySelectorAll(".hf-preset").forEach((x) => x.classList.toggle("active", x === b));
|
|
77
|
+
hfPreview();
|
|
78
|
+
};
|
|
79
|
+
presets.append(b);
|
|
80
|
+
}
|
|
81
|
+
const chips = m.querySelector(".hf-tokens");
|
|
82
|
+
for (const [tok, label] of HF_TOKENS) {
|
|
83
|
+
const c = document.createElement("button");
|
|
84
|
+
c.type = "button";
|
|
85
|
+
c.className = "hf-token";
|
|
86
|
+
c.dataset.token = tok;
|
|
87
|
+
c.textContent = label;
|
|
88
|
+
c.title = tok;
|
|
89
|
+
c.onmousedown = (e) => e.preventDefault(); // não rouba o foco do campo
|
|
90
|
+
c.onclick = () => {
|
|
91
|
+
const inp = hfLastInput || m.querySelector('[data-slot="footer.left"]');
|
|
92
|
+
const a = inp.selectionStart ?? inp.value.length, z = inp.selectionEnd ?? a;
|
|
93
|
+
inp.value = inp.value.slice(0, a) + tok + inp.value.slice(z);
|
|
94
|
+
inp.focus();
|
|
95
|
+
inp.setSelectionRange(a + tok.length, a + tok.length);
|
|
96
|
+
hfPreview();
|
|
97
|
+
};
|
|
98
|
+
chips.append(c);
|
|
99
|
+
}
|
|
100
|
+
m.querySelectorAll("input").forEach((inp) => {
|
|
101
|
+
inp.addEventListener("focus", () => { if (inp.dataset.slot) hfLastInput = inp; });
|
|
102
|
+
inp.addEventListener("input", () => { clearTimeout(hfTimer); hfTimer = setTimeout(hfPreview, 250); });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
hfFill(state.deck);
|
|
106
|
+
m.classList.remove("hidden");
|
|
107
|
+
hfPreview();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function closeHeaderFooter() {
|
|
111
|
+
hfModal().classList.add("hidden");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function hfPreview() {
|
|
115
|
+
const stage = hfModal().querySelector(".hf-stage");
|
|
116
|
+
const spec = hfApplyTo(JSON.parse(JSON.stringify(state.deck)), hfRead());
|
|
117
|
+
// na prévia, um slide que mostra rodapé (a capa não mostra)
|
|
118
|
+
let i = state.currentSlideIndex;
|
|
119
|
+
const NO_BARS = ["cover", "section", "end", "image", "canvas", "full", "headline"];
|
|
120
|
+
if (NO_BARS.includes(spec.slides[i]?.layout)) i = Math.max(0, spec.slides.findIndex((s) => !NO_BARS.includes(s.layout)));
|
|
121
|
+
try {
|
|
122
|
+
const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
|
|
123
|
+
body: JSON.stringify({ slide: spec.slides[i], index: i, spec }) })).json();
|
|
124
|
+
stage.innerHTML = `<div class="thumb-render">${r.html}</div>`;
|
|
125
|
+
requestAnimationFrame(() => fitRendered(stage));
|
|
126
|
+
stage.style.setProperty("--thumb-scale", String(stage.clientWidth / 1920));
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applyHeaderFooter() {
|
|
131
|
+
hfApplyTo(state.deck, hfRead());
|
|
132
|
+
closeHeaderFooter();
|
|
133
|
+
syncDeckToServer();
|
|
134
|
+
renderCurrentSlide();
|
|
135
|
+
renderThumbnails();
|
|
136
|
+
showToast("Cabeçalho e rodapé aplicados em todos os slides", 2200);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { openHeaderFooter, closeHeaderFooter, applyHeaderFooter };
|
|
140
|
+
};
|