knxdisplay/web-interface/src/stores/editor.js
2026-01-28 22:41:05 +01:00

559 lines
20 KiB
JavaScript

import { defineStore } from 'pinia';
import { ref, computed, reactive } from 'vue';
import { normalizeScreen, normalizeWidget } from '../utils';
import { MAX_SCREENS, WIDGET_DEFAULTS, WIDGET_TYPES, TEXT_ALIGNS } from '../constants';
export const useEditorStore = defineStore('editor', () => {
const config = reactive({
startScreen: 0,
standby: { enabled: false, screen: -1, minutes: 5 },
screens: []
});
const knxAddresses = ref([]);
const selectedWidgetId = ref(null);
const activeScreenId = ref(0);
const canvasScale = ref(0.6);
const showGrid = ref(true);
const snapToGrid = ref(true);
const gridSize = ref(20);
const powerLinkMode = reactive({ active: false, powerflowId: null, fromNodeId: null });
const nextScreenId = ref(0);
const nextWidgetId = ref(0);
function mapLegacyKnxAddresses() {
if (!knxAddresses.value.length || !Array.isArray(config.screens)) return;
const addrByIndex = new Map();
const gaSet = new Set();
knxAddresses.value.forEach((addr) => {
if (typeof addr.index === 'number' && typeof addr.addr === 'number') {
addrByIndex.set(addr.index, addr.addr);
gaSet.add(addr.addr);
}
});
config.screens.forEach((screen) => {
if (!Array.isArray(screen.widgets)) return;
screen.widgets.forEach((w) => {
if (typeof w.knxAddr === 'number' && w.knxAddr > 0) {
if (!gaSet.has(w.knxAddr) && addrByIndex.has(w.knxAddr)) {
w.knxAddr = addrByIndex.get(w.knxAddr);
}
}
if (typeof w.knxAddrWrite === 'number' && w.knxAddrWrite > 0) {
if (!gaSet.has(w.knxAddrWrite) && addrByIndex.has(w.knxAddrWrite)) {
w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite);
}
}
});
});
}
const activeScreen = computed(() => {
return config.screens.find(s => s.id === activeScreenId.value) || config.screens[0];
});
const widgetTree = computed(() => {
if (!activeScreen.value) return [];
const widgets = activeScreen.value.widgets.filter(w => w.type !== WIDGET_TYPES.POWERLINK);
// Map ID -> Widget
const map = {};
widgets.forEach(w => map[w.id] = { ...w, children: [] });
const roots = [];
widgets.forEach(w => {
const node = map[w.id];
if (w.parentId !== -1 && map[w.parentId]) {
map[w.parentId].children.push(node);
} else {
roots.push(node);
}
});
return roots;
});
const selectedWidget = computed(() => {
if (!activeScreen.value || selectedWidgetId.value === null) return null;
return activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
});
function ensureButtonLabels(screen) {
if (!screen || !Array.isArray(screen.widgets)) return;
const hasLabelChild = new Set();
screen.widgets.forEach((w) => {
if (w.type === WIDGET_TYPES.LABEL && w.parentId !== -1) {
hasLabelChild.add(w.parentId);
}
});
screen.widgets.forEach((w) => {
if (w.type !== WIDGET_TYPES.BUTTON) return;
w.isContainer = true;
if (hasLabelChild.has(w.id)) return;
const defaults = WIDGET_DEFAULTS.label;
const labelText = w.text || WIDGET_DEFAULTS.button.text;
const label = {
id: nextWidgetId.value++,
parentId: w.id,
type: WIDGET_TYPES.LABEL,
x: 0,
y: 0,
w: w.w,
h: w.h,
visible: true,
textSrc: defaults.textSrc,
text: labelText,
knxAddr: defaults.knxAddr,
fontSize: w.fontSize ?? defaults.fontSize,
textAlign: w.textAlign ?? TEXT_ALIGNS.CENTER,
textColor: w.textColor ?? defaults.textColor,
bgColor: defaults.bgColor,
bgOpacity: 0,
radius: 0,
shadow: { ...defaults.shadow, enabled: false },
isToggle: false,
knxAddrWrite: 0,
action: 0,
targetScreen: 0,
iconCodepoint: w.iconCodepoint || 0,
iconPosition: w.iconPosition ?? defaults.iconPosition,
iconSize: w.iconSize ?? defaults.iconSize,
iconGap: w.iconGap ?? defaults.iconGap
};
screen.widgets.push(label);
hasLabelChild.add(w.id);
});
}
async function loadKnxAddresses() {
try {
// Mock or Real API
const resp = await fetch('/api/knx/addresses');
if (resp.ok) {
knxAddresses.value = await resp.json();
mapLegacyKnxAddresses();
} else {
knxAddresses.value = [];
}
} catch (e) {
console.error(e);
knxAddresses.value = [];
}
}
async function loadConfig() {
try {
const resp = await fetch('/api/config');
let data = {};
if (resp.ok) {
data = await resp.json();
} else {
// Fallback for dev without API
console.warn("API not available, loading defaults");
data = { screens: [] };
}
if (Array.isArray(data.screens)) {
// reset reactive object properties
Object.assign(config, data);
} else {
// Default init
config.screens = [
{
id: 0,
name: 'Screen 1',
mode: 0,
bgColor: data.bgColor || '#1A1A2E',
widgets: data.widgets || []
}
];
config.startScreen = 0;
config.standby = { enabled: false, screen: -1, minutes: 5 };
}
if (!config.standby) {
config.standby = { enabled: false, screen: -1, minutes: 5 };
}
mapLegacyKnxAddresses();
// Recalculate IDs
nextWidgetId.value = 0;
nextScreenId.value = 0;
config.screens.forEach((screen) => {
if (typeof screen.id === 'number') {
nextScreenId.value = Math.max(nextScreenId.value, screen.id + 1);
}
});
config.screens.forEach((screen) => {
normalizeScreen(screen, nextScreenId, nextWidgetId);
ensureButtonLabels(screen);
// Also update max widget id
screen.widgets.forEach(w => {
nextWidgetId.value = Math.max(nextWidgetId.value, w.id + 1);
});
});
// Validate start/standby screens
if (config.standby.screen >= 255) config.standby.screen = -1;
const startExists = config.screens.find(s => s.id === config.startScreen);
if (!startExists && config.screens.length) {
config.startScreen = config.screens[0].id;
}
activeScreenId.value = config.screens.find(s => s.id === config.startScreen)?.id ?? 0;
} catch (e) {
console.error('Load config failed', e);
}
}
async function saveConfig() {
await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
await fetch('/api/save', { method: 'POST' });
}
async function resetConfig() {
await fetch('/api/reset', { method: 'POST' });
await loadConfig();
}
function addScreen() {
if (config.screens.length >= MAX_SCREENS) return;
const id = nextScreenId.value++;
const newScreen = {
id,
name: `Screen ${id}`,
mode: 0,
bgColor: '#1A1A2E',
widgets: []
};
normalizeScreen(newScreen, null, nextWidgetId);
config.screens.push(newScreen);
activeScreenId.value = id;
selectedWidgetId.value = null;
}
function deleteScreen() {
if (config.screens.length <= 1) return;
config.screens = config.screens.filter(s => s.id !== activeScreenId.value);
if (config.startScreen === activeScreenId.value) {
config.startScreen = config.screens[0].id;
}
if (config.standby.screen === activeScreenId.value) {
config.standby.screen = -1;
config.standby.enabled = false;
}
activeScreenId.value = config.screens[0].id;
selectedWidgetId.value = null;
}
function addWidget(typeStr) {
if (!activeScreen.value) return;
let typeValue;
switch (typeStr) {
case 'label': typeValue = WIDGET_TYPES.LABEL; break;
case 'button': typeValue = WIDGET_TYPES.BUTTON; break;
case 'led': typeValue = WIDGET_TYPES.LED; break;
case 'icon': typeValue = WIDGET_TYPES.ICON; break;
case 'tabview': typeValue = WIDGET_TYPES.TABVIEW; break;
case 'tabpage': typeValue = WIDGET_TYPES.TABPAGE; break;
case 'powerflow': typeValue = WIDGET_TYPES.POWERFLOW; break;
case 'powernode': typeValue = WIDGET_TYPES.POWERNODE; break;
case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break;
default: typeValue = WIDGET_TYPES.LABEL;
}
// Special case: Add Page to existing TabView
if (typeStr === 'tabview' && selectedWidgetId.value !== null) {
const sel = activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
if (sel && sel.type === WIDGET_TYPES.TABVIEW) {
// User clicked "Tabs" while a TabView is selected -> Add a Page
addWidget('tabpage');
return;
}
}
const defaults = WIDGET_DEFAULTS[typeStr];
// Determine parent
let parentId = -1;
let startX = 120;
let startY = 120;
if (selectedWidgetId.value !== null) {
const parent = activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
if (parent) {
if (parent.type === WIDGET_TYPES.BUTTON || parent.type === WIDGET_TYPES.TABPAGE) {
parentId = parent.id;
startX = 10;
startY = 10;
} else if (parent.type === WIDGET_TYPES.TABVIEW && typeStr === 'tabpage') {
parentId = parent.id;
startX = 0;
startY = 0;
} else if (parent.type === WIDGET_TYPES.POWERFLOW && typeStr === 'powernode') {
parentId = parent.id;
startX = 40;
startY = 40;
} else if (parent.type === WIDGET_TYPES.POWERNODE && typeStr === 'powernode') {
const grandParent = activeScreen.value.widgets.find(w => w.id === parent.parentId);
if (grandParent && grandParent.type === WIDGET_TYPES.POWERFLOW) {
parentId = grandParent.id;
startX = 40;
startY = 40;
}
}
}
}
const w = {
id: nextWidgetId.value++,
parentId: parentId,
type: typeValue,
x: startX,
y: startY,
w: defaults.w,
h: defaults.h,
visible: true,
textSrc: defaults.textSrc,
text: defaults.text,
knxAddr: defaults.knxAddr,
fontSize: defaults.fontSize,
textAlign: defaults.textAlign,
isContainer: defaults.isContainer,
textColor: defaults.textColor,
bgColor: defaults.bgColor,
bgOpacity: defaults.bgOpacity,
radius: defaults.radius,
shadow: { ...defaults.shadow },
isToggle: defaults.isToggle,
knxAddrWrite: defaults.knxAddrWrite,
action: defaults.action,
targetScreen: defaults.targetScreen,
iconCodepoint: defaults.iconCodepoint || 0,
iconPosition: defaults.iconPosition || 0,
iconSize: defaults.iconSize || 1,
iconGap: defaults.iconGap || 8
};
activeScreen.value.widgets.push(w);
selectedWidgetId.value = w.id;
if (typeStr === 'button') {
const labelDefaults = WIDGET_DEFAULTS.label;
const label = {
id: nextWidgetId.value++,
parentId: w.id,
type: WIDGET_TYPES.LABEL,
x: 0,
y: 0,
w: w.w,
h: w.h,
visible: true,
textSrc: labelDefaults.textSrc,
text: w.text,
knxAddr: labelDefaults.knxAddr,
fontSize: w.fontSize,
textAlign: TEXT_ALIGNS.CENTER,
textColor: w.textColor,
bgColor: labelDefaults.bgColor,
bgOpacity: 0,
radius: 0,
shadow: { ...labelDefaults.shadow, enabled: false },
isToggle: false,
knxAddrWrite: 0,
action: 0,
targetScreen: 0,
iconCodepoint: w.iconCodepoint || 0,
iconPosition: w.iconPosition || 0,
iconSize: w.iconSize || 1,
iconGap: w.iconGap || 8
};
activeScreen.value.widgets.push(label);
}
// Auto-create pages for new TabView
if (typeStr === 'tabview') {
// Deselect to reset parent logic for recursive calls, or explicitly pass parent
// Easier: just manually push 2 pages
const p1 = { ...WIDGET_DEFAULTS.tabpage, id: nextWidgetId.value++, parentId: w.id, text: 'Tab 1', type: WIDGET_TYPES.TABPAGE };
const p2 = { ...WIDGET_DEFAULTS.tabpage, id: nextWidgetId.value++, parentId: w.id, text: 'Tab 2', type: WIDGET_TYPES.TABPAGE };
activeScreen.value.widgets.push(p1);
activeScreen.value.widgets.push(p2);
}
}
function setPowerLinkMode(active, powerflowId = null) {
powerLinkMode.active = active;
powerLinkMode.powerflowId = active ? powerflowId : null;
powerLinkMode.fromNodeId = null;
}
function addPowerLink(powerflowId, fromNodeId, toNodeId) {
if (!activeScreen.value) return;
if (fromNodeId === toNodeId) return;
const existing = activeScreen.value.widgets.find((w) => {
if (w.parentId !== powerflowId || w.type !== WIDGET_TYPES.POWERLINK) return false;
return (w.x === fromNodeId && w.y === toNodeId) || (w.x === toNodeId && w.y === fromNodeId);
});
if (existing) return;
const defaults = WIDGET_DEFAULTS.powerlink;
const link = {
id: nextWidgetId.value++,
parentId: powerflowId,
type: WIDGET_TYPES.POWERLINK,
x: fromNodeId,
y: toNodeId,
w: defaults.w,
h: defaults.h,
visible: true,
textSrc: defaults.textSrc,
text: defaults.text,
knxAddr: defaults.knxAddr,
fontSize: defaults.fontSize,
textAlign: defaults.textAlign,
isContainer: false,
textColor: defaults.textColor,
bgColor: defaults.bgColor,
bgOpacity: defaults.bgOpacity,
radius: defaults.radius,
shadow: { ...defaults.shadow },
isToggle: defaults.isToggle,
knxAddrWrite: defaults.knxAddrWrite,
action: defaults.action,
targetScreen: defaults.targetScreen,
iconCodepoint: defaults.iconCodepoint || 0,
iconPosition: defaults.iconPosition || 0,
iconSize: defaults.iconSize || 0,
iconGap: defaults.iconGap || 0
};
activeScreen.value.widgets.push(link);
}
function removePowerLink(linkId) {
if (!activeScreen.value) return;
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => w.id !== linkId);
}
function handlePowerNodeLink(nodeId, powerflowId) {
if (!powerLinkMode.active || powerLinkMode.powerflowId !== powerflowId) {
selectedWidgetId.value = nodeId;
return;
}
if (!powerLinkMode.fromNodeId || powerLinkMode.fromNodeId === nodeId) {
powerLinkMode.fromNodeId = nodeId;
return;
}
addPowerLink(powerflowId, powerLinkMode.fromNodeId, nodeId);
powerLinkMode.fromNodeId = nodeId;
}
function deleteWidget() {
if (!activeScreen.value || selectedWidgetId.value === null) return;
// Also delete children recursively?
// For now, simpler: reparent children to root or delete them.
// Let's delete them to avoid orphans.
const idToDelete = selectedWidgetId.value;
const deleteIds = [idToDelete];
// Find all descendants
let found = true;
while (found) {
found = false;
activeScreen.value.widgets.forEach(w => {
if (deleteIds.includes(w.parentId) && !deleteIds.includes(w.id)) {
deleteIds.push(w.id);
found = true;
}
});
}
// Remove power links connected to deleted nodes
activeScreen.value.widgets.forEach((w) => {
if (w.type === WIDGET_TYPES.POWERLINK) {
if (deleteIds.includes(w.x) || deleteIds.includes(w.y)) {
deleteIds.push(w.id);
}
}
});
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => !deleteIds.includes(w.id));
selectedWidgetId.value = null;
}
function reparentWidget(widgetId, newParentId) {
if (!activeScreen.value) return;
// Prevent cycles: Check if newParentId is a descendant of widgetId
let curr = newParentId;
while (curr !== -1) {
if (curr === widgetId) return; // Cycle detected
const p = activeScreen.value.widgets.find(w => w.id === curr);
if (!p) break;
curr = p.parentId;
}
const widget = activeScreen.value.widgets.find(w => w.id === widgetId);
if (widget) {
// Adjust position to be relative to new parent (simplified: reset to 0,0 or keep absolute?)
// Keeping absolute is hard because we don't know absolute pos easily here.
// Resetting to 10,10 is safer.
widget.x = 10;
widget.y = 10;
widget.parentId = newParentId;
}
}
return {
config,
knxAddresses,
selectedWidgetId,
activeScreenId,
canvasScale,
showGrid,
snapToGrid,
gridSize,
powerLinkMode,
activeScreen,
widgetTree,
selectedWidget,
loadKnxAddresses,
loadConfig,
saveConfig,
resetConfig,
addScreen,
deleteScreen,
addWidget,
setPowerLinkMode,
addPowerLink,
removePowerLink,
handlePowerNodeLink,
deleteWidget,
reparentWidget
};
});