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