bun-app-window 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,436 @@
1
+ /**
2
+ * titlebar.ts — Win 11 风格的应用标题栏
3
+ *
4
+ * 为什么是组件而不是壳的内建能力:窗口壳只负责一个窗口,标题栏属于应用外观。
5
+ * 把外观内建进壳,等于替所有接入方做了审美决定;做成组件,接入方拿走的是一份
6
+ * 已经调好的观感,改不改、怎么改都随它。
7
+ *
8
+ * 三条边界:
9
+ *
10
+ * 1. **不依赖 `bun-app-window` 的任何内部模块。** 窗口控制走注入的 `bridge`,
11
+ * 因此这个组件也能用在 Electron / Tauri / 纯浏览器里。
12
+ * 2. **不依赖 DOM 类型声明。** `tsconfig` 的 `lib` 只有 ESNext,这里自带一套
13
+ * 最小的元素接口;运行时用真实的 DOM 对象,类型上按结构匹配。
14
+ * 3. **不自己建连接。** 谁连 WebSocket 是应用的事,组件只调 `bridge`。
15
+ *
16
+ * 尺寸与配色取自 Win 11:标题栏 32px、按钮 46×32、关闭悬停 `#c42b1c`。
17
+ * 全部颜色走 CSS 变量,覆盖 `--ws-tb-*` 即可换肤。
18
+ */
19
+
20
+ /** 窗口控制通道。实现方决定它接到哪里(IPC / Electron / 直接调用) */
21
+ export type TitleBarBridge = {
22
+ minimize(): void;
23
+ toggleMaximize(): void;
24
+ close(): void;
25
+ /** 开始一次原生窗口拖拽(含贴边分屏) */
26
+ drag(): void;
27
+ };
28
+
29
+ /** 组件真正用到的最小元素接口,按结构匹配真实 DOM 元素 */
30
+ export type TitleBarElement = {
31
+ className: string;
32
+ textContent: string | null;
33
+ readonly style: { setProperty(name: string, value: string): void };
34
+ append(...nodes: TitleBarElement[]): void;
35
+ appendChild(child: TitleBarElement): TitleBarElement;
36
+ setAttribute(name: string, value: string): void;
37
+ getAttribute(name: string): string | null;
38
+ addEventListener(type: string, fn: (ev: never) => void): void;
39
+ removeEventListener(type: string, fn: (ev: never) => void): void;
40
+ remove(): void;
41
+ };
42
+
43
+ export type TitleBarDocument = {
44
+ createElement(tag: string): TitleBarElement;
45
+ body?: TitleBarElement;
46
+ getElementById(id: string): TitleBarElement | null;
47
+ querySelector(selector: string): TitleBarElement | null;
48
+ };
49
+
50
+ type EventLike = {
51
+ button?: number;
52
+ target?: unknown;
53
+ preventDefault?(): void;
54
+ };
55
+
56
+ export type TitleBarIconSet = "unicode" | "fluent";
57
+
58
+ /**
59
+ * 按钮字形。
60
+ *
61
+ * `unicode` 是几何符号,任何字体都有,零依赖;
62
+ * `fluent` 是 Segoe Fluent Icons 的私有区码位,观感更接近 Win 11 原生,
63
+ * 但该字体缺失时会显示成豆腐块——所以不是默认值。
64
+ */
65
+ export const TITLEBAR_GLYPHS = {
66
+ unicode: { minimize: "\u2500", maximize: "\u25A1", restore: "\u2750", close: "\u2715" },
67
+ fluent: { minimize: "\uE921", maximize: "\uE922", restore: "\uE923", close: "\uE8BB" },
68
+ } as const;
69
+
70
+ /**
71
+ * 取某个按钮当前该显示的字形。
72
+ *
73
+ * 最大化按钮在两种状态下是两个字形,这是标题栏最容易做错的一处:
74
+ * 漏掉「已最大化」分支,用户就永远看到「最大化」图标。
75
+ */
76
+ export function glyphFor(
77
+ icons: TitleBarIconSet,
78
+ action: "minimize" | "maximize" | "close",
79
+ maximized: boolean,
80
+ ): string {
81
+ const set = TITLEBAR_GLYPHS[icons];
82
+ if (action === "maximize") {
83
+ return maximized ? set.restore : set.maximize;
84
+ }
85
+ return action === "minimize" ? set.minimize : set.close;
86
+ }
87
+
88
+ /** 最大/还原按钮的无障碍标签 */
89
+ export function labelFor(action: "minimize" | "maximize" | "close", maximized: boolean): string {
90
+ if (action === "minimize") {
91
+ return "最小化";
92
+ }
93
+ if (action === "close") {
94
+ return "关闭";
95
+ }
96
+ return maximized ? "向下还原" : "最大化";
97
+ }
98
+
99
+ const STYLE_ID = "bun-app-window-titlebar-css";
100
+
101
+ /**
102
+ * 组件样式。
103
+ *
104
+ * 用 `data-theme` 而非 media query 做主题开关:应用可能想让窗口主题与系统
105
+ * 深色模式无关(例如跟随自己的设置项)。缺省值仍跟随 `prefers-color-scheme`。
106
+ */
107
+ export function titleBarCss(): string {
108
+ return `
109
+ .ws-titlebar {
110
+ --ws-tb-height: 32px;
111
+ --ws-tb-bg: #202020;
112
+ --ws-tb-fg: #ffffff;
113
+ --ws-tb-dim: rgba(255, 255, 255, 0.786);
114
+ --ws-tb-hover: rgba(255, 255, 255, 0.0605);
115
+ --ws-tb-active: rgba(255, 255, 255, 0.0419);
116
+ --ws-tb-close-hover: #c42b1c;
117
+ --ws-tb-close-active: #b02a1c;
118
+ --ws-tb-border: rgba(255, 255, 255, 0.08);
119
+
120
+ display: flex;
121
+ align-items: stretch;
122
+ height: var(--ws-tb-height);
123
+ background: var(--ws-tb-bg);
124
+ color: var(--ws-tb-fg);
125
+ font: 400 12px/1 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
126
+ user-select: none;
127
+ -webkit-user-select: none;
128
+ border-bottom: 1px solid var(--ws-tb-border);
129
+ box-sizing: border-box;
130
+ }
131
+
132
+ .ws-titlebar[data-theme="light"] {
133
+ --ws-tb-bg: #f3f3f3;
134
+ --ws-tb-fg: #000000;
135
+ --ws-tb-dim: rgba(0, 0, 0, 0.6063);
136
+ --ws-tb-hover: rgba(0, 0, 0, 0.0373);
137
+ --ws-tb-active: rgba(0, 0, 0, 0.0241);
138
+ --ws-tb-border: rgba(0, 0, 0, 0.0578);
139
+ }
140
+
141
+ .ws-titlebar__lead {
142
+ display: flex;
143
+ align-items: center;
144
+ gap: 12px;
145
+ padding-inline-start: 12px;
146
+ min-width: 0;
147
+ flex: 1;
148
+ cursor: default;
149
+ }
150
+
151
+ .ws-titlebar__icon {
152
+ width: 16px;
153
+ height: 16px;
154
+ object-fit: contain;
155
+ flex: none;
156
+ }
157
+
158
+ .ws-titlebar__icon[hidden] { display: none; }
159
+
160
+ .ws-titlebar__title {
161
+ overflow: hidden;
162
+ text-overflow: ellipsis;
163
+ white-space: nowrap;
164
+ color: var(--ws-tb-fg);
165
+ }
166
+
167
+ .ws-titlebar__controls {
168
+ display: flex;
169
+ align-items: stretch;
170
+ flex: none;
171
+ }
172
+
173
+ .ws-titlebar__btn {
174
+ width: 46px;
175
+ height: 100%;
176
+ border: 0;
177
+ padding: 0;
178
+ background: transparent;
179
+ color: var(--ws-tb-dim);
180
+ font: inherit;
181
+ font-size: 10px;
182
+ line-height: 1;
183
+ display: grid;
184
+ place-items: center;
185
+ cursor: default;
186
+ border-radius: 0;
187
+ }
188
+
189
+ .ws-titlebar__btn:hover { background: var(--ws-tb-hover); color: var(--ws-tb-fg); }
190
+ .ws-titlebar__btn:active { background: var(--ws-tb-active); }
191
+
192
+ .ws-titlebar__btn--close:hover { background: var(--ws-tb-close-hover); color: #ffffff; }
193
+ .ws-titlebar__btn--close:active { background: var(--ws-tb-close-active); color: #ffffff; }
194
+
195
+ .ws-titlebar__btn:focus-visible {
196
+ outline: 1px solid var(--ws-tb-fg);
197
+ outline-offset: -2px;
198
+ }
199
+
200
+ .ws-titlebar[data-icons="fluent"] .ws-titlebar__btn {
201
+ font-family: "Segoe Fluent Icons", "Segoe MDL2 Assets", sans-serif;
202
+ }
203
+ `.trim();
204
+ }
205
+
206
+ export type TitleBarOptions = {
207
+ /** 窗口标题 */
208
+ title?: string;
209
+ /** 图标 URL。不给则隐藏图标位 */
210
+ icon?: string;
211
+ /** 窗口控制通道 */
212
+ bridge: TitleBarBridge;
213
+ /** 初始是否已最大化 */
214
+ maximized?: boolean;
215
+ /** 按钮字形,默认 `unicode` */
216
+ icons?: TitleBarIconSet;
217
+ /** 主题,缺省跟随 `prefers-color-scheme` */
218
+ theme?: "light" | "dark";
219
+ /** 挂载容器,缺省 `document.body` */
220
+ container?: TitleBarElement;
221
+ /** 覆盖文档对象(测试或特殊宿主用) */
222
+ document?: TitleBarDocument;
223
+ };
224
+
225
+ export type TitleBarHandle = {
226
+ readonly el: TitleBarElement;
227
+ setTitle(title: string): void;
228
+ setMaximized(maximized: boolean): void;
229
+ /** 组件最后一次被设置的最大化状态。用于核对与宿主推送是否同步 */
230
+ isMaximized(): boolean;
231
+ setTheme(theme: "light" | "dark"): void;
232
+ /** 移除 DOM 与事件监听。样式表保留(可能是别的实例注入的) */
233
+ destroy(): void;
234
+ };
235
+
236
+ function resolveDocument(doc?: TitleBarDocument): TitleBarDocument {
237
+ const found = doc ?? (globalThis as { document?: TitleBarDocument }).document;
238
+ if (!found) {
239
+ throw new Error("mountTitleBar: no document available; pass options.document");
240
+ }
241
+ return found;
242
+ }
243
+
244
+ /** 事件目标是否落在控件区(点按钮时不能触发窗口拖拽) */
245
+ function inControls(target: unknown): boolean {
246
+ const node = target as { closest?: (selector: string) => unknown } | null | undefined;
247
+ if (!node || typeof node.closest !== "function") {
248
+ return false;
249
+ }
250
+ return node.closest("[data-ws-tb-controls]") !== null;
251
+ }
252
+
253
+ /**
254
+ * 挂载标题栏,返回可更新状态的句柄。
255
+ *
256
+ * ```ts
257
+ * const bar = mountTitleBar({
258
+ * title: "我的应用",
259
+ * icon: "/icon.svg",
260
+ * bridge: {
261
+ * minimize: () => void api.window.minimize(),
262
+ * toggleMaximize: () => void api.window.toggleMaximize(),
263
+ * close: () => void api.window.close(),
264
+ * drag: () => void api.window.drag(),
265
+ * },
266
+ * });
267
+ *
268
+ * api.on("windowState", (s) => bar.setMaximized(s.maximized));
269
+ * ```
270
+ */
271
+ export function mountTitleBar(opts: TitleBarOptions): TitleBarHandle {
272
+ const doc = resolveDocument(opts.document);
273
+ const icons: TitleBarIconSet = opts.icons ?? "unicode";
274
+
275
+ // 样式只注入一次。多个标题栏实例(多窗口页面)共用同一份
276
+ if (!doc.getElementById(STYLE_ID)) {
277
+ const style = doc.createElement("style");
278
+ style.setAttribute("id", STYLE_ID);
279
+ style.textContent = titleBarCss();
280
+ doc.body?.appendChild(style);
281
+ }
282
+
283
+ const root = doc.createElement("header");
284
+ root.className = "ws-titlebar";
285
+ root.setAttribute("data-icons", icons);
286
+ if (opts.theme) {
287
+ root.setAttribute("data-theme", opts.theme);
288
+ }
289
+ if (opts.title !== undefined) {
290
+ root.setAttribute("title", opts.title);
291
+ }
292
+
293
+ const lead = doc.createElement("div");
294
+ lead.className = "ws-titlebar__lead";
295
+
296
+ const icon = doc.createElement("img");
297
+ icon.className = "ws-titlebar__icon";
298
+ icon.setAttribute("alt", "");
299
+ if (opts.icon) {
300
+ icon.setAttribute("src", opts.icon);
301
+ } else {
302
+ icon.setAttribute("hidden", "");
303
+ }
304
+
305
+ const title = doc.createElement("span");
306
+ title.className = "ws-titlebar__title";
307
+ title.textContent = opts.title ?? "";
308
+
309
+ lead.append(icon, title);
310
+
311
+ const controls = doc.createElement("div");
312
+ controls.className = "ws-titlebar__controls";
313
+ controls.setAttribute("data-ws-tb-controls", "");
314
+
315
+ const buttons: Partial<Record<"minimize" | "maximize" | "close", TitleBarElement>> = {};
316
+ for (const action of ["minimize", "maximize", "close"] as const) {
317
+ const btn = doc.createElement("button");
318
+ btn.className =
319
+ action === "close" ? "ws-titlebar__btn ws-titlebar__btn--close" : "ws-titlebar__btn";
320
+ btn.setAttribute("type", "button");
321
+ btn.setAttribute("data-act", action);
322
+ btn.textContent = glyphFor(icons, action, opts.maximized === true);
323
+ btn.setAttribute("aria-label", labelFor(action, opts.maximized === true));
324
+ buttons[action] = btn;
325
+ controls.appendChild(btn);
326
+ }
327
+
328
+ root.append(lead, controls);
329
+
330
+ let maximizedState = opts.maximized === true;
331
+
332
+ const applyMaximized = (next: boolean): void => {
333
+ maximizedState = next;
334
+ const btn = buttons.maximize;
335
+ if (!btn) {
336
+ return;
337
+ }
338
+ btn.textContent = glyphFor(icons, "maximize", next);
339
+ btn.setAttribute("aria-label", labelFor("maximize", next));
340
+ };
341
+
342
+ /** 当前是否最大化。供调用方在本地校验状态是否同步 */
343
+ const isMaximized = (): boolean => maximizedState;
344
+
345
+ const onPointerDown = (ev: EventLike): void => {
346
+ if (ev.button !== undefined && ev.button !== 0) {
347
+ return;
348
+ }
349
+ if (inControls(ev.target)) {
350
+ return;
351
+ }
352
+ ev.preventDefault?.();
353
+ opts.bridge.drag();
354
+ };
355
+
356
+ const onDoubleClick = (ev: EventLike): void => {
357
+ if (inControls(ev.target)) {
358
+ return;
359
+ }
360
+ opts.bridge.toggleMaximize();
361
+ };
362
+
363
+ const onControlsClick = (ev: EventLike): void => {
364
+ const node = ev.target as { closest?: (s: string) => { getAttribute(n: string): string | null } | null } | null;
365
+ const btn = typeof node?.closest === "function" ? node.closest("[data-act]") : null;
366
+ const act = btn?.getAttribute("data-act");
367
+ if (act === "minimize") {
368
+ opts.bridge.minimize();
369
+ } else if (act === "maximize") {
370
+ opts.bridge.toggleMaximize();
371
+ } else if (act === "close") {
372
+ opts.bridge.close();
373
+ }
374
+ };
375
+
376
+ root.addEventListener("pointerdown", onPointerDown as (ev: never) => void);
377
+ root.addEventListener("dblclick", onDoubleClick as (ev: never) => void);
378
+ controls.addEventListener("click", onControlsClick as (ev: never) => void);
379
+
380
+ const container = opts.container ?? doc.body;
381
+ if (!container) {
382
+ throw new Error("mountTitleBar: no container and document.body is missing");
383
+ }
384
+ container.appendChild(root);
385
+
386
+ return {
387
+ get el(): TitleBarElement {
388
+ return root;
389
+ },
390
+ setTitle(next: string): void {
391
+ title.textContent = next;
392
+ root.setAttribute("title", next);
393
+ },
394
+ setMaximized: applyMaximized,
395
+ isMaximized,
396
+ setTheme(theme: "light" | "dark"): void {
397
+ root.setAttribute("data-theme", theme);
398
+ },
399
+ destroy(): void {
400
+ root.removeEventListener("pointerdown", onPointerDown as (ev: never) => void);
401
+ root.removeEventListener("dblclick", onDoubleClick as (ev: never) => void);
402
+ controls.removeEventListener("click", onControlsClick as (ev: never) => void);
403
+ root.remove();
404
+ },
405
+ };
406
+ }
407
+
408
+ /**
409
+ * 便利函数:把 `bun-app-window/ipc` 的客户端包成 `TitleBarBridge`。
410
+ *
411
+ * 传进来的服务需要暴露四个方法(见 `WindowService`)。单独做成函数而不是
412
+ * 让 `mountTitleBar` 自己认 IPC,是为了让组件与控制通道保持解耦。
413
+ */
414
+ export type WindowService = {
415
+ minimize(): Promise<unknown> | unknown;
416
+ toggleMaximize(): Promise<unknown> | unknown;
417
+ close(): Promise<unknown> | unknown;
418
+ drag(): Promise<unknown> | unknown;
419
+ };
420
+
421
+ export function bridgeFromWindowService(service: WindowService): TitleBarBridge {
422
+ return {
423
+ minimize: () => {
424
+ void service.minimize();
425
+ },
426
+ toggleMaximize: () => {
427
+ void service.toggleMaximize();
428
+ },
429
+ close: () => {
430
+ void service.close();
431
+ },
432
+ drag: () => {
433
+ void service.drag();
434
+ },
435
+ };
436
+ }