@artooi/ag-ui-web-component 0.38.0 → 0.40.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/CHANGELOG.md +515 -1
- package/README.md +232 -56
- package/dist/ag-ui-web-component.bundle.js +370 -139
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +17 -14
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/activity_registry.d.ts +46 -0
- package/dist/core/activity_registry.d.ts.map +1 -0
- package/dist/core/ag_ui_chat.d.ts +23 -10
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +28 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/client_seed.d.ts +27 -0
- package/dist/core/client_seed.d.ts.map +1 -0
- package/dist/core/read_max_tool_rounds.d.ts +9 -0
- package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
- package/dist/core/run_handlers.d.ts +80 -0
- package/dist/core/run_handlers.d.ts.map +1 -0
- package/dist/core/storage_scope.d.ts +110 -0
- package/dist/core/storage_scope.d.ts.map +1 -0
- package/dist/core/tool_outcome.d.ts +2 -3
- package/dist/core/tool_outcome.d.ts.map +1 -1
- package/dist/core/utils.d.ts +46 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.js +16268 -14747
- package/dist/index.js.map +4 -4
- package/dist/skills/skill_catalog.d.ts +67 -0
- package/dist/skills/skill_catalog.d.ts.map +1 -0
- package/dist/skills/skill_name_from.d.ts +12 -0
- package/dist/skills/skill_name_from.d.ts.map +1 -0
- package/dist/tools/tool_catalog.d.ts +79 -0
- package/dist/tools/tool_catalog.d.ts.map +1 -0
- package/dist/tools/tool_dispatch.d.ts +104 -0
- package/dist/tools/tool_dispatch.d.ts.map +1 -0
- package/dist/ui/composer/attachment_tray.d.ts.map +1 -1
- package/dist/ui/composer/auto_grow.d.ts +10 -0
- package/dist/ui/composer/auto_grow.d.ts.map +1 -0
- package/dist/ui/composer/composer_attachments.d.ts +57 -0
- package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
- package/dist/ui/composer/composer_voice.d.ts +48 -0
- package/dist/ui/composer/composer_voice.d.ts.map +1 -0
- package/dist/ui/composer/voice_input.d.ts.map +1 -1
- package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
- package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
- package/dist/ui/fill_ui_string.d.ts +32 -0
- package/dist/ui/fill_ui_string.d.ts.map +1 -0
- package/dist/ui/history/conversation_history.d.ts +203 -0
- package/dist/ui/history/conversation_history.d.ts.map +1 -0
- package/dist/ui/history/relative_time.d.ts.map +1 -1
- package/dist/ui/history/thread_drawer.d.ts +0 -14
- package/dist/ui/history/thread_drawer.d.ts.map +1 -1
- package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -1
- package/dist/ui/interrupts/pending_decision.d.ts +23 -0
- package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
- package/dist/ui/placement/is_collapsible_placement.d.ts +18 -0
- package/dist/ui/placement/is_collapsible_placement.d.ts.map +1 -0
- package/dist/ui/placement/is_draggable_placement.d.ts +9 -0
- package/dist/ui/placement/is_draggable_placement.d.ts.map +1 -0
- package/dist/ui/placement/launcher_drag.d.ts +6 -0
- package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
- package/dist/ui/placement/panel_placement.d.ts +119 -0
- package/dist/ui/placement/panel_placement.d.ts.map +1 -0
- package/dist/ui/progress/run_announcer.d.ts +41 -0
- package/dist/ui/progress/run_announcer.d.ts.map +1 -0
- package/dist/ui/progress/subagent_progress.d.ts +58 -0
- package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
- package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
- package/dist/ui/shell/adopt_styles.d.ts +23 -0
- package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
- package/dist/ui/shell/glyph_slot.d.ts +7 -0
- package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
- package/dist/ui/shell/header_button.d.ts +10 -0
- package/dist/ui/shell/header_button.d.ts.map +1 -0
- package/dist/ui/shell/icon_element.d.ts +12 -0
- package/dist/ui/shell/icon_element.d.ts.map +1 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/transcript/answer_actions.d.ts +39 -0
- package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
- package/dist/ui/transcript/answer_stream.d.ts +56 -0
- package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
- package/dist/ui/transcript/render_or_warn.d.ts +16 -0
- package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
- package/dist/ui/transcript/starter_chips.d.ts +21 -0
- package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
- package/dist/ui/transcript/transcript.d.ts +171 -0
- package/dist/ui/transcript/transcript.d.ts.map +1 -0
- package/dist/ui/ui_strings.d.ts +29 -1
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +17 -15
- package/src/core/activity_registry.ts +133 -0
- package/src/core/ag_ui_chat.ts +945 -4419
- package/src/core/agui_client.ts +172 -16
- package/src/core/client_seed.ts +27 -0
- package/src/core/read_max_tool_rounds.ts +13 -0
- package/src/core/remote_conversation_store.ts +1 -1
- package/src/core/run_handlers.ts +501 -0
- package/src/core/storage_scope.ts +279 -0
- package/src/core/tool_outcome.ts +5 -3
- package/src/core/utils.ts +109 -0
- package/src/skills/skill_catalog.ts +166 -0
- package/src/skills/skill_name_from.ts +19 -0
- package/src/tools/tool_catalog.ts +354 -0
- package/src/tools/tool_dispatch.ts +521 -0
- package/src/ui/composer/attachment_tray.ts +6 -5
- package/src/ui/composer/auto_grow.ts +12 -0
- package/src/ui/composer/composer_attachments.ts +324 -0
- package/src/ui/composer/composer_voice.ts +93 -0
- package/src/ui/composer/voice_input.ts +4 -4
- package/src/ui/excerpts/transcript_quote_offer.ts +198 -0
- package/src/ui/fill_ui_string.ts +41 -0
- package/src/ui/history/conversation_history.ts +699 -0
- package/src/ui/history/relative_time.ts +5 -4
- package/src/ui/history/thread_drawer.ts +3 -50
- package/src/ui/interrupts/confirmation_card.ts +4 -2
- package/src/ui/interrupts/pending_decision.ts +34 -0
- package/src/ui/placement/is_collapsible_placement.ts +19 -0
- package/src/ui/placement/is_draggable_placement.ts +23 -0
- package/src/ui/placement/launcher_drag.ts +104 -89
- package/src/ui/placement/panel_placement.ts +1130 -0
- package/src/ui/progress/run_announcer.ts +67 -0
- package/src/ui/progress/subagent_progress.ts +201 -0
- package/src/ui/progress/tool_call_card.ts +2 -0
- package/src/ui/shell/adopt_styles.ts +28 -0
- package/src/ui/shell/glyph_slot.ts +12 -0
- package/src/ui/shell/header_button.ts +21 -0
- package/src/ui/shell/icon_element.ts +33 -0
- package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
- package/src/ui/shell/read_launcher_icon_url.ts +9 -0
- package/src/ui/styles.ts +301 -70
- package/src/ui/transcript/answer_actions.ts +139 -0
- package/src/ui/transcript/answer_stream.ts +123 -0
- package/src/ui/transcript/render_or_warn.ts +22 -0
- package/src/ui/transcript/starter_chips.ts +42 -0
- package/src/ui/transcript/transcript.ts +429 -0
- package/src/ui/ui_strings.ts +38 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
import { EDGE_MARGIN, SCREEN_EDGE_MARGIN } from "../../constants.js";
|
|
2
|
+
import type { ChatCorner, ChatSurfaceReport } from "../../tools/chat_surface_tools.js";
|
|
3
|
+
import type { UiStrings } from "../ui_strings.js";
|
|
4
|
+
import { clampLauncher } from "./clamp_launcher.js";
|
|
5
|
+
import { clampPanel } from "./clamp_panel.js";
|
|
6
|
+
import { isDraggablePlacement } from "./is_draggable_placement.js";
|
|
7
|
+
import { enableLauncherDrag } from "./launcher_drag.js";
|
|
8
|
+
import {
|
|
9
|
+
type ExpandCorner,
|
|
10
|
+
type Extent,
|
|
11
|
+
type LauncherBox,
|
|
12
|
+
launcherPlacement,
|
|
13
|
+
type ViewportBox,
|
|
14
|
+
} from "./launcher_placement.js";
|
|
15
|
+
import { enablePanelDrag } from "./panel_drag.js";
|
|
16
|
+
import { placeWidget } from "./place_widget.js";
|
|
17
|
+
import {
|
|
18
|
+
createResizeHandle,
|
|
19
|
+
gripName,
|
|
20
|
+
type PanelRect,
|
|
21
|
+
type ResizeAnchor,
|
|
22
|
+
type ResizeAxis,
|
|
23
|
+
type ResizeGrip,
|
|
24
|
+
type ResizeSize,
|
|
25
|
+
} from "./resize_handle.js";
|
|
26
|
+
|
|
27
|
+
/** Per-tab persistence key for a dragged panel size. */
|
|
28
|
+
const SIZE_KEY = "ag-ui-chat:size";
|
|
29
|
+
|
|
30
|
+
/** Per-tab persistence key for a dragged launcher position. */
|
|
31
|
+
const LAUNCHER_KEY = "ag-ui-chat:launcher";
|
|
32
|
+
|
|
33
|
+
/** A stored `{ left, top }` pair, or null for anything that is not one. */
|
|
34
|
+
function asPoint(value: unknown): { readonly left: number; readonly top: number } | null {
|
|
35
|
+
if (typeof value !== "object" || value === null) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const { left, top } = value as { left?: unknown; top?: unknown };
|
|
39
|
+
return typeof left === "number" && typeof top === "number" ? { left, top } : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether a box covers the usable viewport, which is what full-bleed means.
|
|
44
|
+
*
|
|
45
|
+
* A pixel of slack on each axis, because a box sized from `100vw` and one sized
|
|
46
|
+
* from the visual viewport disagree by sub-pixel rounding, and a panel that
|
|
47
|
+
* fills the screen must not read as one that could still be moved within it.
|
|
48
|
+
*/
|
|
49
|
+
function coversViewport(box: Extent, viewport: Extent): boolean {
|
|
50
|
+
return box.width >= viewport.width - 1 && box.height >= viewport.height - 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `--ag-ui-inset` value that pins `box` by `corner`.
|
|
55
|
+
*
|
|
56
|
+
* Only the two sides the corner names get a length; the other two are `auto`,
|
|
57
|
+
* so the box keeps its size and grows away from that corner. Right and bottom
|
|
58
|
+
* are measured from `screen`, the layout viewport, because that is what the
|
|
59
|
+
* browser measures a fixed element's inset from.
|
|
60
|
+
*/
|
|
61
|
+
function insetFrom(corner: ResizeAnchor, box: PanelRect, screen: Extent): string {
|
|
62
|
+
const side = (value: number): string => `${Math.round(value)}px`;
|
|
63
|
+
return [
|
|
64
|
+
corner.y === "top" ? side(box.top) : "auto",
|
|
65
|
+
corner.x === "right" ? side(screen.width - box.right) : "auto",
|
|
66
|
+
corner.y === "bottom" ? side(screen.height - box.bottom) : "auto",
|
|
67
|
+
corner.x === "left" ? side(box.left) : "auto",
|
|
68
|
+
].join(" ");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every edge and corner the panel can be dragged by. Corners last, so they sit
|
|
73
|
+
* above the edge strips they overlap and win the pointer at the corners.
|
|
74
|
+
*/
|
|
75
|
+
const RESIZE_GRIPS: readonly ResizeGrip[] = [
|
|
76
|
+
{ y: "top" },
|
|
77
|
+
{ y: "bottom" },
|
|
78
|
+
{ x: "left" },
|
|
79
|
+
{ x: "right" },
|
|
80
|
+
{ x: "left", y: "top" },
|
|
81
|
+
{ x: "right", y: "top" },
|
|
82
|
+
{ x: "left", y: "bottom" },
|
|
83
|
+
{ x: "right", y: "bottom" },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What the placement needs from the element that owns it.
|
|
88
|
+
*
|
|
89
|
+
* Everything the element keeps private is reached through a thunk here, the
|
|
90
|
+
* same shape `PanelDragOptions` and `ResizeOptions` already give their hosts:
|
|
91
|
+
* read per call, so a value the element replaces later (the resolved string
|
|
92
|
+
* table, the connected flag) is never captured stale.
|
|
93
|
+
*/
|
|
94
|
+
export interface PlacementHost {
|
|
95
|
+
/** The custom element itself: its inline style, attributes and box. */
|
|
96
|
+
readonly element: HTMLElement;
|
|
97
|
+
/** The collapsed widget's button, which a drag moves and a resize carries. */
|
|
98
|
+
readonly launcher: HTMLButtonElement;
|
|
99
|
+
/** The shadow root, searched for a header mid-drag. */
|
|
100
|
+
readonly root: ShadowRoot;
|
|
101
|
+
/** Whether the element is in the document. */
|
|
102
|
+
readonly connected: () => boolean;
|
|
103
|
+
/** Whether the widget is collapsed. */
|
|
104
|
+
readonly collapsed: () => boolean;
|
|
105
|
+
/** Whether the current placement has a collapsed state at all. */
|
|
106
|
+
readonly collapsible: () => boolean;
|
|
107
|
+
/** The resolved string table. */
|
|
108
|
+
readonly strings: () => UiStrings;
|
|
109
|
+
/** Read a layout preference, durable store first. */
|
|
110
|
+
readonly readPreference: (base: string) => string | null;
|
|
111
|
+
/** Persist a layout preference to both stores. */
|
|
112
|
+
readonly writePreference: (base: string, value: string) => void;
|
|
113
|
+
/** Drop a layout preference from both stores. */
|
|
114
|
+
readonly clearPreference: (base: string) => void;
|
|
115
|
+
/** Tell the reader the agent moved their window, with the way back. */
|
|
116
|
+
readonly announceSurfaceChange: (text: string, undo: () => void) => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Where the panel and its launcher sit, and how big the panel is: the dragged
|
|
121
|
+
* launcher, the dragged header, the eight resize grips, the corner the panel
|
|
122
|
+
* opens from, and the persisted record of all of it.
|
|
123
|
+
*
|
|
124
|
+
* Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
|
|
125
|
+
* instance, so two elements on a page place themselves independently.
|
|
126
|
+
*/
|
|
127
|
+
export class PanelPlacement {
|
|
128
|
+
readonly #host: PlacementHost;
|
|
129
|
+
|
|
130
|
+
constructor(host: PlacementHost) {
|
|
131
|
+
this.#host = host;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Make the header a title bar. Only while open: a collapsed widget has no
|
|
136
|
+
* header on screen, and the launcher is the handle then.
|
|
137
|
+
*/
|
|
138
|
+
enablePanelDrag(header: HTMLElement): void {
|
|
139
|
+
enablePanelDrag(header, {
|
|
140
|
+
enabled: () => !this.#host.collapsed() && this.#launcherDraggable(),
|
|
141
|
+
rect: () => this.#host.element.getBoundingClientRect(),
|
|
142
|
+
apply: (box, from) => this.#movePanel(box, from),
|
|
143
|
+
commit: (box, from) => this.#commitPanel(box, from),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Let the launcher be dragged. Only while collapsed: the launcher is scaled
|
|
149
|
+
* away and unclickable behind the open panel, so a drag there would move
|
|
150
|
+
* something nobody can see.
|
|
151
|
+
*/
|
|
152
|
+
enableLauncherDrag(signal: AbortSignal): void {
|
|
153
|
+
enableLauncherDrag(this.#host.launcher, {
|
|
154
|
+
signal,
|
|
155
|
+
enabled: () => this.#host.collapsed() && this.#launcherDraggable(),
|
|
156
|
+
rect: () => this.#launcherBox(),
|
|
157
|
+
viewport: () => this.#viewport(),
|
|
158
|
+
apply: (left, top) => this.#moveLauncher(left, top),
|
|
159
|
+
commit: (left, top) => this.#commitLauncher(left, top),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Build the eight resize grips into `chat`, with one of them in the tab order. */
|
|
164
|
+
mountResizeGrips(chat: HTMLElement): void {
|
|
165
|
+
for (const grip of RESIZE_GRIPS) {
|
|
166
|
+
const handle = createResizeHandle(grip, {
|
|
167
|
+
axis: () => this.#resizeAxis(),
|
|
168
|
+
rect: () => this.#host.element.getBoundingClientRect(),
|
|
169
|
+
apply: (box) => this.#applyResize(grip, box),
|
|
170
|
+
commit: (box) => this.#commitResize(grip, box),
|
|
171
|
+
label: this.#host.strings().resizePanel,
|
|
172
|
+
});
|
|
173
|
+
// Only one of the eight is in the tab order. Eight separators between the
|
|
174
|
+
// transcript and the composer is not keyboard parity, it is a keyboard
|
|
175
|
+
// obstacle -- and one grip already reaches both axes, which is exactly
|
|
176
|
+
// what the single grip this replaced offered. syncResizeAnchor decides
|
|
177
|
+
// which one, and it is the free corner, so an arrow key grows the panel
|
|
178
|
+
// rather than moving it.
|
|
179
|
+
handle.tabIndex = -1;
|
|
180
|
+
handle.setAttribute("aria-hidden", "true");
|
|
181
|
+
this.#resizeHandles.set(gripName(grip), handle);
|
|
182
|
+
chat.appendChild(handle);
|
|
183
|
+
}
|
|
184
|
+
this.#focusableGrip();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Restore a dragged size before the panel paints. */
|
|
188
|
+
restoreSize(): void {
|
|
189
|
+
this.#applySize(this.#readSize());
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A zero-sized box carrying the host's viewport insets as padding, so they
|
|
194
|
+
* can be read back as used pixel lengths. See the `.viewport-probe` rule for
|
|
195
|
+
* why a custom property cannot be read directly.
|
|
196
|
+
*/
|
|
197
|
+
readonly probe = document.createElement("div");
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Where the user dragged the launcher, in viewport coordinates, or null
|
|
201
|
+
* while the host's own CSS still places it. Set means this element owns its
|
|
202
|
+
* position -- see #applyLauncherPlacement for what that costs the host.
|
|
203
|
+
*/
|
|
204
|
+
#launcherPos: { readonly left: number; readonly top: number } | null = null;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Where the user dragged the *panel*, in viewport coordinates, or null while
|
|
208
|
+
* its position is still derived from the launcher's.
|
|
209
|
+
*
|
|
210
|
+
* The two gestures state different things and are restored differently. A
|
|
211
|
+
* launcher drag says where the bubble goes and leaves the panel to open into
|
|
212
|
+
* whatever space the viewport has, so it is re-derived every time -- which is
|
|
213
|
+
* what lets a widget re-decide its direction when the window changes under
|
|
214
|
+
* it. A header drag states the panel's own position, and re-deriving that
|
|
215
|
+
* from the launcher would move the panel the user just placed.
|
|
216
|
+
*/
|
|
217
|
+
#panelPos: { readonly left: number; readonly top: number } | null = null;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The corner the panel opens away from, once this element is placing itself.
|
|
221
|
+
* Null means the host's layout still decides, and the anchor is measured.
|
|
222
|
+
*/
|
|
223
|
+
#expandCorner: ExpandCorner | null = null;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The edges the layout is holding still, as last measured. Cached because a
|
|
227
|
+
* resize reads it per pointer move and measuring forces a reflow -- thirty a
|
|
228
|
+
* second while the panel is already being laid out on every one of them.
|
|
229
|
+
*/
|
|
230
|
+
#anchor: ResizeAnchor = { x: "right", y: "bottom" };
|
|
231
|
+
|
|
232
|
+
/** The eight grips, by name, so the keyboard-reachable one can be moved. */
|
|
233
|
+
readonly #resizeHandles = new Map<string, HTMLDivElement>();
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Hold a resized box inside the part of the screen the host left free.
|
|
237
|
+
*
|
|
238
|
+
* Each edge on its own, unlike the drag's clamp: a drag moves a box of fixed
|
|
239
|
+
* size, so pushing it back in is right, while a resize is anchored on the
|
|
240
|
+
* opposite edge and pushing it back would move the edge the user is not
|
|
241
|
+
* touching. Bounding each edge instead leaves the grip stopped at the limit
|
|
242
|
+
* -- the gesture keeps going and the panel simply stops growing, which is
|
|
243
|
+
* what dragging already does.
|
|
244
|
+
*
|
|
245
|
+
* The minimum size is the grip's own concern and is applied before this, so
|
|
246
|
+
* a panel that cannot fit the space is left at its minimum and overflowing
|
|
247
|
+
* rather than collapsed to nothing.
|
|
248
|
+
*/
|
|
249
|
+
#withinViewport(box: PanelRect): PanelRect {
|
|
250
|
+
const viewport = this.#viewport();
|
|
251
|
+
// The same bound a drag stops at, so a grip pulled to the edge and a panel
|
|
252
|
+
// dragged to it come to rest on the same line. The inner Math.max/min pair
|
|
253
|
+
// keeps an already-inverted box from turning inside out.
|
|
254
|
+
const left = viewport.left + SCREEN_EDGE_MARGIN;
|
|
255
|
+
const top = viewport.top + SCREEN_EDGE_MARGIN;
|
|
256
|
+
const right = viewport.left + viewport.width - SCREEN_EDGE_MARGIN;
|
|
257
|
+
const bottom = viewport.top + viewport.height - SCREEN_EDGE_MARGIN;
|
|
258
|
+
return {
|
|
259
|
+
left: Math.min(Math.max(box.left, left), box.right),
|
|
260
|
+
top: Math.min(Math.max(box.top, top), box.bottom),
|
|
261
|
+
right: Math.max(Math.min(box.right, right), box.left),
|
|
262
|
+
bottom: Math.max(Math.min(box.bottom, bottom), box.top),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Whether a pointer or key gesture is currently placing the widget.
|
|
268
|
+
*
|
|
269
|
+
* Read from the stamp the drag helpers already set, rather than tracked
|
|
270
|
+
* separately: one source of truth, and it clears on `pointercancel` as well
|
|
271
|
+
* as `pointerup`, which is the end a touch gesture usually gets.
|
|
272
|
+
*/
|
|
273
|
+
dragging(): boolean {
|
|
274
|
+
return (
|
|
275
|
+
this.#host.launcher.hasAttribute("data-dragging") ||
|
|
276
|
+
this.#host.root.querySelector(".header[data-dragging]") !== null
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The report behind `AgUiChat.describeSurface`, whose doc is the contract. */
|
|
281
|
+
describeSurface(): ChatSurfaceReport {
|
|
282
|
+
const box = this.#host.element.getBoundingClientRect();
|
|
283
|
+
const viewport = this.#viewport();
|
|
284
|
+
const fullBleed = coversViewport(box, viewport);
|
|
285
|
+
return {
|
|
286
|
+
placement: this.#host.element.getAttribute("placement"),
|
|
287
|
+
collapsed: this.#host.collapsed(),
|
|
288
|
+
collapsible: this.#host.collapsible(),
|
|
289
|
+
movable: this.#launcherDraggable() && !fullBleed,
|
|
290
|
+
draggable: this.#launcherDraggable(),
|
|
291
|
+
fullBleed,
|
|
292
|
+
box: {
|
|
293
|
+
left: Math.round(box.left),
|
|
294
|
+
top: Math.round(box.top),
|
|
295
|
+
width: Math.round(box.width),
|
|
296
|
+
height: Math.round(box.height),
|
|
297
|
+
},
|
|
298
|
+
viewport: {
|
|
299
|
+
left: Math.round(viewport.left),
|
|
300
|
+
top: Math.round(viewport.top),
|
|
301
|
+
width: Math.round(viewport.width),
|
|
302
|
+
height: Math.round(viewport.height),
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The move behind `AgUiChat.moveTo`, whose doc is the contract. */
|
|
308
|
+
moveTo(corner: ChatCorner, options: { readonly announce?: boolean } = {}): boolean {
|
|
309
|
+
if (!this.#launcherDraggable()) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
const restore = options.announce === true ? this.#captureGeometry() : null;
|
|
313
|
+
const viewport = this.#viewport();
|
|
314
|
+
const box = this.#host.element.getBoundingClientRect();
|
|
315
|
+
if (coversViewport(box, viewport)) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const [edgeY, edgeX] = corner.split("-");
|
|
319
|
+
// Every term is an absolute screen coordinate, because that is what the
|
|
320
|
+
// clamps and the insets both speak. The usable box carries an origin, so
|
|
321
|
+
// its near edge is `viewport.left`, not zero, and its far edge is
|
|
322
|
+
// `viewport.left + viewport.width` -- a margin applied to the extents
|
|
323
|
+
// alone would send the agent's own move under the chrome the host
|
|
324
|
+
// reserved, which is the failure the usable box exists to prevent.
|
|
325
|
+
const nearX = viewport.left + EDGE_MARGIN;
|
|
326
|
+
const nearY = viewport.top + EDGE_MARGIN;
|
|
327
|
+
const left =
|
|
328
|
+
edgeX === "left"
|
|
329
|
+
? nearX
|
|
330
|
+
: Math.max(nearX, viewport.left + viewport.width - EDGE_MARGIN - box.width);
|
|
331
|
+
const top =
|
|
332
|
+
edgeY === "top"
|
|
333
|
+
? nearY
|
|
334
|
+
: Math.max(nearY, viewport.top + viewport.height - EDGE_MARGIN - box.height);
|
|
335
|
+
const host = { left, top, right: left + box.width, bottom: top + box.height };
|
|
336
|
+
// Both axes measured, rather than one read twice: a host can restyle the
|
|
337
|
+
// launcher as a pill, and squaring it here would put it off the corner.
|
|
338
|
+
const launcherWidth = this.#host.launcher.offsetWidth;
|
|
339
|
+
const launcherHeight = this.#host.launcher.offsetHeight;
|
|
340
|
+
this.#placePanelAndLauncher(host, {
|
|
341
|
+
left: edgeX === "left" ? host.left : host.right - launcherWidth,
|
|
342
|
+
top: edgeY === "top" ? host.top : host.bottom - launcherHeight,
|
|
343
|
+
width: launcherWidth,
|
|
344
|
+
height: launcherHeight,
|
|
345
|
+
});
|
|
346
|
+
this.#storeLauncherPosition();
|
|
347
|
+
if (restore !== null) {
|
|
348
|
+
this.#host.announceSurfaceChange(this.#host.strings().chatMoved, restore);
|
|
349
|
+
}
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Snapshot the panel's stated position, and return a function that puts it
|
|
355
|
+
* back.
|
|
356
|
+
*
|
|
357
|
+
* Both insets and the expand corner, because they are one decision: the
|
|
358
|
+
* corner is what the panel grows from, so restoring a position without it
|
|
359
|
+
* puts the box back and animates it out of the wrong side. Absent values are
|
|
360
|
+
* captured as absent and removed on the way back, rather than written as
|
|
361
|
+
* empty strings that would outrank the placement.
|
|
362
|
+
*/
|
|
363
|
+
#captureGeometry(): () => void {
|
|
364
|
+
const inset = this.#host.element.style.getPropertyValue("--ag-ui-inset");
|
|
365
|
+
const launcherInset = this.#host.element.style.getPropertyValue("--ag-ui-launcher-inset");
|
|
366
|
+
const corner = this.#host.element.getAttribute("data-expand-corner");
|
|
367
|
+
const launcherPos = this.#launcherPos;
|
|
368
|
+
const panelPos = this.#panelPos;
|
|
369
|
+
const expandCorner = this.#expandCorner;
|
|
370
|
+
return () => {
|
|
371
|
+
this.#restoreProperty("--ag-ui-inset", inset);
|
|
372
|
+
this.#restoreProperty("--ag-ui-launcher-inset", launcherInset);
|
|
373
|
+
if (corner === null) {
|
|
374
|
+
this.#host.element.removeAttribute("data-expand-corner");
|
|
375
|
+
} else {
|
|
376
|
+
this.#host.element.setAttribute("data-expand-corner", corner);
|
|
377
|
+
}
|
|
378
|
+
this.#launcherPos = launcherPos;
|
|
379
|
+
this.#panelPos = panelPos;
|
|
380
|
+
this.#expandCorner = expandCorner;
|
|
381
|
+
// Erased rather than rewritten when there was nothing to go back to.
|
|
382
|
+
// #storeLauncherPosition returns early for a null position, which would
|
|
383
|
+
// leave the move this is undoing sitting in storage -- and since that
|
|
384
|
+
// store outlives the tab, the next resize or reload would quietly put
|
|
385
|
+
// the panel back in the corner the user had just rejected.
|
|
386
|
+
if (launcherPos === null) {
|
|
387
|
+
this.#host.clearPreference(LAUNCHER_KEY);
|
|
388
|
+
} else {
|
|
389
|
+
this.#storeLauncherPosition();
|
|
390
|
+
}
|
|
391
|
+
this.syncResizeAnchor();
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Which axes the current placement allows.
|
|
397
|
+
*
|
|
398
|
+
* A full-bleed layout is `100vw`/`100vh` by definition and cannot be resized
|
|
399
|
+
* at all; a docked panel owns its height, leaving only its inner edge. Read
|
|
400
|
+
* per interaction, because `placement` is a live attribute.
|
|
401
|
+
*/
|
|
402
|
+
#resizeAxis(): ResizeAxis {
|
|
403
|
+
switch (this.#host.element.getAttribute("placement")) {
|
|
404
|
+
case "full":
|
|
405
|
+
case "page":
|
|
406
|
+
return "none";
|
|
407
|
+
case "sidebar":
|
|
408
|
+
case "side":
|
|
409
|
+
return "width";
|
|
410
|
+
default:
|
|
411
|
+
return "both";
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Which edges the layout is holding still, by measuring rather than guessing:
|
|
417
|
+
* nudge the size by a pixel, see which edges stayed put, and undo. One forced
|
|
418
|
+
* reflow per drag.
|
|
419
|
+
*
|
|
420
|
+
* `placement` cannot answer this — an embedded panel goes wherever the page's
|
|
421
|
+
* CSS puts it — and see {@link createResizeHandle} for why guessing produces
|
|
422
|
+
* a visibly broken control.
|
|
423
|
+
*/
|
|
424
|
+
#measureAnchor(): ResizeAnchor {
|
|
425
|
+
const before = this.#host.element.getBoundingClientRect();
|
|
426
|
+
const width = this.#host.element.style.getPropertyValue("--ag-ui-width");
|
|
427
|
+
const height = this.#host.element.style.getPropertyValue("--ag-ui-height");
|
|
428
|
+
// Shrink first. Growing is the obvious probe and cannot answer the question
|
|
429
|
+
// at a size already resting against max-width or max-height: the box does
|
|
430
|
+
// not change, no edge moves, and every clamped axis then reads as pinned on
|
|
431
|
+
// the side that did not move -- which is the side that is free. That is not
|
|
432
|
+
// an edge case. The default panel is 380px wide against a max-width of
|
|
433
|
+
// 100vw minus 48, so any viewport under 428px is born clamped, and the grip
|
|
434
|
+
// rendered on the wrong corner with the drag inverted before anyone touched
|
|
435
|
+
// it. Shrinking always moves an edge, because the shrink is measured from
|
|
436
|
+
// the box's *used* width rather than from whatever was asked for.
|
|
437
|
+
const shrunk = this.#probeAnchor(before, -1);
|
|
438
|
+
// Unless a host rule sets a minimum, in which case that axis is asked the
|
|
439
|
+
// opposite question rather than left to a guess.
|
|
440
|
+
const grown = shrunk.x === null || shrunk.y === null ? this.#probeAnchor(before, 1) : shrunk;
|
|
441
|
+
// Restore exactly what was there, including "nothing" — leaving a probe
|
|
442
|
+
// value behind would pin a panel that had been sizing itself.
|
|
443
|
+
this.#restoreProperty("--ag-ui-width", width);
|
|
444
|
+
this.#restoreProperty("--ag-ui-height", height);
|
|
445
|
+
return {
|
|
446
|
+
// Neither direction moved it: the axis cannot be resized at all, so the
|
|
447
|
+
// floating default is the best answer available and is the one the
|
|
448
|
+
// stylesheet would have used with no measurement at all.
|
|
449
|
+
x: shrunk.x ?? grown.x ?? "right",
|
|
450
|
+
y: shrunk.y ?? grown.y ?? "bottom",
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Which edge each axis holds still when the panel changes size by `delta`.
|
|
456
|
+
*
|
|
457
|
+
* Null for an axis whose size did not change: nothing moved, so nothing was
|
|
458
|
+
* learned, and reporting the unmoved edge as the pinned one would be exactly
|
|
459
|
+
* backwards.
|
|
460
|
+
*/
|
|
461
|
+
#probeAnchor(
|
|
462
|
+
before: DOMRect,
|
|
463
|
+
delta: number,
|
|
464
|
+
): { x: "left" | "right" | null; y: "top" | "bottom" | null } {
|
|
465
|
+
this.#applySize({ width: before.width + delta, height: before.height + delta });
|
|
466
|
+
const after = this.#host.element.getBoundingClientRect();
|
|
467
|
+
const moved = (a: number, b: number): boolean => Math.abs(a - b) >= 0.5;
|
|
468
|
+
return {
|
|
469
|
+
x: moved(after.width, before.width)
|
|
470
|
+
? moved(after.left, before.left)
|
|
471
|
+
? "right"
|
|
472
|
+
: "left"
|
|
473
|
+
: null,
|
|
474
|
+
y: moved(after.height, before.height)
|
|
475
|
+
? moved(after.top, before.top)
|
|
476
|
+
? "bottom"
|
|
477
|
+
: "top"
|
|
478
|
+
: null,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Stamp the measured anchor so the shadow CSS can place the grip. */
|
|
483
|
+
syncResizeAnchor(): void {
|
|
484
|
+
if (!this.#host.connected()) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
// When this element owns its position it knows which edges are pinned --
|
|
488
|
+
// it just wrote them -- so there is nothing to probe. The probe is also
|
|
489
|
+
// unreliable at a size resting against max-width or max-height, where a
|
|
490
|
+
// nudge moves no edge and every axis reads as pinned on the wrong side.
|
|
491
|
+
const anchor = this.#expandCorner ?? this.#measureAnchor();
|
|
492
|
+
this.#anchor = anchor;
|
|
493
|
+
this.#host.element.setAttribute("data-resize-anchor", `${anchor.y}-${anchor.x}`);
|
|
494
|
+
this.#focusableGrip();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Put a custom property back to a previous value, or remove it if there was none. */
|
|
498
|
+
#restoreProperty(name: string, value: string): void {
|
|
499
|
+
if (value === "") {
|
|
500
|
+
this.#host.element.style.removeProperty(name);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
this.#host.element.style.setProperty(name, value);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Write a dragged size onto the host, on the axes this placement leaves free.
|
|
508
|
+
*
|
|
509
|
+
* Writing the custom property rather than inline `width` / `height` does not
|
|
510
|
+
* by itself leave placement in charge: an inline custom property still
|
|
511
|
+
* outranks a `:host([placement=…])` rule setting the same property, so a
|
|
512
|
+
* height dragged while floating would cap a docked sidebar asking for
|
|
513
|
+
* `100vh`. The cascade cannot arbitrate this, so the axis check must — a
|
|
514
|
+
* placement owns the axes it fixes, and a persisted size is applied only to
|
|
515
|
+
* the ones it leaves free.
|
|
516
|
+
*/
|
|
517
|
+
#applySize(size: ResizeSize): void {
|
|
518
|
+
const axis = this.#resizeAxis();
|
|
519
|
+
if (axis === "none") {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
// The placement's max-width and max-height are left alone, which means a
|
|
523
|
+
// grip pushed against the edge the placement is *not* anchored to stops one
|
|
524
|
+
// gutter short of the screen. Moving the cap with the size fixes that and
|
|
525
|
+
// shifts several resting sizes by a pixel or two, because the cap and the
|
|
526
|
+
// size are not measured from the same box -- not worth the churn for a
|
|
527
|
+
// symmetry nobody has asked for. The limit that matters, staying inside
|
|
528
|
+
// what the host left free, is enforced above.
|
|
529
|
+
if (size.width !== undefined) {
|
|
530
|
+
this.#host.element.style.setProperty("--ag-ui-width", `${size.width}px`);
|
|
531
|
+
}
|
|
532
|
+
if (size.height !== undefined && axis === "both") {
|
|
533
|
+
this.#host.element.style.setProperty("--ag-ui-height", `${size.height}px`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Drop any dragged size the new placement has taken ownership of.
|
|
539
|
+
*
|
|
540
|
+
* Without this a size survives the switch as an inline property and silently
|
|
541
|
+
* overrides the placement it moved to — the panel keeps a floating height
|
|
542
|
+
* while docked, and reads as a component that cannot do full height.
|
|
543
|
+
*/
|
|
544
|
+
releaseOwnedAxes(): void {
|
|
545
|
+
const axis = this.#resizeAxis();
|
|
546
|
+
if (axis !== "both") {
|
|
547
|
+
this.#host.element.style.removeProperty("--ag-ui-height");
|
|
548
|
+
}
|
|
549
|
+
if (axis === "none") {
|
|
550
|
+
this.#host.element.style.removeProperty("--ag-ui-width");
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Whether this placement lets the launcher be dragged, read per interaction
|
|
556
|
+
* because `placement` is a live attribute. `data-launcher-drag="false"` opts
|
|
557
|
+
* a host out without giving up the launcher itself.
|
|
558
|
+
*/
|
|
559
|
+
#launcherDraggable(): boolean {
|
|
560
|
+
return (
|
|
561
|
+
this.#host.element.getAttribute("data-launcher-drag") !== "false" &&
|
|
562
|
+
isDraggablePlacement(this.#host.element.getAttribute("placement"))
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The viewport the launcher and the panel both have to fit inside.
|
|
568
|
+
*
|
|
569
|
+
* The *visual* viewport, not the layout one, because they come apart exactly
|
|
570
|
+
* when this matters. An on-screen keyboard shrinks the visual viewport and
|
|
571
|
+
* leaves the layout viewport alone, so clamping against `innerHeight` parks
|
|
572
|
+
* the launcher behind the keyboard and decides which corner to open into
|
|
573
|
+
* using space that is not on the screen. Pinch-zoom does the same on both
|
|
574
|
+
* axes.
|
|
575
|
+
*
|
|
576
|
+
* Falls back where the API is absent, which keeps this working in the
|
|
577
|
+
* happy-dom project as well as in an old browser.
|
|
578
|
+
*/
|
|
579
|
+
#viewport(): ViewportBox {
|
|
580
|
+
const visual = window.visualViewport;
|
|
581
|
+
const width = visual?.width ?? window.innerWidth;
|
|
582
|
+
const height = visual?.height ?? window.innerHeight;
|
|
583
|
+
// And minus whatever the host reserved for its own chrome. Without this a
|
|
584
|
+
// panel is clamped against the whole screen and settles happily underneath
|
|
585
|
+
// a sticky header, where it cannot be reached -- and where collapsing it,
|
|
586
|
+
// the one thing a user tries, hides it completely rather than rescuing it.
|
|
587
|
+
// Read as padding off the probe, not as custom properties off this
|
|
588
|
+
// element. `getPropertyValue` on an unregistered custom property returns
|
|
589
|
+
// the token stream rather than a length, so a host stating `4rem` reserves
|
|
590
|
+
// four pixels here and sixty-four in the stylesheet, and one stating
|
|
591
|
+
// `calc(56px + env(safe-area-inset-top))` -- which is the natural spelling
|
|
592
|
+
// of what the token's own documentation recommends -- parses as NaN and
|
|
593
|
+
// takes the panel's whole inset down with it.
|
|
594
|
+
const style = getComputedStyle(this.probe);
|
|
595
|
+
const edge = (name: string): number => {
|
|
596
|
+
const value = Number.parseFloat(style.getPropertyValue(name));
|
|
597
|
+
// A detached or not-yet-rendered probe resolves to nothing at all, and
|
|
598
|
+
// reserving NaN is worse than reserving zero in every case.
|
|
599
|
+
return Number.isFinite(value) ? value : 0;
|
|
600
|
+
};
|
|
601
|
+
const left = edge("padding-left");
|
|
602
|
+
const top = edge("padding-top");
|
|
603
|
+
return {
|
|
604
|
+
left,
|
|
605
|
+
top,
|
|
606
|
+
width: Math.max(0, width - left - edge("padding-right")),
|
|
607
|
+
height: Math.max(0, height - top - edge("padding-bottom")),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* The whole viewport, before anything the host reserved is taken out of it.
|
|
613
|
+
*
|
|
614
|
+
* Distinct from {@link #viewport} on purpose, and the two must not be
|
|
615
|
+
* swapped. The usable box decides where the widget may rest; this is what a
|
|
616
|
+
* CSS `inset` on a fixed element is measured from, because that is what the
|
|
617
|
+
* browser measures it from.
|
|
618
|
+
*/
|
|
619
|
+
#screen(): Extent {
|
|
620
|
+
// The *layout* viewport, and `clientWidth`/`clientHeight` rather than
|
|
621
|
+
// `innerWidth`/`innerHeight`, because those two disagree by the width of a
|
|
622
|
+
// classic scrollbar and it is the smaller one a fixed element is laid out
|
|
623
|
+
// against. Reading the visual viewport here would be the same mistake one
|
|
624
|
+
// level up as clamping against the whole screen was one level down: a
|
|
625
|
+
// keyboard shrinks the visual viewport without moving the box CSS measures
|
|
626
|
+
// an inset from, so a panel the clamp had just held inside the visible
|
|
627
|
+
// band would be written back out behind the keyboard.
|
|
628
|
+
//
|
|
629
|
+
// The zero checks are for a detached or not-yet-laid-out document, where
|
|
630
|
+
// `clientWidth` is 0 and no viewport ever is.
|
|
631
|
+
const root = document.documentElement;
|
|
632
|
+
return {
|
|
633
|
+
width: root.clientWidth || window.innerWidth,
|
|
634
|
+
height: root.clientHeight || window.innerHeight,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Publish the measured viewport height so the stylesheet can size a
|
|
640
|
+
* full-bleed panel to what the user can see.
|
|
641
|
+
*
|
|
642
|
+
* No CSS length carries this. An on-screen keyboard has no effect on any
|
|
643
|
+
* viewport-percentage unit, so a panel sized from `100dvh` puts its composer
|
|
644
|
+
* behind the keyboard being typed into. Written inline, and read through a
|
|
645
|
+
* token the host's own `--ag-ui-viewport-height` still outranks.
|
|
646
|
+
*
|
|
647
|
+
* Removed rather than frozen when the two viewports agree, so a desktop that
|
|
648
|
+
* never diverges carries no inline override at all and the declared fallback
|
|
649
|
+
* stays in charge.
|
|
650
|
+
*/
|
|
651
|
+
publishVisualViewport(): void {
|
|
652
|
+
const visual = window.visualViewport;
|
|
653
|
+
if (visual === null || visual === undefined) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
// The layout viewport from the box a fixed element is laid out against,
|
|
657
|
+
// for the reasons #screen gives -- and one more that only a phone shows.
|
|
658
|
+
// While iOS Safari has the visible area panned down to show a focused
|
|
659
|
+
// field, innerHeight reads the layout height less the pan, and goes on
|
|
660
|
+
// reading it until a window scroll the element never hears about. Measured
|
|
661
|
+
// from that, the band below came out as nothing, and at the deepest pan the
|
|
662
|
+
// browser makes -- the one that shows a composer docked at the foot of the
|
|
663
|
+
// screen -- the shorter number equals the visible height, so the check
|
|
664
|
+
// below took every measurement back and put the panel at the layout top at
|
|
665
|
+
// full height, its header above the screen. clientHeight held the layout
|
|
666
|
+
// height throughout.
|
|
667
|
+
const layout = this.#screen().height;
|
|
668
|
+
if (Math.abs(visual.height - layout) < 1) {
|
|
669
|
+
this.#host.element.style.removeProperty("--ag-ui-visual-viewport-height");
|
|
670
|
+
this.#host.element.style.removeProperty("--ag-ui-visual-viewport-inset-bottom");
|
|
671
|
+
this.#host.element.style.removeProperty("--ag-ui-visual-viewport-inset-top");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
this.#host.element.style.setProperty(
|
|
675
|
+
"--ag-ui-visual-viewport-height",
|
|
676
|
+
`${Math.round(visual.height)}px`,
|
|
677
|
+
);
|
|
678
|
+
// What is hidden below the visible area, which is where a keyboard is. A
|
|
679
|
+
// shorter panel does not help anything anchored to the bottom: a floating
|
|
680
|
+
// widget is positioned against the layout viewport, so its bottom edge and
|
|
681
|
+
// the launcher at that corner stay behind the keyboard until this lifts
|
|
682
|
+
// them. Never negative -- a visual viewport panned up past the layout one
|
|
683
|
+
// would otherwise pull the panel down off the screen.
|
|
684
|
+
const hidden = layout - visual.height - visual.offsetTop;
|
|
685
|
+
this.#host.element.style.setProperty(
|
|
686
|
+
"--ag-ui-visual-viewport-inset-bottom",
|
|
687
|
+
`${Math.max(0, Math.round(hidden))}px`,
|
|
688
|
+
);
|
|
689
|
+
// And what is hidden above it. To show a field the keyboard would cover, a
|
|
690
|
+
// browser pans the visual viewport down the layout one, and a fixed panel
|
|
691
|
+
// anchored at the top stays at the layout top: sized to the visible height,
|
|
692
|
+
// it then shows only its lower part, from the pan down, with its header off
|
|
693
|
+
// the screen and page background under it. Never negative, for the same
|
|
694
|
+
// reason as the band below.
|
|
695
|
+
this.#host.element.style.setProperty(
|
|
696
|
+
"--ag-ui-visual-viewport-inset-top",
|
|
697
|
+
`${Math.max(0, Math.round(visual.offsetTop))}px`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* The launcher's box in viewport coordinates, with its transform divided out.
|
|
703
|
+
*
|
|
704
|
+
* The launcher is scaled in four states -- the collapse animation, hover,
|
|
705
|
+
* press, and the resting scale(0.4) it sits at while the panel is open -- and
|
|
706
|
+
* `getBoundingClientRect` reports every one of them. A drag that started from
|
|
707
|
+
* that rect would begin a couple of pixels off, because a press is one of
|
|
708
|
+
* those states.
|
|
709
|
+
*
|
|
710
|
+
* So the size comes from `offsetWidth`/`offsetHeight`, which are layout
|
|
711
|
+
* metrics no transform reaches, and the position from the rect's *centre*,
|
|
712
|
+
* which a centred scale is the one point that cannot move.
|
|
713
|
+
*/
|
|
714
|
+
#launcherBox(): LauncherBox {
|
|
715
|
+
const width = this.#host.launcher.offsetWidth;
|
|
716
|
+
const height = this.#host.launcher.offsetHeight;
|
|
717
|
+
const dragged = this.#launcherPos;
|
|
718
|
+
if (dragged !== null) {
|
|
719
|
+
return { left: dragged.left, top: dragged.top, width, height };
|
|
720
|
+
}
|
|
721
|
+
const rect = this.#host.launcher.getBoundingClientRect();
|
|
722
|
+
return {
|
|
723
|
+
left: rect.left + rect.width / 2 - width / 2,
|
|
724
|
+
top: rect.top + rect.height / 2 - height / 2,
|
|
725
|
+
width,
|
|
726
|
+
height,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Place the host box and the launcher for the position the user dragged to.
|
|
732
|
+
*
|
|
733
|
+
* This writes `--ag-ui-inset`, which is a host-facing property: an inline
|
|
734
|
+
* value outranks the page's own rule for it, exactly as a dragged width
|
|
735
|
+
* outranks a placement's. That is the intent -- the user moved it -- and it
|
|
736
|
+
* is why switching to a placement that owns its position hands the property
|
|
737
|
+
* back rather than leaving a stale inline one behind.
|
|
738
|
+
*/
|
|
739
|
+
#applyLauncherPlacement(at: { readonly left: number; readonly top: number }): void {
|
|
740
|
+
// The single gate: callers hand over a position and this decides whether
|
|
741
|
+
// it is this element's to honour. Checking in both places instead would
|
|
742
|
+
// leave one of the two checks permanently unreachable.
|
|
743
|
+
if (!this.#launcherDraggable()) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
this.#launcherPos = at;
|
|
747
|
+
// Dropping the bubble hands the panel's position back to the placement.
|
|
748
|
+
// Keeping a stated one would pin the panel where it was dragged and leave
|
|
749
|
+
// the launcher deriving nothing, which is the gesture doing half its job.
|
|
750
|
+
this.#panelPos = null;
|
|
751
|
+
// The host box keeps its expanded size while collapsed, so its own rect is
|
|
752
|
+
// the panel's size in either state and needs no separate bookkeeping.
|
|
753
|
+
const panel = this.#host.element.getBoundingClientRect();
|
|
754
|
+
const placement = launcherPlacement(
|
|
755
|
+
this.#launcherBox(),
|
|
756
|
+
{ width: panel.width, height: panel.height },
|
|
757
|
+
this.#viewport(),
|
|
758
|
+
this.#screen(),
|
|
759
|
+
);
|
|
760
|
+
this.#host.element.style.setProperty("--ag-ui-inset", placement.hostInset);
|
|
761
|
+
this.#host.element.style.setProperty("--ag-ui-launcher-inset", placement.launcherInset);
|
|
762
|
+
this.#expandCorner = placement.corner;
|
|
763
|
+
// The corner the panel grows from, for the open/close animation's origin.
|
|
764
|
+
this.#host.element.setAttribute(
|
|
765
|
+
"data-expand-corner",
|
|
766
|
+
`${placement.corner.y}-${placement.corner.x}`,
|
|
767
|
+
);
|
|
768
|
+
this.syncResizeAnchor();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Move the launcher live during a drag, without persisting. */
|
|
772
|
+
#moveLauncher(left: number, top: number): void {
|
|
773
|
+
this.#applyLauncherPlacement({ left, top });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Move the launcher and remember where, per tab, like the dragged size. */
|
|
777
|
+
#commitLauncher(left: number, top: number): void {
|
|
778
|
+
this.#moveLauncher(left, top);
|
|
779
|
+
this.#storeLauncherPosition();
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Move the panel live during a header drag, without persisting.
|
|
784
|
+
*
|
|
785
|
+
* Only the host box is written, and that is the whole trick: the launcher is
|
|
786
|
+
* positioned *inside* that box, so leaving its own inset alone carries it
|
|
787
|
+
* along by exactly the distance the panel travelled -- which is what a person
|
|
788
|
+
* dragging a window expects of the thing it collapses into. Placing it on the
|
|
789
|
+
* panel's pinned corner instead, as an earlier version did, sent it leaping
|
|
790
|
+
* across the panel the moment the drag re-picked that corner.
|
|
791
|
+
*
|
|
792
|
+
* The corner is therefore held for the length of the gesture. Both insets are
|
|
793
|
+
* measured from it, and rewriting one of them from a new corner while the
|
|
794
|
+
* other still names the old one would move the launcher for no reason.
|
|
795
|
+
*/
|
|
796
|
+
#movePanel(box: PanelRect, from: PanelRect): { held: PanelRect; launcher: LauncherBox | null } {
|
|
797
|
+
if (!this.#launcherDraggable()) {
|
|
798
|
+
return { held: box, launcher: null };
|
|
799
|
+
}
|
|
800
|
+
// Where the launcher rests, recorded before the first move writes anything.
|
|
801
|
+
// From here on the DOM shows it mid-gesture, so this is the last moment it
|
|
802
|
+
// can be read rather than derived.
|
|
803
|
+
if (this.#launcherPos === null) {
|
|
804
|
+
const resting = this.#launcherBox();
|
|
805
|
+
this.#launcherPos = { left: resting.left, top: resting.top };
|
|
806
|
+
}
|
|
807
|
+
// The launcher as it was when the gesture began. Held for the whole drag:
|
|
808
|
+
// every move measures from here, so the two halves cannot drift apart.
|
|
809
|
+
const start = this.#launcherPos;
|
|
810
|
+
// The screen-edge bound, not the resting gutter. The 24px margin is where
|
|
811
|
+
// a placement rests one, not a rule about where a person may put it, and
|
|
812
|
+
// enforcing it against a drag is what made the panel feel stuck short of
|
|
813
|
+
// every edge on all four sides at once. Zero was the correction and it
|
|
814
|
+
// went too far the other way: it welded the panel to the boundary while
|
|
815
|
+
// the launcher -- same shadow, same rounded edge -- was held 8px off it,
|
|
816
|
+
// and it disagreed with the restore below, so a panel dragged flush leapt
|
|
817
|
+
// inward the next time the viewport changed.
|
|
818
|
+
const held = clampPanel(box, this.#viewport(), SCREEN_EDGE_MARGIN);
|
|
819
|
+
const corner = this.#expandCorner ?? this.#anchor;
|
|
820
|
+
// The usable box decides where the panel may rest, above; the screen is
|
|
821
|
+
// what these insets are measured from, because that is what the browser
|
|
822
|
+
// measures a fixed element's inset from. Using the usable box here made a
|
|
823
|
+
// right or bottom short by whatever the host had reserved, and the panel
|
|
824
|
+
// jumped by that much the first time a gesture wrote one.
|
|
825
|
+
this.#host.element.style.setProperty("--ag-ui-inset", insetFrom(corner, held, this.#screen()));
|
|
826
|
+
this.#panelPos = { left: held.left, top: held.top };
|
|
827
|
+
|
|
828
|
+
// Where the launcher has ended up, derived rather than read. During a
|
|
829
|
+
// header drag it rides inside the host box with its own inset untouched,
|
|
830
|
+
// so the DOM shows it moving while `#launcherPos` still holds where it
|
|
831
|
+
// started -- reading it back mid-gesture returns the stale value, and
|
|
832
|
+
// adding the panel's travel to that a second time on release is a jump.
|
|
833
|
+
// Measured from the box the press started on, so a long drag cannot
|
|
834
|
+
// accumulate the rounding each move writes.
|
|
835
|
+
const carried = {
|
|
836
|
+
...this.#launcherBox(),
|
|
837
|
+
left: start.left + (held.left - from.left),
|
|
838
|
+
top: start.top + (held.top - from.top),
|
|
839
|
+
};
|
|
840
|
+
// A bubble carried into an edge the host reserved is one nobody can press.
|
|
841
|
+
// Clamping it live rather than at the end is what makes releasing the drag
|
|
842
|
+
// change nothing: leaving it until then parked it under a nav bar for the
|
|
843
|
+
// whole gesture and hopped it out on pointerup.
|
|
844
|
+
const launcher = { ...carried, ...clampLauncher(carried, this.#viewport()) };
|
|
845
|
+
this.#host.element.style.setProperty(
|
|
846
|
+
"--ag-ui-launcher-inset",
|
|
847
|
+
placeWidget(held, launcher, corner, this.#screen()).launcherInset,
|
|
848
|
+
);
|
|
849
|
+
return { held, launcher };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Finish a header drag: settle where both halves ended up, and remember it.
|
|
854
|
+
*
|
|
855
|
+
* The launcher travels the distance the panel actually travelled, which is
|
|
856
|
+
* the clamped distance rather than the pointer's -- a panel held against the
|
|
857
|
+
* viewport margin stops, and so does the bubble attached to it. Measured from
|
|
858
|
+
* the box the press started on, so a long drag cannot accumulate the rounding
|
|
859
|
+
* each move writes into the inset.
|
|
860
|
+
*
|
|
861
|
+
* Only now is the corner re-picked, from where the launcher has ended up, so
|
|
862
|
+
* the panel opens into clear space next time. Re-picking it moves nothing:
|
|
863
|
+
* both insets are rewritten from positions that are already decided.
|
|
864
|
+
*/
|
|
865
|
+
#commitPanel(box: PanelRect, from: PanelRect): void {
|
|
866
|
+
// Exactly what the last move applied, rather than the same sum computed
|
|
867
|
+
// again. Recomputing it is how the two came apart: releasing the drag
|
|
868
|
+
// moved the bubble by the panel's whole travel a second time.
|
|
869
|
+
const { held, launcher } = this.#movePanel(box, from);
|
|
870
|
+
if (launcher === null) {
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
this.#placePanelAndLauncher(held, launcher);
|
|
874
|
+
this.#storeLauncherPosition();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Write both insets for a panel and launcher that are already positioned,
|
|
879
|
+
* re-picking the corner they are measured from.
|
|
880
|
+
*/
|
|
881
|
+
#placePanelAndLauncher(host: PanelRect, launcher: LauncherBox): void {
|
|
882
|
+
const viewport = this.#viewport();
|
|
883
|
+
const screen = this.#screen();
|
|
884
|
+
const size = { width: host.right - host.left, height: host.bottom - host.top };
|
|
885
|
+
const { corner } = launcherPlacement(launcher, size, viewport, screen);
|
|
886
|
+
// The screen again, not the usable box: these are CSS insets on a fixed
|
|
887
|
+
// element and the browser measures them from the real edges.
|
|
888
|
+
const insets = placeWidget(host, launcher, corner, screen);
|
|
889
|
+
this.#host.element.style.setProperty("--ag-ui-inset", insets.hostInset);
|
|
890
|
+
this.#host.element.style.setProperty("--ag-ui-launcher-inset", insets.launcherInset);
|
|
891
|
+
this.#launcherPos = { left: launcher.left, top: launcher.top };
|
|
892
|
+
this.#panelPos = { left: host.left, top: host.top };
|
|
893
|
+
this.#expandCorner = corner;
|
|
894
|
+
this.#host.element.setAttribute("data-expand-corner", `${corner.y}-${corner.x}`);
|
|
895
|
+
this.syncResizeAnchor();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Re-apply a panel position the user stated, against the current viewport.
|
|
900
|
+
*
|
|
901
|
+
* The launcher keeps its offset from the panel through the clamp -- it was
|
|
902
|
+
* put where it is relative to the panel, and a viewport that has since shrunk
|
|
903
|
+
* is no reason to move one without the other -- and is then held on screen in
|
|
904
|
+
* its own right.
|
|
905
|
+
*/
|
|
906
|
+
#restorePanelPosition(at: { readonly left: number; readonly top: number }): void {
|
|
907
|
+
if (!this.#launcherDraggable()) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const rect = this.#host.element.getBoundingClientRect();
|
|
911
|
+
// The same bound the drag itself used. Taking the default here instead is
|
|
912
|
+
// what made a panel dragged to an edge jump a whole resting gutter inward
|
|
913
|
+
// on the next resize, reload or expand -- re-placing a position the user
|
|
914
|
+
// had stated, against a limit they had never been shown.
|
|
915
|
+
const held = clampPanel(
|
|
916
|
+
{ left: at.left, top: at.top, right: at.left + rect.width, bottom: at.top + rect.height },
|
|
917
|
+
this.#viewport(),
|
|
918
|
+
SCREEN_EDGE_MARGIN,
|
|
919
|
+
);
|
|
920
|
+
const launcher = this.#launcherBox();
|
|
921
|
+
const carried = {
|
|
922
|
+
...launcher,
|
|
923
|
+
left: launcher.left + (held.left - at.left),
|
|
924
|
+
top: launcher.top + (held.top - at.top),
|
|
925
|
+
};
|
|
926
|
+
this.#placePanelAndLauncher(held, {
|
|
927
|
+
...carried,
|
|
928
|
+
...clampLauncher(carried, this.#viewport()),
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Write the current position, if this element owns one.
|
|
934
|
+
*
|
|
935
|
+
* The panel's own position rides along only when the user stated it, because
|
|
936
|
+
* its presence is what tells a restore which of the two gestures to honour:
|
|
937
|
+
* with it, the panel goes back where it was put; without it, the panel is
|
|
938
|
+
* re-derived from the launcher and opens into whatever room the viewport has
|
|
939
|
+
* now.
|
|
940
|
+
*/
|
|
941
|
+
#storeLauncherPosition(): void {
|
|
942
|
+
const position = this.#launcherPos;
|
|
943
|
+
if (position === null) {
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const panel = this.#panelPos;
|
|
947
|
+
this.#host.writePreference(
|
|
948
|
+
LAUNCHER_KEY,
|
|
949
|
+
JSON.stringify(panel === null ? position : { ...position, panel }),
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Re-apply the dragged position against the current viewport: on connect,
|
|
955
|
+
* whenever the window resizes, and before an expand. The viewport that
|
|
956
|
+
* stored a position may since have shrunk, and a launcher past the edge is
|
|
957
|
+
* unreachable -- it is the only way back to a collapsed conversation.
|
|
958
|
+
*/
|
|
959
|
+
restoreLauncherPosition(): void {
|
|
960
|
+
const stored = this.#readLauncherPosition();
|
|
961
|
+
const launcher = this.#launcherPos ?? stored;
|
|
962
|
+
if (launcher === null) {
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const panel = this.#panelPos ?? stored?.panel ?? null;
|
|
966
|
+
if (panel !== null) {
|
|
967
|
+
// Stated rather than derived: the launcher is only read here to keep the
|
|
968
|
+
// offset the two were left with, so it has to be seeded before the panel
|
|
969
|
+
// is placed around it.
|
|
970
|
+
this.#launcherPos = { left: launcher.left, top: launcher.top };
|
|
971
|
+
this.#restorePanelPosition(panel);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const box = this.#launcherBox();
|
|
975
|
+
this.#applyLauncherPlacement(
|
|
976
|
+
clampLauncher({ ...box, left: launcher.left, top: launcher.top }, this.#viewport()),
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** The persisted position for this instance, or null. */
|
|
981
|
+
#readLauncherPosition(): {
|
|
982
|
+
readonly left: number;
|
|
983
|
+
readonly top: number;
|
|
984
|
+
readonly panel?: { readonly left: number; readonly top: number };
|
|
985
|
+
} | null {
|
|
986
|
+
const raw = this.#host.readPreference(LAUNCHER_KEY);
|
|
987
|
+
if (raw === null) {
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
const parsed: unknown = JSON.parse(raw);
|
|
992
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
const { left, top, panel } = parsed as { left?: unknown; top?: unknown; panel?: unknown };
|
|
996
|
+
if (typeof left !== "number" || typeof top !== "number") {
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
// A record written before the panel could be dragged has no panel half,
|
|
1000
|
+
// and one written by a launcher drag never will -- both restore by
|
|
1001
|
+
// deriving the panel, which is what they meant.
|
|
1002
|
+
const at = asPoint(panel);
|
|
1003
|
+
return at === null ? { left, top } : { left, top, panel: at };
|
|
1004
|
+
} catch {
|
|
1005
|
+
// A corrupt entry is not worth failing a mount over; fall back to the
|
|
1006
|
+
// placement's own corner.
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Give the position back to the host when the new placement owns it, the way
|
|
1013
|
+
* releaseOwnedAxes gives back a size. Without this a dragged inset survives
|
|
1014
|
+
* the switch inline and pins a sidebar to wherever the floating launcher was.
|
|
1015
|
+
*/
|
|
1016
|
+
releaseLauncherPosition(): void {
|
|
1017
|
+
if (this.#launcherDraggable()) {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
this.#launcherPos = null;
|
|
1021
|
+
this.#panelPos = null;
|
|
1022
|
+
this.#expandCorner = null;
|
|
1023
|
+
this.#host.element.style.removeProperty("--ag-ui-inset");
|
|
1024
|
+
this.#host.element.style.removeProperty("--ag-ui-launcher-inset");
|
|
1025
|
+
this.#host.element.removeAttribute("data-expand-corner");
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Apply the box a grip is asking for.
|
|
1030
|
+
*
|
|
1031
|
+
* The size is the easy half. The other half is that **dragging the edge the
|
|
1032
|
+
* layout is holding still moves the panel as well as resizing it**, and the
|
|
1033
|
+
* layout cannot express that on its own: a floating panel pinned bottom-right
|
|
1034
|
+
* cannot grow rightward, because its right edge is what the placement fixed.
|
|
1035
|
+
* So a grip on a pinned edge takes the position over -- which is the same
|
|
1036
|
+
* ownership the launcher drag takes, written the same way.
|
|
1037
|
+
*
|
|
1038
|
+
* A grip on a free edge writes nothing but the size, exactly as before, so a
|
|
1039
|
+
* host positioning the panel with its own rule keeps that rule until someone
|
|
1040
|
+
* drags the edge it was holding.
|
|
1041
|
+
*/
|
|
1042
|
+
#applyResize(grip: ResizeGrip, box: PanelRect): PanelRect {
|
|
1043
|
+
box = this.#withinViewport(box);
|
|
1044
|
+
this.#applySize({ width: box.right - box.left, height: box.bottom - box.top });
|
|
1045
|
+
if (grip.x !== this.#anchor.x && grip.y !== this.#anchor.y) {
|
|
1046
|
+
return box;
|
|
1047
|
+
}
|
|
1048
|
+
// The whole screen, not the usable box: a CSS inset on a fixed element is
|
|
1049
|
+
// measured from the real edges, so expressing a right or bottom against a
|
|
1050
|
+
// box the host has inset comes out short by exactly that inset.
|
|
1051
|
+
const anchor = this.#anchor;
|
|
1052
|
+
this.#host.element.style.setProperty("--ag-ui-inset", insetFrom(anchor, box, this.#screen()));
|
|
1053
|
+
// The launcher lives at this corner of the panel, so a corner that moved
|
|
1054
|
+
// takes it along. Without this the next expand would re-derive the panel's
|
|
1055
|
+
// position from a launcher still standing where the panel used to be, and
|
|
1056
|
+
// undo the move.
|
|
1057
|
+
if (this.#launcherPos !== null) {
|
|
1058
|
+
const size = this.#host.launcher.offsetWidth;
|
|
1059
|
+
this.#launcherPos = {
|
|
1060
|
+
left: anchor.x === "left" ? box.left : box.right - size,
|
|
1061
|
+
top: anchor.y === "top" ? box.top : box.bottom - size,
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
// A stated panel position is a claim about this box, so it moves with it.
|
|
1065
|
+
if (this.#panelPos !== null) {
|
|
1066
|
+
this.#panelPos = { left: box.left, top: box.top };
|
|
1067
|
+
}
|
|
1068
|
+
return box;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** Finish a resize: keep the box, remember it, and re-read the pinned edges. */
|
|
1072
|
+
#commitResize(grip: ResizeGrip, box: PanelRect): void {
|
|
1073
|
+
// The bounded box, not the one the pointer asked for. Persisting the raw
|
|
1074
|
+
// one would store a size the panel never had and restore it on the next
|
|
1075
|
+
// mount, which is the same disagreement between apply and commit that made
|
|
1076
|
+
// the header drag jump on release.
|
|
1077
|
+
const held = this.#applyResize(grip, box);
|
|
1078
|
+
this.#persistSize({ width: held.right - held.left, height: held.bottom - held.top });
|
|
1079
|
+
this.#storeLauncherPosition();
|
|
1080
|
+
// Re-stamp after the drag: a host whose layout changed underneath us would
|
|
1081
|
+
// otherwise keep the tab-reachable grip in the old corner, which reads as
|
|
1082
|
+
// the control being in the wrong place even though the drag was right.
|
|
1083
|
+
this.syncResizeAnchor();
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Put the tab stop on the grip diagonally opposite the pinned corner.
|
|
1088
|
+
*
|
|
1089
|
+
* That is the corner a resize grows the panel from, so an arrow key there
|
|
1090
|
+
* changes the size and never the position -- the behaviour the single grip
|
|
1091
|
+
* this replaced had, kept for the one path that cannot simply grab a
|
|
1092
|
+
* different edge.
|
|
1093
|
+
*/
|
|
1094
|
+
#focusableGrip(): void {
|
|
1095
|
+
const free = `${this.#anchor.y === "top" ? "bottom" : "top"}-${
|
|
1096
|
+
this.#anchor.x === "left" ? "right" : "left"
|
|
1097
|
+
}`;
|
|
1098
|
+
for (const [name, handle] of this.#resizeHandles) {
|
|
1099
|
+
const reachable = name === free;
|
|
1100
|
+
handle.tabIndex = reachable ? 0 : -1;
|
|
1101
|
+
if (reachable) {
|
|
1102
|
+
handle.removeAttribute("aria-hidden");
|
|
1103
|
+
} else {
|
|
1104
|
+
handle.setAttribute("aria-hidden", "true");
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
|
|
1110
|
+
#persistSize(size: ResizeSize): void {
|
|
1111
|
+
const stored = { ...this.#readSize(), ...size };
|
|
1112
|
+
this.#host.writePreference(SIZE_KEY, JSON.stringify(stored));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** The persisted size for this instance, or an empty record. */
|
|
1116
|
+
#readSize(): ResizeSize {
|
|
1117
|
+
const raw = this.#host.readPreference(SIZE_KEY);
|
|
1118
|
+
if (raw === null) {
|
|
1119
|
+
return {};
|
|
1120
|
+
}
|
|
1121
|
+
try {
|
|
1122
|
+
const parsed: unknown = JSON.parse(raw);
|
|
1123
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as ResizeSize) : {};
|
|
1124
|
+
} catch {
|
|
1125
|
+
// A corrupt entry is not worth failing a mount over; fall back to the
|
|
1126
|
+
// placement's own size.
|
|
1127
|
+
return {};
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|