This commit is contained in:
Mateusz Gruszczyński
2026-09-19 21:51:51 +02:00
parent ac2dd8b62a
commit 45a993ed80
18 changed files with 252 additions and 62 deletions
+78 -8
View File
@@ -110,7 +110,7 @@ function renderFlows() {
host.innerHTML = app.flows.length ? app.flows.map(flow => `<article class="list-card flow-card ${flow.enabled && !flow.draft ? '' : 'is-disabled'} ${flow.draft ? 'is-draft' : ''}" data-flow-card-id="${esc(flow.id)}" tabindex="0" aria-label="${esc(`${tr('flow.openEditor')}: ${flow.name}`)}">
<div class="list-card-head"><div><h3>${esc(flow.name)}${flow.draft ? ` <span class="badge flow-draft-badge">${esc(tr('flow.draft'))}</span>` : ''}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div>${flow.draft ? `<button type="button" class="enable-toggle" disabled title="${esc(tr('flow.draftDisabledHint'))}"><span>${uiIcon('edit')}</span>${esc(tr('flow.disabledState'))}</button>` : `<button type="button" class="enable-toggle ${flow.enabled ? 'active' : ''}" data-action="toggle-flow-enabled" data-id="${esc(flow.id)}" data-value="${flow.enabled ? 'false' : 'true'}" aria-label="${esc(tr(flow.enabled ? 'flow.disable' : 'flow.enable'))}" title="${esc(tr('flow.quickToggleHint'))}"><span>${uiIcon(flow.enabled ? 'check' : 'circle')}</span>${esc(flow.enabled ? tr('flow.enabledState') : tr('flow.disabledState'))}</button>`}</div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('flow.blocks'))}</small><strong>${flow.nodes?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.schedules'))}</small><strong>${flow.compiled_schedule_ids?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.automations'))}</small><strong>${flow.compiled_automation_ids?.length || 0}</strong></div></div>
<div class="card-footer"><small>${esc(flow.draft ? tr('flow.draftNoExecution') : tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button class="card-primary-action" data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>
<div class="card-footer"><small>${esc(flow.draft ? tr('flow.draftNoExecution') : tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button class="card-primary-action" data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button data-action="export-flow-by-id" data-id="${esc(flow.id)}">${esc(tr('flow.export'))}</button><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>
</article>`).join('') : `<div class="empty"><strong>${esc(tr('flow.emptyTitle'))}</strong>${esc(tr('flow.emptyText'))}</div>`;
}
@@ -757,14 +757,28 @@ function downloadJsonFile(filename, data) {
setTimeout(() => URL.revokeObjectURL(url), 0);
}
function flowExportFilename(name) {
const safe = String(name || 'flow').replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'flow';
return `${safe}.flow.json`;
}
async function exportFlowById(id) {
if (!id) return;
try {
const flow = app.flows.find(item => item.id === id);
const envelope = await api(`/api/flows/${encodeURIComponent(id)}/export`);
downloadJsonFile(flowExportFilename(flow?.name || envelope?.flow?.name), envelope);
toast(tr('flow.exported'));
} catch (error) { toast(error.message, true); }
}
async function exportFlow() {
if (!app.flowDraft) return;
try {
const envelope = app.flowDraft.id && !app.flowDirty
? await api(`/api/flows/${encodeURIComponent(app.flowDraft.id)}/export`)
: { format: 'gree-controller-flow', version: 1, exported_at: new Date().toISOString(), shared_inputs: flowExportSharedInputs(), flow: flowSourcePayload() };
const safe = (flowSourcePayload()?.name || 'flow').replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'flow';
downloadJsonFile(`${safe}.flow.json`, envelope); toast(tr('flow.exported'));
downloadJsonFile(flowExportFilename(flowSourcePayload()?.name), envelope); toast(tr('flow.exported'));
} catch (error) { toast(error.message, true); }
}
@@ -1250,6 +1264,14 @@ function duplicateSelectedFlowNodes() {
if (pasteFlowNodes({ notify: false })) toast(tr('flow.blocksDuplicated', { count }));
}
function cutSelectedFlowNodes() {
const count = (app.flowSelectedNodeIds || []).length;
if (!count || !copySelectedFlowNodes({ notify: false })) return false;
removeSelectedFlowNodes();
toast(tr('flow.blocksCut', { count }));
return true;
}
function removeSelectedFlowNodes() {
if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return;
const selected = new Set(app.flowSelectedNodeIds);
@@ -1259,6 +1281,24 @@ function removeSelectedFlowNodes() {
app.flowSelectedNodeIds = []; app.flowSelectedNodeId = null; app.flowDirty = true; renderFlowEditor();
}
function nudgeSelectedFlowNodes(dx, dy) {
if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return false;
const selected = new Set(app.flowSelectedNodeIds);
app.flowDraft.nodes.forEach(node => {
if (!selected.has(node.id)) return;
node.x = Math.max(12, Number(node.x || 0) + dx);
node.y = Math.max(12, Number(node.y || 0) + dy);
});
app.flowDirty = true;
renderFlowEditor();
return true;
}
function openFlowShortcuts() {
const dialog = $('#flowShortcutDialog');
if (dialog && !dialog.open) dialog.showModal();
}
function updateFlowConfig(input) {
const node = flowNodeById(app.flowSelectedNodeId); if (!node) return;
const key = input.dataset.flowConfig; if (!key) return;
@@ -1428,11 +1468,33 @@ document.addEventListener('keydown', event => {
if (flowCard && event.target === flowCard && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openFlowEditor(flowCard.dataset.flowCardId); return; }
if ($('#flowEditor')?.hidden || !app.flowDraft) return;
if (event.target.closest?.('input,select,textarea,[contenteditable="true"]')) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); selectAllFlowNodes(); return; }
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') { event.preventDefault(); copySelectedFlowNodes(); return; }
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') { event.preventDefault(); pasteFlowNodes(); return; }
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') { event.preventDefault(); duplicateSelectedFlowNodes(); return; }
if ((event.key === 'Delete' || event.key === 'Backspace') && (app.flowSelectedNodeIds || []).length) { event.preventDefault(); removeSelectedFlowNodes(); }
if (event.target.closest?.('dialog[open]') || document.querySelector('dialog[open]')) return;
const modifier = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (modifier && key === 's') { event.preventDefault(); saveFlow(); return; }
if (modifier && key === 'k') { event.preventDefault(); openFlowBlockLibrary(); return; }
if (modifier && key === 'a') { event.preventDefault(); selectAllFlowNodes(); return; }
if (modifier && key === 'c') { event.preventDefault(); copySelectedFlowNodes(); return; }
if (modifier && key === 'x') { event.preventDefault(); cutSelectedFlowNodes(); return; }
if (modifier && key === 'v') { event.preventDefault(); pasteFlowNodes(); return; }
if (modifier && key === 'd') { event.preventDefault(); duplicateSelectedFlowNodes(); return; }
if (modifier && event.key === '0') { event.preventDefault(); fitFlowToView(); return; }
if (modifier && (event.key === '=' || event.key === '+')) { event.preventDefault(); setFlowZoom((app.flowZoom || 1) + .1); return; }
if (modifier && event.key === '-') { event.preventDefault(); setFlowZoom((app.flowZoom || 1) - .1); return; }
if (!modifier && !event.altKey && (key === 'a' || event.key === 'Insert')) { event.preventDefault(); openFlowBlockLibrary(); return; }
if (!modifier && event.key === '?') { event.preventDefault(); openFlowShortcuts(); return; }
if (event.key === 'Escape' && ((app.flowSelectedNodeIds || []).length || app.flowConnectFrom)) {
event.preventDefault(); app.flowConnectFrom = null; clearFlowSelection(); return;
}
if ((event.key === 'Delete' || event.key === 'Backspace') && (app.flowSelectedNodeIds || []).length) { event.preventDefault(); removeSelectedFlowNodes(); return; }
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key) && (app.flowSelectedNodeIds || []).length) {
event.preventDefault();
const step = event.shiftKey ? 1 : 10;
const dx = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0;
const dy = event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0;
nudgeSelectedFlowNodes(dx, dy);
}
});
document.addEventListener('click', event => {
@@ -1454,6 +1516,7 @@ document.addEventListener('click', event => {
if (action === 'new-flow') openFlowEditor();
else if (action === 'edit-flow') openFlowEditor(actionButton.dataset.id);
else if (action === 'delete-flow') deleteFlow(actionButton.dataset.id);
else if (action === 'export-flow-by-id') exportFlowById(actionButton.dataset.id);
else if (action === 'toggle-flow-enabled') toggleFlowEnabled(actionButton.dataset.id, actionButton.dataset.value === 'true');
else if (action === 'save-flow') saveFlow();
else if (action === 'flow-mobile-actions') $('#flowEditorActionsDialog')?.showModal();
@@ -1476,6 +1539,7 @@ document.addEventListener('click', event => {
else if (action === 'flow-select-all') selectAllFlowNodes();
else if (action === 'flow-duplicate-selection') duplicateSelectedFlowNodes();
else if (action === 'flow-clear-selection') clearFlowSelection();
else if (action === 'flow-shortcuts') openFlowShortcuts();
else if (action === 'edit-flow-name') editFlowName();
else if (action === 'flow-shared-input-settings') {
if (!showView('homeassistant')) return;
@@ -1495,6 +1559,12 @@ document.addEventListener('change', event => {
$('#flowImportFile')?.addEventListener('change', event => importFlowFile(event.target.files?.[0]));
$('#flowTemplateSearch')?.addEventListener('input', event => { flowPresetSearch = event.target.value || ''; renderFlowPresetBrowser(flowPresetActiveCategory); });
$('#flowBlockSearch')?.addEventListener('input', event => renderFlowBlockLibrary(event.target.value));
$('#flowBlockSearch')?.addEventListener('keydown', event => {
if (event.key !== 'Enter') return;
const first = $('#flowBlockLibrary [data-flow-add]');
if (!first) return;
event.preventDefault(); first.click();
});
$('#flowName')?.addEventListener('input', () => { if (app.flowDraft) { app.flowDirty = true; renderFlowSaveStatus(); } });
$('#flowName')?.addEventListener('keydown', event => {
if (event.key === 'Enter') { event.preventDefault(); commitFlowName(); }