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
+47
View File
@@ -10416,3 +10416,50 @@ select.select-native-proxy[aria-invalid="true"]+.custom-select .custom-select-tr
.flow-natural-preview .flow-preview-runtime span {
white-space: normal;
}
.flow-shortcut-dialog {
width: min(680px, calc(100vw - 28px));
}
.flow-shortcut-grid {
display: grid;
gap: 8px;
margin-top: 14px;
}
.flow-shortcut-grid > div {
display: grid;
grid-template-columns: minmax(0, auto) 1fr;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 0;
border-bottom: 1px solid var(--line);
}
.flow-shortcut-keys {
grid-column: 1;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.flow-shortcut-grid > div > span:last-child {
grid-column: 2;
}
.flow-shortcut-grid kbd {
display: inline-block;
min-width: 34px;
padding: 4px 7px;
border: 1px solid var(--line-strong, var(--line));
border-radius: 6px;
background: var(--surface-2);
box-shadow: 0 1px 0 rgba(0, 0, 0, .08);
font: inherit;
font-size: .78rem;
font-weight: 700;
text-align: center;
white-space: nowrap;
}
+31 -1
View File
@@ -1001,7 +1001,9 @@
class="secondary flow-selection-action" data-action="flow-duplicate-selection"
data-i18n="flow.duplicateSelection" disabled>Duplikuj</button><button type="button"
class="secondary flow-selection-action" data-action="flow-clear-selection"
data-i18n="flow.clearSelection">Wyczyść zaznaczenie</button><span id="flowSelectionCount"
data-i18n="flow.clearSelection">Wyczyść zaznaczenie</button><button type="button"
class="secondary flow-selection-action" data-action="flow-shortcuts"
data-i18n="flow.shortcuts">Skróty</button><span id="flowSelectionCount"
class="badge"></span></div>
<div class="flow-zoom-controls" role="group" data-i18n-aria="flow.zoomControls" aria-label="Canvas zoom">
<button type="button" class="secondary icon-button" data-action="flow-zoom-out"
@@ -1043,6 +1045,32 @@
</div>
</section>
<dialog id="flowShortcutDialog" class="sheet flow-shortcut-dialog">
<div class="dialog-head">
<div><span class="eyebrow" data-i18n="flow.editorEyebrow">Flow editor</span>
<h2 data-i18n="flow.shortcuts">Skróty klawiaturowe</h2>
</div><button type="button" data-close data-ui-icon="close"></button>
</div>
<p class="field-note" data-i18n="flow.shortcutsHint">Skróty działają, gdy kursor nie znajduje się w polu formularza.</p>
<div class="flow-shortcut-grid">
<div><span class="flow-shortcut-keys"><kbd>A</kbd><kbd>Insert</kbd><kbd>Ctrl/Cmd+K</kbd></span><span data-i18n="flow.shortcutAdd">Dodaj / wyszukaj blok</span></div>
<div><span class="flow-shortcut-keys"><kbd>Enter</kbd></span><span data-i18n="flow.shortcutAddFirst">Dodaj pierwszy wynik wyszukiwania</span></div>
<div><span class="flow-shortcut-keys"><kbd>Ctrl/Cmd+S</kbd></span><span data-i18n="flow.shortcutSave">Zapisz Flow</span></div>
<div><span class="flow-shortcut-keys"><kbd>Ctrl/Cmd+A</kbd></span><span data-i18n="flow.shortcutSelectAll">Zaznacz wszystkie bloki</span></div>
<div><span class="flow-shortcut-keys"><kbd>Ctrl/Cmd+C</kbd></span><span data-i18n="flow.shortcutCopy">Kopiuj zaznaczone bloki</span></div>
<div><span class="flow-shortcut-keys"><kbd>Ctrl/Cmd+X</kbd></span><span data-i18n="flow.shortcutCut">Wytnij zaznaczone bloki</span></div>
<div><span class="flow-shortcut-keys"><kbd>Ctrl/Cmd+V</kbd></span><span data-i18n="flow.shortcutPaste">Wklej bloki</span></div>
<div><span class="flow-shortcut-keys"><kbd>Ctrl/Cmd+D</kbd></span><span data-i18n="flow.shortcutDuplicate">Duplikuj zaznaczenie</span></div>
<div><span class="flow-shortcut-keys"><kbd>Delete</kbd><kbd>Backspace</kbd></span><span data-i18n="flow.shortcutDelete">Usuń zaznaczone bloki</span></div>
<div><span class="flow-shortcut-keys"><kbd>← ↑ ↓ →</kbd></span><span data-i18n="flow.shortcutMove">Przesuń zaznaczenie o 10 px</span></div>
<div><span class="flow-shortcut-keys"><kbd>Shift+← ↑ ↓ →</kbd></span><span data-i18n="flow.shortcutMoveFine">Przesuń precyzyjnie o 1 px</span></div>
<div><span class="flow-shortcut-keys"><kbd>Esc</kbd></span><span data-i18n="flow.shortcutClear">Wyczyść zaznaczenie / anuluj łączenie</span></div>
<div><span class="flow-shortcut-keys"><kbd>Ctrl/Cmd+0</kbd></span><span data-i18n="flow.shortcutFit">Dopasuj cały Flow do widoku</span></div>
<div><span class="flow-shortcut-keys"><kbd>Ctrl/Cmd++ / -</kbd></span><span data-i18n="flow.shortcutZoom">Powiększ / pomniejsz</span></div>
<div><span class="flow-shortcut-keys"><kbd>?</kbd></span><span data-i18n="flow.shortcutHelp">Pokaż tę listę skrótów</span></div>
</div>
</dialog>
<dialog id="flowBlockDialog" class="sheet flow-block-dialog">
<div class="dialog-head">
<div><span class="eyebrow" data-i18n="flow.blocks">Blocks</span>
@@ -1069,6 +1097,8 @@
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="flow-logs"><span data-i18n="flow.logs">Logs</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="flow-shortcuts"><span data-i18n="flow.shortcuts">Shortcuts</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
</div>
</dialog>
+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(); }