Repository navigation
Expand file tree
/
Copy pathwindow-manager.js
More file actions
406 lines (383 loc) · 17 KB
/
Copy pathwindow-manager.js
File metadata and controls
406 lines (383 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
/* WindowManager: 无遮罩可拖动可关闭窗口系统
* 与全屏 overlay 不同: 窗口直接挂 body (position:fixed), 不铺背景/不锁滚动/无点击背景关闭;
* 支持拖标题栏自由移动、右下角拖动改大小、⛶ 最大化/还原、标题栏 ✕ 关闭、点击窗口聚焦置顶。
* 层级: 窗口层在 990000~998000 的有界区间内轮转 (绝不能冲过应用标题栏的 1000000, 否则
* 右上角的关闭按钮会被窗口盖住点不到); 顶部还会让开标题栏/菜单栏, 保证窗口自己的 ✕ 可点。
* 用法:
* var win = WindowManager.open({ title, content, width, height, x, y, className, onClose,
* minWidth, minHeight, resizable, maximizable });
* 可选 maxTitle/closeTitle/resizeTitle: 控件提示文案 (缺省走 windowManager.* 语言包)。
* win.close(); win.setTitle('...'); win.el; win.body;
* win.setSize(w, h); win.getSize(); win.toggleMax(); win.isMaximized(); win.onResize(fn);
* 内容需要跟着窗口大小变化时, 用 win.onResize(fn) (窗口元素上也会派发 'cw:resize' 事件)。
*/
(function () {
var root = (typeof window !== 'undefined') ? window : globalThis;
if (root.WindowManager) return;
var _windows = []; // 打开的窗口控制对象
// z-index 分层: 应用标题栏 1000000 / 菜单栏 1000001 (它们必须在窗口之上, 否则右上角的
// 关闭按钮会被窗口盖住点不到), 富文本提示 999600, 弹窗遮罩 999997~999999。
// 所以窗口层用一个有上限、可回收的区间, 绝不能无限递增冲进 1000000 以上。
var _zBase = 990000;
var _zMax = 998000;
var _zTop = _zBase;
var DEF_MIN_W = 320, DEF_MIN_H = 200;
function allocZ() {
if (_zTop >= _zMax) repackZ(); // 到顶就按当前前后顺序重新紧凑分配
_zTop += 1;
return _zTop;
}
function repackZ() {
var ws = _windows.slice().sort(function (a, b) {
return (parseInt(a.el.style.zIndex, 10) || 0) - (parseInt(b.el.style.zIndex, 10) || 0);
});
_zTop = _zBase;
for (var i = 0; i < ws.length; i++) {
_zTop += 1;
ws[i].el.style.zIndex = _zTop;
}
}
function frontmostZ() {
var m = 0;
for (var i = 0; i < _windows.length; i++) {
var z = parseInt(_windows[i].el.style.zIndex, 10) || 0;
if (z > m) m = z;
}
return m;
}
function bringToFront(win) {
if (win._closed) return;
// 只用有界计数器: 不能混用 frontmostZ()+1 —— 计数器回收时那个值会在回收前算出来,
// 于是「越大越回收、回收完又更大」, 最终冲破 1000000 盖住应用标题栏
win.el.style.zIndex = allocZ();
}
// 应用顶部 chrome (标题栏 + 菜单栏) 的高度: 窗口不能钻到它下面 ——
// 否则窗口自己的标题栏/✕ 会被盖住, 关不掉窗口; 应用右上角的关闭键也可能受影响
function chromeTop() {
if (typeof document === 'undefined' || !document.querySelector) return 0;
var h = 0;
var tb = document.querySelector('.title-bar');
if (tb && tb.offsetHeight) h += tb.offsetHeight;
var mb = document.querySelector('.menu-bar');
if (mb && mb.offsetHeight && mb.offsetParent !== null) h += mb.offsetHeight;
return h;
}
// 八向拖拽手柄: 四条边 (n/s/e/w) + 四个角 (ne/nw/se/sw)。
// se 沿用原来的 .cw-resize (带可见斜纹装饰, 也是旧测试/样式约定的锚点), 其余是纯热区。
var RESIZE_DIRS = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
function resizeHandlesHtml(opts) {
if (opts.resizable === false) return '';
var resizeTip = esc(opts.resizeTitle || defaultTitles().resize);
var out = '';
for (var i = 0; i < RESIZE_DIRS.length; i++) {
var d = RESIZE_DIRS[i];
// 只有右下角带可见斜纹, 其它方向是纯热区 (cursor 提示方向即可)
// se 的 class 里把 cw-resize 放最前: 老代码/样式/测试按 `class="cw-resize"` 精确匹配它
var cls = d === 'se'
? 'cw-resize cw-rz-deco cw-resize-edge cw-rz-se'
: 'cw-resize-edge cw-rz-' + d;
out += '<div class="' + cls + '" data-rz="' + d + '"'
+ (d === 'se' ? ' data-tip="' + resizeTip + '" title="' + resizeTip + '"' : '')
+ '></div>';
}
return out;
}
function open(opts) {
opts = opts || {};
var el = document.createElement('div');
el.className = 'cw-window' + (opts.className ? ' ' + opts.className : '');
var width = opts.width || 640;
var height = opts.height || 480;
var minW = opts.minWidth || DEF_MIN_W;
var minH = opts.minHeight || DEF_MIN_H;
el.style.width = width + 'px';
el.style.height = height + 'px';
el.style.minWidth = minW + 'px';
el.style.minHeight = minH + 'px';
var defTitle = defaultTitles();
var maxBtnHtml = (opts.maximizable === false) ? ''
: '<button type="button" class="cw-max" title="' + esc(opts.maxTitle || defTitle.max) + '">⛶</button>';
el.innerHTML =
'<div class="cw-titlebar">' +
'<span class="cw-title"></span>' +
maxBtnHtml +
'<button type="button" class="cw-close" data-tip="' + esc(opts.closeTitle || defTitle.close) + '" title="' + esc(opts.closeTitle || defTitle.close) + '">✕</button>' +
'</div>' +
'<div class="cw-body"></div>' +
resizeHandlesHtml(opts);
var titleEl = el.querySelector('.cw-title');
var bodyEl = el.querySelector('.cw-body');
var closeBtn = el.querySelector('.cw-close');
var maxBtn = el.querySelector('.cw-max');
var resizeEls = el.querySelectorAll('.cw-resize-edge');
titleEl.textContent = opts.title || '';
// 内容: HTMLElement 或 HTML 字符串
if (typeof opts.content === 'string') bodyEl.innerHTML = opts.content;
else if (opts.content && opts.content.nodeType === 1) bodyEl.appendChild(opts.content);
// 定位: 显式 x/y, 否则居中 (多窗口级联偏移)
var x = opts.x;
var y = opts.y;
if (x == null || y == null) {
var n = _windows.length;
var off = (n % 5) * 28;
if (x == null) x = Math.max(8, Math.round((window.innerWidth - width) / 2) + off);
if (y == null) y = Math.max(8, Math.round((window.innerHeight - height) / 2) + off);
}
clampPos(el, x, y);
var _restoreRect = null; // 最大化前的尺寸/位置
var _resizeCbs = []; // onResize 回调
var _resizeRAF = 0;
var win = {
el: el,
body: bodyEl,
opts: opts,
_closed: false,
setTitle: function (s) { titleEl.textContent = s; },
close: function () { close(win); },
getSize: function () { return { w: el.offsetWidth, h: el.offsetHeight }; },
setSize: function (w, h) {
el.style.width = Math.max(minW, Math.round(w)) + 'px';
el.style.height = Math.max(minH, Math.round(h)) + 'px';
clampPos(el, el.offsetLeft, el.offsetTop);
notifyResize(win, true);
},
isMaximized: function () { return !!_restoreRect; },
toggleMax: function () { toggleMax(win); },
onResize: function (fn) { if (typeof fn === 'function') _resizeCbs.push(fn); return win; },
_notifyResize: function (immediate) { notifyResize(win, immediate); },
_refit: function () { refit(win); },
_resizeCbs: _resizeCbs,
_getRestoreRect: function () { return _restoreRect; },
_setRestoreRect: function (r) { _restoreRect = r; },
_minW: minW,
_minH: minH,
};
function toggleMax(w) {
var cur = w._getRestoreRect();
if (cur) { // 还原
w._setRestoreRect(null);
el.style.width = cur.w + 'px';
el.style.height = cur.h + 'px';
clampPos(el, cur.x, cur.y);
if (maxBtn) { maxBtn.textContent = '⛶'; maxBtn.classList.remove('is-max'); }
} else { // 最大化: 铺到视口内, 顶部让开应用标题栏/菜单栏
w._setRestoreRect({ w: el.offsetWidth, h: el.offsetHeight, x: el.offsetLeft, y: el.offsetTop });
var m = 8;
var top = chromeTop() + m;
el.style.width = Math.max(w._minW, window.innerWidth - m * 2) + 'px';
el.style.height = Math.max(w._minH, window.innerHeight - top - m) + 'px';
el.style.left = m + 'px';
el.style.top = top + 'px';
if (maxBtn) { maxBtn.textContent = '❐'; maxBtn.classList.add('is-max'); }
}
notifyResize(win, true);
}
// 关闭按钮
if (opts.closable === false) {
closeBtn.style.display = 'none';
} else {
closeBtn.addEventListener('click', function () { close(win); });
}
if (maxBtn) maxBtn.addEventListener('click', function (e) { e.stopPropagation(); toggleMax(win); });
// 拖动: 标题栏 mousedown → 全局 mousemove/mouseup; 排除按钮
var titlebar = el.querySelector('.cw-titlebar');
titlebar.addEventListener('mousedown', function (e) {
if (e.button !== 0) return;
if (e.target === closeBtn || e.target === maxBtn) return;
startDrag(win, e);
});
// 双击标题栏 = 最大化/还原
titlebar.addEventListener('dblclick', function (e) {
if (e.target === closeBtn || e.target === maxBtn) return;
toggleMax(win);
});
// 八向拖动改大小 (拖动过程节流, 松手时再补一次)
for (var re = 0; re < resizeEls.length; re++) {
resizeEls[re].addEventListener('mousedown', function (e) {
if (e.button !== 0) return;
startResize(win, e, this.getAttribute('data-rz') || 'se');
});
}
// 聚焦置顶
el.addEventListener('mousedown', function () { bringToFront(win); });
document.body.appendChild(el);
el.style.zIndex = allocZ();
_windows.push(win);
return win;
}
// 尺寸变化通知: rAF 节流 (拖动时不必每像素都重绘), immediate = 立刻发
function notifyResize(win, immediate) {
var fire = function () {
win._resizeRAF = 0;
try {
win.el.dispatchEvent(new CustomEvent('cw:resize', { detail: { w: win.el.offsetWidth, h: win.el.offsetHeight } }));
} catch (e) { /* ignore */ }
for (var i = 0; i < win._resizeCbs.length; i++) {
try { win._resizeCbs[i](win.el.offsetWidth, win.el.offsetHeight); } catch (err) { /* ignore */ }
}
};
if (win._resizeRAF) { if (!immediate) return; cancelAnimationFrame(win._resizeRAF); win._resizeRAF = 0; }
if (immediate) fire();
else win._resizeRAF = requestAnimationFrame(fire);
}
// dir: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
// 拖 n/w 方向时窗口的 left/top 要跟着动, 且不能把宽高压到 min 以下 ——
// 用「先算新矩形, 再按 min 夹住, 最后反推 left/top」的写法避免抖动。
function startResize(win, e, dir) {
var el = win.el;
dir = dir || 'se';
var startX = e.clientX, startY = e.clientY;
var origW = el.offsetWidth, origH = el.offsetHeight;
var origL = el.offsetLeft, origT = el.offsetTop;
var west = dir.indexOf('w') >= 0, east = dir.indexOf('e') >= 0;
var north = dir.indexOf('n') >= 0, south = dir.indexOf('s') >= 0;
el.classList.add('is-resizing');
// 一旦手动改大小就退出「最大化」状态
win._setRestoreRect(null);
var maxBtn = el.querySelector('.cw-max');
if (maxBtn) { maxBtn.textContent = '⛶'; maxBtn.classList.remove('is-max'); }
var topLimit = chromeTop(); // 上边缘不能被拖到应用标题栏/菜单栏下面
function onMove(ev) {
var dx = ev.clientX - startX, dy = ev.clientY - startY;
var w = origW, h = origH, l = origL, t = origT;
if (east) w = origW + dx;
if (west) { w = origW - dx; }
if (south) h = origH + dy;
if (north) { h = origH - dy; }
// 夹住尺寸 (不小于最小尺寸)
w = Math.max(win._minW, w);
h = Math.max(win._minH, h);
// 西/北方向: 反向推 left/top, 保证被拖的那条边跟着指针走
if (west) l = origL + (origW - w);
if (north) t = origT + (origH - h);
// 上边缘不能被拖到应用标题栏/菜单栏下面 (先夹 top, 再把高度缩回来)
if (t < topLimit) {
if (north) h = Math.max(win._minH, h - (topLimit - t));
t = topLimit;
}
// 视口约束: 右/下不越界; 东/南边把尺寸夹回可用空间
if (!west) w = Math.min(w, Math.max(win._minW, window.innerWidth - 4 - l));
if (!north) h = Math.min(h, Math.max(win._minH, window.innerHeight - 4 - t));
w = Math.max(win._minW, Math.min(w, window.innerWidth - 8));
h = Math.max(win._minH, Math.min(h, window.innerHeight - 8));
if (l > window.innerWidth - 40) l = window.innerWidth - 40;
if (t > window.innerHeight - 28) t = window.innerHeight - 28;
el.style.width = Math.round(w) + 'px';
el.style.height = Math.round(h) + 'px';
el.style.left = Math.round(l) + 'px';
el.style.top = Math.round(t) + 'px';
win._notifyResize(false);
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
el.classList.remove('is-resizing');
win._notifyResize(true);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
e.stopPropagation();
}
function startDrag(win, e) {
var el = win.el;
var startX = e.clientX;
var startY = e.clientY;
var origLeft = el.offsetLeft;
var origTop = el.offsetTop;
el.classList.add('is-dragging');
function onMove(ev) {
clampPos(el, origLeft + (ev.clientX - startX), origTop + (ev.clientY - startY));
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
el.classList.remove('is-dragging');
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
}
// 约束窗口位置: 标题栏至少 28px 留在视口内, 左侧留 32px 可抓回;
// 顶部不越过应用标题栏/菜单栏, 否则窗口自己的 ✕ 会被它们盖住点不到
function clampPos(el, x, y) {
var w = el.offsetWidth, h = el.offsetHeight;
var vw = window.innerWidth, vh = window.innerHeight;
var top = chromeTop();
x = Math.min(Math.max(x, 40 - w), vw - 40);
y = Math.min(Math.max(y, top), vh - 28);
el.style.left = x + 'px';
el.style.top = y + 'px';
}
// 视口变化 (应用窗口被拉大/缩小) 时: 最大化中的窗口跟着铺满新视口,
// 普通窗口收回视口内 —— 否则右上角的 ✕ 可能跑到屏幕外, 窗口就关不掉了
function refitAll() {
for (var i = 0; i < _windows.length; i++) refit(_windows[i]);
}
function refit(win) {
if (!win || win._closed) return;
var el = win.el;
var m = 8, top = chromeTop() + m;
if (win._getRestoreRect()) {
el.style.width = Math.max(win._minW, window.innerWidth - m * 2) + 'px';
el.style.height = Math.max(win._minH, window.innerHeight - top - m) + 'px';
el.style.left = m + 'px';
el.style.top = top + 'px';
} else {
var w = Math.min(el.offsetWidth, window.innerWidth - 8);
var h = Math.min(el.offsetHeight, window.innerHeight - top - 8);
if (w !== el.offsetWidth) el.style.width = Math.max(win._minW, w) + 'px';
if (h !== el.offsetHeight) el.style.height = Math.max(win._minH, h) + 'px';
// 位置严格收进视口 (不能用 clampPos 的「留 40px」宽松规则, 那样 ✕ 会在屏幕外)
var x = Math.min(Math.max(el.offsetLeft, 0), Math.max(0, window.innerWidth - el.offsetWidth));
var y = Math.min(Math.max(el.offsetTop, top), Math.max(top, window.innerHeight - el.offsetHeight));
el.style.left = x + 'px';
el.style.top = y + 'px';
}
notifyResize(win, true);
}
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('resize', refitAll);
}
function close(win) {
if (win._closed) return;
win._closed = true;
win.el.remove();
var i = _windows.indexOf(win);
if (i >= 0) _windows.splice(i, 1);
var fn = win.opts.onClose;
if (fn) try { fn(); } catch (err) { console.error('WindowManager onClose error:', err); }
}
function esc(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
// 默认控件提示 (跟随语言设置; I18N 未就绪/不存在时回退中文源文案)
function tr(key, fb) {
try {
var T = root.I18N && root.I18N.t;
if (T) { var v = T(key); if (v && v !== key) return v; }
} catch (e) { /* ignore */ }
return fb;
}
function defaultTitles() {
return {
max: tr('windowManager.maxRestore', '最大化 / 还原'),
close: tr('windowManager.close', '关闭'),
resize: tr('windowManager.resize', '拖动改大小'),
};
}
root.WindowManager = {
open: open,
close: close,
refitAll: refitAll,
chromeTop: chromeTop,
// 诊断: 窗口层的 z-index 区间 (上限必须低于应用标题栏的 1000000)
debugZ: function () {
return {
base: _zBase, max: _zMax, top: _zTop,
windows: _windows.map(function (w) { return parseInt(w.el.style.zIndex, 10) || 0; })
};
},
get windows() { return _windows.slice(); },
};
})();