knxdisplay/web-interface/src/components/WidgetElement.vue
2026-01-28 16:52:05 +01:00

603 lines
22 KiB
Vue

<template>
<div
class="z-[1] select-none touch-none"
:class="[
selected ? 'outline outline-2 outline-accent outline-offset-2' : '',
isLabel ? 'px-1.5 py-1 rounded-md overflow-hidden whitespace-nowrap' : ''
]"
:style="computedStyle"
@mousedown.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@click.stop="handleWidgetClick"
>
<!-- Recursive Children -->
<!-- Special handling for TabView to render structure -->
<template v-if="isTabView">
<div class="flex w-full h-full overflow-hidden" :style="tabViewStyle">
<div class="flex overflow-hidden bg-black/20" :style="tabBtnsStyle">
<div
v-for="(child, idx) in children"
:key="child.id"
:class="tabBtnClass(activePageId === child.id)"
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
>
{{ child.text }}
</div>
</div>
<div class="flex-1 relative overflow-hidden">
<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>
<template v-else-if="isPowerFlow">
<div class="absolute inset-0" :style="powerFlowBgStyle"></div>
<div v-if="widget.text" class="absolute left-4 top-3 text-[13px] uppercase tracking-[0.08em]" :style="{ color: widget.textColor }">
{{ widget.text }}
</div>
<svg class="absolute inset-0 pointer-events-none" :width="widget.w * scale" :height="widget.h * scale">
<path
v-for="link in powerFlowLinks"
:key="`link-${link.id}`"
:d="link.path"
:stroke="link.color"
:stroke-width="link.width"
stroke-linecap="round"
fill="none"
:opacity="link.opacity"
/>
<circle
v-for="link in powerFlowLinks"
:key="`dot-${link.id}`"
:r="link.dotRadius"
:fill="link.color"
:opacity="link.opacity"
>
<animateMotion
:dur="`${link.duration}s`"
repeatCount="indefinite"
:path="link.path"
/>
</circle>
</svg>
<div v-if="!powerNodes.length" class="absolute inset-0 grid place-items-center text-[12px] text-muted">
Power Nodes hinzufuegen
</div>
<WidgetElement
v-for="child in powerFlowChildren"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</template>
<!-- Standard Recursive Children (for Buttons, Pages, etc) -->
<template v-else>
<WidgetElement
v-for="child in children"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</template>
<!-- Power Node Widget -->
<template v-if="isPowerNode">
<div class="flex flex-col items-center justify-center w-full h-full text-center leading-tight">
<span v-if="powerNodeParts.label" :style="powerNodeLabelStyle">{{ powerNodeParts.label }}</span>
<span v-if="widget.iconCodepoint" class="material-symbols-outlined mt-1" :style="powerNodeIconStyle">
{{ iconChar }}
</span>
<span v-if="powerNodeParts.value" class="mt-1" :style="powerNodeValueStyle">{{ powerNodeParts.value }}</span>
</div>
</template>
<!-- Icon-only Widget -->
<template v-else-if="isIcon">
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
{{ iconChar }}
</span>
</template>
<!-- Label/Button with Icon -->
<template v-else-if="hasIcon">
<div :style="contentStyle">
<span
v-if="iconPosition === 0 || iconPosition === 2"
class="material-symbols-outlined flex-shrink-0"
:style="iconStyle"
>{{ iconChar }}</span>
<span class="flex-shrink-0">{{ widget.text }}</span>
<span
v-if="iconPosition === 1 || iconPosition === 3"
class="material-symbols-outlined flex-shrink-0"
:style="iconStyle"
>{{ iconChar }}</span>
</div>
</template>
<!-- Standard (no icon) -->
<template v-else>
<span v-if="showDefaultText">{{ widget.text }}</span>
</template>
<!-- Resize Handle (at end to be on top) -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useEditorStore } from '../stores/editor';
import { WIDGET_TYPES, fontSizes, ICON_POSITIONS } from '../constants';
import { clamp, hexToRgba } from '../utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
const emit = defineEmits(['select', 'drag-start', 'resize-start']);
const store = useEditorStore();
const children = computed(() => {
if (!store.activeScreen) return [];
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
});
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 isButtonContainer = computed(() => isButton.value && props.widget.isContainer);
const isPowerFlow = computed(() => props.widget.type === WIDGET_TYPES.POWERFLOW);
const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE);
const isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK);
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.
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(() => {
if (isButtonContainer.value) return false;
return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0;
});
const iconChar = computed(() => {
const cp = props.widget.iconCodepoint || 0xe88a;
return String.fromCodePoint(cp);
});
const iconPosition = computed(() => props.widget.iconPosition || 0);
const textAlign = computed(() => props.widget.textAlign ?? 1);
const showDefaultText = computed(() => {
if (isTabView.value || isTabPage.value) return false;
if (isPowerFlow.value || isPowerNode.value) return false;
if (isPowerLink.value) return false;
if (isButtonContainer.value) return false;
return true;
});
const justifyForAlign = (align) => {
if (align === 0) return 'flex-start';
if (align === 2) return 'flex-end';
return 'center';
};
const textAlignCss = (align) => {
if (align === 0) return 'left';
if (align === 2) return 'right';
return 'center';
};
const contentJustify = computed(() => {
if (isButton.value || isLabel.value) return justifyForAlign(textAlign.value);
return 'center';
});
const isVerticalLayout = computed(() => {
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
});
const contentStyle = computed(() => {
const s = props.scale;
const gap = (props.widget.iconGap || 8) * s;
return {
display: 'flex',
flexDirection: isVerticalLayout.value ? 'column' : 'row',
alignItems: 'center',
justifyContent: contentJustify.value,
gap: `${gap}px`,
width: '100%',
height: '100%'
};
});
const iconStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1;
const size = fontSizes[sizeIdx] || 18;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const iconOnlyStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? 3;
const size = fontSizes[sizeIdx] || 28;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const powerNodeParts = computed(() => splitPowerNodeText(props.widget.text));
const powerNodeValueSize = computed(() => {
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return size * props.scale;
});
const powerNodeLabelSize = computed(() => {
return Math.max(10 * props.scale, powerNodeValueSize.value * 0.55);
});
const powerNodeIconSize = computed(() => {
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return size * props.scale;
});
const powerNodeIconStyle = computed(() => ({
fontSize: `${powerNodeIconSize.value}px`,
color: props.widget.textColor
}));
const powerNodeLabelStyle = computed(() => ({
fontSize: `${powerNodeLabelSize.value}px`,
color: hexToRgba(props.widget.textColor, 0.72)
}));
const powerNodeValueStyle = computed(() => ({
fontSize: `${powerNodeValueSize.value}px`,
color: props.widget.textColor,
fontWeight: '600'
}));
const powerNodes = computed(() => {
if (!isPowerFlow.value) return [];
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERNODE && child.visible !== false);
});
const powerLinkWidgets = computed(() => {
if (!isPowerFlow.value) return [];
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERLINK && child.visible !== false);
});
const powerFlowChildren = computed(() => {
if (!isPowerFlow.value) return [];
return children.value.filter((child) => child.type !== WIDGET_TYPES.POWERLINK);
});
const powerFlowLinks = computed(() => {
if (!isPowerFlow.value || powerNodes.value.length < 2 || !powerLinkWidgets.value.length) return [];
const s = props.scale;
const nodeMap = new Map(powerNodes.value.map((node) => [node.id, node]));
return powerLinkWidgets.value.flatMap((link, idx) => {
const fromNode = nodeMap.get(link.x);
const toNode = nodeMap.get(link.y);
if (!fromNode || !toNode) return [];
const lineWidth = Math.max(3, link.w || 3);
const fromCenter = {
x: (fromNode.x + fromNode.w / 2) * s,
y: (fromNode.y + fromNode.h / 2) * s
};
const toCenter = {
x: (toNode.x + toNode.w / 2) * s,
y: (toNode.y + toNode.h / 2) * s
};
const dx = toCenter.x - fromCenter.x;
const dy = toCenter.y - fromCenter.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const fromRadius = Math.max(0, (Math.min(fromNode.w, fromNode.h) * 0.5 - lineWidth * 0.5) * s);
const toRadius = Math.max(0, (Math.min(toNode.w, toNode.h) * 0.5 - lineWidth * 0.5) * s);
let startX = fromCenter.x + ux * fromRadius;
let startY = fromCenter.y + uy * fromRadius;
let endX = toCenter.x - ux * toRadius;
let endY = toCenter.y - uy * toRadius;
if (len <= fromRadius + toRadius + 1) {
startX = fromCenter.x;
startY = fromCenter.y;
endX = toCenter.x;
endY = toCenter.y;
}
const dxTrim = endX - startX;
const dyTrim = endY - startY;
const lenTrim = Math.hypot(dxTrim, dyTrim) || 1;
const nx = -dyTrim / lenTrim;
const ny = dxTrim / lenTrim;
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
const curveSign = idx % 2 === 0 ? 1 : -1;
const curve = Math.min(42 * s, lenTrim * 0.3) * curveSign;
const cpx = midX + nx * curve;
const cpy = midY + ny * curve;
const dotRadius = Math.min(8, Math.max(4, lineWidth * 1.6));
const rawValue = parseFloat(link.text);
const hasRaw = Number.isFinite(rawValue);
const isStatic = (link.textSrc ?? 0) === 0;
const factor = hasRaw ? rawValue : (isStatic ? 60 : 1);
const previewValue = 50;
const speed = Math.max(5, isStatic ? factor : Math.abs(previewValue) * factor);
const duration = Math.max(2, Math.min(10, lenTrim / speed));
return [{
id: link.id,
path: `M ${startX} ${startY} Q ${cpx} ${cpy} ${endX} ${endY}`,
color: link.bgColor || '#6fa7d8',
opacity: clamp((link.bgOpacity ?? 255) / 255, 0.1, 1),
width: lineWidth,
dotRadius,
duration
}];
});
});
const powerFlowBgStyle = computed(() => {
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
const base = hexToRgba(props.widget.bgColor, alpha);
const dot = hexToRgba('#9aa7b4', 0.25);
const dotSize = 16 * props.scale;
return {
backgroundColor: base,
backgroundImage: `radial-gradient(${dot} 0.9px, transparent 1px), linear-gradient(140deg, rgba(255,255,255,0.9) 0%, ${base} 70%)`,
backgroundSize: `${dotSize}px ${dotSize}px, 100% 100%`,
backgroundPosition: '0 0, 0 0'
};
});
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = {
left: `${w.x * s}px`,
top: `${w.y * s}px`,
width: `${w.w * s}px`,
height: `${w.h * s}px`,
fontSize: `${(fontSizes[w.fontSize] || 14) * s}px`,
color: w.textColor,
position: 'absolute',
zIndex: 1,
cursor: 'move',
userSelect: 'none',
touchAction: 'none'
};
if (isIcon.value) {
// Icon widget
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = 'center';
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
if (w.radius > 0) {
style.borderRadius = `${w.radius * s}px`;
}
} else if (isLabel.value) {
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
style.display = 'flex';
style.alignItems = 'center';
if (!hasIcon.value) {
style.justifyContent = justifyForAlign(textAlign.value);
style.textAlign = textAlignCss(textAlign.value);
}
} else if (isButton.value) {
style.background = w.bgColor;
style.borderRadius = `${w.radius * s}px`;
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = contentJustify.value;
style.fontWeight = '600';
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
}
} else if (isLed.value) {
style.borderRadius = '999px';
const brightness = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
const glowColor = (w.shadow && w.shadow.color) ? w.shadow.color : w.bgColor;
const highlight = clamp(brightness + 0.25, 0, 1);
const core = clamp(brightness, 0, 1);
const edge = clamp(brightness * 0.5, 0, 1);
style.background = `radial-gradient(circle at 30% 30%, ${hexToRgba(w.bgColor, highlight)} 0%, ${hexToRgba(w.bgColor, core)} 45%, ${hexToRgba(w.bgColor, edge)} 70%, rgba(0,0,0,0.4) 100%)`;
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
const glowAlpha = clamp(0.4 + brightness * 0.6, 0, 1);
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(glowColor, glowAlpha)}`;
} else {
style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
}
} else if (isPowerFlow.value) {
style.borderRadius = `${w.radius * s}px`;
style.overflow = 'hidden';
style.border = `1px solid ${hexToRgba('#94a3b8', 0.35)}`;
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
}
} else if (isPowerNode.value) {
const ringAlpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
const ring = Math.max(3, Math.round(Math.min(w.w, w.h) * 0.06 * s));
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = 'center';
style.borderRadius = '999px';
style.background = hexToRgba('#ffffff', 0.96);
style.border = `${ring}px solid ${hexToRgba(w.bgColor, ringAlpha)}`;
style.textAlign = 'center';
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
const glowAlpha = clamp(0.35 + ringAlpha * 0.5, 0, 1);
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(w.shadow.color || w.bgColor, glowAlpha)}`;
} else {
style.boxShadow = '0 8px 18px rgba(15, 23, 42, 0.12)';
}
if (store.powerLinkMode.active && store.powerLinkMode.powerflowId === w.parentId) {
style.cursor = 'crosshair';
}
if (store.powerLinkMode.active && store.powerLinkMode.fromNodeId === w.id && store.powerLinkMode.powerflowId === w.parentId) {
style.outline = `2px dashed ${hexToRgba('#4f8ad9', 0.9)}`;
style.outlineOffset = '2px';
}
} 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 {
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row'
};
});
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
flexDirection: isVert ? 'row' : 'column'
};
});
const tabBtnClass = (isActive) => {
const isVerticalTabs = tabPosition.value === 2 || tabPosition.value === 3;
const base = 'flex-1 flex items-center justify-center px-1 py-1 text-[12px] cursor-pointer select-none';
const border = isVerticalTabs ? 'border-b border-white/10' : 'border-b border-r border-white/10';
const active = isVerticalTabs
? 'bg-white/10 font-bold border-b-0 border-r-2 border-accent'
: 'bg-white/10 font-bold border-b-2 border-accent';
return `${base} ${border}${isActive ? ` ${active}` : ''}`;
};
function splitPowerNodeText(text) {
if (typeof text !== 'string') return { label: '', value: '' };
const parts = text.split('\n');
const label = parts[0] ?? '';
const value = parts.slice(1).join('\n');
return { label, value };
}
function handleWidgetClick() {
if (isPowerNode.value && store.powerLinkMode.active) {
store.handlePowerNodeLink(props.widget.id, props.widget.parentId);
return;
}
emit('select');
}
</script>