flexdesk 0.12.1 → 0.14.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -566,12 +566,17 @@ export function buildLegendLayout(position) {
566
566
  * `yAxisId` matches an axis id in sp.yAxes[].id; omitted → first axis.
567
567
  * @param {Array<{x0:number, x1:number, label?:string, color?:string, opacity?:number, labelY?:number, labelColor?:string}>} [opts.shadedBands]
568
568
  * Vertical stripes (rectangles spanning full y range) to mark policy/recession periods.
569
+ * @param {Array<{x:(number|string|Date), label?:string, color?:string, lineStyle?:string, lineWidth?:number, labelY?:number}>} [opts.verticalLines]
570
+ * Vertical markers at one x — a "today" line on a burndown. Drawn full height
571
+ * (`yref: 'paper'`), after the reference lines, so a call without them builds
572
+ * exactly what it built before they existed. `x` is in the x axis's own units:
573
+ * a number, a category, or a date string/Date on a date axis.
569
574
  * @param {Object<string,string>} [opts.yRefByAxisId]
570
575
  * Mapping from subplot yAxes[i].id → Plotly yref ('y', 'y2', ...).
571
576
  * @returns {{shapes: Array, annotations: Array}}
572
577
  */
573
578
  export function buildOverlayShapes(opts = {}) {
574
- const { referenceLines = [], shadedBands = [], yRefByAxisId = {} } = opts;
579
+ const { referenceLines = [], shadedBands = [], verticalLines = [], yRefByAxisId = {} } = opts;
575
580
  const shapes = [];
576
581
  const annotations = [];
577
582
  const dashMap = { solid: 'solid', dashed: 'dash', dotted: 'dot', dashdot: 'dashdot' };
@@ -643,6 +648,40 @@ export function buildOverlayShapes(opts = {}) {
643
648
  }
644
649
  }
645
650
 
651
+ for (const mark of verticalLines) {
652
+ // `0` is a real x, so the test is null/undefined/NaN — never truthiness.
653
+ if (mark == null || mark.x == null || (typeof mark.x === 'number' && !Number.isFinite(mark.x))) continue;
654
+ const color = mark.color || 'rgba(255, 200, 0, 0.75)';
655
+ shapes.push({
656
+ type: 'line',
657
+ xref: 'x',
658
+ yref: 'paper',
659
+ x0: mark.x,
660
+ x1: mark.x,
661
+ y0: 0,
662
+ y1: 1,
663
+ line: {
664
+ color,
665
+ width: mark.lineWidth ?? 1.5,
666
+ dash: dashMap[mark.lineStyle || 'dashed'] || 'dash',
667
+ },
668
+ layer: 'above',
669
+ });
670
+ if (mark.label) {
671
+ annotations.push({
672
+ x: mark.x,
673
+ y: mark.labelY ?? 1.0,
674
+ xref: 'x',
675
+ yref: 'paper',
676
+ text: mark.label,
677
+ showarrow: false,
678
+ font: { size: 10, color },
679
+ xanchor: 'left',
680
+ yanchor: 'bottom',
681
+ });
682
+ }
683
+ }
684
+
646
685
  return { shapes, annotations };
647
686
  }
648
687
 
@@ -154,6 +154,10 @@ export function openPopover({ anchor, content, label, className = '', onClose =
154
154
  handle.close('escape');
155
155
  };
156
156
  const onResize = () => place();
157
+ // Placed before its content is drawn — a picker fills its list after this
158
+ // returns — so it is placed again whenever its size changes, or it would
159
+ // run past the window's edge.
160
+ const sized = typeof ResizeObserver === 'function' ? new ResizeObserver(() => place()) : null;
157
161
 
158
162
  const handle = {
159
163
  el: pop,
@@ -168,6 +172,7 @@ export function openPopover({ anchor, content, label, className = '', onClose =
168
172
  document.removeEventListener('mousedown', onPress, true);
169
173
  pop.removeEventListener('keydown', onKey);
170
174
  window.removeEventListener('resize', onResize);
175
+ sized?.disconnect();
171
176
  pop.remove();
172
177
  if (ACTIVE === handle) ACTIVE = null;
173
178
  // Focus that LEFT the popover (a Tab out of it) is going somewhere the
@@ -185,6 +190,7 @@ export function openPopover({ anchor, content, label, className = '', onClose =
185
190
  }, 0);
186
191
  pop.addEventListener('keydown', onKey);
187
192
  window.addEventListener('resize', onResize);
193
+ sized?.observe(pop);
188
194
  place();
189
195
  (focus || pop).focus?.({ preventScroll: true });
190
196
  return handle;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * photino_host.js — the host port over a Photino.NET window.
3
+ *
4
+ * Photino gives the page exactly two things: `window.external.sendMessage(text)`
5
+ * and `window.external.receiveMessage(callback)`. Strings in, strings out, no
6
+ * replies. This adapter puts a request/reply protocol over them, matched by id:
7
+ *
8
+ * page → host {"id": 7, "cap": "window", "op": "setBounds", "args": [{"x":0,"y":0,"width":1200,"height":800}]}
9
+ * host → page {"id": 7, "ok": true, "value": null}
10
+ * host → page {"id": 8, "ok": false, "error": "no such op"}
11
+ * host → page {"event": "window.maximizedChanged", "value": true} // unsolicited, no id
12
+ *
13
+ * and offers the port's `window`, `dialogs` and `state` capabilities on top.
14
+ * The C# side — one dispatcher on the window's web-message handler, every op
15
+ * with its args and reply — is documented in `photino_host.md` beside this file.
16
+ *
17
+ * Like pywebview_host.js it knows the transport and NOTHING about the app: an
18
+ * embedder that keeps some capability elsewhere (BugDesk keeps `state` on its
19
+ * own server) passes that capability in, or `null` to leave it out.
20
+ *
21
+ * In a browser tab there is no `window.external.sendMessage`, and
22
+ * createPhotinoHost() returns NULL_HOST — the fallback every FlexDesk consumer
23
+ * already has to survive.
24
+ */
25
+
26
+ import { createHost, NULL_HOST } from './host.js';
27
+
28
+ /** True when `ext` (default: `window.external`) is a Photino bridge. */
29
+ export function isPhotinoWindow(ext = (typeof window !== 'undefined' ? window.external : undefined)) {
30
+ return !!ext && typeof ext.sendMessage === 'function' && typeof ext.receiveMessage === 'function';
31
+ }
32
+
33
+ /**
34
+ * The bare request/reply layer. `call(cap, op, ...args)` sends one request and
35
+ * resolves with the reply's `value` (`request(cap, op, args, {wait})` is the
36
+ * same with a timeout of its own). It REJECTS when the host answers
37
+ * `ok: false` (with the host's error) and when no reply arrives within
38
+ * `timeoutMs`. A reply for an id nobody is waiting on, a message that is not
39
+ * JSON, and anything that is neither a reply nor an event are dropped.
40
+ *
41
+ * @param {object} opts
42
+ * @param {{sendMessage: Function, receiveMessage: Function}} opts.external
43
+ * @param {number} [opts.timeoutMs=10000]
44
+ * @param {(name: string, value: any) => void} [opts.onEvent] id-less `{event, value}` messages
45
+ * @param {object} [opts.logger]
46
+ */
47
+ export function createPhotinoTransport({ external, timeoutMs = 10000, onEvent = null, logger = console }) {
48
+ const pending = new Map();
49
+ let seq = 0;
50
+ external.receiveMessage((text) => {
51
+ let m;
52
+ try { m = typeof text === 'string' ? JSON.parse(text) : text; } catch { return; }
53
+ if (!m || typeof m !== 'object') return;
54
+ if (m.id == null) {
55
+ if (typeof m.event === 'string' && onEvent) {
56
+ try { onEvent(m.event, m.value); }
57
+ catch (e) { logger.warn?.('[host] photino event handler failed', m.event, e); }
58
+ }
59
+ return;
60
+ }
61
+ const p = pending.get(m.id);
62
+ if (!p) return;
63
+ pending.delete(m.id);
64
+ clearTimeout(p.timer);
65
+ if (m.ok) p.resolve(m.value === undefined ? null : m.value);
66
+ else p.reject(new Error(m.error || `${p.what}: refused by the host`));
67
+ });
68
+ // `wait` is this request's timeout in ms; 0 waits for as long as it takes.
69
+ const request = (cap, op, args = [], { wait = timeoutMs } = {}) => new Promise((resolve, reject) => {
70
+ const id = ++seq;
71
+ const what = `${cap}.${op}`;
72
+ const timer = wait > 0 ? setTimeout(() => {
73
+ if (pending.delete(id)) reject(new Error(`${what}: no answer from the host within ${wait} ms`));
74
+ }, wait) : null;
75
+ pending.set(id, { resolve, reject, timer, what });
76
+ try {
77
+ external.sendMessage(JSON.stringify({ id, cap, op, args }));
78
+ } catch (e) {
79
+ pending.delete(id);
80
+ clearTimeout(timer);
81
+ reject(e);
82
+ }
83
+ });
84
+ const call = (cap, op, ...args) => request(cap, op, args);
85
+ return { call, request, pending: () => pending.size };
86
+ }
87
+
88
+ /**
89
+ * Build a Host backed by a Photino window.
90
+ *
91
+ * The capability methods follow pywebview_host.js's contract, so a consumer
92
+ * cannot tell the two apart: they DEGRADE instead of rejecting. A failed query
93
+ * reads as false / null, a failed fire-and-forget is logged, and a save that
94
+ * the host refused or never answered resolves `null` — the host.js meaning of
95
+ * "unavailable", which every caller turns into a Blob download.
96
+ *
97
+ * @param {object} [opts]
98
+ * @param {object} [opts.external=window.external] the Photino bridge (a fake one in tests)
99
+ * @param {number} [opts.timeoutMs=10000]
100
+ * @param {(name: string, value: any) => void} [opts.onEvent]
101
+ * @param {object} [opts.logger=console]
102
+ * @param {object|null} [opts.window] replace the window capability, or `null` to leave it out
103
+ * @param {object|null} [opts.dialogs] replace the dialogs capability, or `null` to leave it out
104
+ * @param {object|null} [opts.state] replace the state capability, or `null` to leave it out
105
+ * @returns {Readonly<object>} a Host, or NULL_HOST outside a Photino window
106
+ */
107
+ export function createPhotinoHost(opts = {}) {
108
+ const {
109
+ external = (typeof window !== 'undefined' ? window.external : undefined),
110
+ timeoutMs = 10000,
111
+ onEvent = null,
112
+ logger = console,
113
+ } = opts;
114
+ if (!isPhotinoWindow(external)) return NULL_HOST;
115
+
116
+ const { call, request } = createPhotinoTransport({ external, timeoutMs, onEvent, logger });
117
+ const warn = (cap, op, e) => logger.warn?.('[host] photino call failed', `${cap}.${op}`, e);
118
+ const soft = async (fallback, cap, op, ...args) => {
119
+ try { return await call(cap, op, ...args); }
120
+ catch (e) { warn(cap, op, e); return fallback; }
121
+ };
122
+ const fire = (cap, op, ...args) => { void soft(null, cap, op, ...args); };
123
+
124
+ const defaults = {
125
+ window: {
126
+ // Photino draws a decorated, native window on every OS: FlexDesk's
127
+ // own min/max/close buttons would be a second set.
128
+ chrome: () => 'native',
129
+ minimize: () => { fire('window', 'minimize'); },
130
+ close: () => { fire('window', 'close'); },
131
+ isMaximized: async () => (await soft(false, 'window', 'isMaximized')) === true,
132
+ setMaximized: async (on) => (await soft(false, 'window', 'setMaximized', !!on)) === true,
133
+ isFullscreen: async () => (await soft(false, 'window', 'isFullscreen')) === true,
134
+ toggleFullscreen: async () => (await soft(false, 'window', 'toggleFullscreen')) === true,
135
+ async getBounds() {
136
+ const b = await soft(null, 'window', 'getBounds');
137
+ if (!b || typeof b !== 'object') return null;
138
+ return { x: b.x, y: b.y, width: b.width, height: b.height };
139
+ },
140
+ // Not async, like pywebview's: a rAF hot path during a drag; nobody awaits.
141
+ setBounds({ x = null, y = null, width = null, height = null } = {}) {
142
+ fire('window', 'setBounds', { x, y, width, height });
143
+ },
144
+ },
145
+ dialogs: {
146
+ // `{ok: true, path}` | `{ok: false, cancelled: true}` | `{ok: false, error}`,
147
+ // or null when the host refused: fall back to a download. NO TIMEOUT —
148
+ // the dialog is open for as long as the user takes, and giving up on it
149
+ // would download the file a second time behind the dialog.
150
+ async saveFile({ data, filename, kind } = {}) {
151
+ let r = null;
152
+ try { r = await request('dialogs', 'saveFile', [{ data, filename, kind }], { wait: 0 }); }
153
+ catch (e) { warn('dialogs', 'saveFile', e); }
154
+ return r && typeof r === 'object' ? r : null;
155
+ },
156
+ },
157
+ state: {
158
+ async read(key) {
159
+ const v = await soft(null, 'state', 'read', key);
160
+ if (typeof v !== 'string') return v ?? null;
161
+ try { return JSON.parse(v); }
162
+ catch (e) { logger.warn?.('[host] bad JSON for state key', key, e); return null; }
163
+ },
164
+ async write(key, value) {
165
+ return (await soft(false, 'state', 'write', key, value)) !== false;
166
+ },
167
+ },
168
+ };
169
+
170
+ const caps = {};
171
+ for (const name of Object.keys(defaults)) {
172
+ if (!(name in opts)) caps[name] = defaults[name];
173
+ else if (opts[name] != null) caps[name] = opts[name];
174
+ }
175
+ return createHost(caps);
176
+ }
@@ -0,0 +1,98 @@
1
+ # The Photino host: the C# side
2
+
3
+ `createPhotinoHost()` (`photino_host.js`) talks to a Photino.NET window over
4
+ `window.external.sendMessage` / `receiveMessage`. This is what the window has to
5
+ answer. One handler on the window's web messages does all of it.
6
+
7
+ ## Messages
8
+
9
+ ```
10
+ page → host {"id": 7, "cap": "window", "op": "setBounds", "args": [{"x":0,"y":0,"width":1200,"height":800}]}
11
+ host → page {"id": 7, "ok": true, "value": null}
12
+ host → page {"id": 8, "ok": false, "error": "unknown request window.bogus"}
13
+ host → page {"event": "window.maximizedChanged", "value": true}
14
+ ```
15
+
16
+ - Every request gets exactly one reply with the same `id`. A request that gets
17
+ none is given up after 10 s (`timeoutMs`). `dialogs.saveFile` is the
18
+ exception: the page waits for it without a timeout, because the dialog stays
19
+ open as long as the user takes. Always answer it.
20
+ - `ok: false` means "I can't": the adapter logs it and degrades, exactly as it
21
+ does with pywebview. Use it for unknown ops and for failures.
22
+ - A message with no `id` and an `event` name is an event. The adapter hands it
23
+ to the `onEvent(name, value)` option; it never answers a request.
24
+ - Anything that is not JSON, or is a reply to an id nobody waits for, is
25
+ ignored, so the window may share the channel with other messages.
26
+
27
+ ## Ops
28
+
29
+ | `cap.op` | `args` | reply `value` | Photino |
30
+ |---|---|---|---|
31
+ | `window.minimize` | `[]` | anything | `Minimized = true` |
32
+ | `window.close` | `[]` | anything | `Close()` |
33
+ | `window.isMaximized` | `[]` | `bool` | `Maximized` |
34
+ | `window.setMaximized` | `[bool]` | `bool`, the state now | `Maximized = on` |
35
+ | `window.isFullscreen` | `[]` | `bool` | `FullScreen` |
36
+ | `window.toggleFullscreen` | `[]` | `bool`, the state now | `FullScreen = !FullScreen` |
37
+ | `window.getBounds` | `[]` | `{x, y, width, height}` | `Location`, `Size` |
38
+ | `window.setBounds` | `[{x, y, width, height}]`, any of them `null` = leave alone | anything | `Location`, `Size` |
39
+ | `dialogs.saveFile` | `[{data, filename, kind}]` | see below | `ShowSaveFile(...)` + write |
40
+ | `state.read` | `[key]` | the stored value, or `null` | a file per key |
41
+ | `state.write` | `[key, value]` | `true` | a file per key |
42
+
43
+ `window.chrome()` is answered by the adapter itself (`'native'`): Photino draws a
44
+ native title bar, so FlexDesk draws no buttons of its own. `startNativeDrag` is
45
+ not offered.
46
+
47
+ **`dialogs.saveFile`.** `kind` is the file type the caller is saving, `'csv'`
48
+ or `'png'` today. `data` is the file's text for `csv` and the file's bytes,
49
+ base64, for `png` (decode it before writing). Show a save dialog suggesting
50
+ `filename`, write the file, and reply with one of:
51
+
52
+ ```
53
+ {"ok": true, "path": "C:\\Users\\me\\Documents\\rows.csv"} saved
54
+ {"ok": false, "cancelled": true} the user cancelled: nothing is said
55
+ {"ok": false, "error": "Access to the path is denied."} shown to the user
56
+ ```
57
+
58
+ as the reply's `value` (the envelope's own `ok` stays `true`). A reply with the
59
+ envelope's `ok: false`, or none at all, makes `saveFile` resolve `null`, and the
60
+ caller downloads the file the browser way instead.
61
+
62
+ **`state`.** `value` is any JSON value; store it and give it back unchanged. A
63
+ string value that holds JSON is parsed on the way back, for hosts that store
64
+ the text.
65
+
66
+ ## A dispatcher
67
+
68
+ ```csharp
69
+ window.RegisterWebMessageReceivedHandler((sender, message) =>
70
+ {
71
+ var w = (PhotinoWindow)sender!;
72
+ using var doc = JsonDocument.Parse(message);
73
+ var req = doc.RootElement;
74
+ if (!req.TryGetProperty("id", out var id)) return;
75
+ var args = req.GetProperty("args");
76
+ string reply;
77
+ try
78
+ {
79
+ object? value = (req.GetProperty("cap").GetString(), req.GetProperty("op").GetString()) switch
80
+ {
81
+ ("window", "isMaximized") => w.Maximized,
82
+ ("window", "setMaximized") => w.Maximized = args[0].GetBoolean(),
83
+ ("window", "getBounds") => new { x = w.Left, y = w.Top, width = w.Width, height = w.Height },
84
+ // ... the rest of the table
85
+ var (c, o) => throw new InvalidOperationException($"unknown request {c}.{o}"),
86
+ };
87
+ reply = JsonSerializer.Serialize(new { id = JsonSerializer.Deserialize<object>(id.GetRawText()), ok = true, value });
88
+ }
89
+ catch (Exception ex)
90
+ {
91
+ reply = JsonSerializer.Serialize(new { id = JsonSerializer.Deserialize<object>(id.GetRawText()), ok = false, error = ex.Message });
92
+ }
93
+ w.SendWebMessage(reply);
94
+ });
95
+ ```
96
+
97
+ The handler runs on the window's thread; a save dialog shown from it is modal to
98
+ the window, which is what you want.