603 lines
22 KiB
Vue
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>
|