feat: add calculator app
This commit is contained in:
parent
21e012015a
commit
1664b40480
|
@ -0,0 +1,353 @@
|
|||
<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>
|
|
@ -0,0 +1,262 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
// Calculator state
|
||||
const display = ref('0');
|
||||
const previousValue = ref<number | null>(null);
|
||||
const operation = ref<string | null>(null);
|
||||
const waitingForNewValue = ref(false);
|
||||
|
||||
// Calculator logic
|
||||
function inputNumber(num: string) {
|
||||
if (waitingForNewValue.value) {
|
||||
display.value = num;
|
||||
waitingForNewValue.value = false;
|
||||
} else {
|
||||
display.value = display.value === '0' ? num : display.value + num;
|
||||
}
|
||||
}
|
||||
|
||||
function inputDecimal() {
|
||||
if (waitingForNewValue.value) {
|
||||
display.value = '0.';
|
||||
waitingForNewValue.value = false;
|
||||
} else if (display.value.indexOf('.') === -1) {
|
||||
display.value += '.';
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
display.value = '0';
|
||||
previousValue.value = null;
|
||||
operation.value = null;
|
||||
waitingForNewValue.value = false;
|
||||
}
|
||||
|
||||
function performOperation(nextOperation: string) {
|
||||
const inputValue = parseFloat(display.value);
|
||||
|
||||
if (previousValue.value === null) {
|
||||
previousValue.value = inputValue;
|
||||
} else if (operation.value) {
|
||||
const currentValue = previousValue.value || 0;
|
||||
const newValue = calculate(currentValue, inputValue, operation.value);
|
||||
|
||||
display.value = String(newValue);
|
||||
previousValue.value = newValue;
|
||||
}
|
||||
|
||||
waitingForNewValue.value = true;
|
||||
operation.value = nextOperation;
|
||||
}
|
||||
|
||||
function calculate(firstValue: number, secondValue: number, operation: string): number {
|
||||
switch (operation) {
|
||||
case '+': return firstValue + secondValue;
|
||||
case '-': return firstValue - secondValue;
|
||||
case '×': return firstValue * secondValue;
|
||||
case '÷': return secondValue !== 0 ? firstValue / secondValue : 0;
|
||||
default: return secondValue;
|
||||
}
|
||||
}
|
||||
|
||||
function equals() {
|
||||
const inputValue = parseFloat(display.value);
|
||||
|
||||
if (previousValue.value !== null && operation.value) {
|
||||
const newValue = calculate(previousValue.value, inputValue, operation.value);
|
||||
display.value = String(newValue);
|
||||
previousValue.value = null;
|
||||
operation.value = null;
|
||||
waitingForNewValue.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Format display value
|
||||
const formattedDisplay = computed(() => {
|
||||
const value = parseFloat(display.value);
|
||||
if (isNaN(value)) return '0';
|
||||
|
||||
// Handle very large or very small numbers
|
||||
if (Math.abs(value) > 999999999 || (Math.abs(value) < 0.000001 && value !== 0)) {
|
||||
return value.toExponential(6);
|
||||
}
|
||||
|
||||
// Format with appropriate decimal places
|
||||
return value.toLocaleString('en-US', {
|
||||
maximumFractionDigits: 8,
|
||||
minimumFractionDigits: 0
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calculator">
|
||||
<div class="display">
|
||||
<div class="display-value">{{ formattedDisplay }}</div>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<!-- Row 1 -->
|
||||
<button @click="clear" class="btn btn-function">C</button>
|
||||
<button @click="clear" class="btn btn-function">CE</button>
|
||||
<button @click="performOperation('÷')" class="btn btn-operator">÷</button>
|
||||
<button @click="performOperation('×')" class="btn btn-operator">×</button>
|
||||
|
||||
<!-- Row 2 -->
|
||||
<button @click="inputNumber('7')" class="btn btn-number">7</button>
|
||||
<button @click="inputNumber('8')" class="btn btn-number">8</button>
|
||||
<button @click="inputNumber('9')" class="btn btn-number">9</button>
|
||||
<button @click="performOperation('-')" class="btn btn-operator">−</button>
|
||||
|
||||
<!-- Row 3 -->
|
||||
<button @click="inputNumber('4')" class="btn btn-number">4</button>
|
||||
<button @click="inputNumber('5')" class="btn btn-number">5</button>
|
||||
<button @click="inputNumber('6')" class="btn btn-number">6</button>
|
||||
<button @click="performOperation('+')" class="btn btn-operator">+</button>
|
||||
|
||||
<!-- Row 4 -->
|
||||
<button @click="inputNumber('1')" class="btn btn-number">1</button>
|
||||
<button @click="inputNumber('2')" class="btn btn-number">2</button>
|
||||
<button @click="inputNumber('3')" class="btn btn-number">3</button>
|
||||
<button @click="equals" class="btn btn-equals" rowspan="2">=</button>
|
||||
|
||||
<!-- Row 5 -->
|
||||
<button @click="inputNumber('0')" class="btn btn-number btn-zero">0</button>
|
||||
<button @click="inputDecimal" class="btn btn-number">.</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calculator {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--window-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.display {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.display-value {
|
||||
color: #00ff00;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-number {
|
||||
background: #4a4a4a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-number:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.btn-operator {
|
||||
background: #ff9500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-operator:hover {
|
||||
background: #ffad33;
|
||||
}
|
||||
|
||||
.btn-function {
|
||||
background: #a6a6a6;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.btn-function:hover {
|
||||
background: #b6b6b6;
|
||||
}
|
||||
|
||||
.btn-equals {
|
||||
background: #ff9500;
|
||||
color: white;
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.btn-equals:hover {
|
||||
background: #ffad33;
|
||||
}
|
||||
|
||||
.btn-zero {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
.theme-light .display {
|
||||
background: #f0f0f0;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.theme-light .display-value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.theme-light .btn-number {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.theme-light .btn-number:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.theme-light .btn-function {
|
||||
background: #c0c0c0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.theme-light .btn-function:hover {
|
||||
background: #b0b0b0;
|
||||
}
|
||||
</style>
|
|
@ -2,21 +2,31 @@
|
|||
import { ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useWindowsStore } from '../stores/windows';
|
||||
import { useAppsStore } from '../stores/apps';
|
||||
import { useDesktopStore } from '../stores/desktop';
|
||||
import { useUIStore } from '../stores/ui';
|
||||
import { useSettingsStore } from '../stores/settings'; // Import settings store
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import Window from './Window.vue';
|
||||
import AppWindow from './AppWindow.vue';
|
||||
import DesktopIcon from './DesktopIcon.vue';
|
||||
import Taskbar from './Taskbar.vue';
|
||||
import SnapPreview from './SnapPreview.vue';
|
||||
import StartMenu from './StartMenu.vue';
|
||||
import type { SnapType } from '../composables/useDraggable';
|
||||
|
||||
const windowsStore = useWindowsStore();
|
||||
const appsStore = useAppsStore();
|
||||
const desktopStore = useDesktopStore();
|
||||
const uiStore = useUIStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { orderedWindows } = storeToRefs(windowsStore);
|
||||
const { isStartMenuOpen } = storeToRefs(uiStore); // Get start menu state
|
||||
const { orderedAppInstances, availableApps } = storeToRefs(appsStore);
|
||||
const { iconPositions } = storeToRefs(desktopStore);
|
||||
const { isStartMenuOpen } = storeToRefs(uiStore);
|
||||
const { createWindow, snapWindow, closeAllWindows } = windowsStore;
|
||||
const { launchApp, closeAllAppInstances } = appsStore;
|
||||
const { initializeDesktopIcons, updateIconPosition } = desktopStore;
|
||||
const { closeStartMenu } = uiStore;
|
||||
const { toggleTheme } = settingsStore;
|
||||
|
||||
|
@ -32,11 +42,11 @@ function handleAbout() { handleMenuAction(() => console.log('About clicked')); }
|
|||
function handleSettings() { handleMenuAction(() => console.log('Settings clicked')); }
|
||||
function handleSignOut() { handleMenuAction(() => console.log('Sign Out clicked')); }
|
||||
function handleToggleTheme() { handleMenuAction(toggleTheme); }
|
||||
function handleCloseAllWindows() { handleMenuAction(closeAllWindows); }
|
||||
function handleCloseAllWindows() { handleMenuAction(() => { closeAllWindows(); closeAllAppInstances(); }); }
|
||||
// --------------------------------
|
||||
|
||||
function handleSnapPreview(snapType: SnapType) {
|
||||
if (!snapType) {
|
||||
if (!snapType || typeof window === 'undefined') {
|
||||
snapPreview.value = null;
|
||||
return;
|
||||
}
|
||||
|
@ -60,19 +70,90 @@ function handleSnapPreview(snapType: SnapType) {
|
|||
|
||||
function handleSnapExecute({ windowId, snapType }: { windowId: string; snapType: SnapType }) {
|
||||
snapPreview.value = null;
|
||||
if (snapType) {
|
||||
if (snapType && typeof window !== 'undefined') {
|
||||
// Try to snap app window first, then regular window
|
||||
const appInstance = appsStore.getAppInstanceById(windowId);
|
||||
if (appInstance) {
|
||||
// Handle app window snapping
|
||||
const taskbarHeight = 22;
|
||||
const screenWidth = window.innerWidth;
|
||||
const screenHeight = window.innerHeight - taskbarHeight;
|
||||
|
||||
appInstance.isMaximized = false;
|
||||
|
||||
switch (snapType) {
|
||||
case 'left':
|
||||
appInstance.x = 0;
|
||||
appInstance.y = taskbarHeight;
|
||||
appInstance.width = screenWidth / 2;
|
||||
appInstance.height = screenHeight;
|
||||
break;
|
||||
case 'right':
|
||||
appInstance.x = screenWidth / 2;
|
||||
appInstance.y = taskbarHeight;
|
||||
appInstance.width = screenWidth / 2;
|
||||
appInstance.height = screenHeight;
|
||||
break;
|
||||
case 'top':
|
||||
appInstance.x = 0;
|
||||
appInstance.y = taskbarHeight;
|
||||
appInstance.width = screenWidth;
|
||||
appInstance.height = screenHeight;
|
||||
break;
|
||||
}
|
||||
appsStore.focusAppInstance(windowId);
|
||||
} else {
|
||||
// Handle regular window snapping
|
||||
snapWindow(windowId, snapType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDesktopClick() {
|
||||
closeStartMenu();
|
||||
}
|
||||
|
||||
// Initialize desktop icons when apps are available
|
||||
function initializeDesktop() {
|
||||
if (availableApps.value.length > 0) {
|
||||
initializeDesktopIcons(availableApps.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle icon position updates
|
||||
function handleIconPositionChange(appId: string, x: number, y: number) {
|
||||
updateIconPosition(appId, x, y);
|
||||
}
|
||||
|
||||
// Handle app launch from desktop icon
|
||||
function handleAppLaunch(appId: string) {
|
||||
launchApp(appId);
|
||||
}
|
||||
|
||||
// Get app info for desktop icons
|
||||
const desktopIcons = computed(() => {
|
||||
return iconPositions.value.map(position => {
|
||||
const app = availableApps.value.find(app => app.id === position.appId);
|
||||
return app ? { ...app, ...position } : null;
|
||||
}).filter(Boolean);
|
||||
});
|
||||
|
||||
// Initialize desktop on mount
|
||||
import { onMounted, watch } from 'vue';
|
||||
onMounted(() => {
|
||||
initializeDesktop();
|
||||
});
|
||||
|
||||
// Watch for changes in available apps
|
||||
watch(availableApps, () => {
|
||||
initializeDesktop();
|
||||
}, { immediate: true });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop" @click.self="handleDesktopClick">
|
||||
<!-- Regular Windows -->
|
||||
<Window
|
||||
v-for="window in orderedWindows"
|
||||
:key="window.id"
|
||||
|
@ -81,6 +162,26 @@ function handleDesktopClick() {
|
|||
@snap-execute="handleSnapExecute"
|
||||
/>
|
||||
|
||||
<!-- App Windows -->
|
||||
<AppWindow
|
||||
v-for="appInstance in orderedAppInstances"
|
||||
:key="appInstance.id"
|
||||
:instance="appInstance"
|
||||
@snap-preview="handleSnapPreview"
|
||||
@snap-execute="handleSnapExecute"
|
||||
/>
|
||||
|
||||
<!-- Desktop Icons -->
|
||||
<DesktopIcon
|
||||
v-for="icon in desktopIcons"
|
||||
:key="icon.appId"
|
||||
:app="icon"
|
||||
:x="icon.x"
|
||||
:y="icon.y"
|
||||
@position-change="(x, y) => handleIconPositionChange(icon.appId, x, y)"
|
||||
@launch="handleAppLaunch"
|
||||
/>
|
||||
|
||||
<SnapPreview v-if="snapPreview" :preview="snapPreview" />
|
||||
<StartMenu
|
||||
v-if="isStartMenuOpen"
|
||||
|
@ -91,11 +192,14 @@ function handleDesktopClick() {
|
|||
@close-all-windows="handleCloseAllWindows"
|
||||
/>
|
||||
|
||||
<div class="fixed top-14 left-4 z-[9999] flex flex-col gap-2">
|
||||
<button @click="() => createWindow('New App')" class="bg-white/20 backdrop-blur-md text-white font-bold py-2 px-4 rounded-lg shadow-lg">
|
||||
+ {{ $t('common.createWindow') }}
|
||||
<!-- Legacy Window Button (hidden in corner) -->
|
||||
<button
|
||||
@click="() => createWindow('New Window')"
|
||||
class="legacy-window-btn"
|
||||
title="Create Legacy Window"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Taskbar />
|
||||
</div>
|
||||
|
@ -110,4 +214,31 @@ function handleDesktopClick() {
|
|||
overflow: hidden;
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.legacy-window-btn {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.legacy-window-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import type { AppInfo } from '../stores/apps';
|
||||
import { useAppsStore } from '../stores/apps';
|
||||
import { useDraggable } from '../composables/useDraggable';
|
||||
|
||||
interface DesktopIconProps {
|
||||
app: AppInfo;
|
||||
x: number;
|
||||
y: number;
|
||||
onPositionChange: (x: number, y: number) => void;
|
||||
}
|
||||
|
||||
const props = defineProps<DesktopIconProps>();
|
||||
const emit = defineEmits(['launch']);
|
||||
|
||||
const appsStore = useAppsStore();
|
||||
const iconRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const isDragging = ref(false);
|
||||
const isSelected = ref(false);
|
||||
|
||||
const position = ref({ x: props.x, y: props.y });
|
||||
|
||||
// Desktop boundaries (excluding taskbar)
|
||||
const desktopBounds = computed(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { top: 48, left: 0, right: 800, bottom: 600 }; // Default values for SSR
|
||||
}
|
||||
return {
|
||||
top: 48, // Below taskbar
|
||||
left: 0,
|
||||
right: window.innerWidth - 80, // Icon width
|
||||
bottom: window.innerHeight - 80 // Icon height
|
||||
};
|
||||
});
|
||||
|
||||
const updateBounds = () => {
|
||||
// This will be called when window resizes
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', updateBounds);
|
||||
updateBounds();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', updateBounds);
|
||||
}
|
||||
});
|
||||
|
||||
// Use draggable composable
|
||||
useDraggable(iconRef, {
|
||||
position: position,
|
||||
onDrag: (x, y) => {
|
||||
// Constrain to desktop bounds
|
||||
const bounds = desktopBounds.value;
|
||||
const constrainedX = Math.max(bounds.left, Math.min(x, bounds.right));
|
||||
const constrainedY = Math.max(bounds.top, Math.min(y, bounds.bottom));
|
||||
|
||||
position.value = { x: constrainedX, y: constrainedY };
|
||||
},
|
||||
onDragStart: () => {
|
||||
isDragging.value = true;
|
||||
isSelected.value = true;
|
||||
},
|
||||
onDragEnd: () => {
|
||||
isDragging.value = false;
|
||||
// Update parent with new position
|
||||
props.onPositionChange(position.value.x, position.value.y);
|
||||
},
|
||||
constraints: computed(() => desktopBounds.value),
|
||||
targetSize: computed(() => ({ width: 80, height: 80 })),
|
||||
enabled: ref(true),
|
||||
});
|
||||
|
||||
const iconStyle = computed(() => ({
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
}));
|
||||
|
||||
const iconClasses = computed(() => ({
|
||||
'desktop-icon': true,
|
||||
'is-dragging': isDragging.value,
|
||||
'is-selected': isSelected.value,
|
||||
}));
|
||||
|
||||
function handleClick() {
|
||||
if (!isDragging.value) {
|
||||
isSelected.value = true;
|
||||
emit('launch', props.app.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDoubleClick() {
|
||||
emit('launch', props.app.id);
|
||||
}
|
||||
|
||||
// Click outside to deselect
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (iconRef.value && !iconRef.value.contains(event.target as Node)) {
|
||||
isSelected.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="iconRef"
|
||||
class="desktop-icon"
|
||||
:class="iconClasses"
|
||||
:style="iconStyle"
|
||||
@click="handleClick"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<div class="icon-container">
|
||||
<div class="icon-image">{{ app.icon }}</div>
|
||||
<div class="icon-label">{{ app.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-icon {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.desktop-icon:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.desktop-icon.is-dragging {
|
||||
transform: scale(1.1);
|
||||
z-index: 1000;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.desktop-icon.is-selected .icon-container {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.icon-image {
|
||||
font-size: 32px;
|
||||
margin-bottom: 4px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
font-size: 11px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* Light theme adjustments */
|
||||
.theme-light .icon-label {
|
||||
color: #333;
|
||||
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.theme-light .desktop-icon.is-selected .icon-container {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
|
@ -2,15 +2,19 @@
|
|||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useWindowsStore } from '../stores/windows';
|
||||
import { useAppsStore } from '../stores/apps';
|
||||
import { useUIStore } from '../stores/ui';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const windowsStore = useWindowsStore();
|
||||
const appsStore = useAppsStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const { windows } = storeToRefs(windowsStore);
|
||||
const { appInstances } = storeToRefs(appsStore);
|
||||
const { focusWindow } = windowsStore;
|
||||
const { focusAppInstance } = appsStore;
|
||||
const { toggleStartMenu } = uiStore;
|
||||
|
||||
// --- Datetime Logic ---
|
||||
|
@ -36,12 +40,12 @@ const isLanguageMenuOpen = ref(false);
|
|||
const languageSwitcherWrapper = ref<HTMLElement | null>(null);
|
||||
const availableLanguages = computed(() => [
|
||||
{ key: 'en', label: 'English', display: 'EN' },
|
||||
{ key: 'zh', label: '繁體中文', display: '中' },
|
||||
{ key: 'zh', label: '繁體中文', display: '注' },
|
||||
]);
|
||||
|
||||
const currentLanguageDisplay = computed(() => {
|
||||
const current = availableLanguages.value.find(lang => lang.key === locale.value);
|
||||
return current?.display || '中';
|
||||
return current?.display || '注';
|
||||
});
|
||||
|
||||
function toggleLanguageMenu() {
|
||||
|
@ -73,8 +77,43 @@ onUnmounted(() => {
|
|||
});
|
||||
|
||||
function handleTaskbarButtonClick(windowId: string) {
|
||||
// Try to focus app instance first, then regular window
|
||||
const appInstance = appsStore.getAppInstanceById(windowId);
|
||||
if (appInstance) {
|
||||
focusAppInstance(windowId);
|
||||
} else {
|
||||
focusWindow(windowId);
|
||||
}
|
||||
}
|
||||
|
||||
// Combined taskbar items (windows + app instances)
|
||||
const taskbarItems = computed(() => {
|
||||
const items = [];
|
||||
|
||||
// Add regular windows
|
||||
windows.value.forEach(window => {
|
||||
items.push({
|
||||
id: window.id,
|
||||
title: window.title,
|
||||
isActive: window.isFocused,
|
||||
type: 'window'
|
||||
});
|
||||
});
|
||||
|
||||
// Add app instances
|
||||
appInstances.value.forEach(instance => {
|
||||
const appInfo = appsStore.getAppById(instance.appId);
|
||||
items.push({
|
||||
id: instance.id,
|
||||
title: instance.title,
|
||||
isActive: instance.isFocused,
|
||||
type: 'app',
|
||||
icon: appInfo?.icon
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -82,13 +121,14 @@ function handleTaskbarButtonClick(windowId: string) {
|
|||
<button @click="toggleStartMenu" class="start-button">🚀</button>
|
||||
<div class="window-list">
|
||||
<button
|
||||
v-for="window in windows"
|
||||
:key="window.id"
|
||||
v-for="item in taskbarItems"
|
||||
:key="item.id"
|
||||
class="taskbar-item"
|
||||
:class="{ 'is-active': window.isFocused }"
|
||||
@click="handleTaskbarButtonClick(window.id)"
|
||||
:class="{ 'is-active': item.isActive }"
|
||||
@click="handleTaskbarButtonClick(item.id)"
|
||||
>
|
||||
{{ window.title }}
|
||||
<span v-if="item.icon" class="taskbar-icon">{{ item.icon }}</span>
|
||||
<span class="taskbar-title">{{ item.title }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
@ -170,6 +210,21 @@ function handleTaskbarButtonClick(windowId: string) {
|
|||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.taskbar-icon {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.taskbar-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.taskbar-item:hover {
|
||||
|
|
|
@ -110,9 +110,15 @@ function 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')">_</button>
|
||||
<button @click.stop="toggleMaximize(window.id)" class="control-btn maximize" :title="$t('common.maximize')">[]</button>
|
||||
<button @click.stop="closeWindow(window.id)" class="control-btn close" :title="$t('common.close')">X</button>
|
||||
<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">
|
||||
|
@ -222,11 +228,55 @@ function onMouseDown() {
|
|||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.minimize { background: var(--control-btn-minimize-bg); }
|
||||
.maximize { background: var(--control-btn-maximize-bg); }
|
||||
.close { background: var(--control-btn-close-bg); }
|
||||
.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;
|
||||
|
|
|
@ -13,7 +13,9 @@ export function useBreakpoint() {
|
|||
|
||||
onMounted(() => {
|
||||
update();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', update);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
|
@ -53,6 +53,7 @@ export function useDraggable(target: Ref<HTMLElement | null>, options: UseDragga
|
|||
let newY = startY + dy;
|
||||
|
||||
let snapType: SnapType = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
if (event.clientX <= 1) {
|
||||
snapType = 'left';
|
||||
} else if (event.clientX >= window.innerWidth - 1) {
|
||||
|
@ -60,6 +61,7 @@ export function useDraggable(target: Ref<HTMLElement | null>, options: UseDragga
|
|||
} else if (event.clientY <= 1) {
|
||||
snapType = 'top';
|
||||
}
|
||||
}
|
||||
|
||||
if (snapType !== currentSnapType) {
|
||||
currentSnapType = snapType;
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
},
|
||||
"taskbar": {
|
||||
"language": "語言",
|
||||
"currentLanguage": "中"
|
||||
"currentLanguage": "注"
|
||||
},
|
||||
"common": {
|
||||
"createWindow": "建立視窗",
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
import { ref, computed } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export interface AppInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
component: string;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface AppInstance {
|
||||
id: string;
|
||||
appId: string;
|
||||
title: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
isMinimized: boolean;
|
||||
isMaximized: boolean;
|
||||
isFocused: boolean;
|
||||
}
|
||||
|
||||
let appInstanceIdCounter = 0;
|
||||
|
||||
export const useAppsStore = defineStore('apps', () => {
|
||||
// Available apps registry
|
||||
const availableApps = ref<AppInfo[]>([
|
||||
{
|
||||
id: 'calculator',
|
||||
name: 'Calculator',
|
||||
icon: '🧮',
|
||||
component: 'Calculator',
|
||||
description: 'A simple calculator for basic arithmetic operations',
|
||||
category: 'Utilities'
|
||||
},
|
||||
// More apps can be added here in the future
|
||||
]);
|
||||
|
||||
// Running app instances
|
||||
const appInstances = ref<AppInstance[]>([]);
|
||||
const nextZIndex = ref(100);
|
||||
|
||||
// Getters
|
||||
const getAppById = computed(() => {
|
||||
return (id: string) => availableApps.value.find(app => app.id === id);
|
||||
});
|
||||
|
||||
const getAppInstanceById = computed(() => {
|
||||
return (id: string) => appInstances.value.find(instance => instance.id === id);
|
||||
});
|
||||
|
||||
const orderedAppInstances = computed(() => {
|
||||
return [...appInstances.value].sort((a, b) => a.zIndex - b.zIndex);
|
||||
});
|
||||
|
||||
const focusedAppInstance = computed(() => {
|
||||
return appInstances.value.find(instance => instance.isFocused);
|
||||
});
|
||||
|
||||
// Actions
|
||||
function launchApp(appId: string) {
|
||||
const app = getAppById.value(appId);
|
||||
if (!app) {
|
||||
console.error(`App with id "${appId}" not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const newInstanceId = appInstanceIdCounter++;
|
||||
const newInstance: AppInstance = {
|
||||
id: `app-${appId}-${newInstanceId}`,
|
||||
appId: appId,
|
||||
title: `${app.name} #${newInstanceId + 1}`,
|
||||
x: Math.random() * 200 + 50,
|
||||
y: Math.random() * 100 + 50 + 48, // Below taskbar
|
||||
width: 320,
|
||||
height: 400,
|
||||
zIndex: nextZIndex.value++,
|
||||
isMinimized: false,
|
||||
isMaximized: false,
|
||||
isFocused: true,
|
||||
};
|
||||
|
||||
// Unfocus all other instances
|
||||
appInstances.value.forEach(instance => instance.isFocused = false);
|
||||
|
||||
appInstances.value.push(newInstance);
|
||||
return newInstance;
|
||||
}
|
||||
|
||||
function focusAppInstance(instanceId: string) {
|
||||
const instance = appInstances.value.find(i => i.id === instanceId);
|
||||
if (!instance || instance.isFocused) return;
|
||||
|
||||
// Unfocus all other instances
|
||||
appInstances.value.forEach(i => i.isFocused = false);
|
||||
|
||||
instance.zIndex = nextZIndex.value++;
|
||||
instance.isFocused = true;
|
||||
|
||||
if (instance.isMinimized) {
|
||||
instance.isMinimized = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAppInstance(instanceId: string) {
|
||||
appInstances.value = appInstances.value.filter(i => i.id !== instanceId);
|
||||
}
|
||||
|
||||
function minimizeAppInstance(instanceId: string) {
|
||||
const instance = appInstances.value.find(i => i.id === instanceId);
|
||||
if (instance) {
|
||||
instance.isMinimized = true;
|
||||
instance.isFocused = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMaximizeAppInstance(instanceId: string) {
|
||||
const instance = appInstances.value.find(i => i.id === instanceId);
|
||||
if (instance) {
|
||||
instance.isMaximized = !instance.isMaximized;
|
||||
focusAppInstance(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAppInstancePosition({ id, x, y }: { id: string; x: number; y: number }) {
|
||||
const instance = appInstances.value.find(i => i.id === id);
|
||||
if (instance && !instance.isMaximized) {
|
||||
instance.x = x;
|
||||
instance.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
function updateAppInstanceSize({ id, width, height }: { id: string; width: number; height: number }) {
|
||||
const instance = appInstances.value.find(i => i.id === id);
|
||||
if (instance && !instance.isMaximized) {
|
||||
instance.width = width;
|
||||
instance.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllAppInstances() {
|
||||
appInstances.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
availableApps,
|
||||
appInstances,
|
||||
nextZIndex,
|
||||
getAppById,
|
||||
getAppInstanceById,
|
||||
orderedAppInstances,
|
||||
focusedAppInstance,
|
||||
launchApp,
|
||||
focusAppInstance,
|
||||
closeAppInstance,
|
||||
minimizeAppInstance,
|
||||
toggleMaximizeAppInstance,
|
||||
updateAppInstancePosition,
|
||||
updateAppInstanceSize,
|
||||
closeAllAppInstances,
|
||||
};
|
||||
});
|
|
@ -0,0 +1,105 @@
|
|||
import { ref, computed } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { AppInfo } from './apps';
|
||||
|
||||
export interface DesktopIconPosition {
|
||||
appId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const useDesktopStore = defineStore('desktop', () => {
|
||||
// Desktop icon positions
|
||||
const iconPositions = ref<DesktopIconPosition[]>([]);
|
||||
|
||||
// Default grid positions for new icons
|
||||
const getNextGridPosition = () => {
|
||||
const iconSize = 80;
|
||||
const padding = 20;
|
||||
|
||||
// Check if we're in browser environment
|
||||
const screenWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
|
||||
const cols = Math.floor((screenWidth - padding) / (iconSize + padding));
|
||||
|
||||
const currentCount = iconPositions.value.length;
|
||||
const row = Math.floor(currentCount / cols);
|
||||
const col = currentCount % cols;
|
||||
|
||||
return {
|
||||
x: padding + col * (iconSize + padding),
|
||||
y: 48 + padding + row * (iconSize + padding) // Below taskbar
|
||||
};
|
||||
};
|
||||
|
||||
// Initialize desktop icons for available apps
|
||||
function initializeDesktopIcons(apps: AppInfo[]) {
|
||||
if (iconPositions.value.length === 0) {
|
||||
apps.forEach((app, index) => {
|
||||
const position = getNextGridPosition();
|
||||
iconPositions.value.push({
|
||||
appId: app.id,
|
||||
x: position.x,
|
||||
y: position.y
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update icon position
|
||||
function updateIconPosition(appId: string, x: number, y: number) {
|
||||
const icon = iconPositions.value.find(pos => pos.appId === appId);
|
||||
if (icon) {
|
||||
icon.x = x;
|
||||
icon.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
// Get icon position
|
||||
function getIconPosition(appId: string) {
|
||||
return iconPositions.value.find(pos => pos.appId === appId);
|
||||
}
|
||||
|
||||
// Add new icon to desktop
|
||||
function addIconToDesktop(appId: string) {
|
||||
const existing = iconPositions.value.find(pos => pos.appId === appId);
|
||||
if (!existing) {
|
||||
const position = getNextGridPosition();
|
||||
iconPositions.value.push({
|
||||
appId,
|
||||
x: position.x,
|
||||
y: position.y
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove icon from desktop
|
||||
function removeIconFromDesktop(appId: string) {
|
||||
iconPositions.value = iconPositions.value.filter(pos => pos.appId !== appId);
|
||||
}
|
||||
|
||||
// Auto-arrange icons in grid
|
||||
function arrangeIconsInGrid() {
|
||||
const iconSize = 80;
|
||||
const padding = 20;
|
||||
const screenWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
|
||||
const cols = Math.floor((screenWidth - padding) / (iconSize + padding));
|
||||
|
||||
iconPositions.value.forEach((icon, index) => {
|
||||
const row = Math.floor(index / cols);
|
||||
const col = index % cols;
|
||||
|
||||
icon.x = padding + col * (iconSize + padding);
|
||||
icon.y = 48 + padding + row * (iconSize + padding);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
iconPositions,
|
||||
initializeDesktopIcons,
|
||||
updateIconPosition,
|
||||
getIconPosition,
|
||||
addIconToDesktop,
|
||||
removeIconFromDesktop,
|
||||
arrangeIconsInGrid,
|
||||
};
|
||||
});
|
Loading…
Reference in New Issue