303 lines
8.5 KiB
Vue
303 lines
8.5 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||
import type { WindowState } from '../stores/windows';
|
||
import { useWindowsStore } from '../stores/windows';
|
||
import { useDraggable } from '../composables/useDraggable';
|
||
import type { SnapType } from '../composables/useDraggable';
|
||
import { useResizable } from '../composables/useResizable';
|
||
import { useBreakpoint } from '../composables/useBreakpoint';
|
||
|
||
const props = defineProps<{ window: WindowState }>();
|
||
const emit = defineEmits(['snap-preview', 'snap-execute']);
|
||
|
||
const store = useWindowsStore();
|
||
const { focusWindow, closeWindow, minimizeWindow, toggleMaximize, updateWindowPosition, updateWindowSize } = store;
|
||
|
||
const { isMobile } = useBreakpoint();
|
||
|
||
const windowRef = ref<HTMLElement | null>(null);
|
||
const titleBarRef = ref<HTMLElement | null>(null);
|
||
|
||
const isDragging = ref(false);
|
||
const isResizing = ref(false);
|
||
|
||
const isDraggableAndResizable = computed(() => !isMobile.value);
|
||
|
||
const position = computed(() => ({ x: props.window.x, y: props.window.y }));
|
||
const windowDimensions = computed(() => ({ width: props.window.width, height: props.window.height }));
|
||
|
||
const draggableConstraints = ref({ top: 48, right: window.innerWidth, bottom: window.innerHeight, left: 0 });
|
||
|
||
const updateConstraints = () => {
|
||
draggableConstraints.value = { top: 48, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
|
||
};
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('resize', updateConstraints);
|
||
updateConstraints();
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', updateConstraints);
|
||
});
|
||
|
||
useDraggable(titleBarRef, {
|
||
position: position,
|
||
onDrag: (x, y) => {
|
||
if (!props.window.isMaximized) {
|
||
updateWindowPosition({ id: props.window.id, x, y });
|
||
}
|
||
},
|
||
onDragStart: () => { isDragging.value = true; },
|
||
onDragEnd: (snapType) => {
|
||
isDragging.value = false;
|
||
emit('snap-execute', { windowId: props.window.id, snapType });
|
||
},
|
||
onSnap: (snapType: SnapType) => {
|
||
emit('snap-preview', snapType);
|
||
},
|
||
constraints: draggableConstraints,
|
||
targetSize: windowDimensions,
|
||
enabled: isDraggableAndResizable,
|
||
});
|
||
|
||
useResizable(windowRef, {
|
||
initialSize: { width: props.window.width, height: props.window.height },
|
||
initialPosition: { x: props.window.x, y: props.window.y },
|
||
onResize: ({ x, y, width, height }) => {
|
||
if (!props.window.isMaximized) {
|
||
updateWindowPosition({ id: props.window.id, x, y });
|
||
updateWindowSize({ id: props.window.id, width, height });
|
||
}
|
||
},
|
||
onResizeStart: () => { isResizing.value = true; },
|
||
onResizeEnd: () => { isResizing.value = false; },
|
||
constraints: draggableConstraints,
|
||
enabled: isDraggableAndResizable,
|
||
});
|
||
|
||
const windowStyle = computed(() => ({
|
||
left: `${props.window.x}px`,
|
||
top: `${props.window.y}px`,
|
||
width: `${props.window.width}px`,
|
||
height: `${props.window.height}px`,
|
||
zIndex: props.window.zIndex,
|
||
}));
|
||
|
||
const windowClasses = computed(() => ({
|
||
'is-maximized': props.window.isMaximized,
|
||
'is-minimized': props.window.isMinimized,
|
||
'is-focused': props.window.isFocused,
|
||
'is-dragging': isDragging.value,
|
||
'is-resizing': isResizing.value,
|
||
'is-mobile': isMobile.value,
|
||
}));
|
||
|
||
function onMouseDown() {
|
||
focusWindow(props.window.id);
|
||
}
|
||
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="windowRef"
|
||
class="window-container"
|
||
:style="windowStyle"
|
||
:class="windowClasses"
|
||
@mousedown="onMouseDown"
|
||
>
|
||
<div class="title-bar" ref="titleBarRef">
|
||
<div class="title">{{ window.title }}</div>
|
||
<div class="controls">
|
||
<button @click.stop="minimizeWindow(window.id)" class="control-btn minimize" :title="$t('common.minimize')">
|
||
<span class="control-icon">−</span>
|
||
</button>
|
||
<button @click.stop="toggleMaximize(window.id)" class="control-btn maximize" :title="$t('common.maximize')">
|
||
<span class="control-icon">□</span>
|
||
</button>
|
||
<button @click.stop="closeWindow(window.id)" class="control-btn close" :title="$t('common.close')">
|
||
<span class="control-icon">×</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="content">
|
||
<p>ID: {{ window.id }}</p>
|
||
<p>X: {{ window.x.toFixed(0) }}, Y: {{ window.y.toFixed(0) }}</p>
|
||
<p>W: {{ window.width.toFixed(0) }}, H: {{ window.height.toFixed(0) }}</p>
|
||
<p>Z: {{ window.zIndex }}</p>
|
||
<p>Focused: {{ window.isFocused }}</p>
|
||
</div>
|
||
|
||
<div v-if="isDraggableAndResizable">
|
||
<div class="resizer" data-direction="n"></div>
|
||
<div class="resizer" data-direction="ne"></div>
|
||
<div class="resizer" data-direction="e"></div>
|
||
<div class="resizer" data-direction="se"></div>
|
||
<div class="resizer" data-direction="s"></div>
|
||
<div class="resizer" data-direction="sw"></div>
|
||
<div class="resizer" data-direction="w"></div>
|
||
<div class="resizer" data-direction="nw"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.window-container {
|
||
position: absolute;
|
||
background: var(--window-background);
|
||
border-radius: var(--rounded-window);
|
||
box-shadow: var(--shadow-window);
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition: opacity 0.2s ease, transform 0.2s ease, width 0.2s ease, height 0.2s ease, left 0.2s ease, top 0.2s ease;
|
||
border: 1px solid var(--window-border-color);
|
||
}
|
||
|
||
.window-container.is-mobile {
|
||
left: 0 !important;
|
||
top: 48px !important; /* Adjusted for top taskbar */
|
||
width: 100vw !important;
|
||
height: calc(100vh - 48px) !important;
|
||
border-radius: 0 !important;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
.window-container.is-dragging,
|
||
.window-container.is-resizing {
|
||
transition: none !important;
|
||
}
|
||
|
||
.window-container.is-focused {
|
||
border-color: var(--window-border-color-focused);
|
||
}
|
||
|
||
.window-container.is-maximized {
|
||
left: 0 !important;
|
||
top: 48px !important; /* Adjusted for top taskbar */
|
||
width: 100vw !important;
|
||
height: calc(100vh - 48px) !important;
|
||
border-radius: 0;
|
||
transition: width 0.2s ease, height 0.2s ease;
|
||
}
|
||
|
||
.window-container.is-minimized {
|
||
opacity: 0;
|
||
transform: translateY(200px);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.title-bar {
|
||
height: 36px;
|
||
background: var(--title-bar-background);
|
||
color: var(--title-bar-text-color);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 12px;
|
||
flex-shrink: 0;
|
||
border-top-left-radius: var(--rounded-window);
|
||
border-top-right-radius: var(--rounded-window);
|
||
}
|
||
|
||
.window-container.is-maximized .title-bar,
|
||
.window-container.is-mobile .title-bar {
|
||
border-radius: 0;
|
||
}
|
||
|
||
.title {
|
||
font-weight: bold;
|
||
user-select: none;
|
||
}
|
||
|
||
.controls {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.control-btn {
|
||
width: 20px;
|
||
height: 20px;
|
||
border: none;
|
||
border-radius: var(--rounded-control-btn);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
color: var(--title-bar-text-color);
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
transition: all 0.15s ease;
|
||
position: relative;
|
||
}
|
||
|
||
.control-btn:hover {
|
||
transform: scale(1.1);
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.control-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
font-weight: bold;
|
||
font-size: 11px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.minimize {
|
||
background: var(--control-btn-minimize-bg);
|
||
color: #000;
|
||
}
|
||
|
||
.minimize:hover {
|
||
background: #f39c12;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.maximize {
|
||
background: var(--control-btn-maximize-bg);
|
||
color: #000;
|
||
}
|
||
|
||
.maximize:hover {
|
||
background: #27ae60;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.close {
|
||
background: var(--control-btn-close-bg);
|
||
color: #fff;
|
||
}
|
||
|
||
.close:hover {
|
||
background: #c0392b;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.content {
|
||
flex-grow: 1;
|
||
padding: 16px;
|
||
color: var(--content-text-color);
|
||
overflow: auto;
|
||
}
|
||
|
||
.resizer {
|
||
position: absolute;
|
||
z-index: 10;
|
||
}
|
||
|
||
.resizer[data-direction="n"] { top: -4px; left: 4px; right: 4px; height: 8px; cursor: ns-resize; }
|
||
.resizer[data-direction="s"] { bottom: -4px; left: 4px; right: 4px; height: 8px; cursor: ns-resize; }
|
||
.resizer[data-direction="e"] { top: 4px; right: -4px; bottom: 4px; width: 8px; cursor: ew-resize; }
|
||
.resizer[data-direction="w"] { top: 4px; left: -4px; bottom: 4px; width: 8px; cursor: ew-resize; }
|
||
.resizer[data-direction="ne"] { top: -4px; right: -4px; width: 12px; height: 12px; cursor: nesw-resize; }
|
||
.resizer[data-direction="sw"] { bottom: -4px; left: -4px; width: 12px; height: 12px; cursor: nesw-resize; }
|
||
.resizer[data-direction="nw"] { top: -4px; left: -4px; width: 12px; height: 12px; cursor: nwse-resize; }
|
||
.resizer[data-direction="se"] { bottom: -4px; right: -4px; width: 12px; height: 12px; cursor: nwse-resize; }
|
||
|
||
</style>
|