@janusui/janus-ui-library 0.0.1

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,1010 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, Injectable, signal, inject, input, computed, Directive, makeEnvironmentProviders, output, ChangeDetectionStrategy, Component, viewChild } from '@angular/core';
3
+ import { NgComponentOutlet } from '@angular/common';
4
+ import { ActivatedRoute } from '@angular/router';
5
+
6
+ /** All available color groups for iterating in dropdowns / pickers. */
7
+ const COLOR_GROUPS = [
8
+ 'red',
9
+ 'green',
10
+ 'blue',
11
+ 'yellow',
12
+ 'orange',
13
+ 'purple',
14
+ 'cyan',
15
+ 'magenta',
16
+ 'lime',
17
+ 'pink',
18
+ ];
19
+ /** CSS-friendly hex values for each color group. */
20
+ const COLOR_GROUP_HEX = {
21
+ red: '#ff1744',
22
+ green: '#00c853',
23
+ blue: '#2979ff',
24
+ yellow: '#ffd600',
25
+ orange: '#ff9100',
26
+ purple: '#d500f9',
27
+ cyan: '#00e5ff',
28
+ magenta: '#f50057',
29
+ lime: '#76ff03',
30
+ pink: '#ff80ab',
31
+ };
32
+
33
+ /** Injection token for the ScreenWidgetApi. Provided by ScreenWidgetComponent. */
34
+ const SCREEN_WIDGET_API = new InjectionToken('SCREEN_WIDGET_API');
35
+
36
+ /**
37
+ * Multi-provider token. Each widget module provides its metadata via this token.
38
+ * WorkspaceService collects all provided values to populate the widget registry.
39
+ */
40
+ const WIDGET_META = new InjectionToken('WIDGET_META');
41
+
42
+ /**
43
+ * Pure grid math engine. No DOM manipulation — just calculations.
44
+ * Angular owns all rendering via templates.
45
+ */
46
+ class LayoutEngine {
47
+ TARGET_CELL_WIDTH = 90;
48
+ MIN_COLUMNS = 6;
49
+ MAX_COLUMNS = 36;
50
+ CELL_HEIGHT = 20;
51
+ GAP = 4;
52
+ observers = new WeakMap();
53
+ /** Compute column count for a given container width. */
54
+ computeColumns(containerWidth) {
55
+ if (containerWidth <= 0)
56
+ return this.MIN_COLUMNS;
57
+ return Math.max(this.MIN_COLUMNS, Math.min(this.MAX_COLUMNS, Math.round(containerWidth / this.TARGET_CELL_WIDTH)));
58
+ }
59
+ /** Build full grid config from container width. */
60
+ getConfig(containerWidth) {
61
+ const columns = this.computeColumns(containerWidth);
62
+ const cellWidth = (containerWidth - this.GAP * (columns + 1)) / columns;
63
+ return {
64
+ columns,
65
+ cellWidth,
66
+ cellHeight: this.CELL_HEIGHT,
67
+ gap: this.GAP,
68
+ containerWidth,
69
+ };
70
+ }
71
+ /** Convert grid units to pixel rectangle. All units share the same uniform step. */
72
+ rectToPixel(cfg, col, row, w, h) {
73
+ const step = cfg.cellHeight + cfg.gap;
74
+ return {
75
+ left: col * step + cfg.gap,
76
+ top: row * step + cfg.gap,
77
+ width: Math.max(1, w * step - cfg.gap),
78
+ height: Math.max(1, h * step - cfg.gap),
79
+ };
80
+ }
81
+ /** Snap pixel position to nearest uniform grid cell. */
82
+ snapToGrid(cfg, px, py) {
83
+ const step = cfg.cellHeight + cfg.gap;
84
+ return {
85
+ col: Math.max(0, Math.round((px - cfg.gap) / step)),
86
+ row: Math.max(0, Math.round((py - cfg.gap) / step)),
87
+ };
88
+ }
89
+ /** Snap pixel size to nearest uniform grid size. */
90
+ snapSize(cfg, pw, ph, minCols, minRows) {
91
+ const step = cfg.cellHeight + cfg.gap;
92
+ const w = Math.max(minCols, Math.round((pw + cfg.gap) / step));
93
+ const h = Math.max(minRows, Math.round((ph + cfg.gap) / step));
94
+ return { w, h };
95
+ }
96
+ /** Observe container resizes and call back with new config. */
97
+ observe(container, onResize) {
98
+ const callback = () => onResize(this.getConfig(container.clientWidth));
99
+ const observer = new ResizeObserver(() => callback());
100
+ observer.observe(container);
101
+ this.observers.set(container, () => observer.disconnect());
102
+ // Fire initial.
103
+ callback();
104
+ }
105
+ /** Stop observing a container. */
106
+ unobserve(container) {
107
+ this.observers.get(container)?.();
108
+ this.observers.delete(container);
109
+ }
110
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: LayoutEngine, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
111
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: LayoutEngine, providedIn: 'root' });
112
+ }
113
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: LayoutEngine, decorators: [{
114
+ type: Injectable,
115
+ args: [{ providedIn: 'root' }]
116
+ }] });
117
+
118
+ /**
119
+ * Central singleton that tracks arbitrary JSON payloads per color group.
120
+ *
121
+ * Widgets assigned to the same color group all see the same accumulated
122
+ * data. Each widget publishes partial JSON — the service shallow-merges
123
+ * it into the group state. Keys a widget doesn't touch are left intact.
124
+ *
125
+ * To remove a key you previously set, publish it as `null`.
126
+ *
127
+ * Persistence is handled by WorkspaceService via {@link getAllData} /
128
+ * {@link setAllData}. This service does not touch localStorage directly.
129
+ */
130
+ class WidgetLinkService {
131
+ state = new Map();
132
+ constructor() {
133
+ for (const color of COLOR_GROUPS) {
134
+ this.state.set(color, signal({}));
135
+ }
136
+ }
137
+ // ---- readers --------------------------------------------------------------
138
+ /** Get the reactive data signal for a color group. */
139
+ getData(color) {
140
+ return this.state.get(color);
141
+ }
142
+ /** Read the current data value (non-reactive snapshot). */
143
+ getDataSnapshot(color) {
144
+ return this.state.get(color)();
145
+ }
146
+ // ---- writers --------------------------------------------------------------
147
+ /**
148
+ * Publish a partial JSON payload to a color group.
149
+ *
150
+ * Shallow-merged with the existing state:
151
+ * - New keys are added.
152
+ * - Existing keys the caller provides are overwritten.
153
+ * - Keys the caller does NOT provide survive untouched.
154
+ * - Keys with `null` or `undefined` values are removed.
155
+ */
156
+ publishData(color, partial) {
157
+ const current = this.state.get(color)();
158
+ const merged = { ...current };
159
+ for (const [key, value] of Object.entries(partial)) {
160
+ if (value === null || value === undefined) {
161
+ delete merged[key];
162
+ }
163
+ else {
164
+ merged[key] = value;
165
+ }
166
+ }
167
+ this.state.get(color).set(merged);
168
+ }
169
+ // ---- bulk -----------------------------------------------------------------
170
+ /** Reset all color groups to empty state. */
171
+ resetAll() {
172
+ for (const [, data] of this.state) {
173
+ data.set({});
174
+ }
175
+ }
176
+ // ---- persistence helpers (called by WorkspaceService) ---------------------
177
+ /**
178
+ * Return a snapshot of all color group data for serialization.
179
+ * Called by WorkspaceService during save.
180
+ */
181
+ getAllData() {
182
+ const snapshot = {};
183
+ for (const [color, sig] of this.state) {
184
+ snapshot[color] = sig();
185
+ }
186
+ return snapshot;
187
+ }
188
+ /**
189
+ * Restore color group data from a previously saved snapshot.
190
+ * Called by WorkspaceService during load.
191
+ */
192
+ setAllData(data) {
193
+ for (const color of COLOR_GROUPS) {
194
+ this.state.get(color).set(data[color] ?? {});
195
+ }
196
+ }
197
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WidgetLinkService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
198
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WidgetLinkService, providedIn: 'root' });
199
+ }
200
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WidgetLinkService, decorators: [{
201
+ type: Injectable,
202
+ args: [{
203
+ providedIn: 'root',
204
+ }]
205
+ }], ctorParameters: () => [] });
206
+
207
+ const STORAGE_KEY = 'janus-workspace';
208
+ const HB_PREFIX$1 = 'janus-hb-';
209
+ /** Seconds before a window's heartbeat is considered stale. */
210
+ const HB_STALE_MS = 10_000;
211
+ /** Generates a short unique ID. */
212
+ function uid() {
213
+ return crypto.randomUUID();
214
+ }
215
+ /** Default bounds for a newly created window. */
216
+ function defaultBounds() {
217
+ return { x: 100, y: 100, width: 1200, height: 800 };
218
+ }
219
+ /**
220
+ * Central singleton that owns the full workspace state:
221
+ * an array of WidgetWindows, each containing a grid of widget panels.
222
+ *
223
+ * All mutations flow through this service so that persistence and
224
+ * cross-window sync (BroadcastChannel) stay consistent.
225
+ */
226
+ class WorkspaceService {
227
+ // ---- state ----------------------------------------------------------------
228
+ /** All windows in the workspace. */
229
+ windows = signal([], ...(ngDevMode ? [{ debugName: "windows" }] : /* istanbul ignore next */ []));
230
+ /** Reactive list of registered widget type strings (for dropdowns). */
231
+ widgetTypes = signal([], ...(ngDevMode ? [{ debugName: "widgetTypes" }] : /* istanbul ignore next */ []));
232
+ /** Reactive list of widget labels (for dropdown display). */
233
+ widgetLabels = signal([], ...(ngDevMode ? [{ debugName: "widgetLabels" }] : /* istanbul ignore next */ []));
234
+ /** Registry mapping widget type strings to their metadata + component class. */
235
+ widgetRegistry = new Map();
236
+ #widgetLink = inject(WidgetLinkService);
237
+ constructor() {
238
+ const snapshot = this.#load();
239
+ // Sweep orphaned heartbeat keys that don't match any existing window.
240
+ this.#cleanupHeartbeats(snapshot.windows);
241
+ // Restore color-linked data before widget windows so widgets see it on init.
242
+ this.#widgetLink.setAllData(snapshot.colorData);
243
+ // main-window must always be open — guard against stale / tampered data.
244
+ snapshot.windows = snapshot.windows.map((w) => w.id === 'main-window' ? { ...w, state: 'open' } : w);
245
+ this.windows.set(snapshot.windows);
246
+ // Collect all widget metadata provided via DI (multi-provider injection token).
247
+ const metas = inject(WIDGET_META, { optional: true });
248
+ if (metas) {
249
+ for (const meta of [metas].flat()) {
250
+ this.#register(meta);
251
+ }
252
+ }
253
+ // Cross-window sync: listen for localStorage changes from other tabs/popups.
254
+ window.addEventListener('storage', (event) => {
255
+ if (event.key === STORAGE_KEY && event.newValue) {
256
+ try {
257
+ const parsed = JSON.parse(event.newValue);
258
+ if (parsed &&
259
+ typeof parsed === 'object' &&
260
+ Array.isArray(parsed.windows)) {
261
+ this.windows.set(parsed.windows);
262
+ if (parsed.colorData) {
263
+ this.#widgetLink.setAllData(parsed.colorData);
264
+ }
265
+ }
266
+ }
267
+ catch {
268
+ // Corrupt data — ignore.
269
+ }
270
+ }
271
+ });
272
+ // Detect stale popup heartbeats — only in the main window (not popups).
273
+ // Popups have their own sessionStorage, so their checker would lack the
274
+ // "janus-closed-*" flags and incorrectly process other windows.
275
+ if (!window.opener) {
276
+ setInterval(() => {
277
+ const now = Date.now();
278
+ const current = this.windows();
279
+ let changed = false;
280
+ const toRemove = new Set();
281
+ const staleHbIds = [];
282
+ const updated = current.map((win) => {
283
+ // Only process popup windows (not main-window).
284
+ if (win.id === 'main-window')
285
+ return win;
286
+ const hbKey = HB_PREFIX$1 + win.id;
287
+ const hb = localStorage.getItem(hbKey);
288
+ if (!hb || now - parseInt(hb, 10) > HB_STALE_MS) {
289
+ staleHbIds.push(win.id);
290
+ if (win.widgets.length === 0) {
291
+ changed = true;
292
+ toRemove.add(win.id);
293
+ return win; // will be filtered out below
294
+ }
295
+ // If the user intentionally closed this popup (state === 'closed'),
296
+ // leave it as-is — it appears in the reopen dropdown.
297
+ if (win.state === 'closed') {
298
+ return win;
299
+ }
300
+ // Still 'open' with a stale heartbeat — either closePopupWindow
301
+ // hasn't run yet (rare race) or this is after a refresh. Set
302
+ // state to 'closed' so it appears in the reopen dropdown.
303
+ changed = true;
304
+ return { ...win, state: 'closed' };
305
+ }
306
+ return win;
307
+ });
308
+ if (changed) {
309
+ this.windows.set(toRemove.size > 0
310
+ ? updated.filter((w) => !toRemove.has(w.id))
311
+ : updated);
312
+ this.#save();
313
+ }
314
+ // Clean up heartbeat keys we've already processed.
315
+ for (const id of staleHbIds) {
316
+ localStorage.removeItem(HB_PREFIX$1 + id);
317
+ }
318
+ }, 5_000);
319
+ }
320
+ }
321
+ // ---- public API -----------------------------------------------------------
322
+ /** Get metadata for a widget type. */
323
+ getWidgetMeta(type) {
324
+ return this.widgetRegistry.get(type);
325
+ }
326
+ // ---- window operations ----------------------------------------------------
327
+ /** Create a new empty WidgetWindow and add it to the workspace. */
328
+ addWindow(title = 'My Window') {
329
+ const win = {
330
+ id: uid(),
331
+ title,
332
+ bounds: defaultBounds(),
333
+ minimized: false,
334
+ state: 'open',
335
+ widgets: [],
336
+ };
337
+ this.windows.update((w) => [...w, win]);
338
+ this.#save();
339
+ return win;
340
+ }
341
+ /** Remove a window by ID. The last remaining window cannot be removed. */
342
+ removeWindow(windowId) {
343
+ const current = this.windows();
344
+ if (current.length <= 1)
345
+ return;
346
+ this.windows.set(current.filter((win) => win.id !== windowId));
347
+ this.#save();
348
+ }
349
+ /** Update a window's screen bounds (position / size). */
350
+ updateWindowBounds(windowId, bounds) {
351
+ this.windows.update((w) => w.map((win) => (win.id === windowId ? { ...win, bounds } : win)));
352
+ this.#save();
353
+ }
354
+ /** Toggle minimized state. */
355
+ toggleMinimize(windowId) {
356
+ this.windows.update((w) => w.map((win) => win.id === windowId ? { ...win, minimized: !win.minimized } : win));
357
+ this.#save();
358
+ }
359
+ /** Set a window's lifecycle state ('open' or 'closed'). */
360
+ setWindowState(windowId, state) {
361
+ // main-window can never be closed.
362
+ if (windowId === 'main-window' && state === 'closed')
363
+ return;
364
+ this.windows.update((w) => w.map((win) => (win.id === windowId ? { ...win, state } : win)));
365
+ this.#save();
366
+ }
367
+ /** Set a window's display title. Empty / blank titles default to 'My Window'. */
368
+ setWindowTitle(windowId, title) {
369
+ const safe = title.trim() || 'My Window';
370
+ this.windows.update((w) => w.map((win) => (win.id === windowId ? { ...win, title: safe } : win)));
371
+ this.#save();
372
+ }
373
+ // ---- widget operations ----------------------------------------------------
374
+ /**
375
+ * Add a widget panel to a specific window.
376
+ * Uses the widget's registered metadata for default sizing.
377
+ */
378
+ addWidget(windowId, type) {
379
+ const meta = this.widgetRegistry.get(type);
380
+ const panel = {
381
+ id: uid(),
382
+ type,
383
+ gridX: 0,
384
+ gridY: 0,
385
+ gridCols: meta?.defaultCols ?? 16,
386
+ gridRows: meta?.defaultRows ?? 3,
387
+ };
388
+ let created;
389
+ this.windows.update((wins) => wins.map((win) => {
390
+ if (win.id !== windowId)
391
+ return win;
392
+ created = panel;
393
+ return { ...win, widgets: [...win.widgets, panel] };
394
+ }));
395
+ this.#save();
396
+ return created;
397
+ }
398
+ /** Remove a widget panel by its ID. */
399
+ removeWidget(windowId, widgetId) {
400
+ this.windows.update((wins) => wins.map((win) => {
401
+ if (win.id !== windowId)
402
+ return win;
403
+ return {
404
+ ...win,
405
+ widgets: win.widgets.filter((w) => w.id !== widgetId),
406
+ };
407
+ }));
408
+ this.#save();
409
+ }
410
+ /** Update a widget's grid position and/or size. */
411
+ updateWidgetLayout(windowId, widgetId, patch) {
412
+ this.windows.update((wins) => wins.map((win) => {
413
+ if (win.id !== windowId)
414
+ return win;
415
+ return {
416
+ ...win,
417
+ widgets: win.widgets.map((w) => w.id === widgetId ? { ...w, ...patch } : w),
418
+ };
419
+ }));
420
+ this.#save();
421
+ }
422
+ /** Set the color group for a widget panel. */
423
+ setWidgetColorGroup(windowId, widgetId, colorGroup) {
424
+ this.windows.update((wins) => wins.map((win) => {
425
+ if (win.id !== windowId)
426
+ return win;
427
+ return {
428
+ ...win,
429
+ widgets: win.widgets.map((w) => w.id === widgetId ? { ...w, colorGroup } : w),
430
+ };
431
+ }));
432
+ this.#save();
433
+ }
434
+ // ---- persistence ----------------------------------------------------------
435
+ /** Serialize the entire workspace to localStorage. */
436
+ save() {
437
+ this.#save();
438
+ }
439
+ /** Replace all windows with a previously saved snapshot. */
440
+ restore(snapshot) {
441
+ this.windows.set(snapshot);
442
+ this.#save();
443
+ }
444
+ // ---- private helpers ------------------------------------------------------
445
+ #register(meta) {
446
+ this.widgetRegistry.set(meta.type, meta);
447
+ this.widgetTypes.set(Array.from(this.widgetRegistry.keys()));
448
+ this.widgetLabels.set(Array.from(this.widgetRegistry.values()).map((m) => m.label));
449
+ }
450
+ #save() {
451
+ try {
452
+ const snapshot = {
453
+ windows: this.windows(),
454
+ colorData: this.#widgetLink.getAllData(),
455
+ };
456
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
457
+ }
458
+ catch {
459
+ // localStorage unavailable — workspace still works in memory.
460
+ }
461
+ }
462
+ /** Remove heartbeat keys that don't belong to any existing window. */
463
+ #cleanupHeartbeats(windows) {
464
+ const knownIds = new Set(windows.map((w) => w.id));
465
+ for (let i = localStorage.length - 1; i >= 0; i--) {
466
+ const key = localStorage.key(i);
467
+ if (key?.startsWith(HB_PREFIX$1)) {
468
+ const id = key.slice(HB_PREFIX$1.length);
469
+ if (!knownIds.has(id)) {
470
+ localStorage.removeItem(key);
471
+ }
472
+ }
473
+ }
474
+ }
475
+ #load() {
476
+ try {
477
+ const raw = localStorage.getItem(STORAGE_KEY);
478
+ if (raw) {
479
+ const parsed = JSON.parse(raw);
480
+ // Handle legacy format (plain array of windows).
481
+ if (Array.isArray(parsed) && parsed.length > 0) {
482
+ const windows = parsed.map(({ detached: _, ...rest }) => ({ ...rest, state: rest['state'] ?? 'open', title: rest['title'] || 'My Window' }));
483
+ return { windows, colorData: {} };
484
+ }
485
+ // Handle new unified format.
486
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.windows)) {
487
+ // Strip legacy `detached` field, default missing `state` / `title`.
488
+ const windows = parsed.windows.map(({ detached: _, ...rest }) => ({ ...rest, state: rest['state'] ?? 'open', title: rest['title'] || 'My Window' }));
489
+ return {
490
+ windows,
491
+ colorData: parsed.colorData ?? {},
492
+ };
493
+ }
494
+ }
495
+ }
496
+ catch {
497
+ // Corrupt data or localStorage unavailable — start fresh.
498
+ }
499
+ // Default workspace: one empty window, no color data.
500
+ return {
501
+ windows: [
502
+ {
503
+ id: 'main-window',
504
+ title: 'Main',
505
+ bounds: defaultBounds(),
506
+ minimized: false,
507
+ state: 'open',
508
+ widgets: [],
509
+ },
510
+ ],
511
+ colorData: {},
512
+ };
513
+ }
514
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WorkspaceService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
515
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WorkspaceService, providedIn: 'root' });
516
+ }
517
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WorkspaceService, decorators: [{
518
+ type: Injectable,
519
+ args: [{
520
+ providedIn: 'root',
521
+ }]
522
+ }], ctorParameters: () => [] });
523
+
524
+ /**
525
+ * Format a number to a fixed number of decimal places.
526
+ */
527
+ function fmt(value, decimals = 2) {
528
+ return value.toFixed(decimals);
529
+ }
530
+
531
+ /**
532
+ * Abstract base class for all screen widgets.
533
+ *
534
+ * Provides the shared state and logic that every widget needs:
535
+ * - Inputs: widgetId, windowId, meta
536
+ * - Reactive signals: title, colorGroup, data
537
+ * - Action methods: setTitle, publishData, onColorGroupChange, onRemove
538
+ * - Color picker data: colors, colorHex
539
+ *
540
+ * Concrete widgets extend this class and use {@link WidgetChromeComponent}
541
+ * in their template to render the common chrome (titlebar, color picker,
542
+ * remove button) while projecting their own content into the body slot.
543
+ *
544
+ * All members are `protected` so inheriting widgets can access them
545
+ * directly — no dependency injection token needed.
546
+ */
547
+ class ScreenWidgetBase {
548
+ // ---- inputs ------------------------------------------------------------
549
+ /** Unique widget instance ID — provided by WidgetWindow. */
550
+ widgetId = input.required(...(ngDevMode ? [{ debugName: "widgetId" }] : /* istanbul ignore next */ []));
551
+ /** ID of the parent window — provided by WidgetWindow. */
552
+ windowId = input.required(...(ngDevMode ? [{ debugName: "windowId" }] : /* istanbul ignore next */ []));
553
+ /** Widget metadata (type, label, default sizing, etc.). */
554
+ meta = input.required(...(ngDevMode ? [{ debugName: "meta" }] : /* istanbul ignore next */ []));
555
+ // ---- services ----------------------------------------------------------
556
+ workspace = inject(WorkspaceService);
557
+ widgetLink = inject(WidgetLinkService);
558
+ // ---- reactive state ----------------------------------------------------
559
+ /** Overridable title — starts from meta().label, settable via setTitle(). */
560
+ title = computed(() => this.#overriddenTitle() || this.meta().label, ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
561
+ #overriddenTitle = signal('', ...(ngDevMode ? [{ debugName: "#overriddenTitle" }] : /* istanbul ignore next */ []));
562
+ /** The color group for this widget, read from the workspace state. */
563
+ colorGroup = computed(() => {
564
+ const win = this.workspace
565
+ .windows()
566
+ .find((w) => w.id === this.windowId());
567
+ return win?.widgets.find((p) => p.id === this.widgetId())?.colorGroup;
568
+ }, ...(ngDevMode ? [{ debugName: "colorGroup" }] : /* istanbul ignore next */ []));
569
+ /**
570
+ * The accumulated JSON data for this widget's color group.
571
+ * Automatically tracks the WidgetLinkService signal for whatever
572
+ * color group is currently assigned.
573
+ */
574
+ data = computed(() => {
575
+ const cg = this.colorGroup();
576
+ return cg ? this.widgetLink.getData(cg)() : {};
577
+ }, ...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
578
+ // ---- color picker data -------------------------------------------------
579
+ colors = COLOR_GROUPS;
580
+ colorHex = COLOR_GROUP_HEX;
581
+ // ---- action methods ----------------------------------------------------
582
+ /** Allow the widget to override the header title at runtime. */
583
+ setTitle(title) {
584
+ this.#overriddenTitle.set(title);
585
+ }
586
+ /**
587
+ * Publish partial JSON data to all widgets sharing this widget's color group.
588
+ * Shallow-merged: keys you provide overwrite, keys you omit survive.
589
+ * Pass `null` as a value to remove a key. No-op if no color group assigned.
590
+ *
591
+ * @example
592
+ * this.publishData({ symbol: 'AAPL' });
593
+ * this.publishData({ resistance: 543.21 });
594
+ */
595
+ publishData(partial) {
596
+ const cg = this.colorGroup();
597
+ if (cg) {
598
+ this.widgetLink.publishData(cg, partial);
599
+ this.workspace.save();
600
+ }
601
+ }
602
+ /** Update the color group for this widget in the workspace. */
603
+ onColorGroupChange(cg) {
604
+ this.workspace.setWidgetColorGroup(this.windowId(), this.widgetId(), cg);
605
+ }
606
+ /** Remove this widget from its parent window. */
607
+ onRemove() {
608
+ this.workspace.removeWidget(this.windowId(), this.widgetId());
609
+ }
610
+ /** Convenience alias for the color picker dropdown. */
611
+ selectColor(color) {
612
+ this.onColorGroupChange(color);
613
+ }
614
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ScreenWidgetBase, deps: [], target: i0.ɵɵFactoryTarget.Directive });
615
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.18", type: ScreenWidgetBase, isStandalone: true, inputs: { widgetId: { classPropertyName: "widgetId", publicName: "widgetId", isSignal: true, isRequired: true, transformFunction: null }, windowId: { classPropertyName: "windowId", publicName: "windowId", isSignal: true, isRequired: true, transformFunction: null }, meta: { classPropertyName: "meta", publicName: "meta", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
616
+ }
617
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ScreenWidgetBase, decorators: [{
618
+ type: Directive
619
+ }], propDecorators: { widgetId: [{ type: i0.Input, args: [{ isSignal: true, alias: "widgetId", required: true }] }], windowId: [{ type: i0.Input, args: [{ isSignal: true, alias: "windowId", required: true }] }], meta: [{ type: i0.Input, args: [{ isSignal: true, alias: "meta", required: true }] }] } });
620
+
621
+ /**
622
+ * Register a widget via the WIDGET_META multi-provider token.
623
+ * Call this from a root-level provider factory to make the widget
624
+ * discoverable by WorkspaceService.
625
+ *
626
+ * The `component` field should point to the full widget component
627
+ * (the one that extends {@link ScreenWidgetBase} and includes
628
+ * the {@link WidgetChromeComponent} in its template).
629
+ *
630
+ * @example
631
+ * export function provideWatchlistWidget(): EnvironmentProviders {
632
+ * return provideWidget({
633
+ * type: 'watchlist',
634
+ * component: Watchlist,
635
+ * label: 'Watchlist',
636
+ * defaultCols: 16,
637
+ * defaultRows: 6,
638
+ * minCols: 8,
639
+ * minRows: 3,
640
+ * });
641
+ * }
642
+ */
643
+ function provideWidget(meta) {
644
+ return makeEnvironmentProviders([
645
+ { provide: WIDGET_META, useValue: meta, multi: true },
646
+ ]);
647
+ }
648
+
649
+ /**
650
+ * Reusable widget chrome component that renders the common titlebar:
651
+ * color-group picker, dynamic title, and remove button.
652
+ *
653
+ * Uses `<ng-content>` to project the widget-specific body content.
654
+ *
655
+ * This component is **purely presentational** — all business logic
656
+ * (workspace mutations, symbol linking) lives in {@link ScreenWidgetBase}
657
+ * and is wired up by the concrete widget's template.
658
+ */
659
+ class WidgetChromeComponent {
660
+ /** The current title text to display in the header. */
661
+ title = input.required(...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
662
+ /** The current color group, or undefined if unlinked. */
663
+ colorGroup = input(...(ngDevMode ? [undefined, { debugName: "colorGroup" }] : /* istanbul ignore next */ []));
664
+ /** Emitted when the user selects a different color group (or "No link"). */
665
+ colorChange = output();
666
+ /** Emitted when the user clicks the remove (×) button. */
667
+ remove = output();
668
+ // ---- color picker data -------------------------------------------------
669
+ colors = COLOR_GROUPS;
670
+ colorHex = COLOR_GROUP_HEX;
671
+ // ---- template helpers --------------------------------------------------
672
+ selectColor(color) {
673
+ this.colorChange.emit(color);
674
+ }
675
+ onRemove() {
676
+ this.remove.emit();
677
+ }
678
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WidgetChromeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
679
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: WidgetChromeComponent, isStandalone: true, selector: "janus-widget-chrome", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: true, transformFunction: null }, colorGroup: { classPropertyName: "colorGroup", publicName: "colorGroup", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { colorChange: "colorChange", remove: "remove" }, ngImport: i0, template: "<div class=\"widget-chrome\">\n <header class=\"widget-header\">\n <div class=\"widget-left\">\n <div class=\"dropdown color-picker\">\n <i\n class=\"bi bi-list color-burger dropdown-toggle\"\n [style.color]=\"colorGroup() ? colorHex[colorGroup()!] : 'var(--bs-secondary-color)'\"\n [title]=\"colorGroup() ? 'Linked to ' + colorGroup() : 'Link to color group'\"\n data-bs-toggle=\"dropdown\"\n aria-expanded=\"false\"\n role=\"button\"\n tabindex=\"0\"\n ></i>\n <div class=\"dropdown-menu\">\n <button\n class=\"dropdown-item\"\n type=\"button\"\n (click)=\"selectColor(undefined)\"\n >\n <span class=\"color-swatch color-swatch--none\"></span>\n <span class=\"color-label\">No link</span>\n </button>\n @for (c of colors; track c) {\n <button\n class=\"dropdown-item\"\n [class.active]=\"colorGroup() === c\"\n type=\"button\"\n (click)=\"selectColor(c)\"\n >\n <span\n class=\"color-swatch\"\n [style.background-color]=\"colorHex[c]\"\n ></span>\n <span class=\"color-label\">{{ c }}</span>\n </button>\n }\n </div>\n </div>\n\n <span class=\"widget-title\">{{ title() }}</span>\n </div>\n\n <div class=\"widget-controls\">\n <button\n class=\"header-btn header-btn--remove\"\n title=\"Remove widget\"\n (click)=\"onRemove()\"\n >\n &times;\n </button>\n </div>\n </header>\n\n <div class=\"widget-chrome-body\">\n <ng-content></ng-content>\n </div>\n</div>\n", styles: [".widget-chrome{display:flex;flex-direction:column;height:100%;background-color:var(--bs-body-bg)}.widget-chrome-body{flex:1;overflow:auto}.widget-header{display:flex;align-items:center;justify-content:space-between;height:32px;padding:0 8px;background-color:var(--bs-tertiary-bg);border-bottom:1px solid var(--bs-border-color);-webkit-user-select:none;user-select:none;cursor:grab}.widget-title{font-size:12px;font-weight:600;color:var(--bs-body-color);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.widget-left{display:flex;align-items:center;gap:6px;flex:1;min-width:0}.widget-controls{display:flex;align-items:center;gap:4px;flex-shrink:0}.header-btn{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;border-radius:3px;background:transparent;color:var(--bs-secondary-color);font-size:14px;line-height:1;cursor:pointer}.header-btn:hover{background-color:var(--bs-secondary-bg);color:var(--bs-body-color)}.header-btn--remove{font-size:18px}.header-btn--remove:hover{color:var(--bs-danger)}.color-picker{flex-shrink:0}.color-burger{font-size:16px;cursor:pointer;transition:color .2s ease;line-height:1;padding:2px 4px;border-radius:3px}.color-burger:hover{background-color:var(--bs-secondary-bg)}.color-burger:after{display:none!important}.dropdown-menu{font-size:12px;min-width:130px}.color-swatch{display:inline-block;width:12px;height:12px;border-radius:3px;flex-shrink:0;border:1px solid rgba(127,127,127,.3)}.color-swatch--none{background:repeating-linear-gradient(-45deg,transparent,transparent 2px,var(--bs-secondary-color) 2px,var(--bs-secondary-color) 3px)}.color-label{text-transform:capitalize;margin-left:6px}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
680
+ }
681
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WidgetChromeComponent, decorators: [{
682
+ type: Component,
683
+ args: [{ selector: 'janus-widget-chrome', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"widget-chrome\">\n <header class=\"widget-header\">\n <div class=\"widget-left\">\n <div class=\"dropdown color-picker\">\n <i\n class=\"bi bi-list color-burger dropdown-toggle\"\n [style.color]=\"colorGroup() ? colorHex[colorGroup()!] : 'var(--bs-secondary-color)'\"\n [title]=\"colorGroup() ? 'Linked to ' + colorGroup() : 'Link to color group'\"\n data-bs-toggle=\"dropdown\"\n aria-expanded=\"false\"\n role=\"button\"\n tabindex=\"0\"\n ></i>\n <div class=\"dropdown-menu\">\n <button\n class=\"dropdown-item\"\n type=\"button\"\n (click)=\"selectColor(undefined)\"\n >\n <span class=\"color-swatch color-swatch--none\"></span>\n <span class=\"color-label\">No link</span>\n </button>\n @for (c of colors; track c) {\n <button\n class=\"dropdown-item\"\n [class.active]=\"colorGroup() === c\"\n type=\"button\"\n (click)=\"selectColor(c)\"\n >\n <span\n class=\"color-swatch\"\n [style.background-color]=\"colorHex[c]\"\n ></span>\n <span class=\"color-label\">{{ c }}</span>\n </button>\n }\n </div>\n </div>\n\n <span class=\"widget-title\">{{ title() }}</span>\n </div>\n\n <div class=\"widget-controls\">\n <button\n class=\"header-btn header-btn--remove\"\n title=\"Remove widget\"\n (click)=\"onRemove()\"\n >\n &times;\n </button>\n </div>\n </header>\n\n <div class=\"widget-chrome-body\">\n <ng-content></ng-content>\n </div>\n</div>\n", styles: [".widget-chrome{display:flex;flex-direction:column;height:100%;background-color:var(--bs-body-bg)}.widget-chrome-body{flex:1;overflow:auto}.widget-header{display:flex;align-items:center;justify-content:space-between;height:32px;padding:0 8px;background-color:var(--bs-tertiary-bg);border-bottom:1px solid var(--bs-border-color);-webkit-user-select:none;user-select:none;cursor:grab}.widget-title{font-size:12px;font-weight:600;color:var(--bs-body-color);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.widget-left{display:flex;align-items:center;gap:6px;flex:1;min-width:0}.widget-controls{display:flex;align-items:center;gap:4px;flex-shrink:0}.header-btn{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;border-radius:3px;background:transparent;color:var(--bs-secondary-color);font-size:14px;line-height:1;cursor:pointer}.header-btn:hover{background-color:var(--bs-secondary-bg);color:var(--bs-body-color)}.header-btn--remove{font-size:18px}.header-btn--remove:hover{color:var(--bs-danger)}.color-picker{flex-shrink:0}.color-burger{font-size:16px;cursor:pointer;transition:color .2s ease;line-height:1;padding:2px 4px;border-radius:3px}.color-burger:hover{background-color:var(--bs-secondary-bg)}.color-burger:after{display:none!important}.dropdown-menu{font-size:12px;min-width:130px}.color-swatch{display:inline-block;width:12px;height:12px;border-radius:3px;flex-shrink:0;border:1px solid rgba(127,127,127,.3)}.color-swatch--none{background:repeating-linear-gradient(-45deg,transparent,transparent 2px,var(--bs-secondary-color) 2px,var(--bs-secondary-color) 3px)}.color-label{text-transform:capitalize;margin-left:6px}\n"] }]
684
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: true }] }], colorGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "colorGroup", required: false }] }], colorChange: [{ type: i0.Output, args: ["colorChange"] }], remove: [{ type: i0.Output, args: ["remove"] }] } });
685
+
686
+ class WidgetWindow {
687
+ windowId = input.required(...(ngDevMode ? [{ debugName: "windowId" }] : /* istanbul ignore next */ []));
688
+ workspace = inject(WorkspaceService);
689
+ engine = inject(LayoutEngine);
690
+ bodyEl = viewChild.required('windowBody');
691
+ availableTypes = signal([], ...(ngDevMode ? [{ debugName: "availableTypes" }] : /* istanbul ignore next */ []));
692
+ availableLabels = signal([], ...(ngDevMode ? [{ debugName: "availableLabels" }] : /* istanbul ignore next */ []));
693
+ cfg = signal({ columns: 12, cellWidth: 90, cellHeight: 20, gap: 4, containerWidth: 0 }, ...(ngDevMode ? [{ debugName: "cfg" }] : /* istanbul ignore next */ []));
694
+ /** Active intervals for polling popup closure. */
695
+ popupIntervals = new Set();
696
+ /** Dynamic background grid that matches our uniform layout step. */
697
+ gridBgSize = computed(() => {
698
+ const c = this.cfg();
699
+ const step = c.cellHeight + c.gap;
700
+ return `${step}px ${step}px`;
701
+ }, ...(ngDevMode ? [{ debugName: "gridBgSize" }] : /* istanbul ignore next */ []));
702
+ /** Offset the background so dots land at the center of gap intersections. */
703
+ gridBgPosition = computed(() => {
704
+ const c = this.cfg();
705
+ return `${c.gap / 2}px ${c.gap / 2}px`;
706
+ }, ...(ngDevMode ? [{ debugName: "gridBgPosition" }] : /* istanbul ignore next */ []));
707
+ widgets = computed(() => this.workspace.windows().find((w) => w.id === this.windowId())?.widgets ?? [], ...(ngDevMode ? [{ debugName: "widgets" }] : /* istanbul ignore next */ []));
708
+ widgetRects = computed(() => {
709
+ const c = this.cfg();
710
+ const map = new Map();
711
+ for (const w of this.widgets()) {
712
+ map.set(w.id, this.engine.rectToPixel(c, w.gridX, w.gridY, w.gridCols, w.gridRows));
713
+ }
714
+ return map;
715
+ }, ...(ngDevMode ? [{ debugName: "widgetRects" }] : /* istanbul ignore next */ []));
716
+ /** All popup windows (open or closed), for the window dropdown. */
717
+ popupWindows = computed(() => this.workspace.windows().filter((w) => w.id !== 'main-window'), ...(ngDevMode ? [{ debugName: "popupWindows" }] : /* istanbul ignore next */ []));
718
+ /** Popup windows the user has intentionally closed. */
719
+ closedWindows = computed(() => this.popupWindows().filter((w) => w.state === 'closed'), ...(ngDevMode ? [{ debugName: "closedWindows" }] : /* istanbul ignore next */ []));
720
+ /** Title of the current window (reactive). */
721
+ windowTitle = computed(() => this.workspace.windows().find((w) => w.id === this.windowId())?.title ?? '', ...(ngDevMode ? [{ debugName: "windowTitle" }] : /* istanbul ignore next */ []));
722
+ /** Title text width estimate (14px font, ~8.4px avg char width, + 12px padding). */
723
+ titleWidth = computed(() => Math.max(this.windowTitle().length * 8.4 + 12, 200), ...(ngDevMode ? [{ debugName: "titleWidth" }] : /* istanbul ignore next */ []));
724
+ editingTitle = signal(false, ...(ngDevMode ? [{ debugName: "editingTitle" }] : /* istanbul ignore next */ []));
725
+ constructor() {
726
+ const types = this.workspace.widgetTypes;
727
+ const labels = this.workspace.widgetLabels;
728
+ queueMicrotask(() => {
729
+ this.availableTypes.set(types());
730
+ this.availableLabels.set(labels());
731
+ });
732
+ }
733
+ ngAfterViewInit() {
734
+ this.engine.observe(this.bodyEl().nativeElement, (c) => this.cfg.set(c));
735
+ }
736
+ ngOnDestroy() {
737
+ this.engine.unobserve(this.bodyEl().nativeElement);
738
+ for (const id of this.popupIntervals) {
739
+ clearInterval(id);
740
+ }
741
+ this.popupIntervals.clear();
742
+ }
743
+ addWidget(type) {
744
+ this.workspace.addWidget(this.windowId(), type);
745
+ }
746
+ /** Create a new chromeless popup window containing a fresh WidgetWindow. */
747
+ openNewWindow(existingId) {
748
+ let winId;
749
+ if (existingId) {
750
+ // Reopening a previously closed window — set state back to 'open'.
751
+ this.workspace.setWindowState(existingId, 'open');
752
+ winId = existingId;
753
+ }
754
+ else {
755
+ const win = this.workspace.addWindow();
756
+ winId = win.id;
757
+ }
758
+ const win = this.workspace.windows().find((w) => w.id === winId);
759
+ const bounds = win?.bounds ?? { x: 100, y: 100, width: 1200, height: 800 };
760
+ const url = `${window.location.origin}/window/${winId}`;
761
+ const popup = window.open(url, `janus-win-${winId}`, `width=${bounds.width},height=${bounds.height},left=${bounds.x},top=${bounds.y}`);
762
+ if (!popup) {
763
+ // Popup blocked — remove the window since it can never be shown.
764
+ this.workspace.removeWindow(winId);
765
+ return;
766
+ }
767
+ // Poll for popup closure.
768
+ const interval = setInterval(() => {
769
+ if (popup.closed) {
770
+ this.closePopupWindow(winId);
771
+ clearInterval(interval);
772
+ this.popupIntervals.delete(interval);
773
+ }
774
+ }, 1000);
775
+ this.popupIntervals.add(interval);
776
+ }
777
+ /** Reopen a previously closed popup window. */
778
+ reopenWindow(windowId) {
779
+ this.openNewWindow(windowId);
780
+ }
781
+ /** Permanently discard a closed window and all its widgets. */
782
+ discardWindow(windowId) {
783
+ localStorage.removeItem(`janus-hb-${windowId}`);
784
+ this.workspace.removeWindow(windowId);
785
+ }
786
+ /** Reopen all previously closed popup windows at once. */
787
+ reopenAllClosed() {
788
+ for (const cw of this.closedWindows()) {
789
+ this.reopenWindow(cw.id);
790
+ }
791
+ }
792
+ /**
793
+ * Handle popup closure: discard empty windows, keep windows with content
794
+ * saved in localStorage (hidden from the main workspace).
795
+ */
796
+ closePopupWindow(windowId) {
797
+ const win = this.workspace.windows().find((w) => w.id === windowId);
798
+ if (!win)
799
+ return;
800
+ if (win.widgets.length === 0) {
801
+ this.workspace.removeWindow(windowId);
802
+ }
803
+ else {
804
+ this.workspace.setWindowState(windowId, 'closed');
805
+ }
806
+ }
807
+ // ---- title editing -------------------------------------------------------
808
+ onTitleFocus(event) {
809
+ this.editingTitle.set(true);
810
+ event.target.select();
811
+ }
812
+ onTitleEnter(event) {
813
+ event.target.blur();
814
+ }
815
+ onTitleBlur(event) {
816
+ this.editingTitle.set(false);
817
+ const input = event.target;
818
+ this.workspace.setWindowTitle(this.windowId(), input.value);
819
+ }
820
+ getMeta(type) {
821
+ return this.workspace.getWidgetMeta(type);
822
+ }
823
+ getRect(widgetId) {
824
+ return this.widgetRects().get(widgetId) ?? { left: 0, top: 0, width: 100, height: 100 };
825
+ }
826
+ onDragStart(event, w) {
827
+ // Only start drag from the widget's title bar, not from content or interactive children
828
+ const target = event.target;
829
+ if (!target?.closest('.widget-header') || target?.closest('button, input, select, a')) {
830
+ return;
831
+ }
832
+ event.preventDefault();
833
+ document.body.classList.add('gs-dragging');
834
+ const startX = event.clientX;
835
+ const startY = event.clientY;
836
+ const startCol = w.gridX;
837
+ const startRow = w.gridY;
838
+ const id = w.id;
839
+ const winId = this.windowId();
840
+ const workspace = this.workspace;
841
+ const engine = this.engine;
842
+ const el = document.querySelector(`[data-wid="${id}"]`);
843
+ // Compute start position from grid coords, NOT from the DOM.
844
+ // DOM offsetLeft can be mid-transition (CSS transition: left 0.15s)
845
+ // and would give wrong values for a new drag started during animation.
846
+ const c0 = this.cfg();
847
+ const startRect = engine.rectToPixel(c0, startCol, startRow, w.gridCols, w.gridRows);
848
+ const startOffsetX = startRect.left;
849
+ const startOffsetY = startRect.top;
850
+ const onMove = (e) => {
851
+ // Track pixel-for-pixel during drag, snap only on release
852
+ const px = startOffsetX + (e.clientX - startX);
853
+ const py = startOffsetY + (e.clientY - startY);
854
+ if (el) {
855
+ el.style.left = `${px}px`;
856
+ el.style.top = `${py}px`;
857
+ el.style.zIndex = '1000';
858
+ }
859
+ };
860
+ const onUp = (e) => {
861
+ document.removeEventListener('mousemove', onMove);
862
+ document.removeEventListener('mouseup', onUp);
863
+ document.body.classList.remove('gs-dragging');
864
+ const c = this.cfg();
865
+ const px = startOffsetX + (e.clientX - startX);
866
+ const py = startOffsetY + (e.clientY - startY);
867
+ let { col, row } = engine.snapToGrid(c, px, py);
868
+ // Collision avoidance: push down past any overlapping widgets
869
+ const others = workspace.windows().find((win) => win.id === winId)?.widgets.filter((ow) => ow.id !== id) ?? [];
870
+ for (const ow of others) {
871
+ const colOverlap = col < ow.gridX + ow.gridCols && col + w.gridCols > ow.gridX;
872
+ const rowOverlap = row < ow.gridY + ow.gridRows && row + w.gridRows > ow.gridY;
873
+ if (colOverlap && rowOverlap) {
874
+ row = Math.max(row, ow.gridY + ow.gridRows);
875
+ }
876
+ }
877
+ if (col !== startCol || row !== startRow) {
878
+ workspace.updateWidgetLayout(winId, id, { gridX: col, gridY: row });
879
+ }
880
+ // Set inline styles to the final snapped position so there's no
881
+ // visual gap before Angular re-renders. Angular bindings will
882
+ // confirm the same values on the next CD cycle.
883
+ const finalRect = engine.rectToPixel(c, col, row, w.gridCols, w.gridRows);
884
+ if (el) {
885
+ el.style.left = `${finalRect.left}px`;
886
+ el.style.top = `${finalRect.top}px`;
887
+ el.style.zIndex = '';
888
+ }
889
+ };
890
+ document.addEventListener('mousemove', onMove);
891
+ document.addEventListener('mouseup', onUp);
892
+ }
893
+ onResizeStart(event, w) {
894
+ event.preventDefault();
895
+ event.stopPropagation();
896
+ const startX = event.clientX;
897
+ const startY = event.clientY;
898
+ const startW = w.gridCols;
899
+ const startH = w.gridRows;
900
+ const id = w.id;
901
+ const winId = this.windowId();
902
+ const workspace = this.workspace;
903
+ const engine = this.engine;
904
+ const meta = workspace.getWidgetMeta(w.type) ?? { minCols: 2, minRows: 2 };
905
+ const onMove = (e) => {
906
+ const c = this.cfg();
907
+ const step = c.cellHeight + c.gap;
908
+ const dx = e.clientX - startX;
909
+ const dy = e.clientY - startY;
910
+ const pw = startW * step - c.gap + dx;
911
+ const ph = startH * step - c.gap + dy;
912
+ const { w: nw, h: nh } = engine.snapSize(c, pw, ph, meta.minCols, meta.minRows);
913
+ const rect = engine.rectToPixel(c, w.gridX, w.gridY, nw, nh);
914
+ const el = document.querySelector(`[data-wid="${id}"]`);
915
+ if (el) {
916
+ el.style.width = `${rect.width}px`;
917
+ el.style.height = `${rect.height}px`;
918
+ }
919
+ };
920
+ const onUp = (e) => {
921
+ document.removeEventListener('mousemove', onMove);
922
+ document.removeEventListener('mouseup', onUp);
923
+ const c = this.cfg();
924
+ const step = c.cellHeight + c.gap;
925
+ const dx = e.clientX - startX;
926
+ const dy = e.clientY - startY;
927
+ const pw = startW * step - c.gap + dx;
928
+ const ph = startH * step - c.gap + dy;
929
+ const { w: nw, h: nh } = engine.snapSize(c, pw, ph, meta.minCols, meta.minRows);
930
+ workspace.updateWidgetLayout(winId, id, { gridCols: nw, gridRows: nh });
931
+ // Set inline styles to the final snapped size so there's no visual gap
932
+ // before Angular re-renders.
933
+ const finalRect = engine.rectToPixel(c, w.gridX, w.gridY, nw, nh);
934
+ const el = document.querySelector(`[data-wid="${id}"]`);
935
+ if (el) {
936
+ el.style.width = `${finalRect.width}px`;
937
+ el.style.height = `${finalRect.height}px`;
938
+ }
939
+ };
940
+ document.addEventListener('mousemove', onMove);
941
+ document.addEventListener('mouseup', onUp);
942
+ }
943
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WidgetWindow, deps: [], target: i0.ɵɵFactoryTarget.Component });
944
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: WidgetWindow, isStandalone: true, selector: "janus-widget-window", inputs: { windowId: { classPropertyName: "windowId", publicName: "windowId", isSignal: true, isRequired: true, transformFunction: null } }, viewQueries: [{ propertyName: "bodyEl", first: true, predicate: ["windowBody"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"widget-window\">\n <header class=\"window-header\">\n <input\n class=\"window-title-input\"\n type=\"text\"\n [value]=\"windowTitle()\"\n [style.width.px]=\"titleWidth()\"\n (focus)=\"onTitleFocus($event)\"\n (blur)=\"onTitleBlur($event)\"\n (keydown.enter)=\"onTitleEnter($event)\"\n spellcheck=\"false\"\n />\n <div class=\"window-controls\">\n <div class=\"dropdown\">\n <i\n class=\"bi bi-display-fill window-action-icon dropdown-toggle\"\n title=\"Windows\"\n data-bs-toggle=\"dropdown\"\n aria-expanded=\"false\"\n role=\"button\"\n tabindex=\"0\"\n ></i>\n <div class=\"dropdown-menu\">\n <button class=\"dropdown-item\" type=\"button\" (click)=\"openNewWindow()\">\n <i class=\"bi bi-plus-lg me-1\"></i> New empty window\n </button>\n @if (popupWindows().length) {\n <div class=\"dropdown-divider\"></div>\n @for (pw of popupWindows(); track pw.id) {\n <div class=\"dropdown-item-row\">\n @if (pw.state === 'open') {\n <span class=\"dropdown-item dropdown-item-grow disabled-text\">\n <i class=\"bi bi-circle-fill me-1 open-indicator\"></i>\n {{ pw.title }}\n </span>\n } @else {\n <button\n class=\"dropdown-item dropdown-item-grow\"\n type=\"button\"\n (click)=\"reopenWindow(pw.id)\"\n >\n <i class=\"bi bi-arrow-clockwise me-1\"></i>\n {{ pw.title }}\n </button>\n <button\n class=\"dropdown-item-delete\"\n type=\"button\"\n title=\"Discard window permanently\"\n (click)=\"discardWindow(pw.id)\"\n >\n <i class=\"bi bi-trash3\"></i>\n </button>\n }\n </div>\n }\n @if (closedWindows().length > 1) {\n <div class=\"dropdown-divider\"></div>\n <button class=\"dropdown-item\" type=\"button\" (click)=\"reopenAllClosed()\">\n <i class=\"bi bi-arrow-repeat me-1\"></i> Reopen all closed\n </button>\n }\n }\n </div>\n </div>\n <div class=\"dropdown\">\n\n\n <i\n class=\"bi bi-grid-1x2-fill window-action-icon dropdown-toggle\"\n title=\"Add Widget\"\n data-bs-toggle=\"dropdown\"\n aria-expanded=\"false\"\n role=\"button\"\n tabindex=\"0\"\n ></i>\n <div class=\"dropdown-menu\">\n @for (type of availableTypes(); track type; let i = $index) {\n <button\n class=\"dropdown-item\"\n type=\"button\"\n (click)=\"addWidget(type)\"\n >\n {{ availableLabels()[i] }}\n </button>\n }\n </div>\n </div>\n </div>\n </header>\n\n <div #windowBody class=\"window-body\"\n [style.background-size]=\"gridBgSize()\"\n [style.background-position]=\"gridBgPosition()\">\n @for (w of widgets(); track w.id) {\n <div\n class=\"grid-item\"\n [attr.data-wid]=\"w.id\"\n [style.left.px]=\"getRect(w.id).left\"\n [style.top.px]=\"getRect(w.id).top\"\n [style.width.px]=\"getRect(w.id).width\"\n [style.height.px]=\"getRect(w.id).height\"\n >\n <!-- Drag handle: mousedown triggers native drag -->\n <div class=\"drag-handle\" (mousedown)=\"onDragStart($event, w)\">\n <div class=\"grid-item-content\">\n @if (getMeta(w.type); as meta) {\n <ng-container\n *ngComponentOutlet=\"\n meta.component;\n inputs: { widgetId: w.id, windowId: windowId(), meta: meta }\n \"\n />\n }\n </div>\n <!-- Resize handle -->\n <div class=\"resize-handle\" (mousedown)=\"onResizeStart($event, w)\"></div>\n </div>\n </div>\n } @empty {\n <div class=\"empty-state\">\n <span class=\"empty-state-text\">\n No widgets added. Click the\n <i class=\"bi bi-grid-1x2-fill empty-state-icon\"></i>\n &ldquo;Add Widget&rdquo; button to add widgets to this screen.\n </span>\n </div>\n }\n </div>\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:flex;flex-direction:column;min-height:0}.widget-window{display:flex;flex-direction:column;height:100%;overflow:hidden;background-color:var(--bs-body-bg)}.window-header{display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 8px;background-color:var(--bs-tertiary-bg);border-bottom:1px solid var(--bs-border-color)}.window-title-input{display:inline-block;max-width:calc(100% - 80px);height:26px;padding:0 6px;border:1px solid transparent;border-radius:3px;background:transparent;color:var(--bs-body-color);font-size:14px;font-weight:500;line-height:26px;outline:none}.window-title-input:hover{border-color:var(--bs-border-color)}.window-title-input:focus{border-color:var(--bs-primary);background-color:var(--bs-body-bg)}.window-controls{display:flex;align-items:center;gap:4px}.window-action-icon{font-size:16px;cursor:pointer;color:var(--bs-secondary-color);line-height:1;padding:2px 4px;border-radius:3px;transition:color .2s ease}.window-action-icon:hover{color:var(--bs-body-color);background-color:var(--bs-secondary-bg)}.window-action-icon:after{display:none!important}.dropdown-menu{font-size:12px;min-width:220px}.dropdown-item-row{display:flex;align-items:stretch}.dropdown-item-grow{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-item-delete{display:flex;align-items:center;justify-content:center;width:32px;padding:4px;border:none;background:none;color:var(--bs-secondary-color);cursor:pointer;font-size:13px;border-radius:0}.dropdown-item-delete:hover{color:var(--bs-danger);background-color:var(--bs-tertiary-bg)}.open-indicator{font-size:6px;color:var(--bs-success);vertical-align:middle}.dropdown-item-row .disabled-text{display:block;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bs-secondary-color);cursor:default;pointer-events:none}.window-body{flex:1;overflow:auto;position:relative;background-image:radial-gradient(circle at 0 0,rgba(128,128,128,.35) 1.2px,transparent 1.2px)}.empty-state{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none}.empty-state-text{font-size:14px;color:var(--bs-secondary-color);text-align:center;max-width:360px;line-height:1.5}.empty-state-icon{font-size:14px;vertical-align:-1px}.grid-item{position:absolute;transition:width .15s,height .15s}:host-context(body.gs-dragging) .grid-item{transition:none}.drag-handle{width:100%;height:100%}.drag-handle.dragging{z-index:1000;opacity:.9;box-shadow:0 4px 12px #0000004d}.grid-item-content{height:100%;background-color:var(--bs-body-bg);border:1px solid var(--bs-border-color);border-radius:4px;overflow:auto}.resize-handle{position:absolute;bottom:0;right:0;width:16px;height:16px;cursor:se-resize;background:linear-gradient(135deg,transparent 50%,var(--bs-border-color) 50%);border-radius:0 0 4px;opacity:0;transition:opacity .15s}.grid-item:hover .resize-handle{opacity:1}\n"], dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }] });
945
+ }
946
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WidgetWindow, decorators: [{
947
+ type: Component,
948
+ args: [{ selector: 'janus-widget-window', imports: [NgComponentOutlet], template: "<div class=\"widget-window\">\n <header class=\"window-header\">\n <input\n class=\"window-title-input\"\n type=\"text\"\n [value]=\"windowTitle()\"\n [style.width.px]=\"titleWidth()\"\n (focus)=\"onTitleFocus($event)\"\n (blur)=\"onTitleBlur($event)\"\n (keydown.enter)=\"onTitleEnter($event)\"\n spellcheck=\"false\"\n />\n <div class=\"window-controls\">\n <div class=\"dropdown\">\n <i\n class=\"bi bi-display-fill window-action-icon dropdown-toggle\"\n title=\"Windows\"\n data-bs-toggle=\"dropdown\"\n aria-expanded=\"false\"\n role=\"button\"\n tabindex=\"0\"\n ></i>\n <div class=\"dropdown-menu\">\n <button class=\"dropdown-item\" type=\"button\" (click)=\"openNewWindow()\">\n <i class=\"bi bi-plus-lg me-1\"></i> New empty window\n </button>\n @if (popupWindows().length) {\n <div class=\"dropdown-divider\"></div>\n @for (pw of popupWindows(); track pw.id) {\n <div class=\"dropdown-item-row\">\n @if (pw.state === 'open') {\n <span class=\"dropdown-item dropdown-item-grow disabled-text\">\n <i class=\"bi bi-circle-fill me-1 open-indicator\"></i>\n {{ pw.title }}\n </span>\n } @else {\n <button\n class=\"dropdown-item dropdown-item-grow\"\n type=\"button\"\n (click)=\"reopenWindow(pw.id)\"\n >\n <i class=\"bi bi-arrow-clockwise me-1\"></i>\n {{ pw.title }}\n </button>\n <button\n class=\"dropdown-item-delete\"\n type=\"button\"\n title=\"Discard window permanently\"\n (click)=\"discardWindow(pw.id)\"\n >\n <i class=\"bi bi-trash3\"></i>\n </button>\n }\n </div>\n }\n @if (closedWindows().length > 1) {\n <div class=\"dropdown-divider\"></div>\n <button class=\"dropdown-item\" type=\"button\" (click)=\"reopenAllClosed()\">\n <i class=\"bi bi-arrow-repeat me-1\"></i> Reopen all closed\n </button>\n }\n }\n </div>\n </div>\n <div class=\"dropdown\">\n\n\n <i\n class=\"bi bi-grid-1x2-fill window-action-icon dropdown-toggle\"\n title=\"Add Widget\"\n data-bs-toggle=\"dropdown\"\n aria-expanded=\"false\"\n role=\"button\"\n tabindex=\"0\"\n ></i>\n <div class=\"dropdown-menu\">\n @for (type of availableTypes(); track type; let i = $index) {\n <button\n class=\"dropdown-item\"\n type=\"button\"\n (click)=\"addWidget(type)\"\n >\n {{ availableLabels()[i] }}\n </button>\n }\n </div>\n </div>\n </div>\n </header>\n\n <div #windowBody class=\"window-body\"\n [style.background-size]=\"gridBgSize()\"\n [style.background-position]=\"gridBgPosition()\">\n @for (w of widgets(); track w.id) {\n <div\n class=\"grid-item\"\n [attr.data-wid]=\"w.id\"\n [style.left.px]=\"getRect(w.id).left\"\n [style.top.px]=\"getRect(w.id).top\"\n [style.width.px]=\"getRect(w.id).width\"\n [style.height.px]=\"getRect(w.id).height\"\n >\n <!-- Drag handle: mousedown triggers native drag -->\n <div class=\"drag-handle\" (mousedown)=\"onDragStart($event, w)\">\n <div class=\"grid-item-content\">\n @if (getMeta(w.type); as meta) {\n <ng-container\n *ngComponentOutlet=\"\n meta.component;\n inputs: { widgetId: w.id, windowId: windowId(), meta: meta }\n \"\n />\n }\n </div>\n <!-- Resize handle -->\n <div class=\"resize-handle\" (mousedown)=\"onResizeStart($event, w)\"></div>\n </div>\n </div>\n } @empty {\n <div class=\"empty-state\">\n <span class=\"empty-state-text\">\n No widgets added. Click the\n <i class=\"bi bi-grid-1x2-fill empty-state-icon\"></i>\n &ldquo;Add Widget&rdquo; button to add widgets to this screen.\n </span>\n </div>\n }\n </div>\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:flex;flex-direction:column;min-height:0}.widget-window{display:flex;flex-direction:column;height:100%;overflow:hidden;background-color:var(--bs-body-bg)}.window-header{display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 8px;background-color:var(--bs-tertiary-bg);border-bottom:1px solid var(--bs-border-color)}.window-title-input{display:inline-block;max-width:calc(100% - 80px);height:26px;padding:0 6px;border:1px solid transparent;border-radius:3px;background:transparent;color:var(--bs-body-color);font-size:14px;font-weight:500;line-height:26px;outline:none}.window-title-input:hover{border-color:var(--bs-border-color)}.window-title-input:focus{border-color:var(--bs-primary);background-color:var(--bs-body-bg)}.window-controls{display:flex;align-items:center;gap:4px}.window-action-icon{font-size:16px;cursor:pointer;color:var(--bs-secondary-color);line-height:1;padding:2px 4px;border-radius:3px;transition:color .2s ease}.window-action-icon:hover{color:var(--bs-body-color);background-color:var(--bs-secondary-bg)}.window-action-icon:after{display:none!important}.dropdown-menu{font-size:12px;min-width:220px}.dropdown-item-row{display:flex;align-items:stretch}.dropdown-item-grow{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-item-delete{display:flex;align-items:center;justify-content:center;width:32px;padding:4px;border:none;background:none;color:var(--bs-secondary-color);cursor:pointer;font-size:13px;border-radius:0}.dropdown-item-delete:hover{color:var(--bs-danger);background-color:var(--bs-tertiary-bg)}.open-indicator{font-size:6px;color:var(--bs-success);vertical-align:middle}.dropdown-item-row .disabled-text{display:block;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bs-secondary-color);cursor:default;pointer-events:none}.window-body{flex:1;overflow:auto;position:relative;background-image:radial-gradient(circle at 0 0,rgba(128,128,128,.35) 1.2px,transparent 1.2px)}.empty-state{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none}.empty-state-text{font-size:14px;color:var(--bs-secondary-color);text-align:center;max-width:360px;line-height:1.5}.empty-state-icon{font-size:14px;vertical-align:-1px}.grid-item{position:absolute;transition:width .15s,height .15s}:host-context(body.gs-dragging) .grid-item{transition:none}.drag-handle{width:100%;height:100%}.drag-handle.dragging{z-index:1000;opacity:.9;box-shadow:0 4px 12px #0000004d}.grid-item-content{height:100%;background-color:var(--bs-body-bg);border:1px solid var(--bs-border-color);border-radius:4px;overflow:auto}.resize-handle{position:absolute;bottom:0;right:0;width:16px;height:16px;cursor:se-resize;background:linear-gradient(135deg,transparent 50%,var(--bs-border-color) 50%);border-radius:0 0 4px;opacity:0;transition:opacity .15s}.grid-item:hover .resize-handle{opacity:1}\n"] }]
949
+ }], ctorParameters: () => [], propDecorators: { windowId: [{ type: i0.Input, args: [{ isSignal: true, alias: "windowId", required: true }] }], bodyEl: [{ type: i0.ViewChild, args: ['windowBody', { isSignal: true }] }] } });
950
+
951
+ const HB_PREFIX = 'janus-hb-';
952
+ class WindowOutlet {
953
+ route = inject(ActivatedRoute);
954
+ windowId = '';
955
+ heartbeatInterval;
956
+ ngOnInit() {
957
+ this.windowId = this.route.snapshot.paramMap.get('windowId') ?? '';
958
+ // Heartbeat: write a timestamp to localStorage every 3 seconds.
959
+ // The main WorkspaceService detects stale heartbeats to know when
960
+ // this popup has truly closed (vs. just being refreshed).
961
+ const hbKey = HB_PREFIX + this.windowId;
962
+ localStorage.setItem(hbKey, Date.now().toString());
963
+ this.heartbeatInterval = setInterval(() => {
964
+ localStorage.setItem(hbKey, Date.now().toString());
965
+ }, 3000);
966
+ }
967
+ ngOnDestroy() {
968
+ if (this.heartbeatInterval) {
969
+ clearInterval(this.heartbeatInterval);
970
+ }
971
+ // Do NOT remove the heartbeat key — let it expire naturally (10 s).
972
+ // Removing it here races with closePopupWindow which sets the
973
+ // sessionStorage flag; the heartbeat checker could run in between
974
+ // and incorrectly undetach the window.
975
+ }
976
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WindowOutlet, deps: [], target: i0.ɵɵFactoryTarget.Component });
977
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: WindowOutlet, isStandalone: true, selector: "app-window-outlet", ngImport: i0, template: "@if (windowId) {\n <janus-widget-window\n class=\"full-window\"\n [windowId]=\"windowId\"\n />\n}\n", styles: [":host{display:block;width:100vw;height:100vh;overflow:hidden}.full-window{width:100%;height:100%}\n"], dependencies: [{ kind: "component", type: WidgetWindow, selector: "janus-widget-window", inputs: ["windowId"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
978
+ }
979
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: WindowOutlet, decorators: [{
980
+ type: Component,
981
+ args: [{ selector: 'app-window-outlet', imports: [WidgetWindow], changeDetection: ChangeDetectionStrategy.OnPush, template: "@if (windowId) {\n <janus-widget-window\n class=\"full-window\"\n [windowId]=\"windowId\"\n />\n}\n", styles: [":host{display:block;width:100vw;height:100vh;overflow:hidden}.full-window{width:100%;height:100%}\n"] }]
982
+ }] });
983
+
984
+ class Janusui {
985
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: Janusui, deps: [], target: i0.ɵɵFactoryTarget.Component });
986
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.18", type: Janusui, isStandalone: true, selector: "lib-janusui", ngImport: i0, template: `
987
+ <p>
988
+ janusui works!
989
+ </p>
990
+ `, isInline: true, styles: [""] });
991
+ }
992
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: Janusui, decorators: [{
993
+ type: Component,
994
+ args: [{ selector: 'lib-janusui', imports: [], template: `
995
+ <p>
996
+ janusui works!
997
+ </p>
998
+ ` }]
999
+ }] });
1000
+
1001
+ /*
1002
+ * Public API Surface of janusui
1003
+ */
1004
+
1005
+ /**
1006
+ * Generated bundle index. Do not edit.
1007
+ */
1008
+
1009
+ export { COLOR_GROUPS, COLOR_GROUP_HEX, Janusui, LayoutEngine, SCREEN_WIDGET_API, ScreenWidgetBase, WIDGET_META, WidgetChromeComponent, WidgetLinkService, WidgetWindow, WindowOutlet, WorkspaceService, fmt, provideWidget };
1010
+ //# sourceMappingURL=janusui-janus-ui-library.mjs.map