diff --git a/src/services/windowForm/WindowFormRenderer.ts b/src/services/windowForm/WindowFormRenderer.ts index 16561d2..872cf98 100644 --- a/src/services/windowForm/WindowFormRenderer.ts +++ b/src/services/windowForm/WindowFormRenderer.ts @@ -1,6 +1,7 @@ import type { IEventBuilder } from '@/events/IEventBuilder' import type { IWindowFormEvents, IWindowFormInstance } from './WindowFormDataManager.ts' import { safeSubscribe } from '@/services/windowForm/utils.ts' +import '@/ui/webComponents/WindowFormElement.ts' /** * WindowFormRenderer @@ -8,54 +9,32 @@ import { safeSubscribe } from '@/services/windowForm/utils.ts' * 负责窗口的DOM创建、销毁与视觉状态更新。 */ export class WindowFormRenderer { - private eventBus: IEventBuilder + private wfEventBus: IEventBuilder - constructor(eventBus: IEventBuilder) { - this.eventBus = eventBus + constructor(wfEventBus: IEventBuilder) { + this.wfEventBus = wfEventBus } /** 创建窗口DOM结构 */ async createWindowElement(win: IWindowFormInstance) { - const el = document.createElement('div') - el.className = 'system-window' - el.dataset.id = win.id - el.style.cssText = ` - position:absolute; - width:${win.config.width}px; - height:${win.config.height}px; - left:${win.config.x ?? 200}px; - top:${win.config.y ?? 150}px; - background:#fff; - border-radius:8px; - border:1px solid #ccc; - box-shadow:0 4px 20px rgba(0,0,0,.15); - overflow:hidden; - transition:all .15s ease; - ` - // 标题栏 - const titleBar = this.createTitleBar(win) - const content = document.createElement('div') - content.className = 'window-content' - content.style.cssText = `width:100%;height:calc(100% - 40px);overflow:hidden;` - content.innerHTML = `
加载中...
` - - el.append(titleBar, content) - document.body.appendChild(el) + const el = document.createElement('window-form-element') + el.wfData = win win.element = el + document.body.appendChild(el) - // 生命周期UI响应 - safeSubscribe(win, this.eventBus, 'onLoadStart', id => { - if (id === win.id) this.showLoading(win) - }) - safeSubscribe(win, this.eventBus, 'onLoaded', id => { - if (id === win.id) this.hideLoading(win) - }) - safeSubscribe(win, this.eventBus, 'onError', (id, err) => { - if (id === win.id) this.showError(win, err) - }) - safeSubscribe(win, this.eventBus, 'onDestroy', id => { - if (id === win.id) this.destroy(win) - }) + // // 生命周期UI响应 + // safeSubscribe(win, this.eventBus, 'onLoadStart', id => { + // if (id === win.id) this.showLoading(win) + // }) + // safeSubscribe(win, this.eventBus, 'onLoaded', id => { + // if (id === win.id) this.hideLoading(win) + // }) + // safeSubscribe(win, this.eventBus, 'onError', (id, err) => { + // if (id === win.id) this.showError(win, err) + // }) + // safeSubscribe(win, this.eventBus, 'onDestroy', id => { + // if (id === win.id) this.destroy(win) + // }) } /** 创建标题栏 */ diff --git a/src/ui/webComponents/WindowFormElement.ts b/src/ui/webComponents/WindowFormElement.ts new file mode 100644 index 0000000..daa8da0 --- /dev/null +++ b/src/ui/webComponents/WindowFormElement.ts @@ -0,0 +1,904 @@ +import { LitElement, html, css, unsafeCSS } from 'lit' +import { customElement, property } from 'lit/decorators.js'; +import wfStyle from './css/wf.scss?inline' + +type TWindowFormState = 'default' | 'minimized' | 'maximized'; + +/** 拖拽移动开始的回调 */ +type TDragStartCallback = (x: number, y: number) => void; +/** 拖拽移动中的回调 */ +type TDragMoveCallback = (x: number, y: number) => void; +/** 拖拽移动结束的回调 */ +type TDragEndCallback = (x: number, y: number) => void; + +/** 拖拽调整尺寸的方向 */ +type TResizeDirection = + | 't' // 上 + | 'b' // 下 + | 'l' // 左 + | 'r' // 右 + | 'tl' // 左上 + | 'tr' // 右上 + | 'bl' // 左下 + | 'br'; // 右下 + +/** 拖拽调整尺寸回调数据 */ +interface IResizeCallbackData { + /** 宽度 */ + width: number; + /** 高度 */ + height: number; + /** 顶点坐标(相对 offsetParent) */ + top: number; + /** 左点坐标(相对 offsetParent) */ + left: number; + /** 拖拽调整尺寸的方向 */ + direction: TResizeDirection | null; +} + +/** 元素边界 */ +interface IElementRect { + /** 宽度 */ + width: number; + /** 高度 */ + height: number; + /** 顶点坐标(相对 offsetParent) */ + top: number; + /** 左点坐标(相对 offsetParent) */ + left: number; +} + +/** 窗口自定义事件 */ +export interface WindowFormEventMap extends HTMLElementEventMap { + 'windowForm:dragStart': CustomEvent; + 'windowForm:dragMove': CustomEvent; + 'windowForm:dragEnd': CustomEvent; + 'windowForm:resizeStart': CustomEvent; + 'windowForm:resizeMove': CustomEvent; + 'windowForm:resizeEnd': CustomEvent; + 'windowForm:stateChange': CustomEvent<{ state: TWindowFormState }>; + 'windowForm:stateChange:minimize': CustomEvent<{ state: TWindowFormState }>; + 'windowForm:stateChange:maximize': CustomEvent<{ state: TWindowFormState }>; + 'windowForm:stateChange:restore': CustomEvent<{ state: TWindowFormState }>; + 'windowForm:close': CustomEvent; + 'windowForm:minimize': CustomEvent; +} + +@customElement('window-form-element') +export class WindowFormElement extends LitElement { + // ==== 公共属性 ==== + @property({ type: String }) wid: string + @property({ type: String }) override title = 'Window' + @property({ type: Boolean }) resizable = true + @property({ type: Boolean }) minimizable = true + @property({ type: Boolean }) maximizable = true + @property({ type: Boolean }) closable = true + @property({ type: Boolean, reflect: true }) focused: boolean = true + @property({ type: String, reflect: true }) windowFormState: TWindowFormState = 'default' + @property({ type: Object }) dragContainer?: HTMLElement + @property({ type: Boolean }) allowOverflow = true // 允许窗口超出容器 + @property({ type: Number }) snapDistance = 20 // 吸附距离 + @property({ type: Boolean }) snapAnimation = true // 吸附动画 + @property({ type: Number }) snapAnimationDuration = 300 // 吸附动画时长 ms + @property({ type: Number }) maxWidth?: number = Infinity + @property({ type: Number }) minWidth?: number = 0 + @property({ type: Number }) maxHeight?: number = Infinity + @property({ type: Number }) minHeight?: number = 0 + @property({ type: String }) taskbarElementId?: string + @property({ type: Object }) wfData: any; + + private _listeners: Array<{ type: string; original: Function; wrapped: EventListener }> = [] + + // ==== 拖拽/缩放状态(内部变量,不触发渲染) ==== + private dragging = false + private resizeDir: TResizeDirection | null = null + private startX = 0 + private startY = 0 + private startWidth = 0 + private startHeight = 0 + private startX_host = 0 + private startY_host = 0 + + private x = 0 + private y = 0 + private preX = 0 + private preY = 0 + private width = 640 + private height = 360 + private animationFrame?: number + private resizing = false + + // private get x() { + // return this.wfData.state.x + // } + // private set x(value: number) { + // this.wfData.patch({ x: value }) + // } + // private get y() { + // return this.wfData.state.y + // } + // private set y(value: number) { + // this.wfData.patch({ y: value }) + // } + + // private windowFormState: TWindowFormState = 'default'; + /** 元素信息 */ + private targetBounds: IElementRect + /** 最小化前的元素信息 */ + private targetPreMinimizeBounds?: IElementRect + /** 最大化前的元素信息 */ + private targetPreMaximizedBounds?: IElementRect + + static override styles = css` + ${unsafeCSS(wfStyle)} + ` + + protected override createRenderRoot() { + const root = this.attachShadow({ mode: 'closed' }) + const sheet = new CSSStyleSheet() + sheet.replaceSync(wfStyle) + root.adoptedStyleSheets = [sheet] + return root + } + + public addManagedEventListener( + type: K, + handler: (this: WindowFormElement, ev: WindowFormEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ): void + public addManagedEventListener( + type: K, + handler: (ev: WindowFormEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ): void + /** + * 添加受管理的事件监听 + * @param type 事件类型 + * @param handler 事件处理函数 + */ + public addManagedEventListener( + type: K, + handler: + | ((this: WindowFormElement, ev: WindowFormEventMap[K]) => any) + | ((ev: WindowFormEventMap[K]) => any), + options?: boolean | AddEventListenerOptions + ) { + const wrapped: EventListener = (ev: Event) => { + (handler as any).call(this, ev as WindowFormEventMap[K]) + } + + this.addEventListener(type, wrapped, options) + this._listeners.push({ type, original: handler, wrapped }) + } + + public removeManagedEventListener( + type: K, + handler: + | ((this: WindowFormElement, ev: WindowFormEventMap[K]) => any) + | ((ev: WindowFormEventMap[K]) => any) + ) { + const index = this._listeners.findIndex( + l => l.type === type && l.original === handler + ) + if (index !== -1) { + const { type: t, wrapped } = this._listeners[index] + this.removeEventListener(t, wrapped) + this._listeners.splice(index, 1) + } + } + + /** + * 移除所有受管理事件监听 + */ + public removeAllManagedListeners() { + for (const { type, wrapped } of this._listeners) { + this.removeEventListener(type, wrapped) + } + this._listeners = [] + } + + override firstUpdated() { + console.log(this.wfData) + // wfem.addEventListener('windowFormFocus', this.windowFormFocusFun) + window.addEventListener('pointerup', this.onPointerUp) + window.addEventListener('pointermove', this.onPointerMove) + this.addEventListener('pointerdown', this.toggleFocus) + + const container = this.dragContainer || document.body + const containerRect = container.getBoundingClientRect() + this.x = containerRect.width / 2 - this.width / 2 + this.y = containerRect.height / 2 - this.height / 2 + this.style.width = `${this.width}px` + this.style.height = `${this.height}px` + this.style.transform = `translate(${this.x}px, ${this.y}px)` + + this.targetBounds = { + width: this.offsetWidth, + height: this.offsetHeight, + top: this.x, + left: this.y, + } + } + + override disconnectedCallback() { + super.disconnectedCallback() + window.removeEventListener('pointerup', this.onPointerUp) + window.removeEventListener('pointermove', this.onPointerMove) + this.removeEventListener('pointerdown', this.toggleFocus) + // wfem.removeEventListener('windowFormFocus', this.windowFormFocusFun) + this.removeAllManagedListeners() + } + + private windowFormFocusFun = (id: string) => { + if (id === this.wid) { + this.focused = true + } else { + this.focused = false + } + } + + private toggleFocus = () => { + this.focused = !this.focused + // wfem.notifyEvent('windowFormFocus', this.wid) + this.dispatchEvent( + new CustomEvent('update:focused', { + detail: this.focused, + bubbles: true, + composed: true, + }), + ) + } + + // ====== 拖拽 ====== + private onTitlePointerDown = (e: PointerEvent) => { + if (e.pointerType === 'mouse' && e.button !== 0) return + if ((e.target as HTMLElement).closest('.controls')) return + + e.preventDefault() + + this.dragging = true + this.startX = e.clientX + this.startY = e.clientY + this.preX = this.x + this.preY = this.y + this.setPointerCapture?.(e.pointerId) + + this.dispatchEvent( + new CustomEvent('windowForm:dragStart', { + detail: { x: this.x, y: this.y }, + bubbles: true, + composed: true, + }), + ) + } + + private onPointerMove = (e: PointerEvent) => { + if (this.dragging) { + const dx = e.clientX - this.startX + const dy = e.clientY - this.startY + + const x = this.preX + dx + const y = this.preY + dy + + this.applyPosition(x, y, false) + this.dispatchEvent( + new CustomEvent('windowForm:dragMove', { + detail: { x, y }, + bubbles: true, + composed: true, + }), + ) + } else if (this.resizeDir) { + this.performResize(e) + } + } + + private onPointerUp = (e: PointerEvent) => { + if (this.dragging) { + this.dragUp(e) + } + if (this.resizeDir) { + this.resizeUp(e) + } + this.dragging = false + this.resizing = false + this.resizeDir = null + document.body.style.cursor = '' + try { + this.releasePointerCapture?.(e.pointerId) + } catch {} + } + + /** 获取所有吸附点 */ + private getSnapPoints() { + const snapPoints = { x: [] as number[], y: [] as number[] } + const containerRect = (this.dragContainer || document.body).getBoundingClientRect() + const rect = this.getBoundingClientRect() + snapPoints.x = [0, containerRect.width - rect.width] + snapPoints.y = [0, containerRect.height - rect.height] + return snapPoints + } + + /** + * 获取最近的吸附点 + * @param x 左上角起始点x + * @param y 左上角起始点y + */ + private applySnapping(x: number, y: number) { + let snappedX = x, + snappedY = y + const containerSnap = this.getSnapPoints() + if (this.snapDistance > 0) { + for (const sx of containerSnap.x) + if (Math.abs(x - sx) <= this.snapDistance) { + snappedX = sx + break + } + for (const sy of containerSnap.y) + if (Math.abs(y - sy) <= this.snapDistance) { + snappedY = sy + break + } + } + return { x: snappedX, y: snappedY } + } + + private dragUp(e: PointerEvent) { + const snapped = this.applySnapping(this.x, this.y) + if (this.snapAnimation) { + this.animateTo(snapped.x, snapped.y, this.snapAnimationDuration, () => { + this.updateTargetBounds(snapped.x, snapped.y) + this.dispatchEvent( + new CustomEvent('windowForm:dragEnd', { + detail: { x: snapped.x, y: snapped.y }, + bubbles: true, + composed: true, + }), + ) + }) + } else { + this.applyPosition(snapped.x, snapped.y, true) + this.updateTargetBounds(snapped.x, snapped.y) + this.dispatchEvent( + new CustomEvent('windowForm:dragEnd', { + detail: { x: snapped.x, y: snapped.y }, + bubbles: true, + composed: true, + }), + ) + } + } + + private applyPosition(x: number, y: number, isFinal: boolean) { + this.x = x + this.y = y + this.style.transform = `translate(${x}px, ${y}px)` + if (isFinal) this.applyBoundary() + } + + private applyBoundary() { + if (this.allowOverflow) return + let { x, y } = { x: this.x, y: this.y } + + const rect = this.getBoundingClientRect() + const containerRect = (this.dragContainer || document.body).getBoundingClientRect() + x = Math.min(Math.max(x, 0), containerRect.width - rect.width) + y = Math.min(Math.max(y, 0), containerRect.height - rect.height) + + this.applyPosition(x, y, false) + } + + private animateTo(targetX: number, targetY: number, duration: number, onComplete?: () => void) { + if (this.animationFrame) cancelAnimationFrame(this.animationFrame) + const startX = this.x + const startY = this.y + const deltaX = targetX - startX + const deltaY = targetY - startY + const startTime = performance.now() + + const step = (now: number) => { + const elapsed = now - startTime + const progress = Math.min(elapsed / duration, 1) + const ease = 1 - Math.pow(1 - progress, 3) + + const x = startX + deltaX * ease + const y = startY + deltaY * ease + + this.applyPosition(x, y, false) + this.dispatchEvent( + new CustomEvent('windowForm:dragMove', { + detail: { x, y }, + bubbles: true, + composed: true, + }), + ) + + if (progress < 1) { + this.animationFrame = requestAnimationFrame(step) + } else { + this.applyPosition(targetX, targetY, true) + onComplete?.() + } + } + this.animationFrame = requestAnimationFrame(step) + } + + // ====== 缩放 ====== + private startResize = (dir: TResizeDirection, e: PointerEvent) => { + if (!this.resizable) return + if (e.pointerType === 'mouse' && e.button !== 0) return + + e.preventDefault() + e.stopPropagation() + this.resizing = true + this.resizeDir = dir + this.startX = e.clientX + this.startY = e.clientY + + const rect = this.getBoundingClientRect() + this.startWidth = rect.width + this.startHeight = rect.height + this.startX_host = rect.left + this.startY_host = rect.top + + const target = e.target as HTMLElement + document.body.style.cursor = target.style.cursor || window.getComputedStyle(target).cursor + + this.setPointerCapture?.(e.pointerId) + this.dispatchEvent( + new CustomEvent('windowForm:resizeStart', { + detail: { x: this.x, y: this.y, width: this.width, height: this.height, dir }, + bubbles: true, + composed: true, + }), + ) + } + + private performResize(e: PointerEvent) { + if (!this.resizeDir || !this.resizing) return + + let newWidth = this.startWidth + let newHeight = this.startHeight + let newX = this.startX_host + let newY = this.startY_host + + const dx = e.clientX - this.startX + const dy = e.clientY - this.startY + + switch (this.resizeDir) { + case 'r': + newWidth += dx + break + case 'b': + newHeight += dy + break + case 'l': + newWidth -= dx + newX += dx + break + case 't': + newHeight -= dy + newY += dy + break + case 'tl': + newWidth -= dx + newX += dx + newHeight -= dy + newY += dy + break + case 'tr': + newWidth += dx + newHeight -= dy + newY += dy + break + case 'bl': + newWidth -= dx + newX += dx + newHeight += dy + break + case 'br': + newWidth += dx + newHeight += dy + break + } + + const d = this.applyResizeBounds(newX, newY, newWidth, newHeight) + + this.dispatchEvent( + new CustomEvent('windowForm:resizeMove', { + detail: { dir: this.resizeDir, width: d.width, height: d.height, left: d.left, top: d.top }, + bubbles: true, + composed: true, + }), + ) + } + + /** + * 应用尺寸调整边界 + * @param newX 新的X坐标 + * @param newY 新的Y坐标 + * @param newWidth 新的宽度 + * @param newHeight 新的高度 + * @private + */ + private applyResizeBounds( + newX: number, + newY: number, + newWidth: number, + newHeight: number, + ): { + left: number + top: number + width: number + height: number + } { + // 最小/最大宽高限制 + if (this.minWidth != null) newWidth = Math.max(this.minWidth, newWidth) + if (this.maxWidth != null) newWidth = Math.min(this.maxWidth, newWidth) + if (this.minHeight != null) newHeight = Math.max(this.minHeight, newHeight) + if (this.maxHeight != null) newHeight = Math.min(this.maxHeight, newHeight) + + // 边界限制 + if (this.allowOverflow) { + this.x = newX + this.y = newY + this.width = newWidth + this.height = newHeight + this.style.width = `${newWidth}px` + this.style.height = `${newHeight}px` + this.applyPosition(newX, newY, false) + + return { + left: newX, + top: newY, + width: newWidth, + height: newHeight, + } + } + + const containerRect = (this.dragContainer || document.body).getBoundingClientRect() + newX = Math.min(Math.max(0, newX), containerRect.width - newWidth) + newY = Math.min(Math.max(0, newY), containerRect.height - newHeight) + + this.x = newX + this.y = newY + this.width = newWidth + this.height = newHeight + this.style.width = `${newWidth}px` + this.style.height = `${newHeight}px` + this.applyPosition(newX, newY, false) + + return { + left: newX, + top: newY, + width: newWidth, + height: newHeight, + } + } + + private resizeUp(e: PointerEvent) { + if (!this.resizable) return + + this.updateTargetBounds(this.x, this.y, this.width, this.height) + this.dispatchEvent( + new CustomEvent('windowForm:resizeEnd', { + detail: { + dir: this.resizeDir, + width: this.width, + height: this.height, + left: this.x, + top: this.y, + }, + bubbles: true, + composed: true, + }), + ) + } + + // ====== 窗口操作 ====== + // 最小化到任务栏 + private minimize() { + if (!this.taskbarElementId) return + if (this.windowFormState === 'minimized') return + this.targetPreMinimizeBounds = { ...this.targetBounds } + this.windowFormState = 'minimized' + + const taskbar = document.querySelector(this.taskbarElementId) + if (!taskbar) throw new Error('任务栏元素未找到') + + const rect = taskbar.getBoundingClientRect() + const startX = this.x + const startY = this.y + const startW = this.offsetWidth + const startH = this.offsetHeight + + this.animateWindow( + startX, + startY, + startW, + startH, + rect.left, + rect.top, + rect.width, + rect.height, + 400, + () => { + this.style.display = 'none' + this.dispatchEvent( + new CustomEvent('windowForm:stateChange:minimize', { + detail: { state: this.windowFormState }, + bubbles: true, + composed: true, + }) + ) + } + ) + } + + /** 最大化 */ + private maximize() { + if (this.windowFormState === 'maximized') { + this.restore() + return + } + this.targetPreMaximizedBounds = { ...this.targetBounds } + this.windowFormState = 'maximized' + + const rect = this.getBoundingClientRect() + + const startX = this.x + const startY = this.y + const startW = rect.width + const startH = rect.height + + const targetX = 0 + const targetY = 0 + const containerRect = (this.dragContainer || document.body).getBoundingClientRect() + const targetW = containerRect?.width ?? window.innerWidth + const targetH = containerRect?.height ?? window.innerHeight + + this.animateWindow( + startX, + startY, + startW, + startH, + targetX, + targetY, + targetW, + targetH, + 300, + () => { + this.dispatchEvent( + new CustomEvent('windowForm:stateChange:maximize', { + detail: { state: this.windowFormState }, + bubbles: true, + composed: true, + }), + ) + }, + ) + } + + /** 恢复到默认窗体状态 */ + private restore(onComplete?: () => void) { + if (this.windowFormState === 'default') return + let b: IElementRect + if ( + (this.windowFormState as TWindowFormState) === 'minimized' && + this.targetPreMinimizeBounds + ) { + // 最小化恢复,恢复到最小化前的状态 + b = this.targetPreMinimizeBounds + } else if ( + (this.windowFormState as TWindowFormState) === 'maximized' && + this.targetPreMaximizedBounds + ) { + // 最大化恢复,恢复到最大化前的默认状态 + b = this.targetPreMaximizedBounds + } else { + b = this.targetBounds + } + + this.windowFormState = 'default' + + this.style.display = 'block' + + const startX = this.x + const startY = this.y + const startW = this.offsetWidth + const startH = this.offsetHeight + + this.animateWindow( + startX, + startY, + startW, + startH, + b.left, + b.top, + b.width, + b.height, + 300, + () => { + onComplete?.() + this.dispatchEvent( + new CustomEvent('windowForm:stateChange:restore', { + detail: { state: this.windowFormState }, + bubbles: true, + composed: true, + }), + ) + }, + ) + } + + private windowFormClose() { + this.dispatchEvent( + new CustomEvent('windowForm:close', { + bubbles: true, + composed: true, + }), + ) + } + + /** + * 窗体最大化、最小化和恢复默认 动画 + * @param startX + * @param startY + * @param startW + * @param startH + * @param targetX + * @param targetY + * @param targetW + * @param targetH + * @param duration + * @param onComplete + * @private + */ + private animateWindow( + startX: number, + startY: number, + startW: number, + startH: number, + targetX: number, + targetY: number, + targetW: number, + targetH: number, + duration: number, + onComplete?: () => void, + ) { + const startTime = performance.now() + const step = (now: number) => { + const elapsed = now - startTime + const progress = Math.min(elapsed / duration, 1) + const ease = 1 - Math.pow(1 - progress, 3) + + const x = startX + (targetX - startX) * ease + const y = startY + (targetY - startY) * ease + const w = startW + (targetW - startW) * ease + const h = startH + (targetH - startH) * ease + + this.style.width = `${w}px` + this.style.height = `${h}px` + this.applyPosition(x, y, false) + + if (progress < 1) { + requestAnimationFrame(step) + } else { + this.style.width = `${targetW}px` + this.style.height = `${targetH}px` + this.applyPosition(targetX, targetY, true) + onComplete?.() + this.dispatchEvent( + new CustomEvent('windowForm:stateChange', { + detail: { state: this.windowFormState }, + bubbles: true, + composed: true, + }) + ) + } + } + requestAnimationFrame(step) + } + + private updateTargetBounds(left: number, top: number, width?: number, height?: number) { + this.targetBounds = { + left, + top, + width: width ?? this.offsetWidth, + height: height ?? this.offsetHeight, + } + } + + // ====== 渲染 ====== + override render() { + return html` +
+
+
${this.title}
+
+ ${this.minimizable + ? html`` + : null} + ${this.maximizable + ? html`` + : null} + ${this.closable + ? html`` + : null} +
+
+ +
+ + ${this.resizable + ? html` +
this.startResize('t', e)} + >
+
this.startResize('b', e)} + >
+
this.startResize('r', e)} + >
+
this.startResize('l', e)} + >
+
this.startResize('tr', e)} + >
+
this.startResize('tl', e)} + >
+
this.startResize('br', e)} + >
+
this.startResize('bl', e)} + >
+ ` + : null} +
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + 'window-form-element': WindowFormElement; + } + + interface WindowFormElementEventMap extends WindowFormEventMap {} +} diff --git a/src/ui/webComponents/css/wf.scss b/src/ui/webComponents/css/wf.scss new file mode 100644 index 0000000..86200f9 --- /dev/null +++ b/src/ui/webComponents/css/wf.scss @@ -0,0 +1,101 @@ +*, +*::before, +*::after { + box-sizing: border-box; /* 使用更直观的盒模型 */ + margin: 0; + padding: 0; +} + +:host { + position: absolute; + top: 0; + left: 0; + display: block; + z-index: 10; + user-select: none; + --titlebar-height: 32px; + --shadow: 0 10px 30px rgba(0,0,0,0.25); + font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial; +} + +:host([focused]) { + z-index: 11; + .window { + border-color: #8338ec; + } +} + +:host([windowFormState='maximized']) { + .window { + border-radius: 0; + box-shadow: none; + } +} + +.window { + position: absolute; + box-shadow: var(--shadow, 0 10px 30px rgba(0,0,0,0.25)); + background: linear-gradient(#ffffff, #f6f6f6); + border: 1px solid rgba(0,0,0,0.08); + border-radius: 6px; + overflow: hidden; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + + &.focus { + border-color: #3a86ff; + } +} + +.titlebar { + height: var(--titlebar-height); + display: flex; + align-items: center; + padding: 0 8px; + gap: 8px; + background: linear-gradient(#f2f2f2, #e9e9e9); + border-bottom: 1px solid rgba(0,0,0,0.06); + + .title { + font-size: 13px; + font-weight: 600; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #111; + } + + .controls { + display: flex; gap: 6px; + + button.ctrl { + width: 34px; + height: 24px; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-size: 12px; + + &:hover { + background: rgba(0,0,0,0.06); + } + } + } +} + +.content { flex: 1; overflow: auto; padding: 12px; background: transparent; } + +.resizer { position: absolute; z-index: 20; } +.resizer.t { height: 6px; left: 0; right: 0; top: -3px; cursor: ns-resize; } +.resizer.b { height: 6px; left: 0; right: 0; bottom: -3px; cursor: ns-resize; } +.resizer.r { width: 6px; top: 0; bottom: 0; right: -3px; cursor: ew-resize; } +.resizer.l { width: 6px; top: 0; bottom: 0; left: -3px; cursor: ew-resize; } +.resizer.tr { width: 12px; height: 12px; right: -6px; top: -6px; cursor: nesw-resize; } +.resizer.tl { width: 12px; height: 12px; left: -6px; top: -6px; cursor: nwse-resize; } +.resizer.br { width: 12px; height: 12px; right: -6px; bottom: -6px; cursor: nwse-resize; } +.resizer.bl { width: 12px; height: 12px; left: -6px; bottom: -6px; cursor: nesw-resize; }