@domicile-desktop/component-library 0.0.0-alpha-8f594a0bdff4 → 0.0.0-alpha-ffa113fe68d4

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
@@ -2,8 +2,8 @@
2
2
  "dependencies": {
3
3
  "@base-ui/react": "^1.8.0",
4
4
  "@cprussin/option-result": "^2.0.1",
5
- "@domicile-desktop/sdk": "0.0.0-alpha-8f594a0bdff4",
6
- "@domicile-desktop/system-apps": "0.0.0-alpha-8f594a0bdff4",
5
+ "@domicile-desktop/sdk": "0.0.0-alpha-ffa113fe68d4",
6
+ "@domicile-desktop/system-apps": "0.0.0-alpha-ffa113fe68d4",
7
7
  "@phosphor-icons/react": "^2.1.10"
8
8
  },
9
9
  "description": "React components and the Panda CSS design system that Domicile's shells are built from.",
@@ -58,6 +58,7 @@
58
58
  "./SlideOver": "./src/SlideOver/SlideOver.tsx",
59
59
  "./Slider": "./src/Slider/Slider.tsx",
60
60
  "./Switch": "./src/Switch/Switch.tsx",
61
+ "./shown-windows": "./src/PortalDialogs/shown-windows.ts",
61
62
  "./source-name": "./src/PortalDialogs/source-name.ts",
62
63
  "./spacing": "./src/spacing.ts",
63
64
  "./standalone-theme-source": "./src/ThemeSwitch/standalone-theme-source.ts",
@@ -104,5 +105,5 @@
104
105
  "test:unit": "bun test"
105
106
  },
106
107
  "type": "module",
107
- "version": "0.0.0-alpha-8f594a0bdff4"
108
+ "version": "0.0.0-alpha-ffa113fe68d4"
108
109
  }
@@ -35,6 +35,8 @@ import { PrintDialog } from "./PrintDialog";
35
35
  import { RemoteDesktopDialog } from "./RemoteDesktopDialog";
36
36
  import { ScreenCastDialog } from "./ScreenCastDialog";
37
37
  import { ScreenshotDialog } from "./ScreenshotDialog";
38
+ import type { ShownWindow } from "./shown-windows";
39
+ import { drawnWindows } from "./shown-windows";
38
40
  import { UsbDialog } from "./UsbDialog";
39
41
  import { WallpaperDialog } from "./WallpaperDialog";
40
42
 
@@ -54,6 +56,12 @@ type Props = {
54
56
  * them itself.
55
57
  */
56
58
  omitScreenCasts?: boolean | undefined;
59
+ /**
60
+ * The windows the shell draws on screen, with their whole frames. Without
61
+ * it, the screenshot dialog reads the page's `<app>` and `<webview window>`
62
+ * boxes. See {@link drawnWindows}.
63
+ */
64
+ shownWindows?: (() => readonly ShownWindow[]) | undefined;
57
65
  /** How dialogs read the desktop's files, for tests. */
58
66
  systemOf?: typeof system | undefined;
59
67
  /**
@@ -79,6 +87,7 @@ export const PortalDialogs = ({
79
87
  screen,
80
88
  screenOf,
81
89
  shellChords = NONE,
90
+ shownWindows = drawnOnPage,
82
91
  systemOf = system,
83
92
  }: Props) => {
84
93
  const [requests, setRequests] = useState<readonly PortalRequest[]>([]);
@@ -135,6 +144,7 @@ export const PortalDialogs = ({
135
144
  request={shown}
136
145
  screen={screenFor(shown, screen, screenOf)}
137
146
  shellChords={shellChords}
147
+ shownWindows={shownWindows}
138
148
  system={files}
139
149
  />
140
150
  )}
@@ -142,6 +152,9 @@ export const PortalDialogs = ({
142
152
  );
143
153
  };
144
154
 
155
+ /** The windows this page draws. */
156
+ const drawnOnPage = () => drawnWindows(document);
157
+
145
158
  /** The dialog for `request`'s kind. */
146
159
  const Dialog = ({
147
160
  answer,
@@ -152,6 +165,7 @@ const Dialog = ({
152
165
  request,
153
166
  screen,
154
167
  shellChords,
168
+ shownWindows,
155
169
  system: files,
156
170
  }: {
157
171
  answer: (answer: PortalAnswer) => void;
@@ -162,6 +176,7 @@ const Dialog = ({
162
176
  request: PortalRequest;
163
177
  screen: string | undefined;
164
178
  shellChords: readonly string[];
179
+ shownWindows: () => readonly ShownWindow[];
165
180
  system: System;
166
181
  }) => {
167
182
  switch (request.kind) {
@@ -287,6 +302,7 @@ const Dialog = ({
287
302
  body={request.body}
288
303
  browserWindows={browserWindows}
289
304
  screen={screen}
305
+ shownWindows={shownWindows}
290
306
  />
291
307
  );
292
308
  case PortalKind.PickColor:
@@ -15,9 +15,11 @@ import { flex } from "../../styled-system/patterns";
15
15
  import { Button } from "../Button/Button";
16
16
  import { ModalDialog } from "../ModalDialog/ModalDialog";
17
17
  import type { App } from "../useApps/useApps";
18
- import { browserWindowsOn } from "./browser-windows";
19
18
  import type { FramePoint } from "./frame-point";
20
19
  import { draggedArea, framePoint } from "./frame-point";
20
+ import { onFrame } from "./on-frame";
21
+ import type { ShownWindow } from "./shown-windows";
22
+ import { ShownWindowKind } from "./shown-windows";
21
23
 
22
24
  type Props = {
23
25
  answer: (answer: PortalAnswer) => void;
@@ -29,15 +31,33 @@ type Props = {
29
31
  /** The desktop's browser windows, as the engine lists them. */
30
32
  browserWindows: readonly DomicileBrowserWindow[];
31
33
  screen: string | undefined;
34
+ /** The windows the shell draws on screen, read once as the dialog opens. */
35
+ shownWindows: () => readonly ShownWindow[];
32
36
  };
33
37
 
38
+ /** What saving a choice keeps. */
39
+ enum PickKind {
40
+ Area,
41
+ Window,
42
+ }
43
+
44
+ const Pick = {
45
+ /** An area of the frozen desk. */
46
+ Area: (area: ShotRect) => ({ area, kind: PickKind.Area as const }),
47
+ /** A window not on screen, from its own last frame. */
48
+ Window: (id: string) => ({ id, kind: PickKind.Window as const }),
49
+ };
50
+
51
+ type Pick = ReturnType<(typeof Pick)[keyof typeof Pick]>;
52
+
34
53
  /** Something the dialog offers to save, and how it shows it. */
35
- type Choice = { area: ShotRect; icon: ReactNode; name: string };
54
+ type Choice = { icon: ReactNode; name: string; pick: Pick };
36
55
 
37
56
  /**
38
- * Picks the area of a frozen desk to save: all of it, a screen, a window, or
39
- * an area dragged over it. Windows are the desk's and the browser windows the
40
- * page draws. Dismissing it cancels.
57
+ * Picks what to save of a frozen desk: all of it, a screen, a window, or an
58
+ * area dragged over it. A window the shell shows is its whole frame on the
59
+ * desk; one it does not show, such as a hidden tab, is saved from its own
60
+ * last frame. Dismissing it cancels.
41
61
  */
42
62
  export const ScreenshotDialog = ({
43
63
  answer,
@@ -46,41 +66,39 @@ export const ScreenshotDialog = ({
46
66
  body,
47
67
  browserWindows,
48
68
  screen,
69
+ shownWindows,
49
70
  }: Props) => {
50
71
  // Read once, as the desk froze; a later move would not match the frame.
51
- const [browsers] = useState(() =>
52
- browserWindowsOn(
53
- body,
54
- browserWindows,
55
- document.querySelectorAll("webview[window]"),
56
- ),
72
+ const [shown] = useState(shownWindows);
73
+ const screens: Choice[] = body.monitors.map(
74
+ ({ area, description, name }) => ({
75
+ icon: <MonitorIcon aria-hidden />,
76
+ name: description === "" ? name : description,
77
+ pick: Pick.Area(area),
78
+ }),
57
79
  );
58
- const screens: Choice[] = body.monitors.map(({ area, name }) => ({
59
- area,
60
- icon: <MonitorIcon aria-hidden />,
61
- name,
62
- }));
63
80
  const windows: Choice[] = [
64
- ...body.windows.map(({ appId, area, title }) =>
65
- windowChoice(area, titled(title), appId === "" ? undefined : apps(appId)),
81
+ ...body.windows.map(({ appId, id, title }) =>
82
+ windowChoice(
83
+ pickOf(body, shown, id),
84
+ titled(title),
85
+ appId === "" ? undefined : apps(appId),
86
+ ),
66
87
  ),
67
- ...browsers.map(({ area, title }) => ({
68
- area,
69
- icon: <GlobeIcon aria-hidden />,
70
- name: `Browser: ${titled(title)}`,
71
- })),
88
+ ...shown.flatMap((window) => browserChoices(body, browserWindows, window)),
72
89
  ];
73
90
  const whole: Choice = {
74
- area: { height: body.height, width: body.width, x: 0, y: 0 },
75
91
  icon: undefined,
76
92
  name: "Whole desk",
93
+ pick: Pick.Area({ height: body.height, width: body.width, x: 0, y: 0 }),
77
94
  };
78
95
  const choices = [whole, ...screens, ...windows];
79
96
  // The index of the choice picked, or the area dragged.
80
97
  const [picked, setPicked] = useState<number | ShotRect>(0);
81
98
  const [dragFrom, setDragFrom] = useState<FramePoint | undefined>(undefined);
82
- const area = typeof picked === "number" ? choices[picked]?.area : picked;
83
- if (area === undefined) {
99
+ const pick =
100
+ typeof picked === "number" ? choices[picked]?.pick : Pick.Area(picked);
101
+ if (pick === undefined) {
84
102
  throw new Error(`no screenshot choice ${String(picked)}`);
85
103
  }
86
104
  const button = (choice: Choice, index: number) => (
@@ -119,7 +137,7 @@ export const ScreenshotDialog = ({
119
137
  </Button>
120
138
  <Button
121
139
  onClick={() => {
122
- answer(Answer.Screenshot(area));
140
+ answer(answerOf(pick));
123
141
  }}
124
142
  >
125
143
  Save
@@ -152,6 +170,11 @@ export const ScreenshotDialog = ({
152
170
  </ChoiceGroup>
153
171
  )}
154
172
  </div>
173
+ {pick.kind === PickKind.Window && (
174
+ <p className={askerStyles}>
175
+ Not on screen: saved as it last drew itself.
176
+ </p>
177
+ )}
155
178
  <div
156
179
  aria-label="Drag to pick an area"
157
180
  className={deskStyles}
@@ -176,7 +199,9 @@ export const ScreenshotDialog = ({
176
199
  draggable={false}
177
200
  src={body.frame}
178
201
  />
179
- <div className={pickedStyles} style={placed(area, body)} />
202
+ {pick.kind === PickKind.Area && (
203
+ <div className={pickedStyles} style={placed(pick.area, body)} />
204
+ )}
180
205
  </div>
181
206
  </ModalDialog>
182
207
  );
@@ -252,13 +277,66 @@ const placed = (
252
277
  const titled = (title: string): string =>
253
278
  title === "" ? "Untitled window" : title;
254
279
 
280
+ /** The answer that saves `pick`. */
281
+ const answerOf = (pick: Pick): PortalAnswer => {
282
+ switch (pick.kind) {
283
+ case PickKind.Area:
284
+ return Answer.Screenshot(pick.area);
285
+ case PickKind.Window:
286
+ return Answer.ScreenshotWindow(pick.id);
287
+ }
288
+ };
289
+
290
+ /**
291
+ * What saving window `id` keeps: its frame on the desk while the shell shows
292
+ * it there, else its own last frame.
293
+ */
294
+ const pickOf = (
295
+ desk: FrozenDesk,
296
+ shown: readonly ShownWindow[],
297
+ id: string,
298
+ ): Pick => {
299
+ const window = shown.find(
300
+ (window) => window.kind === ShownWindowKind.App && window.appId === id,
301
+ );
302
+ const area = window === undefined ? undefined : onFrame(desk, window.box);
303
+ return area === undefined ? Pick.Window(id) : Pick.Area(area);
304
+ };
305
+
306
+ /**
307
+ * `window` as a choice when it is a listed browser window on the desk. The
308
+ * engine draws a browser window, so one not on screen has nothing to save.
309
+ */
310
+ const browserChoices = (
311
+ desk: FrozenDesk,
312
+ listed: readonly DomicileBrowserWindow[],
313
+ window: ShownWindow,
314
+ ): Choice[] => {
315
+ switch (window.kind) {
316
+ case ShownWindowKind.App:
317
+ return [];
318
+ case ShownWindowKind.Browser: {
319
+ const browser = listed.find(({ id }) => id === window.id);
320
+ const area = onFrame(desk, window.box);
321
+ return browser === undefined || area === undefined
322
+ ? []
323
+ : [
324
+ {
325
+ icon: <GlobeIcon aria-hidden />,
326
+ name: `Browser: ${titled(browser.title)}`,
327
+ pick: Pick.Area(area),
328
+ },
329
+ ];
330
+ }
331
+ }
332
+ };
333
+
255
334
  /** A window of the desk, named and drawn with its application if it has one. */
256
335
  const windowChoice = (
257
- area: ShotRect,
336
+ pick: Pick,
258
337
  title: string,
259
338
  app: App | undefined,
260
339
  ): Choice => ({
261
- area,
262
340
  icon:
263
341
  app?.icon === undefined ? (
264
342
  <AppWindowIcon aria-hidden />
@@ -266,4 +344,5 @@ const windowChoice = (
266
344
  <img alt="" className={appIconStyles} src={app.icon} />
267
345
  ),
268
346
  name: app === undefined ? title : `${app.name}: ${title}`,
347
+ pick,
269
348
  });
@@ -0,0 +1,32 @@
1
+ import type { FrozenDesk, ShotRect } from "@domicile-desktop/sdk/portal";
2
+ import type { PageBox } from "./shown-windows";
3
+
4
+ /**
5
+ * `box`, in the page's CSS pixels, which are the desk's logical pixels, in
6
+ * `desk`'s frame pixels. Clipped to the desk; `undefined` off it.
7
+ */
8
+ export const onFrame = (
9
+ desk: FrozenDesk,
10
+ box: PageBox,
11
+ ): ShotRect | undefined => {
12
+ const [deskX, deskY] = desk.desk.position;
13
+ const [deskWidth, deskHeight] = desk.desk.size;
14
+ const left = Math.max(box.x, deskX) - deskX;
15
+ const top = Math.max(box.y, deskY) - deskY;
16
+ const right = Math.min(box.x + box.width, deskX + deskWidth) - deskX;
17
+ const bottom = Math.min(box.y + box.height, deskY + deskHeight) - deskY;
18
+ const area = {
19
+ height: pixels(desk, bottom) - pixels(desk, top),
20
+ width: pixels(desk, right) - pixels(desk, left),
21
+ x: pixels(desk, left),
22
+ y: pixels(desk, top),
23
+ };
24
+ return area.width > 0 && area.height > 0 ? area : undefined;
25
+ };
26
+
27
+ /**
28
+ * The frame pixel `logical` pixels into `desk`, rounded as the compositor
29
+ * rounds its monitors.
30
+ */
31
+ const pixels = (desk: FrozenDesk, logical: number): number =>
32
+ Math.round((logical * desk.width) / desk.desk.size[0]);
@@ -0,0 +1,65 @@
1
+ /** A rectangle of the page, in its CSS pixels. */
2
+ export type PageBox = { x: number; y: number; width: number; height: number };
3
+
4
+ /** The kind of a {@link ShownWindow}. */
5
+ export enum ShownWindowKind {
6
+ App,
7
+ Browser,
8
+ }
9
+
10
+ /**
11
+ * A window the shell draws on screen, and its whole frame there: title bar,
12
+ * address bar or tab strip included. The screenshot dialog crops a shown
13
+ * window from the frozen desk by this box.
14
+ */
15
+ export const ShownWindow = {
16
+ /** An `<app>`'s window, by host app id. */
17
+ App: (appId: string, box: PageBox) => ({
18
+ appId,
19
+ box,
20
+ kind: ShownWindowKind.App as const,
21
+ }),
22
+ /** A browser window, by the engine's id. */
23
+ Browser: (id: string, box: PageBox) => ({
24
+ box,
25
+ id,
26
+ kind: ShownWindowKind.Browser as const,
27
+ }),
28
+ };
29
+
30
+ export type ShownWindow = ReturnType<
31
+ (typeof ShownWindow)[keyof typeof ShownWindow]
32
+ >;
33
+
34
+ /**
35
+ * The windows `page` draws: each `<app>` and each `<webview window>` with a
36
+ * box. Their boxes leave out the shell's frame, and a window hidden under
37
+ * another still has one; a shell that knows better passes its own list.
38
+ */
39
+ export const drawnWindows = (page: ParentNode): ShownWindow[] => [
40
+ ...[...page.querySelectorAll("app[app-id]")].flatMap((app) =>
41
+ drawn(app, (box) => ShownWindow.App(attribute(app, "app-id"), box)),
42
+ ),
43
+ ...[...page.querySelectorAll("webview[window]")].flatMap((view) =>
44
+ drawn(view, (box) => ShownWindow.Browser(attribute(view, "window"), box)),
45
+ ),
46
+ ];
47
+
48
+ /** `element` as `shown` makes it, unless it has no box. */
49
+ const drawn = (
50
+ element: Element,
51
+ shown: (box: PageBox) => ShownWindow,
52
+ ): ShownWindow[] => {
53
+ const { height, width, x, y } = element.getBoundingClientRect();
54
+ return width > 0 && height > 0 ? [shown({ height, width, x, y })] : [];
55
+ };
56
+
57
+ /** `element`'s `name` attribute, which its selector guarantees. */
58
+ const attribute = (element: Element, name: string): string => {
59
+ const value = element.getAttribute(name);
60
+ if (value === null) {
61
+ throw new Error(`a selected element has no ${name}`);
62
+ } else {
63
+ return value;
64
+ }
65
+ };
@@ -1,50 +0,0 @@
1
- import type { DomicileBrowserWindow } from "@domicile-desktop/sdk/domicile-host";
2
- import type { FrozenDesk, ShotRect } from "@domicile-desktop/sdk/portal";
3
-
4
- /** A browser window a page draws, where it is on a frozen desk's frame. */
5
- export type ShotBrowserWindow = { title: string; area: ShotRect };
6
-
7
- /**
8
- * The browser windows `views` draw, where they are on `desk`'s frame. The
9
- * compositor sees only `<app>` windows, so the page places these itself.
10
- *
11
- * A view's box is in the page's CSS pixels, which are the desk's logical
12
- * pixels. It is clipped to the desk, and left out when off it, as one on a
13
- * hidden workspace is.
14
- */
15
- export const browserWindowsOn = (
16
- desk: FrozenDesk,
17
- listed: readonly DomicileBrowserWindow[],
18
- views: Iterable<Element>,
19
- ): ShotBrowserWindow[] =>
20
- [...views].flatMap((view) => {
21
- const shown = listed.find(({ id }) => id === view.getAttribute("window"));
22
- const area = onFrame(desk, view.getBoundingClientRect());
23
- return shown === undefined || area === undefined
24
- ? []
25
- : [{ area, title: shown.title }];
26
- });
27
-
28
- /** `box`, in logical pixels, in `desk`'s frame pixels; `undefined` off it. */
29
- const onFrame = (desk: FrozenDesk, box: DOMRect): ShotRect | undefined => {
30
- const [deskX, deskY] = desk.desk.position;
31
- const [deskWidth, deskHeight] = desk.desk.size;
32
- const left = Math.max(box.left, deskX) - deskX;
33
- const top = Math.max(box.top, deskY) - deskY;
34
- const right = Math.min(box.right, deskX + deskWidth) - deskX;
35
- const bottom = Math.min(box.bottom, deskY + deskHeight) - deskY;
36
- const area = {
37
- height: pixels(desk, bottom) - pixels(desk, top),
38
- width: pixels(desk, right) - pixels(desk, left),
39
- x: pixels(desk, left),
40
- y: pixels(desk, top),
41
- };
42
- return area.width > 0 && area.height > 0 ? area : undefined;
43
- };
44
-
45
- /**
46
- * The frame pixel `logical` pixels into `desk`, rounded as the compositor
47
- * rounds its windows.
48
- */
49
- const pixels = (desk: FrozenDesk, logical: number): number =>
50
- Math.round((logical * desk.width) / desk.desk.size[0]);