minterm 0.1.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,425 @@
1
+ /** DOS-style draggable, resizable window manager */
2
+
3
+ import { Emitter } from './emitter.js';
4
+ import { getNextZ, bringToFront, cycleWindow } from './z-index.js';
5
+
6
+ const DEFAULTS = {
7
+ x: 50, y: 50, width: 400, height: null,
8
+ title: '', closable: false, lockable: false,
9
+ minWidth: 200, minHeight: 60,
10
+ classPrefix: 'mt',
11
+ };
12
+
13
+ export class WindowManager extends Emitter {
14
+ /**
15
+ * @param {object} [opts]
16
+ * @param {HTMLElement} [opts.container=document.body] — parent for windows
17
+ * @param {string} [opts.classPrefix='mt'] — CSS class prefix
18
+ * @param {number} [opts.layoutSlots=5] — number of layout snapshot slots (0 to disable keybinds)
19
+ * @param {boolean} [opts.layoutBar=false] — show clickable layout bar UI
20
+ * @param {boolean} [opts.lockable=false] — show lock button on all windows by default
21
+ * @param {boolean} [opts.escapeClose=true] — bind Escape to closeAll()
22
+ */
23
+ constructor(opts = {}) {
24
+ super();
25
+ this._container = opts.container || document.body;
26
+ this._pfx = opts.classPrefix || DEFAULTS.classPrefix;
27
+ this._lockable = opts.lockable ?? false;
28
+ this._escapeClose = opts.escapeClose ?? true;
29
+ this._windows = {};
30
+ this._dragState = null;
31
+ this._resizeState = null;
32
+ this._layouts = {};
33
+ this._layoutSlots = opts.layoutSlots ?? 5;
34
+ this._layoutBarEl = null;
35
+ this._bound = false;
36
+ this._bindEvents();
37
+ if (opts.layoutBar) this._createLayoutBar();
38
+ }
39
+
40
+ /** Create a window. Returns the window element. */
41
+ createWindow(id, opts = {}) {
42
+ if (this._windows[id]) return this._windows[id].el;
43
+
44
+ const o = { ...DEFAULTS, lockable: this._lockable, ...opts };
45
+ const pfx = this._pfx;
46
+
47
+ const win = document.createElement('div');
48
+ win.className = `${pfx}-win`;
49
+ win.id = `${pfx}-win-${id}`;
50
+ win.dataset.winId = id;
51
+ win.style.left = o.x + 'px';
52
+ win.style.top = o.y + 'px';
53
+ if (o.width) win.style.width = o.width + 'px';
54
+ if (o.height) win.style.height = o.height + 'px';
55
+ win.style.zIndex = getNextZ();
56
+ win.dataset.minW = o.minWidth;
57
+ win.dataset.minH = o.minHeight;
58
+
59
+ const lockBtn = o.lockable
60
+ ? `<span class="${pfx}-win-lock" data-lock="${id}" title="Lock window"><svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor"><path d="M9 1C9 .4 8.6 0 8 0S7 .4 7 1v4L4.5 6.5 4 7v1.5l3-.5V15l1 1 1-1V8l3 .5V7l-.5-.5L9 5V1z"/></svg></span>`
61
+ : '';
62
+
63
+ win.innerHTML = `
64
+ <div class="${pfx}-win-titlebar" data-win="${id}">
65
+ <span class="${pfx}-win-title">${o.title}</span>
66
+ ${lockBtn}${o.closable ? `<span class="${pfx}-win-close" data-close="${id}">\u2715</span>` : ''}
67
+ </div>
68
+ <div class="${pfx}-win-body" id="${pfx}-win-body-${id}"></div>
69
+ <div class="${pfx}-win-resize" data-win="${id}"></div>
70
+ `;
71
+
72
+ this._container.appendChild(win);
73
+ this._windows[id] = { el: win, locked: false };
74
+ this.emit('window:create', { id, el: win });
75
+ return win;
76
+ }
77
+
78
+ closeWindow(id) {
79
+ const w = this._windows[id];
80
+ if (!w) return;
81
+ w.el.remove();
82
+ delete this._windows[id];
83
+ this.emit('window:close', { id });
84
+ }
85
+
86
+ /** Close all closable, unlocked windows */
87
+ closeAll() {
88
+ for (const id of Object.keys(this._windows)) {
89
+ const w = this._windows[id];
90
+ if (w.locked) continue;
91
+ if (w.el.querySelector(`.${this._pfx}-win-close`)) {
92
+ this.closeWindow(id);
93
+ }
94
+ }
95
+ }
96
+
97
+ setContent(id, title, html) {
98
+ const w = this._windows[id];
99
+ if (!w) return;
100
+ const pfx = this._pfx;
101
+ w.el.querySelector(`.${pfx}-win-title`).textContent = title;
102
+ w.el.querySelector(`.${pfx}-win-body`).innerHTML = html;
103
+ }
104
+
105
+ getBody(id) {
106
+ const w = this._windows[id];
107
+ return w ? w.el.querySelector(`.${this._pfx}-win-body`) : null;
108
+ }
109
+
110
+ /**
111
+ * Shorthand: create a window and mount a widget in one call.
112
+ * @param {string} id — window id
113
+ * @param {Function} WidgetClass — widget constructor (e.g. MiniChart)
114
+ * @param {object} [widgetOpts] — options forwarded to widget constructor
115
+ * @param {object} [windowOpts] — options forwarded to createWindow
116
+ * @returns {object} the widget instance
117
+ */
118
+ addWidget(id, WidgetClass, widgetOpts = {}, windowOpts = {}) {
119
+ this.createWindow(id, windowOpts);
120
+ return new WidgetClass(this.getBody(id), widgetOpts);
121
+ }
122
+
123
+ getWindow(id) { return this._windows[id]?.el || null; }
124
+ has(id) { return !!this._windows[id]; }
125
+ getIds() { return Object.keys(this._windows); }
126
+
127
+ // ── Lock API ──
128
+
129
+ /** Lock a window — prevents drag and resize */
130
+ lockWindow(id) { this._setLocked(id, true); }
131
+
132
+ /** Unlock a window */
133
+ unlockWindow(id) { this._setLocked(id, false); }
134
+
135
+ /** Toggle lock state */
136
+ toggleLock(id) {
137
+ const w = this._windows[id];
138
+ if (w) this._setLocked(id, !w.locked);
139
+ }
140
+
141
+ /** Check if a window is locked */
142
+ isLocked(id) { return !!this._windows[id]?.locked; }
143
+
144
+ /** Show lock buttons on all windows that have them hidden */
145
+ showLockButtons() { this._toggleLockButtons(true); }
146
+
147
+ /** Hide lock buttons on all windows */
148
+ hideLockButtons() { this._toggleLockButtons(false); }
149
+
150
+ _setLocked(id, locked) {
151
+ const w = this._windows[id];
152
+ if (!w) return;
153
+ w.locked = locked;
154
+ const pfx = this._pfx;
155
+ const el = w.el;
156
+ if (locked) {
157
+ el.classList.add(`${pfx}-win-locked`);
158
+ } else {
159
+ el.classList.remove(`${pfx}-win-locked`);
160
+ }
161
+ const btn = el.querySelector(`.${pfx}-win-lock`);
162
+ if (btn) btn.title = locked ? 'Unlock window' : 'Lock window';
163
+ this.emit('window:lock', { id, locked });
164
+ }
165
+
166
+ _toggleLockButtons(show) {
167
+ const pfx = this._pfx;
168
+ for (const id in this._windows) {
169
+ const btn = this._windows[id].el.querySelector(`.${pfx}-win-lock`);
170
+ if (btn) btn.classList.toggle(`${pfx}-win-lock-hidden`, !show);
171
+ }
172
+ }
173
+
174
+ // ── Focus / Layout ──
175
+
176
+ focus(id) {
177
+ const w = this._windows[id];
178
+ if (w) {
179
+ bringToFront(w.el);
180
+ this.emit('window:focus', { id, el: w.el });
181
+ }
182
+ }
183
+
184
+ saveLayout(slot) {
185
+ const snap = {};
186
+ for (const [id, w] of Object.entries(this._windows)) {
187
+ const el = w.el;
188
+ snap[id] = {
189
+ left: el.style.left, top: el.style.top,
190
+ width: el.style.width, height: el.style.height,
191
+ zIndex: el.style.zIndex, locked: w.locked,
192
+ };
193
+ }
194
+ this._layouts[slot] = snap;
195
+ this.emit('layout:save', { slot });
196
+ this._updateLayoutBar();
197
+ }
198
+
199
+ restoreLayout(slot) {
200
+ const snap = this._layouts[slot];
201
+ if (!snap) return;
202
+ for (const [id, info] of Object.entries(snap)) {
203
+ const w = this._windows[id];
204
+ if (!w) continue;
205
+ Object.assign(w.el.style, { left: info.left, top: info.top, width: info.width });
206
+ if (info.height) w.el.style.height = info.height;
207
+ if (info.zIndex) w.el.style.zIndex = info.zIndex;
208
+ if (info.locked != null) this._setLocked(id, info.locked);
209
+ }
210
+ this.emit('layout:restore', { slot });
211
+ }
212
+
213
+ hasLayout(slot) { return !!this._layouts[slot]; }
214
+
215
+ cycle(reverse = false) {
216
+ cycleWindow(this._container, reverse);
217
+ }
218
+
219
+ _sel(cls) { return `.${this._pfx}-${cls}`; }
220
+
221
+ // ── Layout Bar UI ──
222
+
223
+ _createLayoutBar() {
224
+ if (this._layoutBarEl || this._layoutSlots <= 0) return;
225
+ const pfx = this._pfx;
226
+ const bar = document.createElement('div');
227
+ bar.className = `${pfx}-layout-bar`;
228
+ this._container.appendChild(bar);
229
+ this._layoutBarEl = bar;
230
+
231
+ bar.addEventListener('click', (e) => {
232
+ const slot = e.target.closest('[data-layout-slot]');
233
+ if (!slot) return;
234
+ const num = parseInt(slot.dataset.layoutSlot);
235
+ if (e.ctrlKey || e.metaKey) {
236
+ this.saveLayout(num);
237
+ } else if (this._layouts[num]) {
238
+ this.restoreLayout(num);
239
+ }
240
+ });
241
+
242
+ this._updateLayoutBar();
243
+ }
244
+
245
+ _updateLayoutBar() {
246
+ if (!this._layoutBarEl) return;
247
+ const pfx = this._pfx;
248
+ let html = '';
249
+ for (let i = 1; i <= this._layoutSlots; i++) {
250
+ const saved = !!this._layouts[i];
251
+ const cls = saved ? `${pfx}-layout-slot ${pfx}-layout-saved` : `${pfx}-layout-slot`;
252
+ html += `<span class="${cls}" data-layout-slot="${i}">${i}</span>`;
253
+ }
254
+ this._layoutBarEl.innerHTML = html;
255
+ }
256
+
257
+ // ── Event binding ──
258
+
259
+ _isLocked(win) {
260
+ const id = win?.dataset?.winId;
261
+ return id ? !!this._windows[id]?.locked : false;
262
+ }
263
+
264
+ _bindEvents() {
265
+ if (this._bound) return;
266
+ this._bound = true;
267
+ const self = this;
268
+ const pfx = this._pfx;
269
+
270
+ document.addEventListener('mousedown', (e) => {
271
+ const anyWin = e.target.closest(`.${pfx}-win`);
272
+ if (anyWin) {
273
+ bringToFront(anyWin);
274
+ const winId = anyWin.dataset.winId;
275
+ if (winId) self.emit('window:focus', { id: winId, el: anyWin });
276
+ }
277
+
278
+ // Lock button
279
+ const lockBtn = e.target.closest(`.${pfx}-win-lock`);
280
+ if (lockBtn) { self.toggleLock(lockBtn.dataset.lock); return; }
281
+
282
+ const closeBtn = e.target.closest(`.${pfx}-win-close`);
283
+ if (closeBtn) { self.closeWindow(closeBtn.dataset.close); return; }
284
+
285
+ const titlebar = e.target.closest(`.${pfx}-win-titlebar`);
286
+ if (titlebar) {
287
+ e.preventDefault();
288
+ const win = titlebar.closest(`.${pfx}-win`);
289
+ bringToFront(win);
290
+ if (!self._isLocked(win)) {
291
+ self._dragState = { el: win, ox: e.clientX - win.offsetLeft, oy: e.clientY - win.offsetTop, origX: win.offsetLeft, origY: win.offsetTop };
292
+ win.classList.add(`${pfx}-win-dragging`);
293
+ }
294
+ return;
295
+ }
296
+ const rh = e.target.closest(`.${pfx}-win-resize`);
297
+ if (rh) {
298
+ e.preventDefault();
299
+ const win = rh.closest(`.${pfx}-win`);
300
+ bringToFront(win);
301
+ if (!self._isLocked(win)) {
302
+ self._resizeState = { el: win, startX: e.clientX, startY: e.clientY, startW: win.offsetWidth, startH: win.offsetHeight };
303
+ }
304
+ }
305
+ });
306
+
307
+ document.addEventListener('mousemove', (e) => {
308
+ if (self._dragState) {
309
+ e.preventDefault();
310
+ const d = self._dragState;
311
+ d.tx = e.clientX - d.ox - d.origX;
312
+ d.ty = e.clientY - d.oy - d.origY;
313
+ d.el.style.transform = `translate(${d.tx}px,${d.ty}px)`;
314
+ self.emit('window:move', { id: d.el.dataset.winId, el: d.el });
315
+ }
316
+ if (self._resizeState) {
317
+ e.preventDefault();
318
+ const r = self._resizeState;
319
+ const minW = parseInt(r.el.dataset.minW) || 200;
320
+ const minH = parseInt(r.el.dataset.minH) || 60;
321
+ r.el.style.width = Math.max(minW, r.startW + e.clientX - r.startX) + 'px';
322
+ r.el.style.height = Math.max(minH, r.startH + e.clientY - r.startY) + 'px';
323
+ self.emit('window:resize', { id: r.el.dataset.winId, el: r.el });
324
+ }
325
+ });
326
+
327
+ document.addEventListener('mouseup', () => {
328
+ if (self._dragState) {
329
+ const d = self._dragState;
330
+ // Bake transform into left/top so offsetLeft/offsetTop stay correct
331
+ d.el.style.left = (d.origX + (d.tx || 0)) + 'px';
332
+ d.el.style.top = (d.origY + (d.ty || 0)) + 'px';
333
+ d.el.style.transform = '';
334
+ d.el.classList.remove(`${pfx}-win-dragging`);
335
+ self._dragState = null;
336
+ }
337
+ self._resizeState = null;
338
+ });
339
+
340
+ // Touch
341
+ document.addEventListener('touchstart', (e) => {
342
+ const lockBtn = e.target.closest(`.${pfx}-win-lock`);
343
+ if (lockBtn) { self.toggleLock(lockBtn.dataset.lock); return; }
344
+ const closeBtn = e.target.closest(`.${pfx}-win-close`);
345
+ if (closeBtn) { self.closeWindow(closeBtn.dataset.close); return; }
346
+ const titlebar = e.target.closest(`.${pfx}-win-titlebar`);
347
+ if (titlebar) {
348
+ const win = titlebar.closest(`.${pfx}-win`);
349
+ const t = e.touches[0];
350
+ bringToFront(win);
351
+ if (!self._isLocked(win)) {
352
+ self._dragState = { el: win, ox: t.clientX - win.offsetLeft, oy: t.clientY - win.offsetTop, origX: win.offsetLeft, origY: win.offsetTop };
353
+ win.classList.add(`${pfx}-win-dragging`);
354
+ }
355
+ return;
356
+ }
357
+ const rh = e.target.closest(`.${pfx}-win-resize`);
358
+ if (rh) {
359
+ const win = rh.closest(`.${pfx}-win`);
360
+ const t = e.touches[0];
361
+ bringToFront(win);
362
+ if (!self._isLocked(win)) {
363
+ self._resizeState = { el: win, startX: t.clientX, startY: t.clientY, startW: win.offsetWidth, startH: win.offsetHeight };
364
+ }
365
+ }
366
+ }, { passive: true });
367
+
368
+ document.addEventListener('touchmove', (e) => {
369
+ if (self._dragState) {
370
+ e.preventDefault();
371
+ const t = e.touches[0];
372
+ const d = self._dragState;
373
+ d.tx = t.clientX - d.ox - d.origX;
374
+ d.ty = t.clientY - d.oy - d.origY;
375
+ d.el.style.transform = `translate(${d.tx}px,${d.ty}px)`;
376
+ self.emit('window:move', { id: d.el.dataset.winId, el: d.el });
377
+ }
378
+ if (self._resizeState) {
379
+ e.preventDefault();
380
+ const t = e.touches[0];
381
+ const r = self._resizeState;
382
+ r.el.style.width = Math.max(parseInt(r.el.dataset.minW) || 200, r.startW + t.clientX - r.startX) + 'px';
383
+ r.el.style.height = Math.max(parseInt(r.el.dataset.minH) || 60, r.startH + t.clientY - r.startY) + 'px';
384
+ self.emit('window:resize', { id: r.el.dataset.winId, el: r.el });
385
+ }
386
+ }, { passive: false });
387
+
388
+ document.addEventListener('touchend', () => {
389
+ if (self._dragState) {
390
+ const d = self._dragState;
391
+ d.el.style.left = (d.origX + (d.tx || 0)) + 'px';
392
+ d.el.style.top = (d.origY + (d.ty || 0)) + 'px';
393
+ d.el.style.transform = '';
394
+ d.el.classList.remove(`${pfx}-win-dragging`);
395
+ self._dragState = null;
396
+ }
397
+ self._resizeState = null;
398
+ });
399
+
400
+ // Keyboard shortcuts
401
+ document.addEventListener('keydown', (e) => {
402
+ if (e.key === 'Escape') {
403
+ if (self._escapeClose) self.closeAll();
404
+ self.emit('escape');
405
+ return;
406
+ }
407
+
408
+ if (self._layoutSlots > 0) {
409
+ const num = parseInt(e.key);
410
+ if (num >= 1 && num <= self._layoutSlots) {
411
+ const isInput = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable;
412
+ if (e.ctrlKey || e.metaKey) {
413
+ e.preventDefault();
414
+ self.saveLayout(num);
415
+ } else if (!isInput && !e.altKey) {
416
+ e.preventDefault();
417
+ if (self._layouts[num]) {
418
+ self.restoreLayout(num);
419
+ }
420
+ }
421
+ }
422
+ }
423
+ });
424
+ }
425
+ }
package/src/z-index.js ADDED
@@ -0,0 +1,35 @@
1
+ /** Shared z-index counter for all window systems */
2
+ let nextZ = 100;
3
+
4
+ export function getNextZ() { return ++nextZ; }
5
+
6
+ export function bringToFront(el) {
7
+ if (el) el.style.zIndex = ++nextZ;
8
+ return nextZ;
9
+ }
10
+
11
+ /** Get all visible mt- windows sorted by z-index */
12
+ export function getAllWindows(container) {
13
+ const root = container || document;
14
+ return [...root.querySelectorAll('.mt-win')]
15
+ .filter(el => el.offsetParent !== null)
16
+ .sort((a, b) => (parseInt(a.style.zIndex) || 0) - (parseInt(b.style.zIndex) || 0));
17
+ }
18
+
19
+ /** Cycle to the next (or previous) window */
20
+ export function cycleWindow(container, reverse = false) {
21
+ const wins = getAllWindows(container);
22
+ if (wins.length < 2) return;
23
+ const top = wins[wins.length - 1];
24
+ let target;
25
+ if (reverse) {
26
+ target = wins[wins.length - 2];
27
+ } else {
28
+ top.style.zIndex = 1;
29
+ target = wins[0];
30
+ }
31
+ bringToFront(target);
32
+ const rect = target.getBoundingClientRect();
33
+ if (rect.top < 0) target.style.top = '10px';
34
+ if (rect.left < 0) target.style.left = '10px';
35
+ }