@artooi/ag-ui-web-component 0.32.0 → 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 +162 -1
- package/README.md +182 -23
- package/dist/ag-ui-web-component.bundle.js +341 -117
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +8 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/index.js +1259 -389
- 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/package.json +1 -1
- package/src/constants.ts +12 -0
- package/src/core/ag_ui_chat.ts +480 -24
- 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/version.ts +1 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** What a copied message puts on the clipboard, in both flavours. */
|
|
2
|
+
export interface CopyPayload {
|
|
3
|
+
/** The plain-text flavour, structured so a table survives a paste. */
|
|
4
|
+
readonly text: string;
|
|
5
|
+
/** The rich flavour, so a table pastes as a table. */
|
|
6
|
+
readonly html: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Elements whose content starts on a line of its own. */
|
|
10
|
+
const BLOCK_TAGS = new Set([
|
|
11
|
+
"ADDRESS",
|
|
12
|
+
"ARTICLE",
|
|
13
|
+
"ASIDE",
|
|
14
|
+
"BLOCKQUOTE",
|
|
15
|
+
"DD",
|
|
16
|
+
"DIV",
|
|
17
|
+
"DL",
|
|
18
|
+
"DT",
|
|
19
|
+
"FIGCAPTION",
|
|
20
|
+
"FIGURE",
|
|
21
|
+
"FOOTER",
|
|
22
|
+
"H1",
|
|
23
|
+
"H2",
|
|
24
|
+
"H3",
|
|
25
|
+
"H4",
|
|
26
|
+
"H5",
|
|
27
|
+
"H6",
|
|
28
|
+
"HEADER",
|
|
29
|
+
"HR",
|
|
30
|
+
"MAIN",
|
|
31
|
+
"NAV",
|
|
32
|
+
"OL",
|
|
33
|
+
"P",
|
|
34
|
+
"SECTION",
|
|
35
|
+
"UL",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What to copy from one rendered message, in both clipboard flavours.
|
|
40
|
+
*
|
|
41
|
+
* `textContent` was the obvious answer and the wrong one. It concatenates every
|
|
42
|
+
* descendant with no separator at all, so a markdown table arrives as one run
|
|
43
|
+
* of digits with the headers welded to the first row -- unusable in a
|
|
44
|
+
* spreadsheet, and unreadable anywhere. Lists lose their bullets the same way,
|
|
45
|
+
* and paragraphs run together.
|
|
46
|
+
*
|
|
47
|
+
* So the plain flavour is serialised structurally: table rows become tab
|
|
48
|
+
* separated lines, which is the format spreadsheets parse on paste, and blocks
|
|
49
|
+
* and list items get their own lines. The rich flavour carries the markup, so a
|
|
50
|
+
* target that understands it -- a document, a chat client, a spreadsheet --
|
|
51
|
+
* gets the real table rather than a reconstruction of one.
|
|
52
|
+
*
|
|
53
|
+
* Both are taken from a **clone with the component's own buttons removed**. The
|
|
54
|
+
* code-block copy buttons are appended inside their own `pre`, so they are
|
|
55
|
+
* descendants of the bubble and `textContent` was picking their label up: a
|
|
56
|
+
* message containing a code block copied with the word for Copy sitting in the
|
|
57
|
+
* middle of it.
|
|
58
|
+
*/
|
|
59
|
+
export function copyPayload(root: Element): CopyPayload {
|
|
60
|
+
const clone = root.cloneNode(true) as Element;
|
|
61
|
+
// The component's own affordances, not the message. Markdown cannot produce
|
|
62
|
+
// a button, so anything matching here is chrome this element added.
|
|
63
|
+
for (const control of Array.from(clone.querySelectorAll("button"))) {
|
|
64
|
+
control.remove();
|
|
65
|
+
}
|
|
66
|
+
return { text: normalise(serialise(clone)), html: clone.innerHTML };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Walk a subtree into text, inserting newlines where the layout implies them.
|
|
71
|
+
*
|
|
72
|
+
* Nothing recurses into a `pre` or a table: both are handled whole, because
|
|
73
|
+
* their text is their layout. That is also why this needs no notion of being
|
|
74
|
+
* inside preformatted content -- there is no such position to be in.
|
|
75
|
+
*/
|
|
76
|
+
function serialise(node: Node): string {
|
|
77
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
78
|
+
// Non-null for a text node; nodeValue is nullable only on document and
|
|
79
|
+
// doctype nodes, which cannot appear inside a message. Outside a pre, runs
|
|
80
|
+
// of whitespace collapse to one space -- the same collapsing the renderer
|
|
81
|
+
// does, so what is copied matches what is read.
|
|
82
|
+
return (node.nodeValue as string).replace(/\s+/g, " ");
|
|
83
|
+
}
|
|
84
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
const element = node as Element;
|
|
88
|
+
const tag = element.tagName;
|
|
89
|
+
if (tag === "BR") {
|
|
90
|
+
return "\n";
|
|
91
|
+
}
|
|
92
|
+
if (tag === "PRE") {
|
|
93
|
+
// Verbatim: its whitespace is its content.
|
|
94
|
+
return `\n\n${text(element)}\n\n`;
|
|
95
|
+
}
|
|
96
|
+
if (tag === "TABLE") {
|
|
97
|
+
return `\n\n${tableText(element)}\n\n`;
|
|
98
|
+
}
|
|
99
|
+
if (tag === "UL" || tag === "OL") {
|
|
100
|
+
return `\n\n${listText(element, tag === "OL")}\n\n`;
|
|
101
|
+
}
|
|
102
|
+
const inner = children(element);
|
|
103
|
+
return BLOCK_TAGS.has(tag) ? `\n\n${inner}\n\n` : inner;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every child of `element`, serialised and joined. */
|
|
107
|
+
function children(element: Element): string {
|
|
108
|
+
let joined = "";
|
|
109
|
+
for (const child of Array.from(element.childNodes)) {
|
|
110
|
+
joined += serialise(child);
|
|
111
|
+
}
|
|
112
|
+
return joined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A list, one item per line, marked the way it is rendered.
|
|
117
|
+
*
|
|
118
|
+
* Built here rather than at the item, so the marker comes from the list that
|
|
119
|
+
* owns it and never from an item's parent lookup -- which has a null arm no
|
|
120
|
+
* message can produce.
|
|
121
|
+
*/
|
|
122
|
+
function listText(list: Element, numbered: boolean): string {
|
|
123
|
+
return Array.from(list.children)
|
|
124
|
+
.map((item, index) => `${numbered ? `${index + 1}. ` : "- "}${children(item).trim()}`)
|
|
125
|
+
.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A table as tab-separated rows.
|
|
130
|
+
*
|
|
131
|
+
* Tabs rather than a drawn grid because that is what spreadsheets parse: a
|
|
132
|
+
* paste of tab-separated lines lands one value per cell, which is the whole
|
|
133
|
+
* point of copying a table out of an answer.
|
|
134
|
+
*/
|
|
135
|
+
function tableText(table: Element): string {
|
|
136
|
+
return Array.from(table.querySelectorAll("tr"))
|
|
137
|
+
.map((row) =>
|
|
138
|
+
Array.from(row.children)
|
|
139
|
+
.map((cell) => text(cell).replace(/\s+/g, " ").trim())
|
|
140
|
+
.join("\t"),
|
|
141
|
+
)
|
|
142
|
+
.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** An element's text. Never null: only documents and doctypes have none. */
|
|
146
|
+
function text(element: Element): string {
|
|
147
|
+
return element.textContent as string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Tidy the block boundaries the walk inserted generously. */
|
|
151
|
+
function normalise(text: string): string {
|
|
152
|
+
return text
|
|
153
|
+
.replace(/[^\S\n]+\n/g, "\n")
|
|
154
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
155
|
+
.trim();
|
|
156
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { clampLauncher } from "./clamp_launcher.js";
|
|
2
|
+
import type { Extent, LauncherBox } from "./launcher_placement.js";
|
|
3
|
+
|
|
4
|
+
/** What the drag needs from its host to do its job. */
|
|
5
|
+
export interface LauncherDragOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Whether the current placement lets the launcher move, read per interaction
|
|
8
|
+
* because `placement` is a live attribute. A docked rail and a full-bleed
|
|
9
|
+
* layout have nowhere to move it to.
|
|
10
|
+
*/
|
|
11
|
+
readonly enabled: () => boolean;
|
|
12
|
+
/** The launcher's current box, in viewport coordinates. */
|
|
13
|
+
readonly rect: () => LauncherBox;
|
|
14
|
+
/** The viewport the launcher has to stay inside. */
|
|
15
|
+
readonly viewport: () => Extent;
|
|
16
|
+
/** Put the launcher's top-left at this point. Called per pointer move. */
|
|
17
|
+
readonly apply: (left: number, top: number) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Called once per completed move, for persistence: on `pointerup` for a
|
|
20
|
+
* drag, and when the key comes up (or focus leaves) for a key press. Never
|
|
21
|
+
* per pointer move, and never per key repeat.
|
|
22
|
+
*/
|
|
23
|
+
readonly commit: (left: number, top: number) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How far the pointer must travel before this counts as a drag rather than a
|
|
28
|
+
* click. The launcher's first job is still to expand the panel, so a press
|
|
29
|
+
* that wanders by a pixel has to remain a press.
|
|
30
|
+
*/
|
|
31
|
+
const DRAG_THRESHOLD = 4;
|
|
32
|
+
|
|
33
|
+
/** Keyboard step, and the larger one Shift asks for. */
|
|
34
|
+
const STEP = 16;
|
|
35
|
+
const COARSE_STEP = 64;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Let the user drag the collapsed launcher anywhere on screen.
|
|
39
|
+
*
|
|
40
|
+
* The launcher is a button before it is a handle, and that ordering is the
|
|
41
|
+
* whole difficulty: every drag ends in a `click` the browser synthesises, which
|
|
42
|
+
* would expand the panel the user was only repositioning. So a completed drag
|
|
43
|
+
* arms a one-shot capture listener that eats exactly one click.
|
|
44
|
+
*
|
|
45
|
+
* Two things stop that from eating a click it should not have. A drag ending
|
|
46
|
+
* off the launcher never produces the click it armed, so the arming is cleared
|
|
47
|
+
* on the next `pointerdown` -- a pointer click is always preceded by a press,
|
|
48
|
+
* which puts the clearing ahead of any click it could reach.
|
|
49
|
+
*
|
|
50
|
+
* That leaves the clicks with no press in front of them at all: `Enter` and
|
|
51
|
+
* `Space` on the focused button, assistive technology activating it, and a host
|
|
52
|
+
* calling `click()` itself. None of those is the tail of a drag, and all of them
|
|
53
|
+
* are the only way in for someone not using a pointer, so the suppression is
|
|
54
|
+
* narrowed to clicks carrying a click count -- which is the pointer's own.
|
|
55
|
+
*/
|
|
56
|
+
export function enableLauncherDrag(launcher: HTMLElement, options: LauncherDragOptions): void {
|
|
57
|
+
let suppressClick = false;
|
|
58
|
+
|
|
59
|
+
launcher.addEventListener(
|
|
60
|
+
"click",
|
|
61
|
+
(event: MouseEvent) => {
|
|
62
|
+
// detail is the click count on a pointer click and zero on a keyboard,
|
|
63
|
+
// assistive or programmatic one. Only the first kind can end a drag.
|
|
64
|
+
if (!suppressClick || event.detail === 0) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
suppressClick = false;
|
|
68
|
+
// Capture phase, so this runs before the expand handler bound to the
|
|
69
|
+
// same element rather than after it.
|
|
70
|
+
event.stopPropagation();
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
},
|
|
73
|
+
true,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
launcher.addEventListener("pointerdown", (event: PointerEvent) => {
|
|
77
|
+
// Any suppression left armed by a drag that ended elsewhere dies here,
|
|
78
|
+
// before it can reach a click belonging to this new gesture.
|
|
79
|
+
suppressClick = false;
|
|
80
|
+
if (!options.enabled()) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const start = options.rect();
|
|
84
|
+
const originX = event.clientX;
|
|
85
|
+
const originY = event.clientY;
|
|
86
|
+
let dragging = false;
|
|
87
|
+
|
|
88
|
+
const onMove = (move: PointerEvent): void => {
|
|
89
|
+
const dx = move.clientX - originX;
|
|
90
|
+
const dy = move.clientY - originY;
|
|
91
|
+
if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
dragging = true;
|
|
95
|
+
launcher.setAttribute("data-dragging", "true");
|
|
96
|
+
// Measured from the box the press started on, never from the live one:
|
|
97
|
+
// reading it each move would chase the launcher as it moves and the
|
|
98
|
+
// travel would compound.
|
|
99
|
+
const next = clampLauncher(
|
|
100
|
+
{ ...start, left: start.left + dx, top: start.top + dy },
|
|
101
|
+
options.viewport(),
|
|
102
|
+
);
|
|
103
|
+
options.apply(next.left, next.top);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const onUp = (up: PointerEvent): void => {
|
|
107
|
+
window.removeEventListener("pointermove", onMove);
|
|
108
|
+
window.removeEventListener("pointerup", onUp);
|
|
109
|
+
if (!dragging) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
launcher.removeAttribute("data-dragging");
|
|
113
|
+
suppressClick = true;
|
|
114
|
+
const next = clampLauncher(
|
|
115
|
+
{
|
|
116
|
+
...start,
|
|
117
|
+
left: start.left + (up.clientX - originX),
|
|
118
|
+
top: start.top + (up.clientY - originY),
|
|
119
|
+
},
|
|
120
|
+
options.viewport(),
|
|
121
|
+
);
|
|
122
|
+
options.commit(next.left, next.top);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Listeners on `window`, not the launcher: a fast drag outruns the pointer
|
|
126
|
+
// and would otherwise strand it mid-move with no pointerup.
|
|
127
|
+
window.addEventListener("pointermove", onMove);
|
|
128
|
+
window.addEventListener("pointerup", onUp);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// The position this key gesture has applied but not yet persisted. The
|
|
132
|
+
// pointer path can commit inline because a drag has one unambiguous end; a
|
|
133
|
+
// key press does not, so the gesture's result is held here until it does.
|
|
134
|
+
let pending: { left: number; top: number } | null = null;
|
|
135
|
+
|
|
136
|
+
/** End a key gesture: persist what it applied, once. */
|
|
137
|
+
const settle = (): void => {
|
|
138
|
+
if (pending === null) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const { left, top } = pending;
|
|
142
|
+
pending = null;
|
|
143
|
+
options.commit(left, top);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Keyboard parity: a pointer-only move is unreachable without a mouse, and
|
|
147
|
+
// the launcher has no equivalent control elsewhere. Arrow keys are free on a
|
|
148
|
+
// button, so Enter and Space still expand.
|
|
149
|
+
launcher.addEventListener("keydown", (event: KeyboardEvent) => {
|
|
150
|
+
if (!options.enabled()) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const step = event.shiftKey ? COARSE_STEP : STEP;
|
|
154
|
+
const rect = options.rect();
|
|
155
|
+
let moved: { left: number; top: number } | null = null;
|
|
156
|
+
if (event.key === "ArrowLeft") {
|
|
157
|
+
moved = { left: rect.left - step, top: rect.top };
|
|
158
|
+
} else if (event.key === "ArrowRight") {
|
|
159
|
+
moved = { left: rect.left + step, top: rect.top };
|
|
160
|
+
} else if (event.key === "ArrowUp") {
|
|
161
|
+
moved = { left: rect.left, top: rect.top - step };
|
|
162
|
+
} else if (event.key === "ArrowDown") {
|
|
163
|
+
moved = { left: rect.left, top: rect.top + step };
|
|
164
|
+
}
|
|
165
|
+
if (moved === null) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
event.preventDefault();
|
|
169
|
+
const next = clampLauncher({ ...rect, ...moved }, options.viewport());
|
|
170
|
+
// Live feedback per key event, persistence only when the gesture ends: a
|
|
171
|
+
// held arrow repeats at the OS rate, and committing here would put that
|
|
172
|
+
// many storage writes behind a single press.
|
|
173
|
+
options.apply(next.left, next.top);
|
|
174
|
+
pending = next;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The key coming up ends the gesture, mirroring `pointerup`. `blur` closes
|
|
178
|
+
// one whose keyup never arrives here -- focus moved on mid-press -- because a
|
|
179
|
+
// position applied but never committed is a move the host silently forgets.
|
|
180
|
+
launcher.addEventListener("keyup", settle);
|
|
181
|
+
launcher.addEventListener("blur", settle);
|
|
182
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** A box in viewport coordinates. */
|
|
2
|
+
export interface LauncherBox {
|
|
3
|
+
readonly left: number;
|
|
4
|
+
readonly top: number;
|
|
5
|
+
readonly width: number;
|
|
6
|
+
readonly height: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A width/height pair, in CSS pixels. */
|
|
10
|
+
export interface Extent {
|
|
11
|
+
readonly width: number;
|
|
12
|
+
readonly height: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The corner the panel is pinned by, and therefore grows away from: pinned
|
|
17
|
+
* `top-left` opens down and to the right. It is also the corner the resize grip
|
|
18
|
+
* must stay off, which is why the element stamps it rather than measuring it
|
|
19
|
+
* once the launcher has been moved.
|
|
20
|
+
*/
|
|
21
|
+
export interface ExpandCorner {
|
|
22
|
+
readonly x: "left" | "right";
|
|
23
|
+
readonly y: "top" | "bottom";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Where to put the host box, and where the launcher sits inside it. */
|
|
27
|
+
export interface LauncherPlacement {
|
|
28
|
+
/** The corner the panel opens away from. */
|
|
29
|
+
readonly corner: ExpandCorner;
|
|
30
|
+
/** An `inset` shorthand for the host box. */
|
|
31
|
+
readonly hostInset: string;
|
|
32
|
+
/** An `inset` shorthand for the launcher, relative to the host box. */
|
|
33
|
+
readonly launcherInset: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The gutter a panel keeps from the viewport edge, matching the default
|
|
38
|
+
* `--ag-ui-inset` so an undragged widget resolves to exactly the placement it
|
|
39
|
+
* already had. Changing this moves every clamped panel.
|
|
40
|
+
*/
|
|
41
|
+
const EDGE_MARGIN = 24;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decide where a panel should open from a launcher the user has dragged.
|
|
45
|
+
*
|
|
46
|
+
* The launcher is the fixed point: wherever it was dropped is where it stays,
|
|
47
|
+
* collapsed and expanded alike. Everything here decides what happens *around*
|
|
48
|
+
* it.
|
|
49
|
+
*
|
|
50
|
+
* Two steps, and they are separate on purpose:
|
|
51
|
+
*
|
|
52
|
+
* 1. **Pick the corner with the most clear space.** For each axis, compare the
|
|
53
|
+
* room a panel would have on either side of the launcher and pin the side
|
|
54
|
+
* with more of it. Pinning `left` means the panel runs rightward from the
|
|
55
|
+
* launcher's left edge, so the room that decides it is the distance from
|
|
56
|
+
* that edge to the far side of the viewport -- not "which half of the screen
|
|
57
|
+
* is the launcher in", which gives a different and worse answer for a
|
|
58
|
+
* launcher near the middle.
|
|
59
|
+
*
|
|
60
|
+
* 2. **Clamp the panel into the viewport, then put the launcher back.** A
|
|
61
|
+
* launcher near the centre of a short viewport has a panel taller than
|
|
62
|
+
* either side, so the winning corner still overflows. Clamping moves the
|
|
63
|
+
* host box -- and the launcher is positioned inside that box, so it would be
|
|
64
|
+
* dragged along with it. The launcher's own inset therefore carries the
|
|
65
|
+
* difference: zero in the ordinary case, and exactly the distance the clamp
|
|
66
|
+
* moved the box in the case that needs it. That the launcher may end up
|
|
67
|
+
* outside its own host box is fine; nothing clips it, and it keeps its own
|
|
68
|
+
* pointer events.
|
|
69
|
+
*
|
|
70
|
+
* Both insets are expressed from the *same* corner, which is what stops a
|
|
71
|
+
* later panel resize from dragging the launcher: the pinned corner is the one
|
|
72
|
+
* edge a resize cannot move.
|
|
73
|
+
*/
|
|
74
|
+
export function launcherPlacement(
|
|
75
|
+
launcher: LauncherBox,
|
|
76
|
+
panel: Extent,
|
|
77
|
+
viewport: Extent,
|
|
78
|
+
margin: number = EDGE_MARGIN,
|
|
79
|
+
): LauncherPlacement {
|
|
80
|
+
// Room for a panel pinned to each side of the launcher. A tie goes to the
|
|
81
|
+
// first branch, so the result is deterministic for a centred launcher.
|
|
82
|
+
const roomRunningRight = viewport.width - launcher.left;
|
|
83
|
+
const roomRunningLeft = launcher.left + launcher.width;
|
|
84
|
+
const roomRunningDown = viewport.height - launcher.top;
|
|
85
|
+
const roomRunningUp = launcher.top + launcher.height;
|
|
86
|
+
const corner: ExpandCorner = {
|
|
87
|
+
x: roomRunningRight >= roomRunningLeft ? "left" : "right",
|
|
88
|
+
y: roomRunningDown >= roomRunningUp ? "top" : "bottom",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// The box the panel wants, then the box it can actually have. Clamping the
|
|
92
|
+
// near edge covers the far edge too: the panel is a fixed size here, and a
|
|
93
|
+
// panel too large for the viewport is held against the near margin and left
|
|
94
|
+
// to the max-width and max-height rules rather than centred by force.
|
|
95
|
+
const wantedLeft =
|
|
96
|
+
corner.x === "left" ? launcher.left : launcher.left + launcher.width - panel.width;
|
|
97
|
+
const wantedTop =
|
|
98
|
+
corner.y === "top" ? launcher.top : launcher.top + launcher.height - panel.height;
|
|
99
|
+
const hostLeft = clamp(wantedLeft, margin, viewport.width - margin - panel.width);
|
|
100
|
+
const hostTop = clamp(wantedTop, margin, viewport.height - margin - panel.height);
|
|
101
|
+
|
|
102
|
+
const hostRight = hostLeft + panel.width;
|
|
103
|
+
const hostBottom = hostTop + panel.height;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
corner,
|
|
107
|
+
hostInset: inset({
|
|
108
|
+
top: corner.y === "top" ? hostTop : null,
|
|
109
|
+
right: corner.x === "right" ? viewport.width - hostRight : null,
|
|
110
|
+
bottom: corner.y === "bottom" ? viewport.height - hostBottom : null,
|
|
111
|
+
left: corner.x === "left" ? hostLeft : null,
|
|
112
|
+
}),
|
|
113
|
+
// Measured from the host box's pinned corner to the launcher's matching
|
|
114
|
+
// corner, so the launcher lands exactly where it was dropped.
|
|
115
|
+
launcherInset: inset({
|
|
116
|
+
top: corner.y === "top" ? launcher.top - hostTop : null,
|
|
117
|
+
right: corner.x === "right" ? hostRight - (launcher.left + launcher.width) : null,
|
|
118
|
+
bottom: corner.y === "bottom" ? hostBottom - (launcher.top + launcher.height) : null,
|
|
119
|
+
left: corner.x === "left" ? launcher.left - hostLeft : null,
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Constrain a value, with the lower bound winning a contradiction. `Math.min`
|
|
126
|
+
* first would put a panel wider than the viewport off the left edge instead of
|
|
127
|
+
* against the near margin.
|
|
128
|
+
*/
|
|
129
|
+
function clamp(value: number, low: number, high: number): number {
|
|
130
|
+
return Math.max(low, Math.min(value, high));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** An `inset` shorthand; a null side is `auto`, so the opposite one pins it. */
|
|
134
|
+
function inset(sides: {
|
|
135
|
+
top: number | null;
|
|
136
|
+
right: number | null;
|
|
137
|
+
bottom: number | null;
|
|
138
|
+
left: number | null;
|
|
139
|
+
}): string {
|
|
140
|
+
const side = (value: number | null): string =>
|
|
141
|
+
value === null ? "auto" : `${Math.round(value)}px`;
|
|
142
|
+
return `${side(sides.top)} ${side(sides.right)} ${side(sides.bottom)} ${side(sides.left)}`;
|
|
143
|
+
}
|
|
@@ -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
|
}
|