@artooi/ag-ui-web-component 0.31.1 → 0.33.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 +197 -1
- package/README.md +182 -23
- package/dist/ag-ui-web-component.bundle.js +352 -128
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +34 -7
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +25 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/index.js +1367 -388
- package/dist/index.js.map +4 -4
- package/dist/ui/clamp_launcher.d.ts +15 -0
- package/dist/ui/clamp_launcher.d.ts.map +1 -0
- package/dist/ui/copy_payload.d.ts +30 -0
- package/dist/ui/copy_payload.d.ts.map +1 -0
- package/dist/ui/launcher_drag.d.ts +43 -0
- package/dist/ui/launcher_drag.d.ts.map +1 -0
- package/dist/ui/launcher_placement.d.ts +64 -0
- package/dist/ui/launcher_placement.d.ts.map +1 -0
- package/dist/ui/message_actions.d.ts +30 -2
- package/dist/ui/message_actions.d.ts.map +1 -1
- package/dist/ui/resize_handle.d.ts +32 -26
- package/dist/ui/resize_handle.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/subagent_panel.d.ts +9 -0
- package/dist/ui/subagent_panel.d.ts.map +1 -1
- package/dist/ui/subagent_update.d.ts +9 -0
- package/dist/ui/subagent_update.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +19 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/constants.ts +38 -7
- package/src/core/ag_ui_chat.ts +570 -24
- package/src/core/agui_client.ts +41 -0
- package/src/ui/clamp_launcher.ts +20 -0
- package/src/ui/copy_payload.ts +156 -0
- package/src/ui/launcher_drag.ts +182 -0
- package/src/ui/launcher_placement.ts +143 -0
- package/src/ui/message_actions.ts +93 -19
- package/src/ui/resize_handle.ts +109 -73
- package/src/ui/styles.ts +283 -81
- package/src/ui/subagent_panel.ts +14 -0
- package/src/ui/subagent_update.ts +9 -0
- package/src/ui/ui_strings.ts +22 -0
- package/src/version.ts +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ICON_COPY, ICON_THUMB_DOWN, ICON_THUMB_UP } from "../constants.js";
|
|
1
2
|
import type { UiStrings } from "./ui_strings.js";
|
|
2
3
|
|
|
3
4
|
/** How long a button shows its confirmation before reverting. */
|
|
@@ -18,6 +19,17 @@ export interface MessageActionsOptions {
|
|
|
18
19
|
* disagree.
|
|
19
20
|
*/
|
|
20
21
|
text?: () => string;
|
|
22
|
+
/**
|
|
23
|
+
* The rich flavour to copy alongside `text`, as an HTML fragment. Absent
|
|
24
|
+
* means the clipboard gets plain text only, which is what this did before
|
|
25
|
+
* the option existed.
|
|
26
|
+
*
|
|
27
|
+
* It is what makes a copied table paste as a table: a spreadsheet or a chat
|
|
28
|
+
* client reads `text/html` when it is offered and falls back to the plain
|
|
29
|
+
* flavour when it is not, so the two are the same content at two fidelities
|
|
30
|
+
* rather than a choice the caller has to make.
|
|
31
|
+
*/
|
|
32
|
+
html?: () => string;
|
|
21
33
|
/**
|
|
22
34
|
* Report a rating for this message. Absent means no feedback buttons.
|
|
23
35
|
*
|
|
@@ -53,7 +65,7 @@ export function attachMessageActions(bubble: HTMLElement, options: MessageAction
|
|
|
53
65
|
const bar = messageActionBar(bubble, options.strings);
|
|
54
66
|
const text = options.text;
|
|
55
67
|
if (text !== undefined) {
|
|
56
|
-
bar.appendChild(copyButton(options.strings, text));
|
|
68
|
+
bar.appendChild(copyButton(options.strings, text, options.html));
|
|
57
69
|
}
|
|
58
70
|
if (options.onFeedback !== undefined) {
|
|
59
71
|
bar.append(
|
|
@@ -98,38 +110,102 @@ function existingBar(bubble: HTMLElement): HTMLElement | null {
|
|
|
98
110
|
return next?.classList.contains("message-actions") === true ? (next as HTMLElement) : null;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Build one action button, labelled for screen readers rather than by glyph.
|
|
115
|
+
*
|
|
116
|
+
* `icon` is icon markup rather than a text glyph, because the text glyphs these
|
|
117
|
+
* carried are the obscure end of the character set -- the copy mark in
|
|
118
|
+
* particular has no font behind it on most systems, so it rendered as a mark
|
|
119
|
+
* nobody could name on a control small enough that nobody could hit it either.
|
|
120
|
+
*
|
|
121
|
+
* The label is carried three ways, and each has a reader the others miss:
|
|
122
|
+
* `aria-label` for assistive technology, `title` for the browser's own
|
|
123
|
+
* tooltip, and `data-tooltip` for the one this component draws. The last is
|
|
124
|
+
* not redundant with the second -- a `title` never appears on keyboard focus,
|
|
125
|
+
* so without it a keyboard user has no way to see what the control does.
|
|
126
|
+
*
|
|
127
|
+
* The icon sits in its own part, so a host can restyle or replace it. A slot
|
|
128
|
+
* would be the better channel and cannot be used here: these repeat once per
|
|
129
|
+
* message, and a named slot can only be filled once.
|
|
130
|
+
*/
|
|
102
131
|
export function messageActionButton(
|
|
103
132
|
modifier: string,
|
|
104
133
|
label: string,
|
|
105
|
-
|
|
134
|
+
icon: string,
|
|
106
135
|
): HTMLButtonElement {
|
|
107
136
|
const button = document.createElement("button");
|
|
108
137
|
button.type = "button";
|
|
109
138
|
button.className = `message-action message-action--${modifier}`;
|
|
110
139
|
button.setAttribute("part", `message-action message-action-${modifier}`);
|
|
140
|
+
setLabel(button, label);
|
|
141
|
+
const holder = document.createElement("span");
|
|
142
|
+
holder.className = "message-action-icon";
|
|
143
|
+
holder.setAttribute("part", `message-action-icon message-action-icon-${modifier}`);
|
|
144
|
+
holder.setAttribute("aria-hidden", "true");
|
|
145
|
+
// Author-written markup from constants, never message content.
|
|
146
|
+
holder.innerHTML = icon;
|
|
147
|
+
button.appendChild(holder);
|
|
148
|
+
return button;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Put `label` on every channel that names this button. */
|
|
152
|
+
function setLabel(button: HTMLButtonElement, label: string): void {
|
|
111
153
|
button.title = label;
|
|
112
154
|
button.setAttribute("aria-label", label);
|
|
113
|
-
|
|
114
|
-
icon.setAttribute("aria-hidden", "true");
|
|
115
|
-
icon.textContent = glyph;
|
|
116
|
-
button.appendChild(icon);
|
|
117
|
-
return button;
|
|
155
|
+
button.dataset["tooltip"] = label;
|
|
118
156
|
}
|
|
119
157
|
|
|
120
|
-
function copyButton(
|
|
121
|
-
|
|
158
|
+
function copyButton(
|
|
159
|
+
strings: UiStrings,
|
|
160
|
+
text: () => string,
|
|
161
|
+
html: (() => string) | undefined,
|
|
162
|
+
): HTMLButtonElement {
|
|
163
|
+
const button = messageActionButton("copy", strings.copyMessage, ICON_COPY);
|
|
122
164
|
button.addEventListener("click", () => {
|
|
123
|
-
void
|
|
124
|
-
() => flash(button, strings.copied, strings.copyMessage),
|
|
165
|
+
void write(text(), html?.()).then((ok) => {
|
|
125
166
|
// A denied clipboard permission is the common case, not an exception:
|
|
126
167
|
// say so on the button rather than throwing into an unhandled rejection.
|
|
127
|
-
|
|
128
|
-
);
|
|
168
|
+
flash(button, ok ? strings.copied : strings.copyFailed, strings.copyMessage);
|
|
169
|
+
});
|
|
129
170
|
});
|
|
130
171
|
return button;
|
|
131
172
|
}
|
|
132
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Put the message on the clipboard, richest flavour first.
|
|
176
|
+
*
|
|
177
|
+
* Writing both flavours needs `ClipboardItem`, which not every engine that has
|
|
178
|
+
* `writeText` also has -- and even where the constructor exists the write can
|
|
179
|
+
* be refused. Neither is a failure worth reporting as one while the plain text
|
|
180
|
+
* would still have landed, so both fall through to `writeText` and only that
|
|
181
|
+
* decides what the button says.
|
|
182
|
+
*/
|
|
183
|
+
async function write(text: string, html: string | undefined): Promise<boolean> {
|
|
184
|
+
const clipboard = navigator.clipboard;
|
|
185
|
+
if (clipboard === undefined) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if (html !== undefined && typeof ClipboardItem === "function") {
|
|
189
|
+
try {
|
|
190
|
+
await clipboard.write([
|
|
191
|
+
new ClipboardItem({
|
|
192
|
+
"text/plain": new Blob([text], { type: "text/plain" }),
|
|
193
|
+
"text/html": new Blob([html], { type: "text/html" }),
|
|
194
|
+
}),
|
|
195
|
+
]);
|
|
196
|
+
return true;
|
|
197
|
+
} catch {
|
|
198
|
+
// Fall through to the plain flavour.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await clipboard.writeText(text);
|
|
203
|
+
return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
133
209
|
function feedbackButton(
|
|
134
210
|
rating: "up" | "down",
|
|
135
211
|
label: string,
|
|
@@ -138,7 +214,7 @@ function feedbackButton(
|
|
|
138
214
|
const button = messageActionButton(
|
|
139
215
|
rating === "up" ? "up" : "down",
|
|
140
216
|
label,
|
|
141
|
-
rating === "up" ?
|
|
217
|
+
rating === "up" ? ICON_THUMB_UP : ICON_THUMB_DOWN,
|
|
142
218
|
);
|
|
143
219
|
button.addEventListener("click", () => {
|
|
144
220
|
// Pressed rather than removed: the rating is a standing statement about the
|
|
@@ -159,12 +235,10 @@ function feedbackButton(
|
|
|
159
235
|
* cannot happen and cannot be covered.
|
|
160
236
|
*/
|
|
161
237
|
function flash(button: HTMLButtonElement, message: string, label: string): void {
|
|
162
|
-
button
|
|
163
|
-
button.setAttribute("aria-label", message);
|
|
238
|
+
setLabel(button, message);
|
|
164
239
|
button.classList.add("message-action--confirmed");
|
|
165
240
|
setTimeout(() => {
|
|
166
|
-
button
|
|
167
|
-
button.setAttribute("aria-label", label);
|
|
241
|
+
setLabel(button, label);
|
|
168
242
|
button.classList.remove("message-action--confirmed");
|
|
169
243
|
}, CONFIRM_MS);
|
|
170
244
|
}
|
package/src/ui/resize_handle.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which edges a grip drags. An axis left out is an axis the grip does not
|
|
3
|
+
* move: the left-edge grip is `{ x: "left" }`, the top-right corner is
|
|
4
|
+
* `{ x: "right", y: "top" }`.
|
|
5
|
+
*/
|
|
6
|
+
export interface ResizeGrip {
|
|
7
|
+
readonly x?: "left" | "right";
|
|
8
|
+
readonly y?: "top" | "bottom";
|
|
9
|
+
}
|
|
10
|
+
|
|
1
11
|
/** Which edges the layout holds still while the panel changes size. */
|
|
2
12
|
export interface ResizeAnchor {
|
|
3
13
|
/** The horizontal edge that does not move. */
|
|
@@ -8,7 +18,7 @@ export interface ResizeAnchor {
|
|
|
8
18
|
|
|
9
19
|
/**
|
|
10
20
|
* What the current placement allows: both axes, width only, or nothing. Which
|
|
11
|
-
*
|
|
21
|
+
* edges a given grip drags is separate, and fixed when the grip is built.
|
|
12
22
|
*/
|
|
13
23
|
export type ResizeAxis = "none" | "width" | "both";
|
|
14
24
|
|
|
@@ -18,7 +28,7 @@ export interface ResizeSize {
|
|
|
18
28
|
readonly height?: number;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
/** The panel's position on screen
|
|
31
|
+
/** The panel's position on screen, in viewport coordinates. */
|
|
22
32
|
export interface PanelRect {
|
|
23
33
|
readonly left: number;
|
|
24
34
|
readonly top: number;
|
|
@@ -34,23 +44,16 @@ export interface ResizeOptions {
|
|
|
34
44
|
* would survive the host switching to a docked or full-bleed layout.
|
|
35
45
|
*/
|
|
36
46
|
readonly axis: () => ResizeAxis;
|
|
37
|
-
/**
|
|
38
|
-
* Which edges the layout is holding still, measured at the moment of the drag
|
|
39
|
-
* rather than derived from `placement`: a floating panel is pinned
|
|
40
|
-
* bottom-right, while an embedded one goes wherever the host's CSS puts it,
|
|
41
|
-
* so `placement` alone cannot answer the question.
|
|
42
|
-
*/
|
|
43
|
-
readonly anchor: () => ResizeAnchor;
|
|
44
47
|
/** The panel's current bounding box. */
|
|
45
48
|
readonly rect: () => PanelRect;
|
|
46
|
-
/** Apply a
|
|
47
|
-
readonly apply: (
|
|
49
|
+
/** Apply a box (the host decides what that costs in properties). */
|
|
50
|
+
readonly apply: (box: PanelRect) => void;
|
|
48
51
|
/**
|
|
49
52
|
* Called once per completed resize, for persistence: on `pointerup` for a
|
|
50
53
|
* drag, and when the key comes up (or focus leaves the handle) for a key
|
|
51
54
|
* press. Never per pointer move, and never per key repeat.
|
|
52
55
|
*/
|
|
53
|
-
readonly commit: (
|
|
56
|
+
readonly commit: (box: PanelRect) => void;
|
|
54
57
|
/** Accessible label. */
|
|
55
58
|
readonly label: string;
|
|
56
59
|
}
|
|
@@ -59,68 +62,63 @@ export interface ResizeOptions {
|
|
|
59
62
|
const MIN_WIDTH = 280;
|
|
60
63
|
const MIN_HEIGHT = 240;
|
|
61
64
|
|
|
65
|
+
/** Keyboard step, and the larger one Shift asks for. */
|
|
66
|
+
const STEP = 16;
|
|
67
|
+
const COARSE_STEP = 64;
|
|
68
|
+
|
|
62
69
|
/**
|
|
63
|
-
* A drag handle that resizes the chat panel.
|
|
70
|
+
* A drag handle that resizes the chat panel from one edge or corner.
|
|
64
71
|
*
|
|
65
|
-
*
|
|
72
|
+
* The rule that keeps it correct is that **the edge a grip does not drag is the
|
|
73
|
+
* one that stays put**. That is the whole model, and it is what lets the same
|
|
74
|
+
* code serve all eight grips: the left-edge grip moves the left edge and holds
|
|
75
|
+
* the right, the right-edge grip does the reverse, a corner does both axes.
|
|
66
76
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* so an inline dimension would outrank and fight them — a sidebar would keep
|
|
74
|
-
* its dragged width after switching to fullscreen. Writing the property
|
|
75
|
-
* leaves placement the final say.
|
|
77
|
+
* Which edge the *layout* pins is a different question and is deliberately not
|
|
78
|
+
* asked here. It matters only to the host, which has to rewrite its own
|
|
79
|
+
* position when a grip drags the very edge the layout was holding still --
|
|
80
|
+
* dragging the pinned edge of a panel is a move as much as a resize. The
|
|
81
|
+
* handle reports the box it wants; what that costs in CSS properties is the
|
|
82
|
+
* host's problem.
|
|
76
83
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
84
|
+
* An earlier version took the anchor and derived the direction from it, which
|
|
85
|
+
* is where the asymmetries lived: whether an arrow key grew or shrank the
|
|
86
|
+
* panel depended on which corner the single grip had been placed on. With the
|
|
87
|
+
* grip stated outright, an arrow simply moves the edge it names.
|
|
79
88
|
*/
|
|
80
|
-
export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
|
|
89
|
+
export function createResizeHandle(grip: ResizeGrip, options: ResizeOptions): HTMLDivElement {
|
|
81
90
|
const handle = document.createElement("div");
|
|
82
|
-
handle.className =
|
|
83
|
-
handle.setAttribute("part",
|
|
91
|
+
handle.className = `resize-handle resize-handle--${gripName(grip)}`;
|
|
92
|
+
handle.setAttribute("part", `resize-handle resize-handle-${gripName(grip)}`);
|
|
93
|
+
// A separator with an orientation: an edge grip splits along one axis, and a
|
|
94
|
+
// corner has no single one to report.
|
|
84
95
|
handle.setAttribute("role", "separator");
|
|
96
|
+
if (grip.x === undefined) {
|
|
97
|
+
handle.setAttribute("aria-orientation", "horizontal");
|
|
98
|
+
} else if (grip.y === undefined) {
|
|
99
|
+
handle.setAttribute("aria-orientation", "vertical");
|
|
100
|
+
}
|
|
85
101
|
handle.setAttribute("aria-label", options.label);
|
|
86
102
|
handle.tabIndex = 0;
|
|
87
103
|
|
|
88
|
-
/** The size implied by a pointer at (x, y), given which edges are pinned. */
|
|
89
|
-
const sizeAt = (
|
|
90
|
-
axis: ResizeAxis,
|
|
91
|
-
anchor: ResizeAnchor,
|
|
92
|
-
rect: PanelRect,
|
|
93
|
-
x: number,
|
|
94
|
-
y: number,
|
|
95
|
-
): ResizeSize => {
|
|
96
|
-
const width = anchor.x === "right" ? rect.right - x : x - rect.left;
|
|
97
|
-
const clamped: ResizeSize = { width: Math.max(MIN_WIDTH, width) };
|
|
98
|
-
if (axis !== "both") {
|
|
99
|
-
return clamped;
|
|
100
|
-
}
|
|
101
|
-
const height = anchor.y === "bottom" ? rect.bottom - y : y - rect.top;
|
|
102
|
-
return { ...clamped, height: Math.max(MIN_HEIGHT, height) };
|
|
103
|
-
};
|
|
104
|
-
|
|
105
104
|
handle.addEventListener("pointerdown", (event: PointerEvent) => {
|
|
106
105
|
const axis = options.axis();
|
|
107
|
-
if (axis === "none") {
|
|
106
|
+
if (axis === "none" || !movable(grip, axis)) {
|
|
108
107
|
return;
|
|
109
108
|
}
|
|
110
|
-
// Captured once: the
|
|
111
|
-
// them live would chase the panel as it resizes.
|
|
112
|
-
const anchor = options.anchor();
|
|
109
|
+
// Captured once: the edges this grip is not dragging cannot move during
|
|
110
|
+
// the drag, and reading them live would chase the panel as it resizes.
|
|
113
111
|
const rect = options.rect();
|
|
114
112
|
|
|
115
113
|
const onMove = (move: PointerEvent): void => {
|
|
116
|
-
options.apply(
|
|
114
|
+
options.apply(boxAt(grip, axis, rect, move.clientX, move.clientY));
|
|
117
115
|
};
|
|
118
116
|
|
|
119
117
|
const onUp = (up: PointerEvent): void => {
|
|
120
118
|
window.removeEventListener("pointermove", onMove);
|
|
121
119
|
window.removeEventListener("pointerup", onUp);
|
|
122
120
|
handle.removeAttribute("data-dragging");
|
|
123
|
-
options.commit(
|
|
121
|
+
options.commit(boxAt(grip, axis, rect, up.clientX, up.clientY));
|
|
124
122
|
};
|
|
125
123
|
|
|
126
124
|
handle.setAttribute("data-dragging", "true");
|
|
@@ -131,49 +129,44 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
|
|
|
131
129
|
event.preventDefault();
|
|
132
130
|
});
|
|
133
131
|
|
|
134
|
-
// The
|
|
132
|
+
// The box the current key gesture has applied but not yet persisted. The
|
|
135
133
|
// pointer path can commit inline because a drag has one unambiguous end;
|
|
136
134
|
// a key press does not, so the gesture's result is held here until it does.
|
|
137
|
-
let pending:
|
|
135
|
+
let pending: PanelRect | null = null;
|
|
138
136
|
|
|
139
137
|
/** End a key gesture: persist what it applied, once. */
|
|
140
138
|
const settle = (): void => {
|
|
141
139
|
if (pending === null) {
|
|
142
140
|
return;
|
|
143
141
|
}
|
|
144
|
-
const
|
|
142
|
+
const box = pending;
|
|
145
143
|
pending = null;
|
|
146
|
-
options.commit(
|
|
144
|
+
options.commit(box);
|
|
147
145
|
};
|
|
148
146
|
|
|
149
147
|
// Keyboard parity: a pointer-only resize is unreachable without a mouse, and
|
|
150
|
-
// this control has no equivalent elsewhere in the UI.
|
|
148
|
+
// this control has no equivalent elsewhere in the UI. An arrow moves the edge
|
|
149
|
+
// this grip names, in the direction it names -- so the same key grows one
|
|
150
|
+
// side's grip and shrinks the opposite one, which is what a pointer does too.
|
|
151
151
|
handle.addEventListener("keydown", (event: KeyboardEvent) => {
|
|
152
152
|
const axis = options.axis();
|
|
153
|
-
if (axis === "none") {
|
|
153
|
+
if (axis === "none" || !movable(grip, axis)) {
|
|
154
154
|
return;
|
|
155
155
|
}
|
|
156
|
-
const
|
|
156
|
+
const step = event.shiftKey ? COARSE_STEP : STEP;
|
|
157
157
|
const rect = options.rect();
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const outward = anchor.x === "right" ? -1 : 1;
|
|
162
|
-
const width = rect.right - rect.left;
|
|
163
|
-
const height = rect.bottom - rect.top;
|
|
164
|
-
let next: ResizeSize | null = null;
|
|
165
|
-
if (event.key === "ArrowLeft") {
|
|
166
|
-
next = { width: Math.max(MIN_WIDTH, width - step * outward) };
|
|
167
|
-
} else if (event.key === "ArrowRight") {
|
|
168
|
-
next = { width: Math.max(MIN_WIDTH, width + step * outward) };
|
|
169
|
-
} else if (axis === "both" && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
|
|
170
|
-
const grow = event.key === (anchor.y === "bottom" ? "ArrowUp" : "ArrowDown");
|
|
171
|
-
next = { height: Math.max(MIN_HEIGHT, height + (grow ? step : -step)) };
|
|
158
|
+
const delta = ARROWS[event.key];
|
|
159
|
+
if (delta === undefined) {
|
|
160
|
+
return;
|
|
172
161
|
}
|
|
173
|
-
|
|
162
|
+
// An arrow across this grip's fixed axis has no edge to move.
|
|
163
|
+
if ((delta.x !== 0 && grip.x === undefined) || (delta.y !== 0 && grip.y === undefined)) {
|
|
174
164
|
return;
|
|
175
165
|
}
|
|
176
166
|
event.preventDefault();
|
|
167
|
+
const x = (grip.x === "left" ? rect.left : rect.right) + delta.x * step;
|
|
168
|
+
const y = (grip.y === "top" ? rect.top : rect.bottom) + delta.y * step;
|
|
169
|
+
const next = boxAt(grip, axis, rect, x, y);
|
|
177
170
|
// Live feedback per key event, persistence only when the gesture ends:
|
|
178
171
|
// `commit` promises one call per completed resize, and a held arrow key
|
|
179
172
|
// repeats at the OS rate (20-30 events a second), so committing here would
|
|
@@ -191,3 +184,46 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
|
|
|
191
184
|
|
|
192
185
|
return handle;
|
|
193
186
|
}
|
|
187
|
+
|
|
188
|
+
/** Which way each arrow key pushes the edge under it. */
|
|
189
|
+
const ARROWS: Record<string, { x: number; y: number } | undefined> = {
|
|
190
|
+
ArrowLeft: { x: -1, y: 0 },
|
|
191
|
+
ArrowRight: { x: 1, y: 0 },
|
|
192
|
+
ArrowUp: { x: 0, y: -1 },
|
|
193
|
+
ArrowDown: { x: 0, y: 1 },
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** The hyphenated name of a grip, for its class and part. */
|
|
197
|
+
export function gripName(grip: ResizeGrip): string {
|
|
198
|
+
return [grip.y, grip.x].filter((side) => side !== undefined).join("-");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Whether the current placement leaves this grip anything to move. */
|
|
202
|
+
function movable(grip: ResizeGrip, axis: ResizeAxis): boolean {
|
|
203
|
+
// A docked panel owns its height, so a grip that only moves a horizontal
|
|
204
|
+
// edge has nothing to do and must not pretend otherwise.
|
|
205
|
+
return axis === "both" || grip.x !== undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The box a pointer at (x, y) implies for this grip.
|
|
210
|
+
*
|
|
211
|
+
* Each axis is clamped by pushing the *dragged* edge back to the minimum,
|
|
212
|
+
* never by moving the edge that is supposed to be standing still: clamping the
|
|
213
|
+
* wrong one is how a panel dragged past its minimum starts travelling.
|
|
214
|
+
*/
|
|
215
|
+
function boxAt(
|
|
216
|
+
grip: ResizeGrip,
|
|
217
|
+
axis: ResizeAxis,
|
|
218
|
+
rect: PanelRect,
|
|
219
|
+
x: number,
|
|
220
|
+
y: number,
|
|
221
|
+
): PanelRect {
|
|
222
|
+
const left = grip.x === "left" ? Math.min(x, rect.right - MIN_WIDTH) : rect.left;
|
|
223
|
+
const right = grip.x === "right" ? Math.max(x, rect.left + MIN_WIDTH) : rect.right;
|
|
224
|
+
// A placement that owns its height leaves the vertical edges where they are.
|
|
225
|
+
const vertical = axis === "both";
|
|
226
|
+
const top = vertical && grip.y === "top" ? Math.min(y, rect.bottom - MIN_HEIGHT) : rect.top;
|
|
227
|
+
const bottom = vertical && grip.y === "bottom" ? Math.max(y, rect.top + MIN_HEIGHT) : rect.bottom;
|
|
228
|
+
return { left, top, right, bottom };
|
|
229
|
+
}
|