This commit is contained in:
Mateusz Gruszczyński
2026-09-19 21:37:46 +02:00
parent 044adef401
commit ac2dd8b62a
21 changed files with 288 additions and 97 deletions
+9
View File
@@ -6270,6 +6270,15 @@ textarea[aria-invalid="true"] {
box-shadow: 0 0 0 2px var(--accent-soft), 0 12px 30px rgba(0, 0, 0, .22);
}
.flow-selection-marquee {
position: absolute;
z-index: 4;
pointer-events: none;
border: 1px solid var(--accent);
border-radius: 4px;
background: var(--accent-soft);
}
.flow-editor .flow-node-time {
border-top: 3px solid var(--blue);
}
+3
View File
@@ -998,6 +998,8 @@
data-i18n="flow.addBlock">Add
block</span></button><button type="button" class="secondary flow-selection-action"
data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button"
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"
class="badge"></span></div>
@@ -1015,6 +1017,7 @@
<div id="flowWorkspace" class="flow-workspace" tabindex="0">
<div id="flowCanvas" class="flow-canvas"><svg id="flowEdges" class="flow-edges" aria-hidden="true"></svg>
<div id="flowNodes" class="flow-nodes"></div>
<div id="flowSelectionMarquee" class="flow-selection-marquee" hidden aria-hidden="true"></div>
</div>
<div id="flowEmptyHint" class="flow-empty-hint"><strong data-i18n="flow.emptyCanvas">Start by adding
blocks</strong><span data-i18n="flow.emptyCanvasHint">Conditions go on the left, actions on the
+146 -4
View File
@@ -357,7 +357,13 @@ function flowNodeSummary(node) {
if (node.kind === 'logic_and') return tr('flow.allConditions');
if (node.kind === 'logic_or') return tr('flow.anyCondition');
if (node.kind === 'logic_not') return tr('flow.invertCondition');
if (node.kind === 'zone_thermostat') { const zone = app.zones.find(z => z.id === c.zone_id); return `${zone?.name || tr('common.noZone')} · ${c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort')}`; }
if (node.kind === 'zone_thermostat') {
const zone = app.zones.find(z => z.id === c.zone_id);
const power = c.power == null ? null : (c.power ? tr('common.on') : tr('common.off'));
const mode = c.mode ? (c.mode === 'auto' ? 'Auto' : modeLabel(c.mode)) : null;
const target = c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort');
return [zone?.name || tr('common.noZone'), power, mode, target].filter(Boolean).join(' · ');
}
if (node.kind === 'device_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
if (node.kind === 'device_feature_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${deviceCommandFieldLabel(c.feature || 'light')}${flowDeviceFeatureValueLabel(c.feature, c.value)}`; }
if (node.kind === 'group_action') { const g = app.groups.find(v => v.id === c.group_id); return `${g?.name || tr('groups.group')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
@@ -470,7 +476,9 @@ function renderFlowEditor() {
</article>`;
}).join('');
$('#flowEmptyHint').hidden = draft.nodes.length > 0;
const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count: (app.flowSelectedNodeIds || []).length }) : '';
const selectedCount = (app.flowSelectedNodeIds || []).length;
const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = selectedCount ? tr('flow.selectedCount', { count: selectedCount }) : '';
const duplicateButton = $('[data-action="flow-duplicate-selection"]'); if (duplicateButton) duplicateButton.disabled = !selectedCount;
renderFlowNameMode(); renderFlowEnabledLabel(); setFlowZoom(app.flowZoom || 1, { render: false }); renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); renderFlowSaveStatus(); refreshFlowSharedInputCurrentValues();
const status = $('#flowCompileStatus');
status.textContent = draft.draft ? tr('flow.draftStatus') : tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
@@ -1189,6 +1197,59 @@ function selectAllFlowNodes() {
function clearFlowSelection() { setFlowSelection([], null); }
let flowClipboard = null;
let flowClipboardPasteCount = 0;
function copySelectedFlowNodes({ notify = true } = {}) {
if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return false;
const selected = new Set(app.flowSelectedNodeIds);
const nodes = app.flowDraft.nodes
.filter(node => selected.has(node.id))
.map(node => ({ ...node, config: JSON.parse(JSON.stringify(node.config || {})) }));
const edges = app.flowDraft.edges
.filter(edge => selected.has(edge.from) && selected.has(edge.to))
.map(edge => ({ ...edge }));
if (!nodes.length) return false;
flowClipboard = { nodes, edges };
flowClipboardPasteCount = 0;
if (notify) toast(tr('flow.blocksCopied', { count: nodes.length }));
return true;
}
function pasteFlowNodes({ notify = true } = {}) {
if (!app.flowDraft || !flowClipboard?.nodes?.length) return false;
flowClipboardPasteCount += 1;
const offset = 36 * flowClipboardPasteCount;
const idMap = new Map(flowClipboard.nodes.map(node => [node.id, newFlowId('node')]));
const copies = flowClipboard.nodes.map(node => ({
...node,
id: idMap.get(node.id),
config: JSON.parse(JSON.stringify(node.config || {})),
x: Math.max(12, Number(node.x || 0) + offset),
y: Math.max(12, Number(node.y || 0) + offset),
}));
const copiedEdges = flowClipboard.edges.map(edge => ({
...edge,
id: newFlowId('edge'),
from: idMap.get(edge.from),
to: idMap.get(edge.to),
}));
app.flowDraft.nodes.push(...copies);
app.flowDraft.edges.push(...copiedEdges);
app.flowSelectedNodeIds = copies.map(node => node.id);
app.flowSelectedNodeId = copies[copies.length - 1]?.id || null;
app.flowDirty = true;
renderFlowEditor();
if (notify) toast(tr('flow.blocksPasted', { count: copies.length }));
return true;
}
function duplicateSelectedFlowNodes() {
const count = (app.flowSelectedNodeIds || []).length;
if (!count || !copySelectedFlowNodes({ notify: false })) return;
if (pasteFlowNodes({ notify: false })) toast(tr('flow.blocksDuplicated', { count }));
}
function removeSelectedFlowNodes() {
if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return;
const selected = new Set(app.flowSelectedNodeIds);
@@ -1250,11 +1311,68 @@ function updateFlowConfig(input) {
}
let flowDrag = null;
let flowMarquee = null;
function flowCanvasPoint(event) {
const workspace = $('#flowWorkspace');
if (!workspace) return { x: 0, y: 0 };
const rect = workspace.getBoundingClientRect();
const zoom = app.flowZoom || 1;
return {
x: (event.clientX - rect.left + workspace.scrollLeft) / zoom,
y: (event.clientY - rect.top + workspace.scrollTop) / zoom,
};
}
function updateFlowMarqueeSelection(event) {
if (!flowMarquee || !app.flowDraft) return;
const current = flowCanvasPoint(event);
const left = Math.min(flowMarquee.start.x, current.x);
const top = Math.min(flowMarquee.start.y, current.y);
const right = Math.max(flowMarquee.start.x, current.x);
const bottom = Math.max(flowMarquee.start.y, current.y);
const marquee = $('#flowSelectionMarquee');
if (marquee) {
marquee.hidden = false;
marquee.style.left = `${left}px`;
marquee.style.top = `${top}px`;
marquee.style.width = `${right - left}px`;
marquee.style.height = `${bottom - top}px`;
}
const hits = app.flowDraft.nodes.filter(node => {
const el = $(`[data-flow-node="${CSS.escape(node.id)}"]`);
const nodeLeft = Number(node.x || 0), nodeTop = Number(node.y || 0);
const nodeRight = nodeLeft + Number(el?.offsetWidth || 170);
const nodeBottom = nodeTop + Number(el?.offsetHeight || 96);
return nodeRight >= left && nodeLeft <= right && nodeBottom >= top && nodeTop <= bottom;
}).map(node => node.id);
const selected = [...new Set([...flowMarquee.baseSelection, ...hits])];
app.flowSelectedNodeIds = selected;
app.flowSelectedNodeId = selected[selected.length - 1] || null;
$$('[data-flow-node]').forEach(el => el.classList.toggle('selected', selected.includes(el.dataset.flowNode)));
const count = $('#flowSelectionCount'); if (count) count.textContent = selected.length ? tr('flow.selectedCount', { count: selected.length }) : '';
}
document.addEventListener('pointerdown', event => {
if ($('#flowEditor')?.hidden) return;
if (event.button !== 0) return;
const nodeEl = event.target.closest?.('[data-flow-node]');
if (!nodeEl) {
if (event.target.closest?.('#flowWorkspace') && !event.target.closest('button,input,select,label')) clearFlowSelection();
const workspace = event.target.closest?.('#flowWorkspace');
if (!workspace || event.target.closest?.('button,input,select,label,[data-flow-edge]')) return;
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
flowMarquee = {
start: flowCanvasPoint(event),
clientX: event.clientX,
clientY: event.clientY,
baseSelection: additive ? [...(app.flowSelectedNodeIds || [])] : [],
additive,
moved: false,
pointerId: event.pointerId,
};
workspace.setPointerCapture?.(event.pointerId);
const marquee = $('#flowSelectionMarquee'); if (marquee) marquee.hidden = true;
event.preventDefault();
return;
}
if (event.target.closest('button,input,select,label')) return;
@@ -1271,6 +1389,12 @@ document.addEventListener('pointerdown', event => {
nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault();
});
document.addEventListener('pointermove', event => {
if (flowMarquee && app.flowDraft) {
flowMarquee.moved = flowMarquee.moved || Math.abs(event.clientX - flowMarquee.clientX) > 3 || Math.abs(event.clientY - flowMarquee.clientY) > 3;
if (flowMarquee.moved) updateFlowMarqueeSelection(event);
event.preventDefault();
return;
}
if (!flowDrag || !app.flowDraft) return;
let dx = (event.clientX - flowDrag.x) / (app.flowZoom || 1), dy = (event.clientY - flowDrag.y) / (app.flowZoom || 1);
const minLeft = Math.min(...flowDrag.starts.map(item => item.left));
@@ -1283,7 +1407,21 @@ document.addEventListener('pointermove', event => {
});
app.flowDirty = true; renderFlowSaveStatus(); renderFlowEdges();
});
document.addEventListener('pointerup', () => { flowDrag = null; });
document.addEventListener('pointerup', event => {
if (flowMarquee) {
if (!flowMarquee.moved) {
app.flowSelectedNodeIds = flowMarquee.additive ? [...flowMarquee.baseSelection] : [];
app.flowSelectedNodeId = app.flowSelectedNodeIds[app.flowSelectedNodeIds.length - 1] || null;
}
const workspace = $('#flowWorkspace'); workspace?.releasePointerCapture?.(flowMarquee.pointerId);
const marquee = $('#flowSelectionMarquee'); if (marquee) marquee.hidden = true;
flowMarquee = null;
renderFlowEditor();
event.preventDefault();
return;
}
flowDrag = null;
});
document.addEventListener('keydown', event => {
const flowCard = event.target.closest?.('[data-flow-card-id]');
@@ -1291,6 +1429,9 @@ document.addEventListener('keydown', event => {
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(); }
});
@@ -1333,6 +1474,7 @@ document.addEventListener('click', event => {
else if (action === 'run-flow-dry-run') runFlowDryRun();
else if (action === 'flow-logs') openFlowLogs();
else if (action === 'flow-select-all') selectAllFlowNodes();
else if (action === 'flow-duplicate-selection') duplicateSelectedFlowNodes();
else if (action === 'flow-clear-selection') clearFlowSelection();
else if (action === 'edit-flow-name') editFlowName();
else if (action === 'flow-shared-input-settings') {