v0.15.9
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user