feat: add calculator app

This commit is contained in:
王性驊 2025-09-25 13:38:59 +08:00
parent 21e012015a
commit 1664b40480
11 changed files with 1364 additions and 33 deletions

353
components/AppWindow.vue Normal file
View File

@ -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>

262
components/Calculator.vue Normal file
View File

@ -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>

View File

@ -2,21 +2,31 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useWindowsStore } from '../stores/windows'; import { useWindowsStore } from '../stores/windows';
import { useAppsStore } from '../stores/apps';
import { useDesktopStore } from '../stores/desktop';
import { useUIStore } from '../stores/ui'; import { useUIStore } from '../stores/ui';
import { useSettingsStore } from '../stores/settings'; // Import settings store import { useSettingsStore } from '../stores/settings';
import Window from './Window.vue'; import Window from './Window.vue';
import AppWindow from './AppWindow.vue';
import DesktopIcon from './DesktopIcon.vue';
import Taskbar from './Taskbar.vue'; import Taskbar from './Taskbar.vue';
import SnapPreview from './SnapPreview.vue'; import SnapPreview from './SnapPreview.vue';
import StartMenu from './StartMenu.vue'; import StartMenu from './StartMenu.vue';
import type { SnapType } from '../composables/useDraggable'; import type { SnapType } from '../composables/useDraggable';
const windowsStore = useWindowsStore(); const windowsStore = useWindowsStore();
const appsStore = useAppsStore();
const desktopStore = useDesktopStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { orderedWindows } = storeToRefs(windowsStore); 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 { createWindow, snapWindow, closeAllWindows } = windowsStore;
const { launchApp, closeAllAppInstances } = appsStore;
const { initializeDesktopIcons, updateIconPosition } = desktopStore;
const { closeStartMenu } = uiStore; const { closeStartMenu } = uiStore;
const { toggleTheme } = settingsStore; const { toggleTheme } = settingsStore;
@ -32,11 +42,11 @@ function handleAbout() { handleMenuAction(() => console.log('About clicked')); }
function handleSettings() { handleMenuAction(() => console.log('Settings clicked')); } function handleSettings() { handleMenuAction(() => console.log('Settings clicked')); }
function handleSignOut() { handleMenuAction(() => console.log('Sign Out clicked')); } function handleSignOut() { handleMenuAction(() => console.log('Sign Out clicked')); }
function handleToggleTheme() { handleMenuAction(toggleTheme); } function handleToggleTheme() { handleMenuAction(toggleTheme); }
function handleCloseAllWindows() { handleMenuAction(closeAllWindows); } function handleCloseAllWindows() { handleMenuAction(() => { closeAllWindows(); closeAllAppInstances(); }); }
// -------------------------------- // --------------------------------
function handleSnapPreview(snapType: SnapType) { function handleSnapPreview(snapType: SnapType) {
if (!snapType) { if (!snapType || typeof window === 'undefined') {
snapPreview.value = null; snapPreview.value = null;
return; return;
} }
@ -60,19 +70,90 @@ function handleSnapPreview(snapType: SnapType) {
function handleSnapExecute({ windowId, snapType }: { windowId: string; snapType: SnapType }) { function handleSnapExecute({ windowId, snapType }: { windowId: string; snapType: SnapType }) {
snapPreview.value = null; 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); snapWindow(windowId, snapType);
} }
}
} }
function handleDesktopClick() { function handleDesktopClick() {
closeStartMenu(); 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> </script>
<template> <template>
<div class="desktop" @click.self="handleDesktopClick"> <div class="desktop" @click.self="handleDesktopClick">
<!-- Regular Windows -->
<Window <Window
v-for="window in orderedWindows" v-for="window in orderedWindows"
:key="window.id" :key="window.id"
@ -81,6 +162,26 @@ function handleDesktopClick() {
@snap-execute="handleSnapExecute" @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" /> <SnapPreview v-if="snapPreview" :preview="snapPreview" />
<StartMenu <StartMenu
v-if="isStartMenuOpen" v-if="isStartMenuOpen"
@ -91,11 +192,14 @@ function handleDesktopClick() {
@close-all-windows="handleCloseAllWindows" @close-all-windows="handleCloseAllWindows"
/> />
<div class="fixed top-14 left-4 z-[9999] flex flex-col gap-2"> <!-- Legacy Window Button (hidden in corner) -->
<button @click="() => createWindow('New App')" class="bg-white/20 backdrop-blur-md text-white font-bold py-2 px-4 rounded-lg shadow-lg"> <button
+ {{ $t('common.createWindow') }} @click="() => createWindow('New Window')"
class="legacy-window-btn"
title="Create Legacy Window"
>
+
</button> </button>
</div>
<Taskbar /> <Taskbar />
</div> </div>
@ -110,4 +214,31 @@ function handleDesktopClick() {
overflow: hidden; overflow: hidden;
padding-top: 22px; 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> </style>

205
components/DesktopIcon.vue Normal file
View File

@ -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>

View File

@ -2,15 +2,19 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'; import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useWindowsStore } from '../stores/windows'; import { useWindowsStore } from '../stores/windows';
import { useAppsStore } from '../stores/apps';
import { useUIStore } from '../stores/ui'; import { useUIStore } from '../stores/ui';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t, locale, setLocale } = useI18n(); const { t, locale, setLocale } = useI18n();
const windowsStore = useWindowsStore(); const windowsStore = useWindowsStore();
const appsStore = useAppsStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const { windows } = storeToRefs(windowsStore); const { windows } = storeToRefs(windowsStore);
const { appInstances } = storeToRefs(appsStore);
const { focusWindow } = windowsStore; const { focusWindow } = windowsStore;
const { focusAppInstance } = appsStore;
const { toggleStartMenu } = uiStore; const { toggleStartMenu } = uiStore;
// --- Datetime Logic --- // --- Datetime Logic ---
@ -36,12 +40,12 @@ const isLanguageMenuOpen = ref(false);
const languageSwitcherWrapper = ref<HTMLElement | null>(null); const languageSwitcherWrapper = ref<HTMLElement | null>(null);
const availableLanguages = computed(() => [ const availableLanguages = computed(() => [
{ key: 'en', label: 'English', display: 'EN' }, { key: 'en', label: 'English', display: 'EN' },
{ key: 'zh', label: '繁體中文', display: '' }, { key: 'zh', label: '繁體中文', display: '' },
]); ]);
const currentLanguageDisplay = computed(() => { const currentLanguageDisplay = computed(() => {
const current = availableLanguages.value.find(lang => lang.key === locale.value); const current = availableLanguages.value.find(lang => lang.key === locale.value);
return current?.display || ''; return current?.display || '';
}); });
function toggleLanguageMenu() { function toggleLanguageMenu() {
@ -73,8 +77,43 @@ onUnmounted(() => {
}); });
function handleTaskbarButtonClick(windowId: string) { 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); 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> </script>
<template> <template>
@ -82,13 +121,14 @@ function handleTaskbarButtonClick(windowId: string) {
<button @click="toggleStartMenu" class="start-button">🚀</button> <button @click="toggleStartMenu" class="start-button">🚀</button>
<div class="window-list"> <div class="window-list">
<button <button
v-for="window in windows" v-for="item in taskbarItems"
:key="window.id" :key="item.id"
class="taskbar-item" class="taskbar-item"
:class="{ 'is-active': window.isFocused }" :class="{ 'is-active': item.isActive }"
@click="handleTaskbarButtonClick(window.id)" @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> </button>
</div> </div>
@ -170,6 +210,21 @@ function handleTaskbarButtonClick(windowId: string) {
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
white-space: nowrap; 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 { .taskbar-item:hover {

View File

@ -110,9 +110,15 @@ function onMouseDown() {
<div class="title-bar" ref="titleBarRef"> <div class="title-bar" ref="titleBarRef">
<div class="title">{{ window.title }}</div> <div class="title">{{ window.title }}</div>
<div class="controls"> <div class="controls">
<button @click.stop="minimizeWindow(window.id)" class="control-btn minimize" :title="$t('common.minimize')">_</button> <button @click.stop="minimizeWindow(window.id)" class="control-btn minimize" :title="$t('common.minimize')">
<button @click.stop="toggleMaximize(window.id)" class="control-btn maximize" :title="$t('common.maximize')">[]</button> <span class="control-icon"></span>
<button @click.stop="closeWindow(window.id)" class="control-btn close" :title="$t('common.close')">X</button> </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> </div>
<div class="content"> <div class="content">
@ -222,11 +228,55 @@ function onMouseDown() {
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
line-height: 1; line-height: 1;
transition: all 0.15s ease;
position: relative;
} }
.minimize { background: var(--control-btn-minimize-bg); } .control-btn:hover {
.maximize { background: var(--control-btn-maximize-bg); } transform: scale(1.1);
.close { background: var(--control-btn-close-bg); } 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 { .content {
flex-grow: 1; flex-grow: 1;

View File

@ -13,7 +13,9 @@ export function useBreakpoint() {
onMounted(() => { onMounted(() => {
update(); update();
if (typeof window !== 'undefined') {
window.addEventListener('resize', update); window.addEventListener('resize', update);
}
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@ -53,6 +53,7 @@ export function useDraggable(target: Ref<HTMLElement | null>, options: UseDragga
let newY = startY + dy; let newY = startY + dy;
let snapType: SnapType = null; let snapType: SnapType = null;
if (typeof window !== 'undefined') {
if (event.clientX <= 1) { if (event.clientX <= 1) {
snapType = 'left'; snapType = 'left';
} else if (event.clientX >= window.innerWidth - 1) { } 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) { } else if (event.clientY <= 1) {
snapType = 'top'; snapType = 'top';
} }
}
if (snapType !== currentSnapType) { if (snapType !== currentSnapType) {
currentSnapType = snapType; currentSnapType = snapType;

View File

@ -8,7 +8,7 @@
}, },
"taskbar": { "taskbar": {
"language": "語言", "language": "語言",
"currentLanguage": "" "currentLanguage": ""
}, },
"common": { "common": {
"createWindow": "建立視窗", "createWindow": "建立視窗",

166
stores/apps.ts Normal file
View File

@ -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,
};
});

105
stores/desktop.ts Normal file
View File

@ -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,
};
});