354 lines
9.3 KiB
Vue
354 lines
9.3 KiB
Vue
|
<script setup lang="ts">
|
|||
|
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
|||
|
import type { AppInstance } from '../stores/apps';
|
|||
|
import { useAppsStore } from '../stores/apps';
|
|||
|
import { useDraggable } from '../composables/useDraggable';
|
|||
|
import type { SnapType } from '../composables/useDraggable';
|
|||
|
import { useResizable } from '../composables/useResizable';
|
|||
|
import { useBreakpoint } from '../composables/useBreakpoint';
|
|||
|
import Calculator from './Calculator.vue';
|
|||
|
|
|||
|
const props = defineProps<{
|
|||
|
instance: AppInstance;
|
|||
|
}>();
|
|||
|
|
|||
|
const emit = defineEmits(['snap-preview', 'snap-execute']);
|
|||
|
|
|||
|
const appsStore = useAppsStore();
|
|||
|
const { isMobile } = useBreakpoint();
|
|||
|
|
|||
|
const {
|
|||
|
focusAppInstance,
|
|||
|
closeAppInstance,
|
|||
|
minimizeAppInstance,
|
|||
|
toggleMaximizeAppInstance,
|
|||
|
updateAppInstancePosition,
|
|||
|
updateAppInstanceSize,
|
|||
|
getAppById
|
|||
|
} = appsStore;
|
|||
|
|
|||
|
const appInfo = computed(() => getAppById(props.instance.appId));
|
|||
|
|
|||
|
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.instance.x, y: props.instance.y }));
|
|||
|
const windowDimensions = computed(() => ({ width: props.instance.width, height: props.instance.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.instance.isMaximized) {
|
|||
|
updateAppInstancePosition({ id: props.instance.id, x, y });
|
|||
|
}
|
|||
|
},
|
|||
|
onDragStart: () => { isDragging.value = true; },
|
|||
|
onDragEnd: (snapType) => {
|
|||
|
isDragging.value = false;
|
|||
|
emit('snap-execute', { windowId: props.instance.id, snapType });
|
|||
|
},
|
|||
|
onSnap: (snapType: SnapType) => {
|
|||
|
emit('snap-preview', snapType);
|
|||
|
},
|
|||
|
constraints: draggableConstraints,
|
|||
|
targetSize: windowDimensions,
|
|||
|
enabled: isDraggableAndResizable,
|
|||
|
});
|
|||
|
|
|||
|
useResizable(windowRef, {
|
|||
|
initialSize: { width: props.instance.width, height: props.instance.height },
|
|||
|
initialPosition: { x: props.instance.x, y: props.instance.y },
|
|||
|
onResize: ({ x, y, width, height }) => {
|
|||
|
if (!props.instance.isMaximized) {
|
|||
|
updateAppInstancePosition({ id: props.instance.id, x, y });
|
|||
|
updateAppInstanceSize({ id: props.instance.id, width, height });
|
|||
|
}
|
|||
|
},
|
|||
|
onResizeStart: () => { isResizing.value = true; },
|
|||
|
onResizeEnd: () => { isResizing.value = false; },
|
|||
|
constraints: draggableConstraints,
|
|||
|
enabled: isDraggableAndResizable,
|
|||
|
});
|
|||
|
|
|||
|
const windowStyle = computed(() => ({
|
|||
|
left: `${props.instance.x}px`,
|
|||
|
top: `${props.instance.y}px`,
|
|||
|
width: `${props.instance.width}px`,
|
|||
|
height: `${props.instance.height}px`,
|
|||
|
zIndex: props.instance.zIndex,
|
|||
|
}));
|
|||
|
|
|||
|
const windowClasses = computed(() => ({
|
|||
|
'is-maximized': props.instance.isMaximized,
|
|||
|
'is-minimized': props.instance.isMinimized,
|
|||
|
'is-focused': props.instance.isFocused,
|
|||
|
'is-dragging': isDragging.value,
|
|||
|
'is-resizing': isResizing.value,
|
|||
|
'is-mobile': isMobile.value,
|
|||
|
}));
|
|||
|
|
|||
|
function onMouseDown() {
|
|||
|
focusAppInstance(props.instance.id);
|
|||
|
}
|
|||
|
|
|||
|
// Dynamic component loading
|
|||
|
const appComponent = computed(() => {
|
|||
|
switch (props.instance.appId) {
|
|||
|
case 'calculator':
|
|||
|
return Calculator;
|
|||
|
default:
|
|||
|
return null;
|
|||
|
}
|
|||
|
});
|
|||
|
</script>
|
|||
|
|
|||
|
<template>
|
|||
|
<div
|
|||
|
ref="windowRef"
|
|||
|
class="app-window-container"
|
|||
|
:style="windowStyle"
|
|||
|
:class="windowClasses"
|
|||
|
@mousedown="onMouseDown"
|
|||
|
>
|
|||
|
<div class="title-bar" ref="titleBarRef">
|
|||
|
<div class="title-content">
|
|||
|
<span class="app-icon">{{ appInfo?.icon }}</span>
|
|||
|
<span class="title">{{ instance.title }}</span>
|
|||
|
</div>
|
|||
|
<div class="controls">
|
|||
|
<button @click.stop="minimizeAppInstance(instance.id)" class="control-btn minimize" :title="$t('common.minimize')">
|
|||
|
<span class="control-icon">−</span>
|
|||
|
</button>
|
|||
|
<button @click.stop="toggleMaximizeAppInstance(instance.id)" class="control-btn maximize" :title="$t('common.maximize')">
|
|||
|
<span class="control-icon">□</span>
|
|||
|
</button>
|
|||
|
<button @click.stop="closeAppInstance(instance.id)" class="control-btn close" :title="$t('common.close')">
|
|||
|
<span class="control-icon">×</span>
|
|||
|
</button>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
|
|||
|
<div class="app-content">
|
|||
|
<component
|
|||
|
v-if="appComponent"
|
|||
|
:is="appComponent"
|
|||
|
:instance="instance"
|
|||
|
/>
|
|||
|
<div v-else class="app-not-found">
|
|||
|
<p>App component not found</p>
|
|||
|
<p>App ID: {{ instance.appId }}</p>
|
|||
|
</div>
|
|||
|
</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>
|
|||
|
.app-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);
|
|||
|
}
|
|||
|
|
|||
|
.app-window-container.is-mobile {
|
|||
|
left: 0 !important;
|
|||
|
top: 48px !important;
|
|||
|
width: 100vw !important;
|
|||
|
height: calc(100vh - 48px) !important;
|
|||
|
border-radius: 0 !important;
|
|||
|
border: none !important;
|
|||
|
box-shadow: none !important;
|
|||
|
}
|
|||
|
|
|||
|
.app-window-container.is-dragging,
|
|||
|
.app-window-container.is-resizing {
|
|||
|
transition: none !important;
|
|||
|
}
|
|||
|
|
|||
|
.app-window-container.is-focused {
|
|||
|
border-color: var(--window-border-color-focused);
|
|||
|
}
|
|||
|
|
|||
|
.app-window-container.is-maximized {
|
|||
|
left: 0 !important;
|
|||
|
top: 48px !important;
|
|||
|
width: 100vw !important;
|
|||
|
height: calc(100vh - 48px) !important;
|
|||
|
border-radius: 0;
|
|||
|
transition: width 0.2s ease, height 0.2s ease;
|
|||
|
}
|
|||
|
|
|||
|
.app-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);
|
|||
|
}
|
|||
|
|
|||
|
.app-window-container.is-maximized .title-bar,
|
|||
|
.app-window-container.is-mobile .title-bar {
|
|||
|
border-radius: 0;
|
|||
|
}
|
|||
|
|
|||
|
.title-content {
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
gap: 8px;
|
|||
|
}
|
|||
|
|
|||
|
.app-icon {
|
|||
|
font-size: 16px;
|
|||
|
}
|
|||
|
|
|||
|
.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);
|
|||
|
}
|
|||
|
|
|||
|
.app-content {
|
|||
|
flex-grow: 1;
|
|||
|
overflow: hidden;
|
|||
|
display: flex;
|
|||
|
flex-direction: column;
|
|||
|
}
|
|||
|
|
|||
|
.app-not-found {
|
|||
|
display: flex;
|
|||
|
flex-direction: column;
|
|||
|
align-items: center;
|
|||
|
justify-content: center;
|
|||
|
height: 100%;
|
|||
|
color: var(--content-text-color);
|
|||
|
text-align: center;
|
|||
|
padding: 20px;
|
|||
|
}
|
|||
|
|
|||
|
.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>
|