v0.15.11
This commit is contained in:
@@ -10463,3 +10463,74 @@ select.select-native-proxy[aria-invalid="true"]+.custom-select .custom-select-tr
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* Visual Flow editor feedback and context menu */
|
||||
.flow-editor-notice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
max-width: min(300px, 32vw);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: .72rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
transition: opacity .14s ease, transform .14s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.flow-editor-notice.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.flow-editor-notice.is-error {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.flow-context-menu {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
min-width: 190px;
|
||||
max-width: min(280px, calc(100vw - 16px));
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 16px 38px rgba(0, 0, 0, .28);
|
||||
}
|
||||
.flow-context-menu[hidden] { display: none; }
|
||||
.flow-context-menu button {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: .82rem;
|
||||
}
|
||||
.flow-context-menu button:hover:not(:disabled),
|
||||
.flow-context-menu button:focus-visible:not(:disabled) {
|
||||
background: var(--accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
.flow-context-menu button:disabled {
|
||||
opacity: .45;
|
||||
cursor: default;
|
||||
}
|
||||
.flow-context-separator {
|
||||
height: 1px;
|
||||
margin: 5px 4px;
|
||||
background: var(--line);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.flow-editor-notice { max-width: 42vw; }
|
||||
}
|
||||
|
||||
+4
-1
@@ -1004,7 +1004,7 @@
|
||||
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>
|
||||
class="badge"></span><span id="flowEditorNotice" class="flow-editor-notice" role="status" aria-live="polite"></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"
|
||||
data-i18n-title="flow.zoomOut" title="Zoom out" data-ui-icon="minus"></button><span id="flowZoomLabel"
|
||||
@@ -1044,6 +1044,7 @@
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
<div id="flowContextMenu" class="flow-context-menu" role="menu" hidden></div>
|
||||
|
||||
<dialog id="flowShortcutDialog" class="sheet flow-shortcut-dialog">
|
||||
<div class="dialog-head">
|
||||
@@ -1056,6 +1057,7 @@
|
||||
<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+Z</kbd></span><span data-i18n="flow.shortcutUndo">Cofnij ostatnią zmianę</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>
|
||||
@@ -1068,6 +1070,7 @@
|
||||
<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><span class="flow-shortcut-keys"><kbd data-i18n="flow.rightClickKey">Prawy przycisk</kbd></span><span data-i18n="flow.shortcutContextMenu">Menu kontekstowe Flow</span></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
+227
-39
@@ -151,7 +151,7 @@ function commitFlowName({ keepEditingOnEmpty = true } = {}) {
|
||||
if (keepEditingOnEmpty) { app.flowNameEditing = true; input.focus(); }
|
||||
return false;
|
||||
}
|
||||
if (app.flowDraft.name !== name) { app.flowDraft.name = name; app.flowDirty = true; }
|
||||
if (app.flowDraft.name !== name) { app.flowDraft.name = name; app.flowDirty = true; flowHistoryCommit(); }
|
||||
app.flowNameEditing = false; renderFlowNameMode();
|
||||
return true;
|
||||
}
|
||||
@@ -169,6 +169,7 @@ function openFlowEditor(id = '', { push = true } = {}) {
|
||||
$('#flowNaturalPreview')?.classList.remove('is-expanded');
|
||||
$('#flowNaturalPreview .flow-preview-toggle')?.setAttribute('aria-expanded', 'false');
|
||||
renderFlowEditor();
|
||||
flowHistoryReset();
|
||||
requestAnimationFrame(() => { if (window.matchMedia('(max-width: 760px)').matches && app.flowDraft?.nodes?.length) fitFlowToView({ maxZoom: .9 }); });
|
||||
startFlowSharedInputValueRefresh();
|
||||
if (push) updateBrowserUrl(`/flows/${flow?.id || 'new'}`);
|
||||
@@ -181,6 +182,7 @@ function closeFlowEditor({ push = true, force = false } = {}) {
|
||||
const blockDialog = $('#flowBlockDialog'); if (blockDialog?.open) blockDialog.close();
|
||||
$('#flowEditor').hidden = true; document.body.classList.remove('flow-editor-open');
|
||||
app.flowDraft = null; app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false; app.flowNameEditing = false;
|
||||
flowHistoryClear(); hideFlowContextMenu(); clearFlowEditorNotice();
|
||||
if (push) updateBrowserUrl('/flows');
|
||||
return true;
|
||||
}
|
||||
@@ -389,6 +391,117 @@ function renderFlowSaveStatus() {
|
||||
status.classList.toggle('is-dirty', app.flowDirty || isNew);
|
||||
}
|
||||
|
||||
let flowEditorNoticeTimer = null;
|
||||
function clearFlowEditorNotice() {
|
||||
if (flowEditorNoticeTimer) clearTimeout(flowEditorNoticeTimer);
|
||||
flowEditorNoticeTimer = null;
|
||||
const host = $('#flowEditorNotice');
|
||||
if (!host) return;
|
||||
host.classList.remove('is-visible', 'is-error');
|
||||
host.textContent = '';
|
||||
}
|
||||
function flowEditorNotice(message, { error = false, timeout = 1700 } = {}) {
|
||||
const host = $('#flowEditorNotice');
|
||||
if (!host || !message) return;
|
||||
if (flowEditorNoticeTimer) clearTimeout(flowEditorNoticeTimer);
|
||||
host.textContent = message;
|
||||
host.classList.toggle('is-error', error);
|
||||
host.classList.add('is-visible');
|
||||
flowEditorNoticeTimer = setTimeout(() => {
|
||||
host.classList.remove('is-visible', 'is-error');
|
||||
flowEditorNoticeTimer = null;
|
||||
}, timeout);
|
||||
}
|
||||
function notifyFlowEditorAction(message, mode = 'toast') {
|
||||
if (!message || mode === false || mode === 'none') return;
|
||||
if (mode === 'inline') flowEditorNotice(message);
|
||||
else toast(message);
|
||||
}
|
||||
|
||||
let flowHistory = [];
|
||||
let flowHistoryApplying = false;
|
||||
const FLOW_HISTORY_LIMIT = 100;
|
||||
function flowHistorySnapshot() {
|
||||
if (!app.flowDraft) return null;
|
||||
const enabled = $('#flowEnabled');
|
||||
return {
|
||||
draft: JSON.parse(JSON.stringify(app.flowDraft)),
|
||||
enabled: enabled ? enabled.checked : app.flowDraft.enabled !== false,
|
||||
selectedIds: [...(app.flowSelectedNodeIds || [])],
|
||||
selectedId: app.flowSelectedNodeId || null,
|
||||
connectFrom: app.flowConnectFrom || null,
|
||||
dirty: app.flowDirty === true,
|
||||
};
|
||||
}
|
||||
function flowHistoryKey(snapshot) {
|
||||
if (!snapshot) return '';
|
||||
return JSON.stringify({ draft: snapshot.draft, enabled: snapshot.enabled, dirty: snapshot.dirty });
|
||||
}
|
||||
function flowHistoryClear() { flowHistory = []; flowHistoryApplying = false; }
|
||||
function flowHistoryReset() {
|
||||
const snapshot = flowHistorySnapshot();
|
||||
flowHistory = snapshot ? [snapshot] : [];
|
||||
flowHistoryApplying = false;
|
||||
}
|
||||
function flowHistoryCommit() {
|
||||
if (flowHistoryApplying || !app.flowDraft) return;
|
||||
const snapshot = flowHistorySnapshot();
|
||||
if (!snapshot) return;
|
||||
const previous = flowHistory[flowHistory.length - 1];
|
||||
if (previous && flowHistoryKey(previous) === flowHistoryKey(snapshot)) return;
|
||||
flowHistory.push(snapshot);
|
||||
if (flowHistory.length > FLOW_HISTORY_LIMIT) flowHistory.splice(0, flowHistory.length - FLOW_HISTORY_LIMIT);
|
||||
}
|
||||
function canUndoFlowEdit() { return flowHistory.length > 1; }
|
||||
function undoFlowEdit({ notify = 'inline' } = {}) {
|
||||
if (!app.flowDraft || !canUndoFlowEdit()) {
|
||||
if (notify === 'inline') flowEditorNotice(tr('flow.nothingToUndo'));
|
||||
return false;
|
||||
}
|
||||
flowHistoryApplying = true;
|
||||
flowHistory.pop();
|
||||
const snapshot = flowHistory[flowHistory.length - 1];
|
||||
app.flowDraft = JSON.parse(JSON.stringify(snapshot.draft));
|
||||
const valid = new Set(app.flowDraft.nodes.map(node => node.id));
|
||||
app.flowSelectedNodeIds = (snapshot.selectedIds || []).filter(id => valid.has(id));
|
||||
app.flowSelectedNodeId = snapshot.selectedId && valid.has(snapshot.selectedId)
|
||||
? snapshot.selectedId
|
||||
: (app.flowSelectedNodeIds[app.flowSelectedNodeIds.length - 1] || null);
|
||||
app.flowConnectFrom = snapshot.connectFrom && valid.has(snapshot.connectFrom) ? snapshot.connectFrom : null;
|
||||
app.flowDirty = snapshot.dirty === true;
|
||||
app.flowNameEditing = false;
|
||||
const enabled = $('#flowEnabled'); if (enabled) enabled.checked = snapshot.enabled !== false;
|
||||
renderFlowEditor();
|
||||
flowHistoryApplying = false;
|
||||
notifyFlowEditorAction(tr('flow.undoDone'), notify);
|
||||
return true;
|
||||
}
|
||||
|
||||
let flowContextPoint = null;
|
||||
let flowContextEdgeId = null;
|
||||
let flowBlockInsertPoint = null;
|
||||
function hideFlowContextMenu() {
|
||||
const menu = $('#flowContextMenu');
|
||||
if (menu) menu.hidden = true;
|
||||
flowContextEdgeId = null;
|
||||
}
|
||||
function showFlowContextMenu(items, event) {
|
||||
const menu = $('#flowContextMenu');
|
||||
if (!menu) return;
|
||||
menu.innerHTML = items.map(item => item.separator
|
||||
? '<div class="flow-context-separator" role="separator"></div>'
|
||||
: `<button type="button" role="menuitem" data-flow-context-action="${esc(item.action)}" ${item.disabled ? 'disabled' : ''}>${esc(item.label)}</button>`).join('');
|
||||
menu.hidden = false;
|
||||
menu.style.left = '0px';
|
||||
menu.style.top = '0px';
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const left = Math.max(margin, Math.min(event.clientX, window.innerWidth - rect.width - margin));
|
||||
const top = Math.max(margin, Math.min(event.clientY, window.innerHeight - rect.height - margin));
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function setFlowZoom(value, { render = true } = {}) {
|
||||
const next = clamp(Number(value) || 1, .1, 1.35);
|
||||
app.flowZoom = Math.round(next * 20) / 20;
|
||||
@@ -456,7 +569,8 @@ function renderFlowBlockLibrary(filter = '') {
|
||||
}).join('') || `<div class="empty compact"><strong>${esc(tr('flow.noBlocksFound'))}</strong><span>${esc(tr('flow.noBlocksFoundHint'))}</span></div>`;
|
||||
}
|
||||
|
||||
function openFlowBlockLibrary() {
|
||||
function openFlowBlockLibrary({ point = null } = {}) {
|
||||
flowBlockInsertPoint = point ? { x: Number(point.x || 0), y: Number(point.y || 0) } : null;
|
||||
const search = $('#flowBlockSearch'); if (search) search.value = '';
|
||||
renderFlowBlockLibrary('');
|
||||
$('#flowBlockDialog')?.showModal();
|
||||
@@ -702,29 +816,32 @@ function flowUpstreamNodes(id) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function addFlowNode(kind) {
|
||||
function addFlowNode(kind, { point = flowBlockInsertPoint } = {}) {
|
||||
if (!app.flowDraft || !FLOW_NODE_META[kind]) return;
|
||||
const count = app.flowDraft.nodes.length;
|
||||
const workspace = $('#flowWorkspace');
|
||||
const viewportX = workspace ? (workspace.scrollLeft / (app.flowZoom || 1)) + 48 : 80;
|
||||
const viewportY = workspace ? (workspace.scrollTop / (app.flowZoom || 1)) + 54 : 70;
|
||||
const node = { id: newFlowId('node'), kind, x: Math.max(36, viewportX + (count % 3) * 24), y: Math.max(36, viewportY + (count % 3) * 24), config: flowDefaultConfig(kind) };
|
||||
app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowSelectedNodeIds = [node.id]; app.flowDirty = true; renderFlowEditor();
|
||||
const baseX = point ? Number(point.x || 0) : viewportX + (count % 3) * 24;
|
||||
const baseY = point ? Number(point.y || 0) : viewportY + (count % 3) * 24;
|
||||
const node = { id: newFlowId('node'), kind, x: Math.max(36, baseX), y: Math.max(36, baseY), config: flowDefaultConfig(kind) };
|
||||
flowBlockInsertPoint = null;
|
||||
app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowSelectedNodeIds = [node.id]; app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
|
||||
requestAnimationFrame(() => $(`[data-flow-node="${CSS.escape(node.id)}"]`)?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }));
|
||||
}
|
||||
function removeFlowNode(id) {
|
||||
if (!app.flowDraft) return;
|
||||
app.flowDraft.nodes = app.flowDraft.nodes.filter(node => node.id !== id); app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.from !== id && edge.to !== id);
|
||||
if (app.flowSelectedNodeId === id) app.flowSelectedNodeId = null; app.flowSelectedNodeIds = (app.flowSelectedNodeIds || []).filter(nodeId => nodeId !== id); if (app.flowConnectFrom === id) app.flowConnectFrom = null; app.flowDirty = true; renderFlowEditor();
|
||||
if (app.flowSelectedNodeId === id) app.flowSelectedNodeId = null; app.flowSelectedNodeIds = (app.flowSelectedNodeIds || []).filter(nodeId => nodeId !== id); if (app.flowConnectFrom === id) app.flowConnectFrom = null; app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
|
||||
}
|
||||
function connectFlowNodes(from, to) {
|
||||
if (!app.flowDraft || !from || !to || from === to) return;
|
||||
if (app.flowDraft.edges.some(edge => edge.from === from && edge.to === to)) return;
|
||||
app.flowDraft.edges.push({ id: newFlowId('edge'), from, to }); app.flowDirty = true; app.flowConnectFrom = null; renderFlowEditor();
|
||||
app.flowDraft.edges.push({ id: newFlowId('edge'), from, to }); app.flowDirty = true; app.flowConnectFrom = null; renderFlowEditor(); flowHistoryCommit();
|
||||
}
|
||||
function removeFlowEdge(id) {
|
||||
if (!app.flowDraft) return;
|
||||
app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.id !== id); app.flowDirty = true; renderFlowEditor();
|
||||
app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.id !== id); app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
|
||||
}
|
||||
|
||||
|
||||
@@ -1040,7 +1157,7 @@ function applyFlowTemplate(key) {
|
||||
app.flowDraft.description = flowPresetText(preset.description); app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowDirty = true;
|
||||
if (!app.flowDraft.id || flowCurrentName() === tr('flow.newDefaultName')) { app.flowDraft.name = flowPresetText(preset.name) || preset.id; if (app.flowNameEditing) $('#flowName').value = app.flowDraft.name; renderFlowNameMode(); }
|
||||
markFlowPresetRecent(preset.id);
|
||||
$('#flowTemplateDialog')?.close(); renderFlowEditor();
|
||||
$('#flowTemplateDialog')?.close(); renderFlowEditor(); flowHistoryCommit();
|
||||
}
|
||||
|
||||
|
||||
@@ -1149,14 +1266,14 @@ async function persistFlowDraft(body) {
|
||||
return api(app.flowDraft.id ? `/api/flows/${encodeURIComponent(app.flowDraft.id)}` : '/api/flows', { method: app.flowDraft.id ? 'PUT' : 'POST', body });
|
||||
}
|
||||
|
||||
async function applySavedFlow(saved, messageKey) {
|
||||
async function applySavedFlow(saved, messageKey, { notify = 'inline' } = {}) {
|
||||
const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved);
|
||||
app.flowDraft = flowDraftFrom(saved); app.flowDirty = false; app.flowNameEditing = false;
|
||||
$('#flowEnabled').checked = saved.enabled === true;
|
||||
renderFlows(); renderFlowEditor(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); toast(tr(messageKey));
|
||||
renderFlows(); renderFlowEditor(); flowHistoryReset(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); notifyFlowEditorAction(tr(messageKey), notify);
|
||||
}
|
||||
|
||||
async function saveFlow() {
|
||||
async function saveFlow({ notify = 'inline' } = {}) {
|
||||
if (!app.flowDraft) return;
|
||||
if (app.flowNameEditing && !commitFlowName()) return toast(tr('flow.nameRequired'), true);
|
||||
const name = flowCurrentName(); if (!name) return toast(tr('flow.nameRequired'), true);
|
||||
@@ -1164,14 +1281,14 @@ async function saveFlow() {
|
||||
if (app.flowDraft.id) body.expected_revision = Number(app.flowDraft.revision || 0);
|
||||
try {
|
||||
const saved = await persistFlowDraft(body);
|
||||
await applySavedFlow(saved, 'flow.saved');
|
||||
await applySavedFlow(saved, 'flow.saved', { notify });
|
||||
} catch (error) {
|
||||
if (error.status !== 400) return toast(error.message, true);
|
||||
const saveDraft = confirm(tr('flow.saveAsDraftConfirm', { reason: error.message }));
|
||||
if (!saveDraft) return toast(error.message, true);
|
||||
try {
|
||||
const saved = await persistFlowDraft({ ...body, enabled: false, draft: true });
|
||||
await applySavedFlow(saved, 'flow.savedAsDraft');
|
||||
await applySavedFlow(saved, 'flow.savedAsDraft', { notify });
|
||||
} catch (draftError) { toast(draftError.message, true); }
|
||||
}
|
||||
}
|
||||
@@ -1214,7 +1331,7 @@ function clearFlowSelection() { setFlowSelection([], null); }
|
||||
let flowClipboard = null;
|
||||
let flowClipboardPasteCount = 0;
|
||||
|
||||
function copySelectedFlowNodes({ notify = true } = {}) {
|
||||
function copySelectedFlowNodes({ notify = 'inline' } = {}) {
|
||||
if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return false;
|
||||
const selected = new Set(app.flowSelectedNodeIds);
|
||||
const nodes = app.flowDraft.nodes
|
||||
@@ -1226,21 +1343,25 @@ function copySelectedFlowNodes({ notify = true } = {}) {
|
||||
if (!nodes.length) return false;
|
||||
flowClipboard = { nodes, edges };
|
||||
flowClipboardPasteCount = 0;
|
||||
if (notify) toast(tr('flow.blocksCopied', { count: nodes.length }));
|
||||
notifyFlowEditorAction(tr('flow.blocksCopied', { count: nodes.length }), notify);
|
||||
return true;
|
||||
}
|
||||
|
||||
function pasteFlowNodes({ notify = true } = {}) {
|
||||
function pasteFlowNodes({ notify = 'inline', point = null } = {}) {
|
||||
if (!app.flowDraft || !flowClipboard?.nodes?.length) return false;
|
||||
flowClipboardPasteCount += 1;
|
||||
const offset = 36 * flowClipboardPasteCount;
|
||||
const minX = Math.min(...flowClipboard.nodes.map(node => Number(node.x || 0)));
|
||||
const minY = Math.min(...flowClipboard.nodes.map(node => Number(node.y || 0)));
|
||||
const dx = point ? Number(point.x || 0) - minX : offset;
|
||||
const dy = point ? Number(point.y || 0) - minY : offset;
|
||||
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),
|
||||
x: Math.max(12, Number(node.x || 0) + dx),
|
||||
y: Math.max(12, Number(node.y || 0) + dy),
|
||||
}));
|
||||
const copiedEdges = flowClipboard.edges.map(edge => ({
|
||||
...edge,
|
||||
@@ -1253,22 +1374,24 @@ function pasteFlowNodes({ notify = true } = {}) {
|
||||
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 }));
|
||||
renderFlowEditor(); flowHistoryCommit();
|
||||
notifyFlowEditorAction(tr('flow.blocksPasted', { count: copies.length }), notify);
|
||||
return true;
|
||||
}
|
||||
|
||||
function duplicateSelectedFlowNodes() {
|
||||
function duplicateSelectedFlowNodes({ notify = 'inline' } = {}) {
|
||||
const count = (app.flowSelectedNodeIds || []).length;
|
||||
if (!count || !copySelectedFlowNodes({ notify: false })) return;
|
||||
if (pasteFlowNodes({ notify: false })) toast(tr('flow.blocksDuplicated', { count }));
|
||||
if (!count || !copySelectedFlowNodes({ notify: false })) return false;
|
||||
if (!pasteFlowNodes({ notify: false })) return false;
|
||||
notifyFlowEditorAction(tr('flow.blocksDuplicated', { count }), notify);
|
||||
return true;
|
||||
}
|
||||
|
||||
function cutSelectedFlowNodes() {
|
||||
function cutSelectedFlowNodes({ notify = 'inline' } = {}) {
|
||||
const count = (app.flowSelectedNodeIds || []).length;
|
||||
if (!count || !copySelectedFlowNodes({ notify: false })) return false;
|
||||
removeSelectedFlowNodes();
|
||||
toast(tr('flow.blocksCut', { count }));
|
||||
notifyFlowEditorAction(tr('flow.blocksCut', { count }), notify);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1278,7 +1401,7 @@ function removeSelectedFlowNodes() {
|
||||
app.flowDraft.nodes = app.flowDraft.nodes.filter(node => !selected.has(node.id));
|
||||
app.flowDraft.edges = app.flowDraft.edges.filter(edge => !selected.has(edge.from) && !selected.has(edge.to));
|
||||
if (app.flowConnectFrom && selected.has(app.flowConnectFrom)) app.flowConnectFrom = null;
|
||||
app.flowSelectedNodeIds = []; app.flowSelectedNodeId = null; app.flowDirty = true; renderFlowEditor();
|
||||
app.flowSelectedNodeIds = []; app.flowSelectedNodeId = null; app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
|
||||
}
|
||||
|
||||
function nudgeSelectedFlowNodes(dx, dy) {
|
||||
@@ -1290,7 +1413,7 @@ function nudgeSelectedFlowNodes(dx, dy) {
|
||||
node.y = Math.max(12, Number(node.y || 0) + dy);
|
||||
});
|
||||
app.flowDirty = true;
|
||||
renderFlowEditor();
|
||||
renderFlowEditor(); flowHistoryCommit();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1347,7 +1470,7 @@ function updateFlowConfig(input) {
|
||||
else if (key === 'min_direction_changes' && node.kind === 'oscillates') node.config.min_direction_changes = Math.max(1, Math.floor(Number(input.value || 1)));
|
||||
else if (['value', 'setpoint', 'target_temperature', 'cooldown_seconds', 'fan_speed'].includes(key) && ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'zone_thermostat', 'device_action', 'device_feature_action', 'group_action', 'ha_service_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value);
|
||||
else node.config[key] = input.value;
|
||||
app.flowDirty = true; renderFlowEditor();
|
||||
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
|
||||
}
|
||||
|
||||
let flowDrag = null;
|
||||
@@ -1425,7 +1548,7 @@ document.addEventListener('pointerdown', event => {
|
||||
} else if (!selected.includes(node.id)) selected = [node.id];
|
||||
app.flowSelectedNodeIds = selected; app.flowSelectedNodeId = node.id; renderFlowEditor();
|
||||
const starts = selected.map(id => flowNodeById(id)).filter(Boolean).map(item => ({ id: item.id, left: Number(item.x || 0), top: Number(item.y || 0) }));
|
||||
flowDrag = { x: event.clientX, y: event.clientY, starts };
|
||||
flowDrag = { x: event.clientX, y: event.clientY, starts, moved: false };
|
||||
nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault();
|
||||
});
|
||||
document.addEventListener('pointermove', event => {
|
||||
@@ -1437,6 +1560,7 @@ document.addEventListener('pointermove', event => {
|
||||
}
|
||||
if (!flowDrag || !app.flowDraft) return;
|
||||
let dx = (event.clientX - flowDrag.x) / (app.flowZoom || 1), dy = (event.clientY - flowDrag.y) / (app.flowZoom || 1);
|
||||
flowDrag.moved = flowDrag.moved || Math.abs(dx) > .5 || Math.abs(dy) > .5;
|
||||
const minLeft = Math.min(...flowDrag.starts.map(item => item.left));
|
||||
const minTop = Math.min(...flowDrag.starts.map(item => item.top));
|
||||
dx = Math.max(dx, 12 - minLeft); dy = Math.max(dy, 12 - minTop);
|
||||
@@ -1460,9 +1584,51 @@ document.addEventListener('pointerup', event => {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (flowDrag?.moved) flowHistoryCommit();
|
||||
flowDrag = null;
|
||||
});
|
||||
|
||||
document.addEventListener('contextmenu', event => {
|
||||
if ($('#flowEditor')?.hidden || !app.flowDraft) return;
|
||||
const workspace = event.target.closest?.('#flowWorkspace');
|
||||
if (!workspace || event.target.closest?.('#flowContextMenu,input,select,textarea,[contenteditable="true"]')) return;
|
||||
event.preventDefault();
|
||||
flowContextPoint = flowCanvasPoint(event);
|
||||
flowContextEdgeId = null;
|
||||
const nodeEl = event.target.closest?.('[data-flow-node]');
|
||||
const edgeEl = event.target.closest?.('[data-flow-edge]');
|
||||
if (edgeEl) {
|
||||
flowContextEdgeId = edgeEl.dataset.flowEdge;
|
||||
showFlowContextMenu([{ action: 'delete-edge', label: tr('flow.contextDeleteConnection') }], event);
|
||||
return;
|
||||
}
|
||||
if (nodeEl) {
|
||||
const id = nodeEl.dataset.flowNode;
|
||||
if (!(app.flowSelectedNodeIds || []).includes(id)) {
|
||||
app.flowSelectedNodeIds = [id]; app.flowSelectedNodeId = id; renderFlowEditor();
|
||||
}
|
||||
const count = (app.flowSelectedNodeIds || []).length;
|
||||
showFlowContextMenu([
|
||||
{ action: 'copy', label: tr('flow.contextCopy', { count }) },
|
||||
{ action: 'cut', label: tr('flow.contextCut', { count }) },
|
||||
{ action: 'duplicate', label: tr('flow.contextDuplicate', { count }) },
|
||||
{ action: 'delete', label: tr('flow.contextDelete', { count }) },
|
||||
{ separator: true },
|
||||
{ action: 'paste', label: tr('flow.contextPaste'), disabled: !flowClipboard?.nodes?.length },
|
||||
{ action: 'add', label: tr('flow.contextAddBlock') },
|
||||
], event);
|
||||
return;
|
||||
}
|
||||
showFlowContextMenu([
|
||||
{ action: 'undo', label: tr('flow.contextUndo'), disabled: !canUndoFlowEdit() },
|
||||
{ action: 'add', label: tr('flow.contextAddBlock') },
|
||||
{ action: 'paste', label: tr('flow.contextPasteHere'), disabled: !flowClipboard?.nodes?.length },
|
||||
{ separator: true },
|
||||
{ action: 'select-all', label: tr('flow.selectAll'), disabled: !(app.flowDraft.nodes || []).length },
|
||||
{ action: 'fit', label: tr('flow.fitView'), disabled: !(app.flowDraft.nodes || []).length },
|
||||
], event);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
const flowCard = event.target.closest?.('[data-flow-card-id]');
|
||||
if (flowCard && event.target === flowCard && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openFlowEditor(flowCard.dataset.flowCardId); return; }
|
||||
@@ -1472,18 +1638,20 @@ document.addEventListener('keydown', event => {
|
||||
|
||||
const modifier = event.ctrlKey || event.metaKey;
|
||||
const key = event.key.toLowerCase();
|
||||
if (modifier && key === 's') { event.preventDefault(); saveFlow(); return; }
|
||||
if (modifier && key === 's') { event.preventDefault(); saveFlow({ notify: 'inline' }); return; }
|
||||
if (modifier && key === 'k') { event.preventDefault(); openFlowBlockLibrary(); return; }
|
||||
if (modifier && key === 'z' && !event.shiftKey) { event.preventDefault(); undoFlowEdit({ notify: 'inline' }); 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 && key === 'c') { event.preventDefault(); copySelectedFlowNodes({ notify: 'inline' }); return; }
|
||||
if (modifier && key === 'x') { event.preventDefault(); cutSelectedFlowNodes({ notify: 'inline' }); return; }
|
||||
if (modifier && key === 'v') { event.preventDefault(); pasteFlowNodes({ notify: 'inline' }); return; }
|
||||
if (modifier && key === 'd') { event.preventDefault(); duplicateSelectedFlowNodes({ notify: 'inline' }); 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' && !$('#flowContextMenu')?.hidden) { event.preventDefault(); hideFlowContextMenu(); return; }
|
||||
if (event.key === 'Escape' && ((app.flowSelectedNodeIds || []).length || app.flowConnectFrom)) {
|
||||
event.preventDefault(); app.flowConnectFrom = null; clearFlowSelection(); return;
|
||||
}
|
||||
@@ -1498,10 +1666,29 @@ document.addEventListener('keydown', event => {
|
||||
});
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const contextAction = event.target.closest?.('[data-flow-context-action]');
|
||||
if (contextAction) {
|
||||
const action = contextAction.dataset.flowContextAction;
|
||||
const point = flowContextPoint ? { ...flowContextPoint } : null;
|
||||
const edgeId = flowContextEdgeId;
|
||||
hideFlowContextMenu();
|
||||
if (action === 'copy') copySelectedFlowNodes({ notify: 'inline' });
|
||||
else if (action === 'cut') cutSelectedFlowNodes({ notify: 'inline' });
|
||||
else if (action === 'duplicate') duplicateSelectedFlowNodes({ notify: 'inline' });
|
||||
else if (action === 'delete') removeSelectedFlowNodes();
|
||||
else if (action === 'paste') pasteFlowNodes({ notify: 'inline', point });
|
||||
else if (action === 'add') openFlowBlockLibrary({ point });
|
||||
else if (action === 'select-all') selectAllFlowNodes();
|
||||
else if (action === 'fit') fitFlowToView();
|
||||
else if (action === 'undo') undoFlowEdit({ notify: 'inline' });
|
||||
else if (action === 'delete-edge' && edgeId) removeFlowEdge(edgeId);
|
||||
return;
|
||||
}
|
||||
if (!event.target.closest?.('#flowContextMenu')) hideFlowContextMenu();
|
||||
const flowCard = event.target.closest?.('[data-flow-card-id]');
|
||||
if (flowCard && !event.target.closest?.('button,summary,details,input,select,textarea,a,label')) { openFlowEditor(flowCard.dataset.flowCardId); return; }
|
||||
const edge = event.target.closest?.('[data-flow-edge]'); if (edge) { removeFlowEdge(edge.dataset.flowEdge); return; }
|
||||
const add = event.target.closest?.('[data-flow-add]'); if (add) { addFlowNode(add.dataset.flowAdd); const dialog = add.closest?.('#flowBlockDialog'); if (dialog?.open) dialog.close(); return; }
|
||||
const add = event.target.closest?.('[data-flow-add]'); if (add) { const dialog = add.closest?.('#flowBlockDialog'); if (!dialog) flowBlockInsertPoint = null; addFlowNode(add.dataset.flowAdd); if (dialog?.open) dialog.close(); return; }
|
||||
const templateCategory = event.target.closest?.('[data-flow-template-category]'); if (templateCategory) { renderFlowPresetBrowser(templateCategory.dataset.flowTemplateCategory); return; }
|
||||
const templateFavorite = event.target.closest?.('[data-flow-template-favorite]'); if (templateFavorite) { toggleFlowPresetFavorite(templateFavorite.dataset.flowTemplateFavorite); return; }
|
||||
const templatePreview = event.target.closest?.('[data-flow-template-preview]'); if (templatePreview) { previewFlowTemplate(templatePreview.dataset.flowTemplatePreview); return; }
|
||||
@@ -1520,7 +1707,7 @@ document.addEventListener('click', event => {
|
||||
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();
|
||||
else if (action === 'flow-add-block') openFlowBlockLibrary();
|
||||
else if (action === 'flow-add-block') openFlowBlockLibrary({ point: null });
|
||||
else if (action === 'flow-zoom-out') setFlowZoom((app.flowZoom || 1) - .1);
|
||||
else if (action === 'flow-zoom-in') setFlowZoom((app.flowZoom || 1) + .1);
|
||||
else if (action === 'flow-fit') fitFlowToView();
|
||||
@@ -1574,5 +1761,6 @@ $('#flowName')?.addEventListener('blur', () => {
|
||||
if (!app.flowDraft || !app.flowNameEditing) return;
|
||||
if ($('#flowName').value.trim()) commitFlowName();
|
||||
});
|
||||
$('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) { app.flowDirty = true; renderFlowEnabledLabel(); renderFlowSaveStatus(); } });
|
||||
window.addEventListener('resize', () => { if (!$('#flowEditor')?.hidden) renderFlowEdges(); });
|
||||
$('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) { app.flowDirty = true; renderFlowEnabledLabel(); renderFlowSaveStatus(); flowHistoryCommit(); } });
|
||||
window.addEventListener('resize', () => { hideFlowContextMenu(); if (!$('#flowEditor')?.hidden) renderFlowEdges(); });
|
||||
$('#flowWorkspace')?.addEventListener('scroll', hideFlowContextMenu, { passive: true });
|
||||
|
||||
Reference in New Issue
Block a user