ts6-botflow/public/flow-editor.js
Bxio 1be8a45dde
All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 44s
Expand flow editor with TeamSpeak events, permissions and execution controls
2026-10-10 20:38:31 +01:00

703 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const labels = {
out: "Seguinte",
error: "Erro",
true: "Sim",
false: "Não",
each: "Cada",
done: "Fim",
asc: "Crescente",
desc: "Decrescente",
equals: "Igual",
notEquals: "Diferente",
greater: "Maior",
less: "Menor",
atLeast: "Maior ou igual",
atMost: "Menor ou igual",
contains: "Contém",
startsWith: "Começa com",
endsWith: "Termina com",
exists: "Existe",
client: "Cliente",
channel: "Canal",
server: "Servidor",
temp: "Temporária",
persistent: "Persistente",
set: "Definir",
increment: "Somar",
append: "Acrescentar",
delete: "Apagar",
text: "Texto",
number: "Número",
boolean: "Booleano",
json: "JSON",
mixed: "Letras e números",
numbers: "Números",
letters: "Letras",
};
const id = () => `n${Date.now()}${Math.floor(Math.random() * 100000)}`;
const token = () =>
Array.from(crypto.getRandomValues(new Uint8Array(24)), (b) =>
b.toString(16).padStart(2, "0"),
).join("");
const clone = structuredClone;
export function mountEditor(bot, helpers) {
const {
api,
data,
owner,
dis,
icon,
esc,
heading,
toast,
refresh,
showRun,
onDirty,
onBot,
} = helpers;
let draft = clone(bot),
selected = draft.graph.nodes[0]?.id,
zoom = 0.85,
pending = null,
busy = false,
history = [clone(draft.graph)],
cursor = 0,
eventFixture = {};
const resources = new Map(),
types = data.nodeTypes,
q = (s) => document.querySelector(s);
const active = () => draft.graph.nodes.find((n) => n.id === selected),
outputs = (n) => types[n.type].outputs;
const height = (n) => Math.max(76, outputs(n).length * 25 + 23),
portY = (n, p) =>
outputs(n).length === 1 ? 38 : 28 + outputs(n).indexOf(p) * 25;
const triggerOptions = () =>
draft.graph.nodes
.filter((n) => types[n.type].trigger)
.map((n) => `<option value="${esc(n.id)}">${esc(n.label)}</option>`)
.join("");
const tool = (name, title, content, write = false) =>
`<button class="icon-button" id="${name}" title="${title}" aria-label="${title}" ${write ? dis() : ""}>${content}</button>`;
q("#content").innerHTML =
heading(
"Editor de flows",
"Liga eventos, dados e ações para construir as tuas automações.",
`<select id="bot-select" aria-label="Bot">${data.bots.map((b) => `<option value="${b.id}" ${b.id === bot.id ? "selected" : ""}>${esc(b.name)}</option>`).join("")}</select><span class="badge ${bot.enabled ? "green" : "gray"}">${owner() ? (bot.enabled ? "Ativo" : "Rascunho") : "Só leitura"}</span>`,
) +
`
<div class="editor" id="editor">
<aside class="palette"><div class="panel-heading">Blocos <span class="badge gray">${Object.keys(types).length}</span></div><input id="search-block" placeholder="Pesquisar blocos…" aria-label="Pesquisar blocos">
${[...new Set(Object.values(types).map((t) => t.group))]
.map(
(group) =>
`<div class="palette-group"><span class="overline">${esc(group)}</span>${Object.entries(
types,
)
.filter(([, t]) => t.group === group)
.map(
([type, t]) =>
`<button class="palette-item ${t.color}" data-add="${type}" ${dis()}>${icon(t.trigger ? "clock" : t.command ? "channel" : t.group === "Dados" ? "data" : t.group === "Lógica" ? "flow" : "bot")}<span>${esc(t.title)}</span>${icon("plus")}</button>`,
)
.join("")}</div>`,
)
.join("")}</aside>
<div class="canvas-column">
<div class="canvas-toolbar"><div><strong>${esc(bot.name)}</strong><span id="save-state">Guardado</span></div><div class="editor-tools">
${tool("undo", "Desfazer (Ctrl+Z)", "↶", true)}${tool("redo", "Refazer (Ctrl+Y)", "↷", true)}${tool("duplicate-node", "Duplicar bloco (Ctrl+D)", icon("plus"), true)}${tool("arrange", "Organizar blocos", icon("flow"), true)}${tool("validate", "Validar flow", icon("check"), true)}
${tool("zoom-out", "Reduzir zoom", "−")}<span id="zoom-label">85%</span>${tool("zoom-in", "Aumentar zoom", "+")}${tool("fit", "Ajustar ao ecrã", icon("expand"))}${tool("fullscreen", "Ecrã inteiro", icon("expand"))}</div></div>
<div class="editor-context"><label>Servidor<select id="flow-connection" ${dis()}><option value="">Escolher ligação</option>${data.connections.map((c) => `<option value="${c.id}" ${draft.connectionId === c.id ? "selected" : ""}>${esc(c.name)}</option>`).join("")}</select></label><button class="text-button" id="load-resources" ${dis()}>Carregar listas do servidor</button></div>
<div class="canvas" id="canvas" tabindex="0" aria-label="Área do flow"><div id="world-space"><div class="world" id="world"><svg id="edges"></svg><div id="nodes"></div></div></div></div>
<div class="canvas-footer"><span id="graph-count"></span><span id="canvas-hint">Liga uma saída à entrada de outro bloco.</span></div>
</div>
<aside class="inspector"><div id="node-properties"></div><div class="inspector-actions"><button class="text-button" id="test-data" ${dis()}>Dados da simulação</button><label class="trigger-picker">Início do teste<select id="test-trigger" aria-label="Início do teste">${triggerOptions()}</select></label><button class="button secondary" id="simulate" ${dis()}>${icon("play")}Simular</button><button class="button primary" id="save-flow" ${dis()}>${icon("save")}Guardar</button><button class="text-button" id="run" ${dis()}>Executar no TeamSpeak →</button><button class="text-button danger" id="cancel-flow" hidden>Cancelar execução</button></div></aside>
</div>`;
const dirty = (record = true) => {
onDirty(true);
q("#save-state").textContent = "Por guardar";
q("#save-state").className = "unsaved";
if (record) {
const snapshot = JSON.stringify(draft.graph);
if (snapshot !== JSON.stringify(history[cursor])) {
history = history.slice(0, cursor + 1);
history.push(clone(draft.graph));
if (history.length > 80) history.shift();
cursor = history.length - 1;
}
}
updateTools();
};
function updateTools() {
q("#undo").disabled = !owner() || cursor === 0;
q("#redo").disabled = !owner() || cursor === history.length - 1;
q("#duplicate-node").disabled = !owner() || !active();
}
function undo(direction) {
if (
!owner() ||
cursor + direction < 0 ||
cursor + direction >= history.length
)
return;
cursor += direction;
draft.graph = clone(history[cursor]);
if (!active()) selected = draft.graph.nodes[0]?.id;
dirty(false);
render();
}
function sizing() {
const maxX = Math.max(800, ...draft.graph.nodes.map((n) => n.x + 290)),
maxY = Math.max(
600,
...draft.graph.nodes.map((n) => n.y + height(n) + 80),
);
q("#world").style.width = `${maxX}px`;
q("#world").style.height = `${maxY}px`;
q("#world-space").style.width = `${maxX * zoom}px`;
q("#world-space").style.height = `${maxY * zoom}px`;
q("#world").style.transform = `scale(${zoom})`;
q("#zoom-label").textContent = `${Math.round(zoom * 100)}%`;
}
function fit() {
const nodes = draft.graph.nodes;
if (!nodes.length) return;
const minX = Math.min(...nodes.map((n) => n.x)),
minY = Math.min(...nodes.map((n) => n.y)),
width = Math.max(...nodes.map((n) => n.x + 230)) - minX + 80,
totalHeight = Math.max(...nodes.map((n) => n.y + height(n))) - minY + 80;
zoom = Math.min(
1.2,
Math.max(
0.25,
Math.min(
q("#canvas").clientWidth / width,
q("#canvas").clientHeight / totalHeight,
),
),
);
sizing();
q("#canvas").scrollLeft = Math.max(0, (minX - 40) * zoom);
q("#canvas").scrollTop = Math.max(0, (minY - 40) * zoom);
}
function edges() {
q("#edges").innerHTML =
`<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0 0L10 5L0 10" fill="#9290b8"/></marker></defs>` +
draft.graph.edges
.map((e) => {
const a = draft.graph.nodes.find((n) => n.id === e.from),
b = draft.graph.nodes.find((n) => n.id === e.to);
if (!a || !b) return "";
const x = a.x + 230,
y = a.y + portY(a, e.port),
x2 = b.x,
y2 = b.y + height(b) / 2;
return `<path class="edge ${esc(e.port)}" d="M${x} ${y} C${x + 80} ${y},${x2 - 80} ${y2},${x2} ${y2}" marker-end="url(#arrow)"/>`;
})
.join("");
}
function add(type) {
const t = types[type],
config = clone(t.defaults);
if (type === "webhook") config.secret = token();
const canvas = q("#canvas"),
n = {
id: id(),
type,
label: t.title,
x: Math.round(canvas.scrollLeft / zoom + 80),
y: Math.round(
canvas.scrollTop / zoom + 80 + (draft.graph.nodes.length % 4) * 120,
),
config,
};
draft.graph.nodes.push(n);
selected = n.id;
dirty();
render();
}
function remove() {
if (!owner() || !active()) return;
draft.graph.nodes = draft.graph.nodes.filter((n) => n.id !== selected);
draft.graph.edges = draft.graph.edges.filter(
(e) => e.from !== selected && e.to !== selected,
);
selected = draft.graph.nodes[0]?.id;
dirty();
render();
}
function duplicate() {
if (!owner() || !active()) return;
const n = clone(active());
n.id = id();
n.x += 45;
n.y += 100;
n.label += " (cópia)";
if (n.type === "webhook") n.config.secret = token();
draft.graph.nodes.push(n);
selected = n.id;
dirty();
render();
}
function arrange() {
const rank = new Map(draft.graph.nodes.map((n) => [n.id, 0]));
for (let i = 0; i < draft.graph.nodes.length; i++) {
let changed = false;
for (const edge of draft.graph.edges) {
const next = rank.get(edge.from) + 1;
if (next > rank.get(edge.to)) {
rank.set(edge.to, next);
changed = true;
}
}
if (!changed) break;
if (i === draft.graph.nodes.length - 1) {
toast("Corrige o ciclo de ligações antes de organizar.", true);
return;
}
}
const rows = new Map();
for (const n of draft.graph.nodes) {
const level = rank.get(n.id),
row = rows.get(level) || 0;
n.x = 60 + level * 310;
n.y = 60 + row * 145;
rows.set(level, row + 1);
}
dirty();
render();
fit();
}
function render() {
q("#nodes").innerHTML = draft.graph.nodes
.map((n) => {
const t = types[n.type],
ports = outputs(n);
return `<div class="flow-node ${t.color} ${selected === n.id ? "selected" : ""}" data-node="${esc(n.id)}" style="left:${n.x}px;top:${n.y}px;height:${height(n)}px">
${!t.trigger ? `<button class="port input" style="top:${height(n) / 2 - 5}px" data-input="${n.id}" aria-label="Entrada de ${esc(n.label)}" ${dis()}></button>` : ""}
<div class="node-head">${icon(t.trigger ? "clock" : t.group === "Lógica" ? "flow" : "channel")}<strong>${esc(n.label)}</strong></div><div class="node-subtitle">${esc(n.type === "timer" ? `A cada ${n.config.seconds}s` : n.type === "cron" ? n.config.expression : t.title)}</div>
${ports.map((p) => `<button class="port output ${p}" data-output="${n.id}" data-port="${p}" style="top:${portY(n, p)}px" aria-label="Saída ${labels[p]} de ${esc(n.label)}" ${dis()}></button>${ports.length > 1 ? `<span class="port-label ${p}" style="top:${portY(n, p) - 6}px">${labels[p]}</span>` : ""}`).join("")}</div>`;
})
.join("");
sizing();
edges();
inspect();
q("#graph-count").textContent =
`${draft.graph.nodes.length} blocos · ${draft.graph.edges.length} ligações`;
const oldTrigger = q("#test-trigger").value;
q("#test-trigger").innerHTML = triggerOptions();
if (
draft.graph.nodes.some(
(n) => n.id === oldTrigger && types[n.type].trigger,
)
)
q("#test-trigger").value = oldTrigger;
q("#nodes")
.querySelectorAll("[data-node]")
.forEach(
(el) =>
(el.onpointerdown = (e) => {
if (e.target.closest("button")) return;
const n = draft.graph.nodes.find((n) => n.id === el.dataset.node);
selected = n.id;
q("#nodes")
.querySelectorAll(".flow-node")
.forEach((b) => b.classList.toggle("selected", b === el));
inspect();
updateTools();
if (!owner()) return;
const sx = e.clientX,
sy = e.clientY,
x = n.x,
y = n.y;
el.setPointerCapture(e.pointerId);
let moved = false;
el.onpointermove = (ev) => {
const dx = (ev.clientX - sx) / zoom,
dy = (ev.clientY - sy) / zoom;
if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
n.x = Math.max(20, Math.round(x + dx));
n.y = Math.max(20, Math.round(y + dy));
el.style.left = `${n.x}px`;
el.style.top = `${n.y}px`;
edges();
};
const finish = () => {
el.onpointermove = null;
el.onpointerup = null;
el.onpointercancel = null;
if (moved) {
dirty();
render();
}
};
el.onpointerup = finish;
el.onpointercancel = finish;
}),
);
q("#nodes")
.querySelectorAll("[data-output]")
.forEach(
(b) =>
(b.onclick = () => {
pending = { from: b.dataset.output, port: b.dataset.port };
q("#canvas-hint").textContent =
`Saída ${labels[pending.port]}: escolhe a entrada de destino. Esc cancela.`;
}),
);
q("#nodes")
.querySelectorAll("[data-input]")
.forEach(
(b) =>
(b.onclick = () => {
if (!pending || pending.from === b.dataset.input) return;
const edge = { ...pending, to: b.dataset.input };
const reaches = (node, target, seen = new Set()) => {
if (node === target) return true;
if (seen.has(node)) return false;
seen.add(node);
return draft.graph.edges
.filter((e) => e.from === node)
.some((e) => reaches(e.to, target, seen));
};
if (reaches(edge.to, edge.from)) {
toast(
"Esta ligação criaria um ciclo. Usa o bloco Para cada item.",
true,
);
return;
}
if (
!draft.graph.edges.some(
(e) =>
e.from === edge.from &&
e.to === edge.to &&
e.port === edge.port,
)
) {
draft.graph.edges.push(edge);
dirty();
}
pending = null;
render();
q("#canvas-hint").textContent = "Ligação criada.";
}),
);
updateTools();
}
const resourceFor = {
client: ["clients", "clid", "client_nickname"],
dbclient: ["clients", "client_database_id", "client_nickname"],
channel: ["channels", "cid", "channel_name"],
serverGroup: ["groups", "sgid", "name"],
channelGroup: ["channelGroups", "cgid", "name"],
permission: ["permissions", "permname", "permdesc"],
};
function field(n, [key, label, input, values]) {
const value = n.config[key] ?? types[n.type].defaults[key] ?? "",
attrs = `data-field="${key}" ${owner() ? "" : "readonly"}`;
if (n.type === "message" && key === "target")
input = n.config.mode === "channel" ? "channel" : "client";
if (input === "select" || input === "queryCommand")
return `<label>${esc(label)}<select data-field="${key}" ${dis()}>${(values || data.queryCommands).map((v) => `<option value="${esc(v)}" ${String(value) === String(v) ? "selected" : ""}>${esc(labels[v] || v)}</option>`).join("")}</select></label>`;
if (input === "textarea" || input === "json")
return `<label>${esc(label)}<textarea ${attrs} ${input === "json" ? 'data-json spellcheck="false"' : ""}>${esc(input === "json" ? (typeof value === "string" ? value : JSON.stringify(value, null, 2)) : value)}</textarea></label>`;
const definition = resourceFor[input],
list = definition
? resources.get(draft.connectionId)?.[definition[0]] || []
: [];
const suggestions = definition
? `<datalist id="list-${key}">${list
.filter((item) => item[definition[1]] != null)
.map(
(item) =>
`<option value="${esc(item[definition[1]])}">${esc(item[definition[2]] || item[definition[1]])}</option>`,
)
.join("")}</datalist>`
: "";
return `<label>${esc(label)}<input ${attrs} type="${["number", "password"].includes(input) ? input : "text"}" ${input === "number" ? 'step="any"' : ""} ${definition ? `list="list-${key}"` : ""} value="${esc(value)}" ${input === "password" ? 'autocomplete="new-password"' : ""}>${suggestions}</label>`;
}
function inspect() {
const n = active(),
el = q("#node-properties");
if (!n) {
el.innerHTML =
'<div class="empty"><h3>Seleciona um bloco</h3><p>Adiciona um bloco ou seleciona um existente.</p></div>';
return;
}
const t = types[n.type],
connections = draft.graph.edges
.map((e, i) => ({ e, i }))
.filter(({ e }) => e.from === n.id || e.to === n.id);
el.innerHTML = `<div class="panel-heading"><span>${icon("flow")}${esc(t.title)}</span><button class="icon-button danger" id="delete-node" aria-label="Eliminar bloco" ${dis()}>${icon("trash")}</button></div><div class="inspector-body"><label>Nome do bloco<input id="node-label" value="${esc(n.label)}" ${owner() ? "" : "readonly"}></label><span class="badge ${t.color}">${esc(t.group)}</span><span class="overline">CONFIGURAÇÃO</span>${t.fields.map((f) => field(n, f)).join("") || '<p class="form-help">Sem parâmetros adicionais.</p>'}
${n.type === "webhook" ? `<button class="text-button" id="new-secret" ${dis()}>Gerar novo segredo</button><p class="form-help">POST <code>${esc(location.origin)}/api/hooks/${esc(bot.id)}</code><br>Cabeçalho: <code>X-Webhook-Secret</code></p>` : ""}
${!t.trigger && t.outputs.includes("error") ? `<label>Tentativas adicionais (0–3)<input data-field="retries" type="number" min="0" max="3" value="${Number(n.config.retries || 0)}" ${owner() ? "" : "readonly"}></label><p class="form-help">Liga a saída Erro para tratar a falha. As tentativas repetem a ação; usa-as quando a repetição for adequada.</p>` : ""}
<details class="variable-help"><summary>Variáveis e expressões</summary><p class="form-help"><code>{{server.virtualserver_name}}</code><br><code>{{server.virtualserver_clientsonline - server.virtualserver_queryclientsonline}}</code><br><code>{{event.clid}}</code> · <code>{{event.client_database_id}}</code><br><code>{{item.clid}}</code> no ramo Cada<br><code>{{temp.nome}}</code> · <code>{{variables.nome}}</code><br><code>{{time.time}}:{{time.seconds}}</code><br><code>{{text}}</code> · <code>{{top}}</code><br><code>{{temp.error.message}}</code><br>Funções: round, min, max, length, contains. Filtros: <code>{{text | upper}}</code> e <code>{{temp.result | json}}</code>.</p></details>
<span class="overline">LIGAÇÕES</span>${connections.map(({ e, i }) => `<div class="connection-row"><span>${esc(draft.graph.nodes.find((node) => node.id === (e.from === n.id ? e.to : e.from))?.label)}<small>${e.from === n.id ? "Saída" : "Entrada"} · ${labels[e.port] || e.port}</small></span><button class="icon-button danger" data-delete-edge="${i}" aria-label="Eliminar ligação" ${dis()}>${icon("trash")}</button></div>`).join("") || '<p class="form-help">Ainda sem ligações.</p>'}</div>`;
q("#node-label").oninput = (e) => {
if (!owner()) return;
n.label = e.target.value;
dirty();
q(`[data-node="${n.id}"] strong`).textContent = n.label;
};
el.querySelectorAll("[data-field]").forEach(
(input) =>
(input.onchange = () => {
if (!owner()) return;
try {
n.config[input.dataset.field] = input.hasAttribute("data-json")
? JSON.parse(input.value || "{}")
: input.type === "number"
? Number(input.value)
: input.value;
input.setCustomValidity("");
dirty();
render();
} catch {
input.setCustomValidity("JSON inválido.");
input.reportValidity();
toast("Corrige o JSON do bloco antes de guardar.", true);
}
}),
);
q("#delete-node").onclick = remove;
el.querySelectorAll("[data-delete-edge]").forEach(
(b) =>
(b.onclick = () => {
draft.graph.edges.splice(Number(b.dataset.deleteEdge), 1);
dirty();
render();
}),
);
if (q("#new-secret"))
q("#new-secret").onclick = () => {
n.config.secret = token();
dirty();
render();
};
}
async function validate() {
const invalid = q("#editor input:invalid, #editor textarea:invalid");
if (invalid) {
invalid.reportValidity();
throw new Error("Corrige o campo inválido antes de guardar.");
}
return api("/api/validate", {
method: "POST",
body: JSON.stringify({ graph: draft.graph }),
});
}
async function save() {
try {
const result = await validate();
await api(`/api/bots/${bot.id}`, {
method: "PUT",
body: JSON.stringify({
graph: draft.graph,
connectionId: draft.connectionId,
}),
});
await refresh();
onDirty(false);
q("#save-state").textContent = "Guardado";
q("#save-state").className = "";
toast(
result.warnings.length
? `Guardado. ${result.warnings[0]}`
: "Flow guardado.",
);
return true;
} catch (error) {
toast(error.message, true);
return false;
}
}
async function execute(simulation) {
if (busy) return;
if (
!simulation &&
!confirm("Executar este flow no servidor TeamSpeak configurado?")
)
return;
if (!(await save())) return;
busy = true;
q("#simulate").disabled = q("#run").disabled = true;
q("#cancel-flow").hidden = false;
try {
const result = await api(
`/api/bots/${bot.id}/${simulation ? "simulate" : "run"}`,
{
method: "POST",
body: JSON.stringify({
triggerId: q("#test-trigger").value,
...(simulation ? { event: eventFixture } : {}),
}),
},
);
await refresh();
showRun(result);
} catch (error) {
toast(error.message, true);
} finally {
busy = false;
if (q("#simulate")) {
q("#simulate").disabled = q("#run").disabled = !owner();
q("#cancel-flow").hidden = true;
}
}
}
q("#bot-select").onchange = (e) => onBot(e.target.value);
q("#search-block").oninput = (e) =>
q("#editor")
.querySelectorAll(".palette-item")
.forEach(
(b) =>
(b.hidden = !b.textContent
.toLowerCase()
.includes(e.target.value.toLowerCase())),
);
q("#editor")
.querySelectorAll("[data-add]")
.forEach((b) => (b.onclick = () => add(b.dataset.add)));
q("#undo").onclick = () => undo(-1);
q("#redo").onclick = () => undo(1);
q("#duplicate-node").onclick = duplicate;
q("#arrange").onclick = arrange;
q("#validate").onclick = async () => {
try {
const result = await validate();
toast(
result.warnings.join(" ") ||
"Flow válido: ligações e parâmetros verificados.",
);
} catch (error) {
toast(error.message, true);
}
};
q("#zoom-in").onclick = () => {
zoom = Math.min(1.5, zoom + 0.1);
sizing();
};
q("#zoom-out").onclick = () => {
zoom = Math.max(0.25, zoom - 0.1);
sizing();
};
q("#fit").onclick = fit;
q("#fullscreen").onclick = () =>
document.fullscreenElement
? document.exitFullscreen()
: q("#editor")
.requestFullscreen()
.catch(() => toast("Ecrã inteiro indisponível.", true));
q("#flow-connection").onchange = (e) => {
draft.connectionId = e.target.value;
dirty(false);
inspect();
};
q("#load-resources").onclick = async () => {
if (!draft.connectionId) {
toast("Escolhe uma ligação primeiro.", true);
return;
}
const button = q("#load-resources");
button.disabled = true;
try {
const result = await api(
`/api/connections/${draft.connectionId}/resources`,
{ method: "POST" },
);
resources.set(draft.connectionId, result);
inspect();
toast(
Object.keys(result.errors).length
? `Listas carregadas parcialmente: ${Object.values(result.errors).join("; ")}`
: "Clientes, canais, grupos e permissões carregados.",
Boolean(Object.keys(result.errors).length),
);
} catch (error) {
toast(error.message, true);
} finally {
button.disabled = !owner();
}
};
q("#test-data").onclick = () => {
const dialog = q("#dialog");
dialog.innerHTML = `<form id="test-data-form"><div class="modal-heading"><h2>Dados da simulação</h2><button type="button" class="icon-button" id="close-test-data">${icon("close")}</button></div><p class="form-help">Estes dados substituem o evento de exemplo apenas na simulação. Usa {} para os valores iniciais.</p><label>Evento (JSON)<textarea id="test-event" spellcheck="false">${esc(JSON.stringify(eventFixture, null, 2))}</textarea></label><p class="form-help">Exemplo: <code>{"clid":"1","client_database_id":"10","client_nickname":"Bxio","webhook_body":{"name":"Teste"}}</code></p><button class="button primary">Guardar dados</button></form>`;
dialog.showModal();
q("#close-test-data").onclick = () => dialog.close();
q("#test-data-form").onsubmit = (e) => {
e.preventDefault();
try {
const value = JSON.parse(q("#test-event").value);
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("Usa um objeto JSON.");
eventFixture = value;
dialog.close();
toast("Dados da simulação guardados.");
} catch (error) {
toast(error.message, true);
}
};
};
q("#save-flow").onclick = save;
q("#simulate").onclick = () => execute(true);
q("#run").onclick = () => execute(false);
q("#cancel-flow").onclick = async () => {
try {
await api(`/api/bots/${bot.id}/cancel`, { method: "POST" });
toast("Cancelamento solicitado.");
} catch (error) {
toast(error.message, true);
}
};
q("#canvas").onpointerdown = (e) => {
if (e.target.closest(".flow-node") || !(e.button === 1 || e.shiftKey))
return;
e.preventDefault();
const canvas = q("#canvas"),
x = e.clientX,
y = e.clientY,
sx = canvas.scrollLeft,
sy = canvas.scrollTop;
canvas.setPointerCapture(e.pointerId);
canvas.onpointermove = (ev) => {
canvas.scrollLeft = sx - (ev.clientX - x);
canvas.scrollTop = sy - (ev.clientY - y);
};
canvas.onpointerup = () => (canvas.onpointermove = null);
};
render();
return {
keyboard(e) {
const editing = e.target.closest("input,textarea,select");
if (e.key === "Escape") {
pending = null;
q("#canvas-hint").textContent = "Ligação cancelada.";
return;
}
if (editing) return;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
if (owner()) save();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
undo(e.shiftKey ? 1 : -1);
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
e.preventDefault();
undo(1);
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") {
e.preventDefault();
duplicate();
}
if (e.key === "Delete") {
e.preventDefault();
remove();
}
},
get draft() {
return draft;
},
};
}