desktop: components/window — draggable/resizable window chrome with title bar, min/max/close controls, dark theme

This commit is contained in:
Butterfly Dev 2026-04-07 03:28:44 +00:00
parent 7991237978
commit b5e23e16b7
3 changed files with 277 additions and 0 deletions

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

View 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;
}

View 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);
}
}