529 lines
18 KiB
JavaScript
529 lines
18 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);
|
|
|
|
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
|
|
};
|
|
});
|