Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 116 additions & 4 deletions app/features/os/components/WindowFrame.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onUnmounted, ref, watch } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { useOsStore, type OsWindow } from "../stores/os";
import { useOsStore, type OsWindow, type SnapZone } from "../stores/os";

const props = defineProps<{ win: OsWindow }>();

Expand All @@ -27,6 +27,14 @@ watch(
},
);

const snapZone = ref<SnapZone | null>(null);
const armed = ref(false);
const downPoint = ref<{ x: number; y: number } | null>(null);
const TASKBAR = 48;
const EDGE = 24; // px from a viewport edge that activates a snap zone
const TOP = 6; // thin maximize trigger near the screen edge, Windows-style
const ARM = 8; // travel px that arms zones; below it, clicks are jitter

const style = computed(() => {
if (props.win.state === "maximized") {
return {
Expand All @@ -45,36 +53,140 @@ const style = computed(() => {
zIndex: props.win.z,
};
});

function clamp(x: number, min: number, max: number): number {
return Math.min(Math.max(x, min), max);
}

function zoneAt(pointerX: number, pointerY: number): SnapZone | null {
const vw = window.innerWidth;
const vh = window.innerHeight;
const atLeft = pointerX <= EDGE;
const atRight = pointerX >= vw - EDGE;
const atTop = pointerY <= TOP;
const atBottom = pointerY >= vh - TASKBAR - EDGE - 8;
if (atTop && atLeft) return "tl";
if (atTop && atRight) return "tr";
if (atBottom && atLeft) return "bl";
if (atBottom && atRight) return "br";
if (atTop) return "max";
if (atLeft) return "left";
if (atRight) return "right";
return null;
}

const zoneRect = computed(() => {
const zone = snapZone.value;
if (!zone) return null;
const vw = window.innerWidth;
const vh = window.innerHeight - TASKBAR;
const half = { left: 0, top: 0, width: vw / 2, height: vh };
const quarterH = vw / 2;
const quarterV = vh / 2;
switch (zone) {
case "left":
return half;
case "right":
return { ...half, left: vw / 2 };
case "tl":
return { ...half, width: quarterH, height: quarterV };
case "tr":
return { left: vw / 2, top: 0, width: quarterH, height: quarterV };
case "bl":
return { left: 0, top: quarterV, width: quarterH, height: quarterV };
case "br":
return { left: vw / 2, top: quarterV, width: quarterH, height: quarterV };
case "max":
return { left: 0, top: 0, width: vw, height: vh };
}
return null;
});

function onPointerDown(event: PointerEvent): void {
if (props.win.state === "maximized") return;
if (isPhone.value) return;
store.focusWindow(props.win.id);
dragging.value = true;
armed.value = false;
downPoint.value = { x: event.clientX, y: event.clientY };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}

function onPointerMove(event: PointerEvent): void {
if (!dragging.value) return;
if (!dragging.value) {
// A single move while holding the grab affords unsnapping, but the
// real restore happens on the first move after a snapped state.
return;
}
if (!armed.value) {
const start = downPoint.value;
if (!start) return;
const dx = Math.abs(event.clientX - start.x);
const dy = Math.abs(event.clientY - start.y);
if (dx < ARM && dy < ARM) return; // click jitter guard
armed.value = true;
}
if (props.win.preSnap) {
// Windows-style unsnap (NEXT.md 3.2): restore the saved size, then
// re-place the window so the pointer keeps its relative spot on the
// title bar instead of teleporting to the stale pre-snap origin.
const pre = props.win.preSnap;
const fracX = clamp((event.clientX - props.win.x) / props.win.width, 0, 1);
const grabY = clamp(event.clientY - props.win.y, 0, 30); // stays in the 36px header
store.unsnapWindow(props.win.id);
store.moveWindow(
props.win.id,
event.clientX - clamp(fracX * pre.width, 8, pre.width - 8),
event.clientY - grabY,
);
snapZone.value = zoneAt(event.clientX, event.clientY);
return;
}
const handle = dragHandle.value;
if (!handle) return;
const rect = handle.getBoundingClientRect();
const nx = clamp(props.win.x + event.movementX, -rect.width + 80, window.innerWidth - 80);
const ny = clamp(props.win.y + event.movementY, 0, window.innerHeight - 48 - 40);
store.moveWindow(props.win.id, nx, ny);
snapZone.value = zoneAt(event.clientX, event.clientY);
}

function onPointerUp(event: PointerEvent): void {
dragging.value = false;
armed.value = false;
downPoint.value = null;
const zone = snapZone.value;
snapZone.value = null;
if (zone) {
store.snapWindow(props.win.id, zone);
}
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
}

onUnmounted(() => {
dragging.value = false;
snapZone.value = null;
});

// A dragged pointer leaving the window still updates the zone highlight.
watch(dragging, (active) => {
if (!active) snapZone.value = null;
});
</script>

<template>
<Teleport to="body">
<div
v-if="zoneRect"
class="pointer-events-none fixed z-[999] rounded-lg border-2 border-indigo-400 bg-indigo-400/15 transition-all duration-100"
:style="{
left: `${zoneRect.left}px`,
top: `${zoneRect.top}px`,
width: `${zoneRect.width}px`,
height: `${zoneRect.height}px`,
}"
aria-hidden="true"
/>
</Teleport>
<section
ref="el"
v-show="!win.minimized"
Expand Down
68 changes: 68 additions & 0 deletions app/features/os/stores/os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { computed, ref } from "vue";

export type WindowState = "normal" | "minimized" | "maximized";

export type SnapZone = "left" | "right" | "max" | "tl" | "tr" | "bl" | "br";

export interface OsWindow {
id: string;
appId?: string;
Expand All @@ -14,6 +16,8 @@ export interface OsWindow {
z: number;
state: WindowState;
minimized: boolean;
// Geometry to restore when a snapped window is dragged away (NEXT.md 3.2).
preSnap?: { x: number; y: number; width: number; height: number } | null;
}

let nextId = 1;
Expand Down Expand Up @@ -72,6 +76,67 @@ export const useOsStore = defineStore("os", () => {
win.y = y;
}

function setWindowRect(
id: string,
rect: { x: number; y: number; width: number; height: number },
): void {
const win = windows.value.find((w) => w.id === id);
if (!win) return;
win.x = rect.x;
win.y = rect.y;
win.width = rect.width;
win.height = rect.height;
win.state = "normal";
win.minimized = false;
}

// Aero-style snap (NEXT.md 3.2): the original rect is remembered so a
// later drag away from the snapped position restores it.
function snapWindow(id: string, zone: SnapZone): void {
const win = windows.value.find((w) => w.id === id);
if (!win) return;
const vw = window.innerWidth;
const vh = window.innerHeight - 48; // taskbar height
const gap = 8;
const half = {
x: zone === "right" ? vw / 2 : 0,
y: 0,
width: vw / 2,
height: vh,
};
const quarter = {
x: zone === "tr" || zone === "br" ? vw / 2 : 0,
y: zone === "bl" || zone === "br" ? vh / 2 : 0,
width: vw / 2,
height: vh / 2,
};
const snapped = zone === "max" ? null : zone === "left" || zone === "right" ? half : quarter;
if (zone === "max") {
if (win.state !== "maximized") win.preSnap = { x: win.x, y: win.y, width: win.width, height: win.height };
win.state = "maximized";
} else if (snapped) {
if (!win.preSnap) {
win.preSnap = {
x: win.state === "maximized" ? (window.innerWidth - win.width) / 2 : win.x,
y: win.state === "maximized" ? 0 : win.y,
width: win.width,
height: win.state === "maximized" ? vh : win.height,
};
}
setWindowRect(id, { ...snapped, y: snapped.y + gap / 2, height: snapped.height - gap });
}
focusWindow(id);
}

// Dragging a snapped window away from its zone restores the stored rect.
function unsnapWindow(id: string): void {
const win = windows.value.find((w) => w.id === id);
if (!win || !win.preSnap) return;
const pre = win.preSnap;
win.preSnap = null;
setWindowRect(id, pre);
}

function toggleMaximize(id: string): void {
const win = windows.value.find((w) => w.id === id);
if (!win) return;
Expand All @@ -95,6 +160,9 @@ export const useOsStore = defineStore("os", () => {
closeWindow,
focusWindow,
moveWindow,
setWindowRect,
snapWindow,
unsnapWindow,
toggleMaximize,
toggleMinimize,
};
Expand Down
Loading
Loading