From b5e23e16b73b8fcc8610c9d913a6928d76e7c52f Mon Sep 17 00:00:00 2001 From: Butterfly Dev Date: Tue, 7 Apr 2026 03:28:44 +0000 Subject: [PATCH] =?UTF-8?q?desktop:=20components/window=20=E2=80=94=20drag?= =?UTF-8?q?gable/resizable=20window=20chrome=20with=20title=20bar,=20min/m?= =?UTF-8?q?ax/close=20controls,=20dark=20theme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/window/window.component.html | 52 ++++++++ .../components/window/window.component.scss | 111 +++++++++++++++++ .../app/components/window/window.component.ts | 114 ++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 desktop/src/app/components/window/window.component.html create mode 100644 desktop/src/app/components/window/window.component.scss create mode 100644 desktop/src/app/components/window/window.component.ts diff --git a/desktop/src/app/components/window/window.component.html b/desktop/src/app/components/window/window.component.html new file mode 100644 index 0000000..411c04d --- /dev/null +++ b/desktop/src/app/components/window/window.component.html @@ -0,0 +1,52 @@ +
+ +
+
+ {{ windowState.icon }} + {{ windowState.title }} +
+
+ + + +
+
+ + +
+ +
+ + +
+
diff --git a/desktop/src/app/components/window/window.component.scss b/desktop/src/app/components/window/window.component.scss new file mode 100644 index 0000000..c085ff2 --- /dev/null +++ b/desktop/src/app/components/window/window.component.scss @@ -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; +} diff --git a/desktop/src/app/components/window/window.component.ts b/desktop/src/app/components/window/window.component.ts new file mode 100644 index 0000000..5096c9e --- /dev/null +++ b/desktop/src/app/components/window/window.component.ts @@ -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(); + @Output() minimize = new EventEmitter(); + @Output() maximize = new EventEmitter(); + @Output() focus = new EventEmitter(); + @Output() geometryChange = new EventEmitter<{ id: string; x: number; y: number; width: number; height: number }>(); + + @ViewChild('titleBar') titleBar!: ElementRef; + @ViewChild('resizeHandle') resizeHandle!: ElementRef; + + 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); + } +}