desktop: components/window — draggable/resizable window chrome with title bar, min/max/close controls, dark theme
This commit is contained in:
parent
7991237978
commit
b5e23e16b7
52
desktop/src/app/components/window/window.component.html
Normal file
52
desktop/src/app/components/window/window.component.html
Normal file
@ -0,0 +1,52 @@
|
||||
<div
|
||||
class="butterfly-window"
|
||||
[class.active]="windowState.isActive"
|
||||
[class.minimized]="windowState.isMinimized"
|
||||
[class.maximized]="windowState.isMaximized"
|
||||
[style.display]="windowState.isMinimized ? 'none' : 'flex'"
|
||||
[style.left.px]="windowState.isMaximized ? 0 : windowState.x"
|
||||
[style.top.px]="windowState.isMaximized ? 0 : windowState.y"
|
||||
[style.width.px]="windowState.isMaximized ? window.innerWidth : windowState.width"
|
||||
[style.height.px]="windowState.isMaximized ? (window.innerHeight - 48) : windowState.height"
|
||||
[style.z-index]="windowState.zIndex"
|
||||
>
|
||||
<!-- Title bar -->
|
||||
<div
|
||||
#titleBar
|
||||
class="window-titlebar"
|
||||
(mousedown)="onTitleBarMouseDown($event)"
|
||||
(dblclick)="onDoubleClickTitleBar()"
|
||||
>
|
||||
<div class="titlebar-left">
|
||||
<span class="window-icon">{{ windowState.icon }}</span>
|
||||
<span class="window-title">{{ windowState.title }}</span>
|
||||
</div>
|
||||
<div class="titlebar-controls">
|
||||
<button class="titlebar-btn minimize-btn" (click)="minimize.emit(windowState.id)" title="Minimize">
|
||||
<svg width="10" height="1"><rect width="10" height="1" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button class="titlebar-btn maximize-btn" (click)="maximize.emit(windowState.id)" title="Maximize">
|
||||
<svg width="10" height="10"><rect width="10" height="10" fill="none" stroke="currentColor" stroke-width="1"/></svg>
|
||||
</button>
|
||||
<button class="titlebar-btn close-btn" (click)="close.emit(windowState.id)" title="Close">
|
||||
<svg width="10" height="10">
|
||||
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.2"/>
|
||||
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Window body -->
|
||||
<div class="window-body">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle -->
|
||||
<div
|
||||
#resizeHandle
|
||||
class="window-resize-handle"
|
||||
*ngIf="!windowState.isMaximized"
|
||||
(mousedown)="onResizeMouseDown($event)"
|
||||
></div>
|
||||
</div>
|
||||
111
desktop/src/app/components/window/window.component.scss
Normal file
111
desktop/src/app/components/window/window.component.scss
Normal file
@ -0,0 +1,111 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.butterfly-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25), 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&.active {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.maximized {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Title bar ─────────────────────────────────────────────────────────────
|
||||
|
||||
.window-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 4px 0 12px;
|
||||
background: linear-gradient(180deg, #3c3c3c 0%, #2d2d2d 100%);
|
||||
color: #e0e0e0;
|
||||
cursor: default;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
.active & {
|
||||
background: linear-gradient(180deg, #4a4a4a 0%, #3a3a3a 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.window-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.titlebar-controls {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.titlebar-btn {
|
||||
width: 46px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.1s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&.close-btn:hover {
|
||||
background: #e81123;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Window body ───────────────────────────────────────────────────────────
|
||||
|
||||
.window-body {
|
||||
flex: 1;
|
||||
background: #1e1e1e;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// ── Resize handle ─────────────────────────────────────────────────────────
|
||||
|
||||
.window-resize-handle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
114
desktop/src/app/components/window/window.component.ts
Normal file
114
desktop/src/app/components/window/window.component.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
HostListener,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { type WindowState } from '../../services/window-manager.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-window',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './window.component.html',
|
||||
styleUrl: './window.component.scss',
|
||||
})
|
||||
export class WindowComponent implements AfterViewInit, OnDestroy {
|
||||
@Input({ required: true }) windowState!: WindowState;
|
||||
@Input() appContent: unknown = null; // will be projected via ng-content
|
||||
|
||||
@Output() close = new EventEmitter<string>();
|
||||
@Output() minimize = new EventEmitter<string>();
|
||||
@Output() maximize = new EventEmitter<string>();
|
||||
@Output() focus = new EventEmitter<string>();
|
||||
@Output() geometryChange = new EventEmitter<{ id: string; x: number; y: number; width: number; height: number }>();
|
||||
|
||||
@ViewChild('titleBar') titleBar!: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('resizeHandle') resizeHandle!: ElementRef<HTMLDivElement>;
|
||||
|
||||
private isDragging = false;
|
||||
private isResizing = false;
|
||||
private dragOffsetX = 0;
|
||||
private dragOffsetY = 0;
|
||||
private resizeStartX = 0;
|
||||
private resizeStartY = 0;
|
||||
private resizeStartW = 0;
|
||||
private resizeStartH = 0;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Click on window body to focus.
|
||||
this.element.addEventListener('mousedown', () => this.focus.emit(this.windowState.id));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
document.removeEventListener('mouseup', this.onMouseUp);
|
||||
}
|
||||
|
||||
get element(): HTMLElement {
|
||||
return this.elementRef.nativeElement;
|
||||
}
|
||||
|
||||
constructor(private elementRef: ElementRef) {}
|
||||
|
||||
// ── Drag ───────────────────────────────────────────────────────────────
|
||||
|
||||
onTitleBarMouseDown(event: MouseEvent): void {
|
||||
if (this.windowState.isMaximized) return;
|
||||
event.preventDefault();
|
||||
this.isDragging = true;
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
this.dragOffsetX = event.clientX - rect.left;
|
||||
this.dragOffsetY = event.clientY - rect.top;
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
this.focus.emit(this.windowState.id);
|
||||
}
|
||||
|
||||
// ── Resize ─────────────────────────────────────────────────────────────
|
||||
|
||||
onResizeMouseDown(event: MouseEvent): void {
|
||||
if (this.windowState.isMaximized) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isResizing = true;
|
||||
this.resizeStartX = event.clientX;
|
||||
this.resizeStartY = event.clientY;
|
||||
this.resizeStartW = this.windowState.width;
|
||||
this.resizeStartH = this.windowState.height;
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
this.focus.emit(this.windowState.id);
|
||||
}
|
||||
|
||||
private onMouseMove = (event: MouseEvent): void => {
|
||||
if (this.isDragging) {
|
||||
const x = event.clientX - this.dragOffsetX;
|
||||
const y = event.clientY - this.dragOffsetY;
|
||||
this.geometryChange.emit({ id: this.windowState.id, x, y, width: this.windowState.width, height: this.windowState.height });
|
||||
} else if (this.isResizing) {
|
||||
const dw = event.clientX - this.resizeStartX;
|
||||
const dh = event.clientY - this.resizeStartY;
|
||||
const width = Math.max(this.windowState.minWidth, this.resizeStartW + dw);
|
||||
const height = Math.max(this.windowState.minHeight, this.resizeStartH + dh);
|
||||
this.geometryChange.emit({ id: this.windowState.id, x: this.windowState.x, y: this.windowState.y, width, height });
|
||||
}
|
||||
};
|
||||
|
||||
private onMouseUp = (): void => {
|
||||
this.isDragging = false;
|
||||
this.isResizing = false;
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
document.removeEventListener('mouseup', this.onMouseUp);
|
||||
};
|
||||
|
||||
onDoubleClickTitleBar(): void {
|
||||
this.maximize.emit(this.windowState.id);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user