This commit is contained in:
Thomas Peterson 2026-01-26 09:26:11 +01:00
parent f7f3f8946c
commit ef66dfaa24
16 changed files with 606 additions and 70 deletions

View File

@ -5,6 +5,8 @@ idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "
"widgets/LedWidget.cpp"
"widgets/WidgetFactory.cpp"
"widgets/IconWidget.cpp"
"widgets/TabViewWidget.cpp"
"widgets/TabPageWidget.cpp"
"webserver/WebServer.cpp"
"webserver/StaticFileHandlers.cpp"
"webserver/ConfigHandlers.cpp"

View File

@ -15,6 +15,8 @@ enum class WidgetType : uint8_t {
BUTTON = 1,
LED = 2,
ICON = 3,
TABVIEW = 4,
TABPAGE = 5,
};
enum class IconPosition : uint8_t {

View File

@ -0,0 +1,43 @@
#include "TabPageWidget.hpp"
#include "esp_log.h"
static const char* TAG = "TabPageWidget";
TabPageWidget::TabPageWidget(const WidgetConfig& config)
: Widget(config)
{
}
TabPageWidget::~TabPageWidget() {
}
lv_obj_t* TabPageWidget::create(lv_obj_t* parent) {
// Parent MUST be a TabView
if (!parent || !lv_obj_check_type(parent, &lv_tabview_class)) {
ESP_LOGE(TAG, "Parent of TabPage must be a TabView!");
// Fallback: create a container so we don't crash
obj_ = lv_obj_create(parent ? parent : lv_scr_act());
return obj_;
}
obj_ = lv_tabview_add_tab(parent, config_.text);
// Pages fill the parent, so x/y/w/h are ignored usually,
// but LVGL handles layout.
ESP_LOGI(TAG, "Created TabPage '%s'", config_.text);
return obj_;
}
void TabPageWidget::applyStyle() {
if (obj_ == nullptr) return;
// Pages usually transparent, but we can style them
// Don't use applyCommonStyle fully as it sets pos/size which we don't want
if (config_.bgOpacity > 0) {
lv_obj_set_style_bg_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0);
}
}

View File

@ -0,0 +1,12 @@
#pragma once
#include "Widget.hpp"
class TabPageWidget : public Widget {
public:
explicit TabPageWidget(const WidgetConfig& config);
~TabPageWidget() override;
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
};

View File

@ -0,0 +1,53 @@
#include "TabViewWidget.hpp"
#include "../WidgetManager.hpp"
#include "esp_log.h"
static const char* TAG = "TabViewWidget";
TabViewWidget::TabViewWidget(const WidgetConfig& config)
: Widget(config)
{
}
TabViewWidget::~TabViewWidget() {
}
lv_obj_t* TabViewWidget::create(lv_obj_t* parent) {
// Determine tab position based on config
// We can reuse iconPosition property: 0=Top, 1=Bottom, 2=Left, 3=Right
lv_dir_t tabPos = LV_DIR_TOP;
if (config_.iconPosition == 1) tabPos = LV_DIR_BOTTOM;
else if (config_.iconPosition == 2) tabPos = LV_DIR_LEFT;
else if (config_.iconPosition == 3) tabPos = LV_DIR_RIGHT;
// Use iconSize as tab height (e.g., 50px)
lv_coord_t tabHeight = config_.iconSize > 0 ? config_.iconSize * 10 : 50;
// LVGL v9 API: Only parent as argument
obj_ = lv_tabview_create(parent);
lv_tabview_set_tab_bar_position(obj_, tabPos);
lv_tabview_set_tab_bar_size(obj_, tabHeight);
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_, config_.width > 0 ? config_.width : 300,
config_.height > 0 ? config_.height : 200);
ESP_LOGI(TAG, "Created TabView at %d,%d", config_.x, config_.y);
return obj_;
}
void TabViewWidget::applyStyle() {
if (obj_ == nullptr) return;
// Apply styling to the main container
applyCommonStyle();
// Style the tab buttons (Tab Bar in v9)
lv_obj_t* bar = lv_tabview_get_tab_bar(obj_);
lv_obj_set_style_bg_color(bar, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_text_color(bar, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
}

View File

@ -0,0 +1,12 @@
#pragma once
#include "Widget.hpp"
class TabViewWidget : public Widget {
public:
explicit TabViewWidget(const WidgetConfig& config);
~TabViewWidget() override;
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
};

View File

@ -3,6 +3,8 @@
#include "ButtonWidget.hpp"
#include "LedWidget.hpp"
#include "IconWidget.hpp"
#include "TabViewWidget.hpp"
#include "TabPageWidget.hpp"
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
if (!config.visible) return nullptr;
@ -16,6 +18,10 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
return std::make_unique<LedWidget>(config);
case WidgetType::ICON:
return std::make_unique<IconWidget>(config);
case WidgetType::TABVIEW:
return std::make_unique<TabViewWidget>(config);
case WidgetType::TABPAGE:
return std::make_unique<TabPageWidget>(config);
default:
return nullptr;
}

View File

@ -15,8 +15,8 @@
:scale="store.canvasScale"
:selected="store.selectedWidgetId === widget.id"
@select="store.selectedWidgetId = widget.id"
@drag-start="startDrag(widget.id, $event)"
@resize-start="startResize(widget.id, $event)"
@drag-start="startDrag($event)"
@resize-start="startResize($event)"
/>
</div>
</div>
@ -70,7 +70,8 @@ function snap(val) {
let dragState = null;
let resizeState = null;
function startDrag(id, e) {
function startDrag(payload) {
const { id, event: e } = payload;
if (e.target.closest('.resize-handle')) return;
e.preventDefault();
store.selectedWidgetId = id;
@ -123,10 +124,20 @@ function drag(e) {
if (w.parentId !== -1) {
const parent = store.activeScreen.widgets.find(p => p.id === w.parentId);
if (parent) {
if (parent.type === WIDGET_TYPES.TABPAGE) {
// Parent is a TabPage (w=0, h=0), so look at grandparent (TabView)
const grandParent = store.activeScreen.widgets.find(gp => gp.id === parent.parentId);
if (grandParent) {
maxW = grandParent.w;
maxH = grandParent.h;
// Adjust for tab bar? For simplicity use full size for now
}
} else {
maxW = parent.w;
maxH = parent.h;
}
}
}
w.x = Math.max(0, Math.min(maxW - w.w, Math.round(nx)));
w.y = Math.max(0, Math.min(maxH - w.h, Math.round(ny)));
@ -141,7 +152,8 @@ function endDrag() {
document.removeEventListener('touchend', endDrag);
}
function startResize(id, e) {
function startResize(payload) {
const { id, event: e } = payload;
e.preventDefault();
store.selectedWidgetId = id;

View File

@ -22,6 +22,10 @@
<span class="element-title">Icon</span>
<span class="element-sub">Symbol</span>
</button>
<button class="element-btn" @click="store.addWidget('tabview')">
<span class="element-title">Tabs</span>
<span class="element-sub">Container</span>
</button>
</div>
</section>

View File

@ -93,6 +93,27 @@
</div>
</template>
<template v-if="key === 'tabview'">
<h4>Tabs</h4>
<div class="prop-row"><label>Position</label>
<select v-model.number="w.iconPosition">
<option :value="0">Oben</option>
<option :value="1">Unten</option>
<option :value="2">Links</option>
<option :value="3">Rechts</option>
</select>
</div>
<div class="prop-row"><label>Tab Hoehe</label>
<input type="number" v-model.number="w.iconSize" min="1" max="20">
<span style="font-size:10px; color:var(--muted); margin-left:4px">x10px</span>
</div>
</template>
<template v-if="key === 'tabpage'">
<h4>Tab Seite</h4>
<div class="prop-row"><label>Titel</label><input type="text" v-model="w.text"></div>
</template>
<!-- Typography -->
<template v-if="key === 'label' || key === 'button'">
<h4>Typo</h4>

View File

@ -2,19 +2,37 @@
<div class="tree-node">
<div
class="tree-item"
:class="{ active: store.selectedWidgetId === node.id, hidden: !node.visible }"
:class="{
active: store.selectedWidgetId === node.id,
hidden: !node.visible,
'drag-over': isDragOver
}"
@click.stop="store.selectedWidgetId = node.id"
:style="{ paddingLeft: `${level * 12 + 10}px` }"
:style="{ paddingLeft: `${level * 16 + 8}px` }"
draggable="true"
@dragstart="onDragStart($event, node)"
@dragover.prevent="onDragOver($event)"
@dragleave="isDragOver = false"
@drop.stop="onDrop($event, node)"
>
<span
class="tree-expander"
@click.stop="toggleExpand"
:style="{ visibility: node.children.length > 0 ? 'visible' : 'hidden' }"
>
<span class="tree-icon" v-if="node.children.length > 0">
{{ expanded ? '▼' : '▶' }}
</span>
<span class="tree-tag">{{ TYPE_LABELS[typeKeyFor(node.type)] }}</span>
<span class="tree-icon-type material-symbols-outlined">{{ getIconForType(node.type) }}</span>
<div class="tree-content">
<span class="tree-name">{{ node.text || TYPE_LABELS[typeKeyFor(node.type)] }}</span>
<span class="tree-id">#{{ node.id }}</span>
<span class="tree-type">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span>
</div>
</div>
<div class="tree-children" v-if="node.children.length > 0">
<div class="tree-children" v-if="node.children.length > 0 && expanded">
<div class="tree-guide" :style="{ left: `${level * 16 + 15}px` }"></div>
<TreeItem
v-for="child in node.children"
:key="child.id"
@ -29,7 +47,7 @@
import { ref } from 'vue';
import { useEditorStore } from '../stores/editor';
import { typeKeyFor } from '../utils';
import { TYPE_LABELS } from '../constants';
import { TYPE_LABELS, WIDGET_TYPES } from '../constants';
const props = defineProps({
node: Object,
@ -38,58 +56,151 @@ const props = defineProps({
const store = useEditorStore();
const expanded = ref(true);
const isDragOver = ref(false);
function toggleExpand() {
expanded.value = !expanded.value;
}
function getIconForType(type) {
switch(type) {
case WIDGET_TYPES.LABEL: return 'text_fields';
case WIDGET_TYPES.BUTTON: return 'smart_button';
case WIDGET_TYPES.LED: return 'light_mode';
case WIDGET_TYPES.ICON: return 'image';
case WIDGET_TYPES.TABVIEW: return 'tab';
case WIDGET_TYPES.TABPAGE: return 'article';
default: return 'widgets';
}
}
function onDragStart(e, node) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', node.id.toString());
store.selectedWidgetId = node.id;
}
function onDragOver(e) {
// Only allow dropping on containers (Button, TabView, TabPage)
// Actually, dropping ON a widget means "put inside".
// Dropping BETWEEN is harder to implement in this simple tree.
// Let's allow dropping on anything that CAN accept children.
const type = props.node.type;
const canAccept = type === WIDGET_TYPES.BUTTON ||
type === WIDGET_TYPES.TABVIEW ||
type === WIDGET_TYPES.TABPAGE;
if (canAccept) {
isDragOver.value = true;
e.dataTransfer.dropEffect = 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
}
function onDrop(e, targetNode) {
isDragOver.value = false;
const draggedId = parseInt(e.dataTransfer.getData('text/plain'));
if (draggedId === targetNode.id) return; // Drop on self
// Check compatibility
const type = targetNode.type;
const canAccept = type === WIDGET_TYPES.BUTTON ||
type === WIDGET_TYPES.TABVIEW ||
type === WIDGET_TYPES.TABPAGE;
if (canAccept) {
store.reparentWidget(draggedId, targetNode.id);
}
}
</script>
<style scoped>
.tree-node {
position: relative;
}
.tree-item {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
gap: 6px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
padding: 4px 6px;
color: var(--text);
cursor: pointer;
transition: border-color 0.2s ease;
margin-bottom: 4px;
margin-bottom: 1px;
user-select: none;
}
.tree-item:hover {
background: var(--panel-2);
}
.tree-item.active {
border-color: var(--accent-2);
background: rgba(125, 211, 176, 0.1);
background: rgba(125, 211, 176, 0.15);
border-color: rgba(125, 211, 176, 0.3);
}
.tree-item.drag-over {
background: rgba(246, 193, 119, 0.2);
border: 1px dashed var(--accent);
}
.tree-item.hidden {
opacity: 0.5;
}
.tree-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(125, 211, 176, 0.15);
color: var(--accent-2);
text-transform: uppercase;
letter-spacing: 0.06em;
.tree-expander {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
color: var(--muted);
cursor: pointer;
}
.tree-expander:hover {
color: var(--text);
}
.tree-icon-type {
font-size: 16px;
color: var(--accent);
opacity: 0.8;
}
.tree-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.tree-name {
flex: 1;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-id {
font-size: 10px;
.tree-type {
font-size: 9px;
color: var(--muted);
}
.tree-icon {
font-size: 10px;
color: var(--muted);
width: 12px;
.tree-children {
position: relative;
}
.tree-guide {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.05);
}
</style>

View File

@ -9,18 +9,43 @@
'widget-icon': isIcon
}"
:style="computedStyle"
@mousedown="$emit('drag-start', $event)"
@touchstart="$emit('drag-start', $event)"
@mousedown.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<div
v-if="selected"
class="resize-handle"
@mousedown.stop="$emit('resize-start', $event)"
@touchstart.stop="$emit('resize-start', $event)"
></div>
<!-- Recursive Children -->
<!-- Special handling for TabView to render structure -->
<template v-if="isTabView">
<div class="tabview-container" :class="'pos-'+tabPosition" :style="tabViewStyle">
<div class="tab-buttons" :style="tabBtnsStyle">
<div
v-for="(child, idx) in children"
:key="child.id"
class="tab-btn"
:class="{ active: activePageId === child.id }"
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
>
{{ child.text }}
</div>
</div>
<div class="tab-content">
<WidgetElement
v-for="child in children"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
:style="{ display: activePageId === child.id ? 'block' : 'none' }"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</div>
</div>
</template>
<!-- Standard Recursive Children (for Buttons, Pages, etc) -->
<template v-else>
<WidgetElement
v-for="child in children"
:key="child.id"
@ -31,6 +56,7 @@
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</template>
<!-- Icon-only Widget -->
<template v-if="isIcon">
@ -58,8 +84,16 @@
<!-- Standard (no icon) -->
<template v-else>
{{ widget.text }}
<span v-if="!isTabView && !isTabPage">{{ widget.text }}</span>
</template>
<!-- Resize Handle (at end to be on top) -->
<div
v-if="selected"
class="resize-handle"
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
></div>
</div>
</template>
@ -88,6 +122,37 @@ const isLabel = computed(() => props.widget.type === WIDGET_TYPES.LABEL);
const isButton = computed(() => props.widget.type === WIDGET_TYPES.BUTTON);
const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED);
const isIcon = computed(() => props.widget.type === WIDGET_TYPES.ICON);
const isTabView = computed(() => props.widget.type === WIDGET_TYPES.TABVIEW);
const isTabPage = computed(() => props.widget.type === WIDGET_TYPES.TABPAGE);
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
// Find active tab index (client-side state only, maybe store in widget temporarily?)
// For designer simplicity: show all tabs content stacked, or just first one?
// Better: mimic LVGL. We need state. Let's use a local ref or just show the selected one in tree?
// To keep it simple: Show the active tab based on which child is selected or default to first.
import { ref } from 'vue';
const activeTabIndex = ref(0);
const activePageId = computed(() => {
// If a child page is selected, make it active
const selectedChild = children.value.find(c => store.selectedWidgetId === c.id);
if (selectedChild) return selectedChild.id;
// If a widget inside a page is selected
if (store.selectedWidget && store.selectedWidget.parentId !== -1) {
// Find ancestor page
let curr = store.selectedWidget;
while (curr && curr.parentId !== -1 && curr.parentId !== props.widget.id) {
curr = store.activeScreen.widgets.find(w => w.id === curr.parentId);
}
if (curr && curr.parentId === props.widget.id) return curr.id;
}
if (children.value.length > 0) return children.value[activeTabIndex.value]?.id;
return -1;
});
const hasIcon = computed(() => {
return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0;
@ -213,10 +278,44 @@ const computedStyle = computed(() => {
} else {
style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
}
} else if (isTabView.value) {
style.background = w.bgColor;
style.borderRadius = `${w.radius * s}px`;
// No flex here, we handle internal layout manually
} else if (isTabPage.value) {
style.position = 'relative'; // Relative to content area
style.width = '100%';
style.height = '100%';
style.left = '0';
style.top = '0';
}
return style;
});
const tabViewStyle = computed(() => {
return {
display: 'flex',
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row',
height: '100%',
width: '100%'
};
});
const tabBtnsStyle = computed(() => {
const s = props.scale;
const h = tabHeight.value * s;
const isVert = tabPosition.value === 0 || tabPosition.value === 1;
return {
[isVert ? 'height' : 'width']: `${h}px`,
order: tabPosition.value === 1 || tabPosition.value === 3 ? 2 : 0, // Bottom/Right
display: 'flex',
flexDirection: isVert ? 'row' : 'column',
background: 'rgba(0,0,0,0.2)',
overflow: 'hidden'
};
});
</script>
<style scoped>
@ -239,4 +338,43 @@ const computedStyle = computed(() => {
align-items: center;
justify-content: center;
}
/* TabView Styles */
.tabview-container {
overflow: hidden;
}
.tab-content {
flex: 1;
position: relative;
overflow: hidden;
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
cursor: pointer;
font-size: 12px;
border-right: 1px solid rgba(255,255,255,0.1);
border-bottom: 1px solid rgba(255,255,255,0.1);
user-select: none;
}
.tab-btn.active {
background: rgba(255,255,255,0.1);
font-weight: bold;
border-bottom: 2px solid var(--accent);
}
.pos-2 .tab-btn, .pos-3 .tab-btn {
border-right: none;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.pos-2 .tab-btn.active, .pos-3 .tab-btn.active {
border-bottom: none;
border-right: 2px solid var(--accent);
}
</style>

View File

@ -6,7 +6,9 @@ export const WIDGET_TYPES = {
LABEL: 0,
BUTTON: 1,
LED: 2,
ICON: 3
ICON: 3,
TABVIEW: 4,
TABPAGE: 5
};
export const ICON_POSITIONS = {
@ -26,16 +28,21 @@ export const TYPE_KEYS = {
0: 'label',
1: 'button',
2: 'led',
3: 'icon'
3: 'icon',
4: 'tabview',
5: 'tabpage'
};
export const TYPE_LABELS = {
label: 'Label',
button: 'Button',
led: 'LED',
icon: 'Icon'
icon: 'Icon',
tabview: 'Tabs',
tabpage: 'Seite'
};
export const textSources = {
0: 'Statisch',
1: 'KNX Temperatur',
@ -151,5 +158,47 @@ export const WIDGET_DEFAULTS = {
iconPosition: 0,
iconSize: 3,
iconGap: 8
},
tabview: {
w: 400,
h: 300,
text: '',
textSrc: 0,
fontSize: 1,
textColor: '#FFFFFF',
bgColor: '#2a3543',
bgOpacity: 255,
radius: 8,
shadow: { enabled: false },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0, // 0=Top, 1=Bottom, 2=Left, 3=Right
iconSize: 5, // Used for Tab Height (x10) -> 50px
iconGap: 0
},
tabpage: {
w: 0, // Ignored
h: 0, // Ignored
text: 'Tab',
textSrc: 0,
fontSize: 1,
textColor: '#FFFFFF',
bgColor: '#1A1A2E',
bgOpacity: 0, // Transparent by default
radius: 0,
shadow: { enabled: false },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 0
}
};

View File

@ -188,8 +188,21 @@ export const useEditorStore = defineStore('editor', () => {
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;
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
@ -199,11 +212,16 @@ export const useEditorStore = defineStore('editor', () => {
if (selectedWidgetId.value !== null) {
const parent = activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
// Only BUTTON can be a parent for now (as a container)
if (parent && parent.type === WIDGET_TYPES.BUTTON) {
if (parent) {
if (parent.type === WIDGET_TYPES.BUTTON || parent.type === WIDGET_TYPES.TABPAGE) {
parentId = parent.id;
startX = 10; // Relative to parent
startX = 10;
startY = 10;
} else if (parent.type === WIDGET_TYPES.TABVIEW && typeStr === 'tabpage') {
parentId = parent.id;
startX = 0;
startY = 0;
}
}
}
@ -237,14 +255,66 @@ export const useEditorStore = defineStore('editor', () => {
activeScreen.value.widgets.push(w);
selectedWidgetId.value = w.id;
// 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 deleteWidget() {
if (!activeScreen.value || selectedWidgetId.value === null) return;
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => w.id !== selectedWidgetId.value);
// 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;
}
});
}
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,
@ -264,6 +334,7 @@ export const useEditorStore = defineStore('editor', () => {
addScreen,
deleteScreen,
addWidget,
deleteWidget
deleteWidget,
reparentWidget
};
});

View File

@ -402,7 +402,6 @@ body {
.canvas {
position: relative;
background: var(--canvas-bg);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
}

View File

@ -14,6 +14,7 @@ export function minSizeFor(widget) {
if (key === 'button') return { w: 60, h: 30 };
if (key === 'led') return { w: 20, h: 20 };
if (key === 'icon') return { w: 24, h: 24 };
if (key === 'tabview') return { w: 100, h: 100 };
return { w: 40, h: 20 };
}