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); 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(); } 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 }; } // 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 }; });