@bitvea/feedback-toolbar 0.0.1
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/README.md +262 -0
- package/dist/FeedbackPanel.d.ts +117 -0
- package/dist/FeedbackPanel.d.ts.map +1 -0
- package/dist/FeedbackPanel.js +622 -0
- package/dist/FeedbackPanel.js.map +1 -0
- package/dist/FeedbackToolbar.d.ts +54 -0
- package/dist/FeedbackToolbar.d.ts.map +1 -0
- package/dist/FeedbackToolbar.js +1149 -0
- package/dist/FeedbackToolbar.js.map +1 -0
- package/dist/InboxSheet.d.ts +80 -0
- package/dist/InboxSheet.d.ts.map +1 -0
- package/dist/InboxSheet.js +580 -0
- package/dist/InboxSheet.js.map +1 -0
- package/dist/PinLayer.d.ts +59 -0
- package/dist/PinLayer.d.ts.map +1 -0
- package/dist/PinLayer.js +1702 -0
- package/dist/PinLayer.js.map +1 -0
- package/dist/RichText.d.ts +6 -0
- package/dist/RichText.d.ts.map +1 -0
- package/dist/RichText.js +135 -0
- package/dist/RichText.js.map +1 -0
- package/dist/browser/entry.d.ts +3 -0
- package/dist/browser/entry.d.ts.map +1 -0
- package/dist/browser/entry.js +13 -0
- package/dist/browser/entry.js.map +1 -0
- package/dist/browser/mount.d.ts +37 -0
- package/dist/browser/mount.d.ts.map +1 -0
- package/dist/browser/mount.js +150 -0
- package/dist/browser/mount.js.map +1 -0
- package/dist/browser/toolbar.js +38 -0
- package/dist/browser/toolbar.js.map +7 -0
- package/dist/capture.d.ts +58 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +98 -0
- package/dist/capture.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +196 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/fs.d.ts +16 -0
- package/dist/cli/fs.d.ts.map +1 -0
- package/dist/cli/fs.js +33 -0
- package/dist/cli/fs.js.map +1 -0
- package/dist/cli/init.d.ts +75 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +266 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/register.d.ts +55 -0
- package/dist/cli/register.d.ts.map +1 -0
- package/dist/cli/register.js +84 -0
- package/dist/cli/register.js.map +1 -0
- package/dist/cli/registration.d.ts +37 -0
- package/dist/cli/registration.d.ts.map +1 -0
- package/dist/cli/registration.js +69 -0
- package/dist/cli/registration.js.map +1 -0
- package/dist/cli/strategies.d.ts +98 -0
- package/dist/cli/strategies.d.ts.map +1 -0
- package/dist/cli/strategies.js +198 -0
- package/dist/cli/strategies.js.map +1 -0
- package/dist/client.d.ts +59 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +113 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +92 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +65 -0
- package/dist/config.js.map +1 -0
- package/dist/deepLink.d.ts +86 -0
- package/dist/deepLink.d.ts.map +1 -0
- package/dist/deepLink.js +185 -0
- package/dist/deepLink.js.map +1 -0
- package/dist/fontFace.d.ts +70 -0
- package/dist/fontFace.d.ts.map +1 -0
- package/dist/fontFace.js +88 -0
- package/dist/fontFace.js.map +1 -0
- package/dist/gating.d.ts +32 -0
- package/dist/gating.d.ts.map +1 -0
- package/dist/gating.js +49 -0
- package/dist/gating.js.map +1 -0
- package/dist/icons.d.ts +68 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +97 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +59 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +130 -0
- package/dist/markdown.js.map +1 -0
- package/dist/motion.d.ts +178 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/motion.js +302 -0
- package/dist/motion.js.map +1 -0
- package/dist/preferences.d.ts +71 -0
- package/dist/preferences.d.ts.map +1 -0
- package/dist/preferences.js +195 -0
- package/dist/preferences.js.map +1 -0
- package/dist/rasterise.d.ts +45 -0
- package/dist/rasterise.d.ts.map +1 -0
- package/dist/rasterise.js +490 -0
- package/dist/rasterise.js.map +1 -0
- package/dist/selector.d.ts +19 -0
- package/dist/selector.d.ts.map +1 -0
- package/dist/selector.js +142 -0
- package/dist/selector.js.map +1 -0
- package/dist/session.d.ts +19 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +36 -0
- package/dist/session.js.map +1 -0
- package/dist/shortcut.d.ts +88 -0
- package/dist/shortcut.d.ts.map +1 -0
- package/dist/shortcut.js +149 -0
- package/dist/shortcut.js.map +1 -0
- package/dist/tokens.d.ts +709 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +749 -0
- package/dist/tokens.js.map +1 -0
- package/dist/vendor/types/account.d.ts +26 -0
- package/dist/vendor/types/account.d.ts.map +1 -0
- package/dist/vendor/types/account.js +23 -0
- package/dist/vendor/types/account.js.map +1 -0
- package/dist/vendor/types/apiKey.d.ts +38 -0
- package/dist/vendor/types/apiKey.d.ts.map +1 -0
- package/dist/vendor/types/apiKey.js +36 -0
- package/dist/vendor/types/apiKey.js.map +1 -0
- package/dist/vendor/types/auth.d.ts +10 -0
- package/dist/vendor/types/auth.d.ts.map +1 -0
- package/dist/vendor/types/auth.js +15 -0
- package/dist/vendor/types/auth.js.map +1 -0
- package/dist/vendor/types/entity.d.ts +74 -0
- package/dist/vendor/types/entity.d.ts.map +1 -0
- package/dist/vendor/types/entity.js +111 -0
- package/dist/vendor/types/entity.js.map +1 -0
- package/dist/vendor/types/feedback.d.ts +285 -0
- package/dist/vendor/types/feedback.d.ts.map +1 -0
- package/dist/vendor/types/feedback.js +286 -0
- package/dist/vendor/types/feedback.js.map +1 -0
- package/dist/vendor/types/index.d.ts +14 -0
- package/dist/vendor/types/index.d.ts.map +1 -0
- package/dist/vendor/types/index.js +16 -0
- package/dist/vendor/types/index.js.map +1 -0
- package/dist/vendor/types/invite.d.ts +42 -0
- package/dist/vendor/types/invite.d.ts.map +1 -0
- package/dist/vendor/types/invite.js +30 -0
- package/dist/vendor/types/invite.js.map +1 -0
- package/dist/vendor/types/mcpToken.d.ts +38 -0
- package/dist/vendor/types/mcpToken.d.ts.map +1 -0
- package/dist/vendor/types/mcpToken.js +39 -0
- package/dist/vendor/types/mcpToken.js.map +1 -0
- package/dist/vendor/types/organization.d.ts +13 -0
- package/dist/vendor/types/organization.d.ts.map +1 -0
- package/dist/vendor/types/organization.js +12 -0
- package/dist/vendor/types/organization.js.map +1 -0
- package/dist/vendor/types/project.d.ts +73 -0
- package/dist/vendor/types/project.d.ts.map +1 -0
- package/dist/vendor/types/project.js +117 -0
- package/dist/vendor/types/project.js.map +1 -0
- package/dist/vendor/types/role.d.ts +12 -0
- package/dist/vendor/types/role.d.ts.map +1 -0
- package/dist/vendor/types/role.js +20 -0
- package/dist/vendor/types/role.js.map +1 -0
- package/dist/vendor/types/roleDefinition.d.ts +61 -0
- package/dist/vendor/types/roleDefinition.d.ts.map +1 -0
- package/dist/vendor/types/roleDefinition.js +45 -0
- package/dist/vendor/types/roleDefinition.js.map +1 -0
- package/dist/vendor/types/team.d.ts +25 -0
- package/dist/vendor/types/team.d.ts.map +1 -0
- package/dist/vendor/types/team.js +17 -0
- package/dist/vendor/types/team.js.map +1 -0
- package/dist/vendor/types/util.d.ts +39 -0
- package/dist/vendor/types/util.d.ts.map +1 -0
- package/dist/vendor/types/util.js +31 -0
- package/dist/vendor/types/util.js.map +1 -0
- package/package.json +59 -0
package/dist/PinLayer.js
ADDED
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// Pinning: the centrepiece interaction. Click an element, attach a comment to
|
|
4
|
+
// it, and see everyone else's pins on the same page so nobody reports the same
|
|
5
|
+
// bug twice.
|
|
6
|
+
//
|
|
7
|
+
// Two overlays, both fixed and both above the host page. In picking mode the
|
|
8
|
+
// overlay is what receives the click, which is how a pin can be dropped on a
|
|
9
|
+
// button without the button firing. Out of picking mode nothing intercepts
|
|
10
|
+
// pointer events except the markers themselves, so the page stays usable.
|
|
11
|
+
//
|
|
12
|
+
// The markers are not gated on any surface being open: they are the page's
|
|
13
|
+
// review state, and hiding them until a reviewer opened a panel was how two
|
|
14
|
+
// people ended up filing the same bug twice. The one thing that does hide a
|
|
15
|
+
// marker is a resolved item - see `showResolved`.
|
|
16
|
+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, } from "react";
|
|
17
|
+
import { FEEDBACK_BODY_MAX, } from "./vendor/types/index.js";
|
|
18
|
+
import { captureMetadata, captureScreenshot } from "./capture.js";
|
|
19
|
+
import { ToolbarApiError } from "./client.js";
|
|
20
|
+
import { buildDeepLink, currentPageUrl } from "./deepLink.js";
|
|
21
|
+
import { ChordWatcher, isApplePlatform, isEditableTarget, matchesChord, } from "./shortcut.js";
|
|
22
|
+
import { Avatar, displayNameFor, formatStamp, initialsFor, relativeStamp, } from "./FeedbackPanel.js";
|
|
23
|
+
import { CameraIcon, CheckIcon, CloseIcon, CopyIcon, LinkIcon, SendIcon, } from "./icons.js";
|
|
24
|
+
import { MARKDOWN_MARKS, toggleMark } from "./markdown.js";
|
|
25
|
+
import { RichText } from "./RichText.js";
|
|
26
|
+
import { captureElement } from "./capture.js";
|
|
27
|
+
import { surfaceMotion, useEntered, useNewSinceMount, usePrefersReducedMotion, usePresence, } from "./motion.js";
|
|
28
|
+
import { buildSelector, locatePin, offsetWithin, resolveSelector, } from "./selector.js";
|
|
29
|
+
import { accent, durationMs, exitFor, layers, lightTheme, motion, motionTransform, radii, resetBox, sizes, spacing, srOnly, transition, typeScale, VIEWPORT_GUTTER, } from "./tokens.js";
|
|
30
|
+
/**
|
|
31
|
+
* What counts as "inside the pin UI" for the outside-click dismissal. The
|
|
32
|
+
* composer is included because a click that lands on it is a reviewer still
|
|
33
|
+
* writing, and the toolbar's own bar is included because closing a thread on
|
|
34
|
+
* the way to pressing a bar button would make the bar feel unreliable.
|
|
35
|
+
*/
|
|
36
|
+
const PIN_SURFACE_SELECTOR = '[data-bitvea-pin-surface], [data-testid="pin-composer"], [data-bitvea-feedback-toolbar]';
|
|
37
|
+
/**
|
|
38
|
+
* How far down and right of its marker an anchored thread sits, in pixels.
|
|
39
|
+
*
|
|
40
|
+
* A constant rather than two string literals in a style object because the
|
|
41
|
+
* thread's `transform-origin` is derived from it - the growth origin has to
|
|
42
|
+
* land back on the marker, and that is this offset negated. Two places that
|
|
43
|
+
* have to agree, so one value.
|
|
44
|
+
*/
|
|
45
|
+
const THREAD_ANCHOR_OFFSET = 16;
|
|
46
|
+
/**
|
|
47
|
+
* The composer's own version of the offset above. 12 rather than 16 because it
|
|
48
|
+
* hangs off a cursor rather than off a 28px teardrop, and the unit test that
|
|
49
|
+
* asserts the growth origin lands on the clicked pixel reads it as `-12px`.
|
|
50
|
+
*/
|
|
51
|
+
const COMPOSER_ANCHOR_OFFSET = 12;
|
|
52
|
+
/**
|
|
53
|
+
* How wide an anchored surface is allowed to grow: the viewport, less
|
|
54
|
+
* `VIEWPORT_GUTTER` on each side.
|
|
55
|
+
*
|
|
56
|
+
* Derived rather than spelled `calc(100vw - 32px)` at the three call sites
|
|
57
|
+
* that had it, which is what makes the clamps below agree with the width they
|
|
58
|
+
* clamp - by construction, rather than by two numbers happening to match. A
|
|
59
|
+
* card whose `maxWidth` is wider than the clamp believes hangs off the very
|
|
60
|
+
* edge the clamp exists to hold it inside, and the clamp cannot see it happen.
|
|
61
|
+
*/
|
|
62
|
+
const VIEWPORT_MAX_WIDTH = `calc(100vw - ${VIEWPORT_GUTTER * 2}px)`;
|
|
63
|
+
/**
|
|
64
|
+
* How much of the composer is kept on screen vertically.
|
|
65
|
+
*
|
|
66
|
+
* A guess, and stated as one: nothing measures the composer's height before it
|
|
67
|
+
* is placed, and doing so would mean laying it out off-screen first. It is
|
|
68
|
+
* generous enough to hold the selector line, three rows of textarea and the
|
|
69
|
+
* button row, which is the composer at rest.
|
|
70
|
+
*/
|
|
71
|
+
const COMPOSER_VIEWPORT_RESERVE = 200;
|
|
72
|
+
/**
|
|
73
|
+
* The card width as a number, for the two clamps that have to do arithmetic
|
|
74
|
+
* with it. Parsed from the token rather than declared beside it - see
|
|
75
|
+
* `sizes.thread`, where a hard-coded `300` in exactly this position is the
|
|
76
|
+
* defect that token exists to close.
|
|
77
|
+
*/
|
|
78
|
+
const THREAD_WIDTH = Number.parseFloat(sizes.thread);
|
|
79
|
+
/**
|
|
80
|
+
* How tall the reply box may grow before it scrolls instead - six lines of
|
|
81
|
+
* `bodySm`. Past that the composer would push the send control out of a card
|
|
82
|
+
* that is itself capped at `sizes.threadMaxHeight`.
|
|
83
|
+
*/
|
|
84
|
+
const REPLY_MAX_HEIGHT = 120;
|
|
85
|
+
/**
|
|
86
|
+
* The key a not-yet-submitted report's capture is held under.
|
|
87
|
+
*
|
|
88
|
+
* Attachments are keyed by feedback item id, and a draft has no id - it is the
|
|
89
|
+
* report being written. A reserved key rather than a second piece of state,
|
|
90
|
+
* because everything else about the two composers is now shared and a parallel
|
|
91
|
+
* `draftAttachment` would be the one thing that could drift.
|
|
92
|
+
*
|
|
93
|
+
* Not a valid cuid, so it cannot collide with a real item id.
|
|
94
|
+
*/
|
|
95
|
+
const DRAFT_ATTACHMENT = "draft";
|
|
96
|
+
/** How long a copy button shows its check. Long enough to read, and no longer. */
|
|
97
|
+
const COPIED_FOR_MS = 1200;
|
|
98
|
+
export function PinLayer({ client, config, organizationId, theme = lightTheme, picking, onPickingChange, showResolved = false, focusPinId = null, onFocusHandled, deepLinkPinId = null, onOpenCountChange, }) {
|
|
99
|
+
const [pins, setPins] = useState([]);
|
|
100
|
+
const [draft, setDraft] = useState(null);
|
|
101
|
+
/**
|
|
102
|
+
* The camera in a thread's composer arms the SAME picker the pin tool uses,
|
|
103
|
+
* and the two are one mechanism with two intents: point at an element, and
|
|
104
|
+
* do something with it. The alternative was a second overlay with its own
|
|
105
|
+
* hit-testing and its own escape hatch, which is how two pickers end up
|
|
106
|
+
* disagreeing about what "the element under the cursor" means.
|
|
107
|
+
*
|
|
108
|
+
* Which thread asked is held here rather than in the thread, because the
|
|
109
|
+
* overlay is rendered by this component: a thread cannot own a surface that
|
|
110
|
+
* covers the whole viewport. The captured image is held here for the same
|
|
111
|
+
* reason and handed back down.
|
|
112
|
+
*/
|
|
113
|
+
const [capturingFor, setCapturingFor] = useState(null);
|
|
114
|
+
const [attachments, setAttachments] = useState({});
|
|
115
|
+
const [openPinId, setOpenPinId] = useState(null);
|
|
116
|
+
// Which threads this reviewer has already looked at, keyed by reply count so
|
|
117
|
+
// a new reply on an already-read thread marks it unread again. Session-local
|
|
118
|
+
// because there is no server-side read model: the alternative was to call
|
|
119
|
+
// every thread with replies unread forever, which makes the dot meaningless.
|
|
120
|
+
const [read, setRead] = useState({});
|
|
121
|
+
// Whether the first list has come back. Only a deep link needs this: "no
|
|
122
|
+
// such item on this page" and "the request has not answered yet" have to be
|
|
123
|
+
// told apart before anything is said about a missing item, or every deep
|
|
124
|
+
// link flashes an error on its way to working.
|
|
125
|
+
const [loaded, setLoaded] = useState(false);
|
|
126
|
+
// Bumped on scroll and resize to recompute every marker's position from its
|
|
127
|
+
// anchor. Positions are derived, never stored in viewport pixels, which is
|
|
128
|
+
// what lets a pin survive a reload onto a different viewport.
|
|
129
|
+
const [tick, setTick] = useState(0);
|
|
130
|
+
// Derived, not read off the address bar. A deep link arrives with our
|
|
131
|
+
// fragment on the URL and a host framework can put it back after the shell
|
|
132
|
+
// has removed it; a `pageUrl` carrying it matches no stored row, so the page
|
|
133
|
+
// renders as empty at the exact moment somebody followed a link to a report
|
|
134
|
+
// on it. See `currentPageUrl`.
|
|
135
|
+
const pageUrl = currentPageUrl();
|
|
136
|
+
const reload = useCallback(async () => {
|
|
137
|
+
try {
|
|
138
|
+
setPins(await client.listForPage(organizationId, pageUrl));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// A pin list that will not load must not take the toolbar with it: the
|
|
142
|
+
// reviewer can still write a general note.
|
|
143
|
+
setPins([]);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
setLoaded(true);
|
|
147
|
+
}
|
|
148
|
+
}, [client, organizationId, pageUrl]);
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
void reload();
|
|
151
|
+
}, [reload]);
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const onMove = () => setTick((t) => t + 1);
|
|
154
|
+
window.addEventListener("scroll", onMove, { passive: true });
|
|
155
|
+
window.addEventListener("resize", onMove);
|
|
156
|
+
return () => {
|
|
157
|
+
window.removeEventListener("scroll", onMove);
|
|
158
|
+
window.removeEventListener("resize", onMove);
|
|
159
|
+
};
|
|
160
|
+
}, []);
|
|
161
|
+
// Escape gets out of everything this layer opened, in the order a reviewer
|
|
162
|
+
// means it: the composer they are typing in, then the pin tool they armed,
|
|
163
|
+
// then the thread they opened.
|
|
164
|
+
//
|
|
165
|
+
// There was no key handling in this package at all, and for the pin tool
|
|
166
|
+
// that was not a missing nicety - combined with the picker painting over the
|
|
167
|
+
// bar it meant picking mode had no exit whatsoever. The z-index fix makes
|
|
168
|
+
// the button reachable again; this makes the keyboard work as well, which is
|
|
169
|
+
// what an overlay in somebody else's app owes them.
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
const onKeyDown = (event) => {
|
|
172
|
+
if (event.key !== "Escape")
|
|
173
|
+
return;
|
|
174
|
+
if (draft !== null) {
|
|
175
|
+
setDraft(null);
|
|
176
|
+
}
|
|
177
|
+
else if (capturingFor !== null) {
|
|
178
|
+
// Before `picking`, because a capture is the innermost thing open: the
|
|
179
|
+
// reviewer armed it from a thread they still want.
|
|
180
|
+
setCapturingFor(null);
|
|
181
|
+
}
|
|
182
|
+
else if (picking) {
|
|
183
|
+
onPickingChange(false);
|
|
184
|
+
}
|
|
185
|
+
else if (openPinId !== null) {
|
|
186
|
+
setOpenPinId(null);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Only when something was actually dismissed: a host app may have its
|
|
192
|
+
// own Escape handling, and swallowing the key when the toolbar had
|
|
193
|
+
// nothing open would break it.
|
|
194
|
+
event.stopPropagation();
|
|
195
|
+
};
|
|
196
|
+
window.addEventListener("keydown", onKeyDown);
|
|
197
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
198
|
+
}, [draft, openPinId, picking, capturingFor, onPickingChange]);
|
|
199
|
+
// ARMING THE PICKER FROM THE KEYBOARD, which is the mirror of the handler
|
|
200
|
+
// above and deliberately sits beside it: those two are now the whole of what
|
|
201
|
+
// this package does with a key, and splitting them across files is how they
|
|
202
|
+
// start disagreeing about what "something is open" means.
|
|
203
|
+
//
|
|
204
|
+
// It lives in THIS component and not in the bar, and three of the feature's
|
|
205
|
+
// rules are satisfied by that placement alone rather than by a condition:
|
|
206
|
+
//
|
|
207
|
+
// - A ready session is required. `FeedbackToolbar` renders this layer only
|
|
208
|
+
// once the session resolves, so before that there is no listener - not a
|
|
209
|
+
// listener that checks.
|
|
210
|
+
// - The bar may be collapsed. This layer is mounted outside the dock and
|
|
211
|
+
// survives a collapse, so the chord keeps working while the bar is put
|
|
212
|
+
// away, which is the case it is most wanted in. Nothing here touches the
|
|
213
|
+
// `collapsed` preference: a keystroke that silently rewrote a stored
|
|
214
|
+
// setting would be a side effect nobody asked for.
|
|
215
|
+
// - A draft must not be destroyed. `draft` is state in this component, so
|
|
216
|
+
// the no-op below reads it directly instead of a flag lifted up to the
|
|
217
|
+
// bar and back.
|
|
218
|
+
//
|
|
219
|
+
// CAPTURE PHASE, unlike the Escape handler. A host application that calls
|
|
220
|
+
// `stopPropagation()` in its own keydown handler would swallow the chord
|
|
221
|
+
// before a bubble-phase listener ever saw it, and the reviewer would get no
|
|
222
|
+
// feedback and no way to understand why. Escape can afford to bubble because
|
|
223
|
+
// it only ever acts on something the toolbar already opened; this one has to
|
|
224
|
+
// work when the toolbar has nothing open at all, which is precisely when the
|
|
225
|
+
// host is in charge of the keyboard.
|
|
226
|
+
//
|
|
227
|
+
// The swallow stays conditional, exactly as above: every suppressed case
|
|
228
|
+
// here leaves the event completely untouched.
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const chord = config.pinShortcut;
|
|
231
|
+
// No chord means NO LISTENER. A host that switched the shortcut off must
|
|
232
|
+
// not have its keystrokes observed by a handler that merely declines them.
|
|
233
|
+
if (chord === null)
|
|
234
|
+
return;
|
|
235
|
+
const watcher = new ChordWatcher();
|
|
236
|
+
const apple = isApplePlatform(typeof navigator === "undefined" ? undefined : navigator);
|
|
237
|
+
const onKeyDown = (event) => {
|
|
238
|
+
// Held keys are tracked even while the reviewer is typing in the host's
|
|
239
|
+
// own field: the suppression below is about whether we ACT, and a set
|
|
240
|
+
// that stopped tracking mid-word would be wrong about what is down by
|
|
241
|
+
// the time focus leaves.
|
|
242
|
+
watcher.down(event.key);
|
|
243
|
+
if (isEditableTarget(event.target))
|
|
244
|
+
return;
|
|
245
|
+
if (!matchesChord(chord, event, watcher.keys(), apple))
|
|
246
|
+
return;
|
|
247
|
+
// Mid-sentence in a composer, this does nothing. Deliberately NOT
|
|
248
|
+
// symmetric with Escape above: Escape is an explicit "get me out of
|
|
249
|
+
// this" and may discard a draft, while this is a "get me in" that has
|
|
250
|
+
// landed at a bad moment, and it must never cost unsent words.
|
|
251
|
+
if (draft !== null)
|
|
252
|
+
return;
|
|
253
|
+
// Arming only. Pressing it again is not a toggle - Escape is the exit,
|
|
254
|
+
// and the handler above owns what that means in every state.
|
|
255
|
+
if (picking)
|
|
256
|
+
return;
|
|
257
|
+
onPickingChange(true);
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
event.stopPropagation();
|
|
260
|
+
};
|
|
261
|
+
const onKeyUp = (event) => watcher.up(event.key);
|
|
262
|
+
// Both of these mean the same thing: this document has stopped hearing the
|
|
263
|
+
// reviewer's keystrokes and cannot know what they released. Without them a
|
|
264
|
+
// keyup lost to a tab switch - or to Spotlight, which this chord provokes
|
|
265
|
+
// on macOS - leaves Space "held" forever, and a later lone F arms picking
|
|
266
|
+
// out of nowhere.
|
|
267
|
+
const onRelease = () => watcher.clear();
|
|
268
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
269
|
+
window.addEventListener("keyup", onKeyUp, true);
|
|
270
|
+
window.addEventListener("blur", onRelease);
|
|
271
|
+
document.addEventListener("visibilitychange", onRelease);
|
|
272
|
+
return () => {
|
|
273
|
+
window.removeEventListener("keydown", onKeyDown, true);
|
|
274
|
+
window.removeEventListener("keyup", onKeyUp, true);
|
|
275
|
+
window.removeEventListener("blur", onRelease);
|
|
276
|
+
document.removeEventListener("visibilitychange", onRelease);
|
|
277
|
+
};
|
|
278
|
+
}, [config.pinShortcut, draft, picking, onPickingChange]);
|
|
279
|
+
// A click on the page closes an open thread. A popover parked over the
|
|
280
|
+
// element under review, dismissable only by finding its own small close
|
|
281
|
+
// control again, is a review tool getting in the way of the review.
|
|
282
|
+
//
|
|
283
|
+
// `pointerdown` rather than `click`, so the thread is gone before the host's
|
|
284
|
+
// own click handler runs on the thing the reviewer actually aimed at.
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (openPinId === null)
|
|
287
|
+
return;
|
|
288
|
+
const onPointerDown = (event) => {
|
|
289
|
+
const target = event.target;
|
|
290
|
+
if (target instanceof Element &&
|
|
291
|
+
target.closest(PIN_SURFACE_SELECTOR) !== null) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
setOpenPinId(null);
|
|
295
|
+
};
|
|
296
|
+
window.addEventListener("pointerdown", onPointerDown, true);
|
|
297
|
+
return () => window.removeEventListener("pointerdown", onPointerDown, true);
|
|
298
|
+
}, [openPinId]);
|
|
299
|
+
const openCount = useMemo(() => pins.filter((p) => p.status !== "DONE").length, [pins]);
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
onOpenCountChange?.(openCount);
|
|
302
|
+
}, [openCount, onOpenCountChange]);
|
|
303
|
+
// Which pins belong on the page right now, as ids.
|
|
304
|
+
//
|
|
305
|
+
// Split out of `placed` and deliberately independent of `tick`: this is the
|
|
306
|
+
// cheap set-membership question, and `placed` below is the expensive one - it
|
|
307
|
+
// resolves a selector and reads a bounding rect per pin, on every scroll and
|
|
308
|
+
// resize event. Resolved items leave the page unless they have been asked
|
|
309
|
+
// back, with one exception: an open thread stays whatever its status, which
|
|
310
|
+
// is what makes a deep link to a resolved item work - the link reveals that
|
|
311
|
+
// one item without turning every closed pin on the page back on.
|
|
312
|
+
const onPageIds = useMemo(() => pins
|
|
313
|
+
.filter((p) => p.kind === "PINNED" &&
|
|
314
|
+
p.pin &&
|
|
315
|
+
(showResolved || p.status !== "DONE" || p.id === openPinId))
|
|
316
|
+
.map((p) => p.id), [openPinId, pins, showResolved]);
|
|
317
|
+
// Pins that were on the page a moment ago and are not any more, kept just
|
|
318
|
+
// long enough to fade out (issue #24). A resolve used to end with a marker
|
|
319
|
+
// blinking out of existence, which reads as a glitch rather than as an
|
|
320
|
+
// acknowledgement.
|
|
321
|
+
//
|
|
322
|
+
// Derived during render from the previous set rather than in an effect: an
|
|
323
|
+
// effect would paint one frame with the marker already gone, which is the
|
|
324
|
+
// frame the fade needed. This is the same "a value changed, so derive some
|
|
325
|
+
// state from it" shape the focus request above uses.
|
|
326
|
+
//
|
|
327
|
+
// The initial value is the point of the whole latch. `onPageIds` starts empty
|
|
328
|
+
// and fills in when the fetch answers, so a page loading with twenty resolved
|
|
329
|
+
// pins hidden compares an empty previous set against an empty current one and
|
|
330
|
+
// fades nothing - DESIGN.md: nothing animates on first mount. Only a pin that
|
|
331
|
+
// was visible and stopped being visible ends up here, and it is usually
|
|
332
|
+
// `openPinId` or `showResolved` changing that puts it there rather than the
|
|
333
|
+
// status write, since the exception above keeps a pin resolved from its own
|
|
334
|
+
// thread on the page until that thread closes.
|
|
335
|
+
const [wasOnPage, setWasOnPage] = useState(onPageIds);
|
|
336
|
+
const [departingIds, setDepartingIds] = useState([]);
|
|
337
|
+
if (wasOnPage !== onPageIds) {
|
|
338
|
+
const gone = wasOnPage.filter((id) => !onPageIds.includes(id));
|
|
339
|
+
setWasOnPage(onPageIds);
|
|
340
|
+
// Added to whatever is already leaving rather than replacing it. Two pins
|
|
341
|
+
// departing 50ms apart is ordinary - resolving from the sheet while a
|
|
342
|
+
// deep-linked thread closes - and a replace dropped the first one from
|
|
343
|
+
// `placed` mid-fade, which unmounts a marker at opacity 0.6. That is the
|
|
344
|
+
// blink the fade exists to remove, arriving by a different route.
|
|
345
|
+
if (gone.length > 0) {
|
|
346
|
+
setDepartingIds((leaving) => [
|
|
347
|
+
...leaving,
|
|
348
|
+
...gone.filter((id) => !leaving.includes(id)),
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (departingIds.length === 0)
|
|
354
|
+
return;
|
|
355
|
+
const timer = window.setTimeout(() => setDepartingIds([]), durationMs(motion.duration.fast));
|
|
356
|
+
return () => window.clearTimeout(timer);
|
|
357
|
+
}, [departingIds]);
|
|
358
|
+
const placed = useMemo(() => {
|
|
359
|
+
void tick;
|
|
360
|
+
const onPage = new Set(onPageIds);
|
|
361
|
+
return (pins
|
|
362
|
+
// Only the pins that have to be drawn: the ones on the page, plus the
|
|
363
|
+
// handful on their way off it. Anything else never reaches `locatePin`,
|
|
364
|
+
// which is what keeps the cost of a scroll proportional to what is
|
|
365
|
+
// visible rather than to the project's whole review history.
|
|
366
|
+
.filter((p) => p.kind === "PINNED" &&
|
|
367
|
+
p.pin &&
|
|
368
|
+
(onPage.has(p.id) || departingIds.includes(p.id)))
|
|
369
|
+
.map((p) => ({
|
|
370
|
+
item: p,
|
|
371
|
+
at: locatePin(p.pin.selector, p.pin.offsetX, p.pin.offsetY),
|
|
372
|
+
departing: !onPage.has(p.id),
|
|
373
|
+
}))
|
|
374
|
+
// A pin whose anchor no longer exists is hidden rather than parked in a
|
|
375
|
+
// corner: a marker floating over unrelated content is worse than none.
|
|
376
|
+
.filter((p) => p.at !== null));
|
|
377
|
+
}, [departingIds, onPageIds, pins, tick]);
|
|
378
|
+
// A focus request - from the inbox sheet, or from a deep link - opens that
|
|
379
|
+
// thread. Adjusted during render rather than in an effect: an effect would
|
|
380
|
+
// paint the un-focused state first and then correct it, and React's own
|
|
381
|
+
// guidance for "a prop changed, so derive some state from it" is exactly
|
|
382
|
+
// this shape.
|
|
383
|
+
const requestedPinId = focusPinId ?? deepLinkPinId;
|
|
384
|
+
const [lastFocus, setLastFocus] = useState(null);
|
|
385
|
+
if (requestedPinId !== null && requestedPinId !== lastFocus) {
|
|
386
|
+
setLastFocus(requestedPinId);
|
|
387
|
+
setOpenPinId(requestedPinId);
|
|
388
|
+
}
|
|
389
|
+
// The scroll is a real side effect on the host document, so it does belong
|
|
390
|
+
// in an effect. Handing the request back here is what makes it fire once.
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
if (focusPinId === null)
|
|
393
|
+
return;
|
|
394
|
+
scrollToAnchor(pins, focusPinId);
|
|
395
|
+
onFocusHandled?.();
|
|
396
|
+
}, [focusPinId, pins, onFocusHandled]);
|
|
397
|
+
// The deep link's own scroll, which cannot use the effect above: the id is
|
|
398
|
+
// known before the list is, so this waits for the fetch instead of firing
|
|
399
|
+
// against an empty array. The ref is what keeps it a one-shot - the list is
|
|
400
|
+
// re-read after every reply, and re-scrolling the reviewer's page each time
|
|
401
|
+
// would fight them for control of the viewport. A ref rather than state
|
|
402
|
+
// because "has this side effect run" is not something a render reads.
|
|
403
|
+
const deepLinkScrolled = useRef(false);
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
if (deepLinkPinId === null || deepLinkScrolled.current || !loaded)
|
|
406
|
+
return;
|
|
407
|
+
deepLinkScrolled.current = true;
|
|
408
|
+
scrollToAnchor(pins, deepLinkPinId);
|
|
409
|
+
}, [deepLinkPinId, loaded, pins]);
|
|
410
|
+
// An open item that has no marker to hang its thread off. Two ways to get
|
|
411
|
+
// here, and both must still show the thread: the selector no longer
|
|
412
|
+
// resolves because the markup changed since the pin was made, or the item
|
|
413
|
+
// is a general note about the page and never had an anchor. Silently doing
|
|
414
|
+
// nothing is the one unacceptable outcome - a reviewer who followed a link
|
|
415
|
+
// to a report has to be told what happened to it.
|
|
416
|
+
const openItem = pins.find((p) => p.id === openPinId) ?? null;
|
|
417
|
+
const stranded = openItem !== null && !placed.some((p) => p.item.id === openItem.id)
|
|
418
|
+
? openItem
|
|
419
|
+
: null;
|
|
420
|
+
// A deep link naming an item this page's list does not contain at all: it
|
|
421
|
+
// was filed on another page, or it has since been deleted. Dismissable
|
|
422
|
+
// because it is a dead end, unlike a stranded thread which can still be
|
|
423
|
+
// replied to.
|
|
424
|
+
const [missingDismissed, setMissingDismissed] = useState(false);
|
|
425
|
+
const showMissing = deepLinkPinId !== null &&
|
|
426
|
+
loaded &&
|
|
427
|
+
!missingDismissed &&
|
|
428
|
+
!pins.some((p) => p.id === deepLinkPinId);
|
|
429
|
+
function markRead(item) {
|
|
430
|
+
setRead((current) => ({ ...current, [item.id]: item.replies.length }));
|
|
431
|
+
}
|
|
432
|
+
async function onCaptureClick(e) {
|
|
433
|
+
const itemId = capturingFor;
|
|
434
|
+
const target = elementUnder(e.currentTarget, e.clientX, e.clientY);
|
|
435
|
+
// Disarm first, whatever happens next. A capture that fails must not leave
|
|
436
|
+
// the reviewer trapped in an overlay with no way back to their draft.
|
|
437
|
+
setCapturingFor(null);
|
|
438
|
+
if (!target || itemId === null)
|
|
439
|
+
return;
|
|
440
|
+
const shot = await captureElement(target);
|
|
441
|
+
if (shot === null)
|
|
442
|
+
return;
|
|
443
|
+
setAttachments((current) => ({ ...current, [itemId]: shot }));
|
|
444
|
+
}
|
|
445
|
+
function onPickClick(e) {
|
|
446
|
+
const target = elementUnder(e.currentTarget, e.clientX, e.clientY);
|
|
447
|
+
if (!target)
|
|
448
|
+
return;
|
|
449
|
+
const selector = buildSelector(target);
|
|
450
|
+
if (!selector) {
|
|
451
|
+
onPickingChange(false);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const { offsetX, offsetY } = offsetWithin(target, e.clientX, e.clientY);
|
|
455
|
+
setDraft({ selector, offsetX, offsetY, x: e.clientX, y: e.clientY });
|
|
456
|
+
onPickingChange(false);
|
|
457
|
+
}
|
|
458
|
+
return (_jsxs(_Fragment, { children: [picking ? _jsx(Picker, { theme: theme, onPick: onPickClick }) : null, capturingFor !== null ? (_jsx(Picker, { theme: theme, onPick: (e) => void onCaptureClick(e) })) : null, placed.map(({ item, at, departing }) => (_jsx(PinMarker, { item: item, at: at, theme: theme, departing: departing, dimmed: item.status === "DONE", focused: openPinId === item.id, unread: item.replies.length > (read[item.id] ?? -1), outdated: isOutdated(item, config), attachment: attachments[item.id] ?? null, onRequestCapture: () => setCapturingFor(item.id), onClearAttachment: () => setAttachments(({ [item.id]: _dropped, ...rest }) => rest), apiUrl: config.apiUrl, open: openPinId === item.id, onToggle: () => {
|
|
459
|
+
markRead(item);
|
|
460
|
+
setOpenPinId((id) => (id === item.id ? null : item.id));
|
|
461
|
+
}, client: client, organizationId: organizationId, onChanged: reload }, item.id))), stranded ? (_jsx("div", { "data-testid": `pin-stranded-${stranded.id}`, "data-bitvea-pin-surface": "", style: {
|
|
462
|
+
position: "fixed",
|
|
463
|
+
left: "50%",
|
|
464
|
+
top: "50%",
|
|
465
|
+
transform: "translate(-50%, -50%)",
|
|
466
|
+
// The same rung an anchored thread rises to when it opens: this is
|
|
467
|
+
// one of those, minus a marker to hang off. See `layers`.
|
|
468
|
+
zIndex: layers.thread,
|
|
469
|
+
}, children: _jsx(PinThread, { item: stranded, theme: theme, client: client, organizationId: organizationId, onChanged: reload, outdated: isOutdated(stranded, config), attachment: attachments[stranded.id] ?? null, onRequestCapture: () => setCapturingFor(stranded.id), onClearAttachment: () => setAttachments(({ [stranded.id]: _dropped, ...rest }) => rest), apiUrl: config.apiUrl, position: { position: "relative" }, note: stranded.pin
|
|
470
|
+
? "The element this was pinned to is gone, so the thread is shown here instead."
|
|
471
|
+
: "A note about the page as a whole, so it has no pin.", onClose: () => setOpenPinId(null) }) })) : null, showMissing ? (_jsxs("div", { role: "alert", "data-testid": "pin-missing", style: {
|
|
472
|
+
position: "fixed",
|
|
473
|
+
left: "50%",
|
|
474
|
+
top: spacing.md,
|
|
475
|
+
transform: "translateX(-50%)",
|
|
476
|
+
zIndex: layers.alert,
|
|
477
|
+
display: "flex",
|
|
478
|
+
alignItems: "center",
|
|
479
|
+
gap: spacing.xs,
|
|
480
|
+
padding: `${spacing.xs} ${spacing.sm}`,
|
|
481
|
+
maxWidth: VIEWPORT_MAX_WIDTH,
|
|
482
|
+
background: theme.colors.canvas,
|
|
483
|
+
border: `1px solid ${theme.colors.hairline}`,
|
|
484
|
+
borderRadius: radii.lg,
|
|
485
|
+
boxShadow: theme.shadows.popover,
|
|
486
|
+
...typeScale.bodySm,
|
|
487
|
+
color: theme.colors.body,
|
|
488
|
+
}, children: [_jsx("span", { children: "That feedback is not on this page any more." }), _jsx("button", { type: "button", "data-testid": "pin-missing-dismiss", onClick: () => setMissingDismissed(true), style: {
|
|
489
|
+
padding: 0,
|
|
490
|
+
border: "none",
|
|
491
|
+
background: "transparent",
|
|
492
|
+
// `body`, not `mute`: this is the control that gets rid of the
|
|
493
|
+
// alert, and 3.4:1 is not a control.
|
|
494
|
+
color: theme.colors.body,
|
|
495
|
+
cursor: "pointer",
|
|
496
|
+
...typeScale.bodySmStrong,
|
|
497
|
+
outline: "revert",
|
|
498
|
+
}, children: "Dismiss" })] })) : null, draft ? (_jsx(PinComposer, { draft: draft, theme: theme, client: client, config: config, organizationId: organizationId, onDone: () => {
|
|
499
|
+
setDraft(null);
|
|
500
|
+
// The capture belonged to the report that just landed.
|
|
501
|
+
setAttachments(({ [DRAFT_ATTACHMENT]: _sent, ...rest }) => rest);
|
|
502
|
+
void reload();
|
|
503
|
+
}, attachment: attachments[DRAFT_ATTACHMENT] ?? null, onRequestCapture: () => setCapturingFor(DRAFT_ATTACHMENT), onClearAttachment: () => setAttachments(({ [DRAFT_ATTACHMENT]: _dropped, ...rest }) => rest) })) : null] }));
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Scroll the element one item is pinned to into the middle of the viewport.
|
|
507
|
+
* A no-op for an item this page's list does not have, and for one whose
|
|
508
|
+
* selector no longer resolves - in both cases the layer has already decided
|
|
509
|
+
* what to show instead, and scrolling to nowhere would only move the page
|
|
510
|
+
* under the reviewer for no reason.
|
|
511
|
+
*/
|
|
512
|
+
function scrollToAnchor(pins, id) {
|
|
513
|
+
const item = pins.find((p) => p.id === id);
|
|
514
|
+
const target = item?.pin ? resolveSelector(item.pin.selector) : null;
|
|
515
|
+
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Ask the document what is under a point, with the overlay taken out of the
|
|
519
|
+
* way. `elementFromPoint` returns the TOPMOST element, which is the overlay
|
|
520
|
+
* itself, so it has to be hidden for the duration of the hit test or every pin
|
|
521
|
+
* anchors to the picker rather than to the page.
|
|
522
|
+
*/
|
|
523
|
+
function elementUnder(overlay, clientX, clientY) {
|
|
524
|
+
const previousVisibility = overlay.style.visibility;
|
|
525
|
+
overlay.style.visibility = "hidden";
|
|
526
|
+
const target = document.elementFromPoint(clientX, clientY);
|
|
527
|
+
overlay.style.visibility = previousVisibility;
|
|
528
|
+
if (!target ||
|
|
529
|
+
target === document.body ||
|
|
530
|
+
target === document.documentElement ||
|
|
531
|
+
// Never anchor to the toolbar's own UI.
|
|
532
|
+
target.closest("[data-bitvea-feedback-toolbar]") !== null) {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
return target;
|
|
536
|
+
}
|
|
537
|
+
/* ── Picking ──────────────────────────────────────────────────────────── */
|
|
538
|
+
/**
|
|
539
|
+
* The picking overlay, plus the dashed accent outline around whatever is under
|
|
540
|
+
* the cursor. The outline is what makes picking predictable: the overlay
|
|
541
|
+
* swallows every hover the page would otherwise show, so without it the
|
|
542
|
+
* reviewer is clicking blind and finds out what they hit only afterwards.
|
|
543
|
+
*/
|
|
544
|
+
function Picker({ theme, onPick, }) {
|
|
545
|
+
const [box, setBox] = useState(null);
|
|
546
|
+
return (_jsx("div", { "data-testid": "pin-picker", onClick: onPick, onMouseMove: (e) => {
|
|
547
|
+
const target = elementUnder(e.currentTarget, e.clientX, e.clientY);
|
|
548
|
+
setBox(target ? target.getBoundingClientRect() : null);
|
|
549
|
+
}, onMouseLeave: () => setBox(null), style: {
|
|
550
|
+
position: "fixed",
|
|
551
|
+
inset: 0,
|
|
552
|
+
// Below the bar, which is what makes the pin tool cancellable. This
|
|
553
|
+
// overlay used to sit above it: it covered the whole bar, `elementUnder`
|
|
554
|
+
// refuses anything inside the toolbar, and `onPickClick` answered a
|
|
555
|
+
// click on the pin button with `if (!target) return` - so once picking
|
|
556
|
+
// was armed there was no way out of it at all. See `layers`.
|
|
557
|
+
zIndex: layers.picker,
|
|
558
|
+
cursor: "crosshair",
|
|
559
|
+
// Faint, so the page underneath is still readable while choosing.
|
|
560
|
+
background: theme.colors.pickTint,
|
|
561
|
+
}, children: box ? (_jsx("div", { "data-testid": "pin-picker-outline", "aria-hidden": "true", style: {
|
|
562
|
+
position: "fixed",
|
|
563
|
+
left: `${box.left}px`,
|
|
564
|
+
top: `${box.top}px`,
|
|
565
|
+
width: `${box.width}px`,
|
|
566
|
+
height: `${box.height}px`,
|
|
567
|
+
// Ink, not the accent. The outline is chrome - it says "this is
|
|
568
|
+
// what you are about to pin" - and the accent's one remaining job
|
|
569
|
+
// is being the pin itself.
|
|
570
|
+
border: `2px dashed ${theme.colors.ink}`,
|
|
571
|
+
borderRadius: radii.sm,
|
|
572
|
+
background: theme.colors.inkSoft,
|
|
573
|
+
// And ink alone is not enough, because this is the ONE surface in
|
|
574
|
+
// the package painted directly onto the host's own content rather
|
|
575
|
+
// than onto a canvas of ours. The toolbar's theme follows the
|
|
576
|
+
// reviewer's OS, not the host page: a dark-mode reviewer on a
|
|
577
|
+
// light-only staging site gets `ink` = #ffffff drawn over white,
|
|
578
|
+
// and the outline vanishes at the exact moment it has to be
|
|
579
|
+
// trusted. `markerRing` is the palette's opposite pole (#ffffff
|
|
580
|
+
// light, #0a0a0a dark), so sandwiching the dashes between two
|
|
581
|
+
// 1px rings of it leaves a legible edge whichever way the host
|
|
582
|
+
// page and the toolbar's theme disagree.
|
|
583
|
+
boxShadow: `0 0 0 1px ${theme.colors.markerRing}, inset 0 0 0 1px ${theme.colors.markerRing}`,
|
|
584
|
+
pointerEvents: "none",
|
|
585
|
+
} })) : null }));
|
|
586
|
+
}
|
|
587
|
+
/* ── A placed pin, and its thread ─────────────────────────────────────── */
|
|
588
|
+
function PinMarker({ item, at, theme, departing, dimmed, focused, unread, open, onToggle, client, organizationId, onChanged, outdated, attachment, onRequestCapture, onClearAttachment, apiUrl, }) {
|
|
589
|
+
// The thread outlives `open` by one exit, so the reviewer sees it leave
|
|
590
|
+
// rather than blink out. `open` itself is already false by then - only the
|
|
591
|
+
// DOM removal is deferred. See `usePresence`.
|
|
592
|
+
//
|
|
593
|
+
// `exitFor(fast)` rather than `instant` spelled out: the thread enters at
|
|
594
|
+
// `surfaceMotion`'s default step, and this timer has to be the same value
|
|
595
|
+
// that helper puts in the transition. They agree today at 80ms; derived off
|
|
596
|
+
// one rule they cannot stop agreeing.
|
|
597
|
+
const thread = usePresence(open, exitFor(motion.duration.fast));
|
|
598
|
+
// A cross-fade is not vestibular motion, so reduced motion keeps it and only
|
|
599
|
+
// shortens it - DESIGN.md's Reduced Motion rule.
|
|
600
|
+
const reduced = usePrefersReducedMotion();
|
|
601
|
+
// WHERE THE THREAD OPENS: which side of the marker, and then inside the
|
|
602
|
+
// viewport either way.
|
|
603
|
+
//
|
|
604
|
+
// There never was a right-edge clamp here: the card is `left: 16px` inside a
|
|
605
|
+
// zero-width wrapper parked at the anchor, so a pin in the right third of a
|
|
606
|
+
// page has always hung its thread off the edge - `maxWidth` caps the WIDTH
|
|
607
|
+
// and says nothing about the OFFSET. At 280px that was survivable and at
|
|
608
|
+
// issue #20's 440px it is 160px worse, so the widening is what forces this.
|
|
609
|
+
//
|
|
610
|
+
// Two decisions, and they are deliberately not the same one. SIDE is a
|
|
611
|
+
// preference: right of the marker, unless the card does not fit there and
|
|
612
|
+
// the left is roomier. CLAMP is a guarantee: wherever it ends up, the card
|
|
613
|
+
// stays between the two viewport gutters. Choosing a side alone is what a
|
|
614
|
+
// flip on its own gets wrong - on a viewport narrower than the card plus its
|
|
615
|
+
// offsets neither side fits, and "the roomier one" then picks which EDGE to
|
|
616
|
+
// hang off rather than avoiding both. Flipping left is the worse of those
|
|
617
|
+
// two failures as well, because the half that goes off-screen is the one
|
|
618
|
+
// holding every avatar, author name and the first word of every line.
|
|
619
|
+
//
|
|
620
|
+
// `width` is what the card will actually render at, `maxWidth` included:
|
|
621
|
+
// `calc(100vw - 32px)` is the same two gutters, so on a narrow viewport the
|
|
622
|
+
// card narrows and the clamp narrows with it rather than pushing a card it
|
|
623
|
+
// thinks is 440px wide off the left edge.
|
|
624
|
+
//
|
|
625
|
+
// All of it is LAYOUT - one `left`, in the wrapper's own coordinates - and
|
|
626
|
+
// deliberately nothing else. The wrapper stays transform-free, which is the
|
|
627
|
+
// rule this whole branch is built on: a transform on it would become the
|
|
628
|
+
// containing block for every `position: fixed` marker on the page. See the
|
|
629
|
+
// note on `zIndex` below and `surfaceMotion`'s placement inside `PinThread`.
|
|
630
|
+
const cardWidth = Math.min(THREAD_WIDTH, window.innerWidth - VIEWPORT_GUTTER * 2);
|
|
631
|
+
const roomRight = window.innerWidth - VIEWPORT_GUTTER - (at.x + THREAD_ANCHOR_OFFSET);
|
|
632
|
+
const roomLeft = at.x - THREAD_ANCHOR_OFFSET - VIEWPORT_GUTTER;
|
|
633
|
+
const preferredLeft = roomRight < cardWidth && roomLeft > roomRight
|
|
634
|
+
? at.x - THREAD_ANCHOR_OFFSET - cardWidth
|
|
635
|
+
: at.x + THREAD_ANCHOR_OFFSET;
|
|
636
|
+
// `max` inside `min`, so the left gutter wins on a viewport too narrow for
|
|
637
|
+
// both - the card is then flush left with its right edge inside the screen,
|
|
638
|
+
// which is the only arrangement in which all of it can be read.
|
|
639
|
+
const cardLeft = Math.min(Math.max(preferredLeft, VIEWPORT_GUTTER), window.innerWidth - VIEWPORT_GUTTER - cardWidth);
|
|
640
|
+
return (_jsxs("div", { "data-bitvea-pin-surface": "",
|
|
641
|
+
// See the note beside `pointerEvents` below: the fade is for eyes only.
|
|
642
|
+
inert: departing, style: {
|
|
643
|
+
position: "fixed",
|
|
644
|
+
left: at.x,
|
|
645
|
+
top: at.y,
|
|
646
|
+
// This wrapper is `position: fixed` with a z-index, which makes it a
|
|
647
|
+
// stacking context whatever else is declared on it - so the rung the
|
|
648
|
+
// THREAD paints at is the one written here, not one the thread could
|
|
649
|
+
// set on itself. Pinned at `marker` for every pin, an open thread
|
|
650
|
+
// therefore rendered under the inbox sheet and under the composer, and
|
|
651
|
+
// `layers` in tokens.ts documented an order that was quietly untrue for
|
|
652
|
+
// every anchored thread on the page (issue #23).
|
|
653
|
+
//
|
|
654
|
+
// A closed marker is page state, so it stays at `marker`, below any
|
|
655
|
+
// open surface. An open one is the surface the reviewer is reading, and
|
|
656
|
+
// rises with the thread it is holding.
|
|
657
|
+
//
|
|
658
|
+
// Keyed on `mounted` rather than on `open` so it also holds for the
|
|
659
|
+
// 80ms the thread spends leaving. On `open` the rung dropped on the
|
|
660
|
+
// closing click, and a thread that ducks behind the inbox sheet and
|
|
661
|
+
// only then fades is a worse exit than no exit at all.
|
|
662
|
+
zIndex: thread.mounted ? layers.thread : layers.marker,
|
|
663
|
+
// A marker on its way off the page is a picture of a pin, not a pin:
|
|
664
|
+
// clicking it would open a thread for an item that is no longer here,
|
|
665
|
+
// and a screen reader should not be offered a pin with 120ms left to
|
|
666
|
+
// live. `inert` is the one platform feature that says exactly that -
|
|
667
|
+
// out of the accessibility tree, out of the tab order, unclickable -
|
|
668
|
+
// and it is the reason this is not `aria-hidden` on the button. That
|
|
669
|
+
// spelling was a WCAG violation waiting for one ordinary sequence:
|
|
670
|
+
// resolve from the thread, then click the MARKER to close it, and
|
|
671
|
+
// `aria-hidden` lands on the element that currently holds focus.
|
|
672
|
+
// Becoming inert sheds that focus by specification instead.
|
|
673
|
+
//
|
|
674
|
+
// `pointerEvents` stays as well, for a browser old enough to ignore
|
|
675
|
+
// `inert`: there the fade is merely unclickable rather than fully
|
|
676
|
+
// inert, which is the failure worth having.
|
|
677
|
+
...(departing ? { pointerEvents: "none" } : {}),
|
|
678
|
+
}, children: [_jsxs("button", { type: "button", "data-testid": `pin-${item.id}`, "data-bitvea-pin": item.id, "aria-label": `Feedback from ${displayNameFor(item.author.name, item.author.email)}: ${item.body.slice(0, 60)}`, onClick: onToggle, style: {
|
|
679
|
+
position: "absolute",
|
|
680
|
+
// The teardrop's point is its bottom-left corner, and that corner is
|
|
681
|
+
// what sits on the anchor - so the marker hangs above and to the
|
|
682
|
+
// right of the exact pixel that was clicked, the way a map pin does.
|
|
683
|
+
transform: "translate(0, -100%)",
|
|
684
|
+
width: sizes.icon,
|
|
685
|
+
height: sizes.icon,
|
|
686
|
+
// The 2px ring is a border, and the marker's bottom-left corner is
|
|
687
|
+
// what has to sit on the pixel that was clicked. Under a host page's
|
|
688
|
+
// default `content-box` this box would be 32px and the point would
|
|
689
|
+
// miss by 4px in both axes.
|
|
690
|
+
boxSizing: "border-box",
|
|
691
|
+
display: "inline-flex",
|
|
692
|
+
alignItems: "center",
|
|
693
|
+
justifyContent: "center",
|
|
694
|
+
padding: 0,
|
|
695
|
+
// 4px, the radius scale's smallest step, replacing an invented 3px.
|
|
696
|
+
borderRadius: `50% 50% 50% ${radii.xs}`,
|
|
697
|
+
border: `2px solid ${theme.colors.markerRing}`,
|
|
698
|
+
// The one place the brand orange survives. A pin has to be findable
|
|
699
|
+
// over arbitrary host content - somebody else's page, in colours
|
|
700
|
+
// nobody here chose - and it is the object that means "BitVea
|
|
701
|
+
// Feedback is on this page". Everything else in the toolbar gave the
|
|
702
|
+
// accent back; this kept it, and the mesh gradient is deliberately
|
|
703
|
+
// NOT substituted here because that would miniaturise and crop it.
|
|
704
|
+
background: accent.gradient,
|
|
705
|
+
color: accent.onAccent,
|
|
706
|
+
...typeScale.caption,
|
|
707
|
+
fontWeight: 500,
|
|
708
|
+
boxShadow: focused
|
|
709
|
+
? `${theme.shadows.focusRing}, ${theme.shadows.marker}`
|
|
710
|
+
: theme.shadows.marker,
|
|
711
|
+
// Resolved-but-shown pins stay legible while reading as settled. On
|
|
712
|
+
// the marker and not on the wrapper above, because `opacity`
|
|
713
|
+
// composites the whole subtree it is set on: at 45% up there it
|
|
714
|
+
// painted the open thread translucent too, and the host page's table
|
|
715
|
+
// rows and body copy read straight through the card (issue #23).
|
|
716
|
+
// The unread dot and the focus ring are inside this box and dim with
|
|
717
|
+
// it, which is what a settled pin should do.
|
|
718
|
+
//
|
|
719
|
+
// Zero while departing, which is the same declaration doing the
|
|
720
|
+
// fade off the page: one property, two jobs, and one transition
|
|
721
|
+
// below covering both.
|
|
722
|
+
opacity: departing ? 0 : dimmed ? 0.45 : 1,
|
|
723
|
+
// Half of the resolve/reopen acknowledgement, and the cheap half: the
|
|
724
|
+
// marker fades between its two weights instead of snapping, so a pin
|
|
725
|
+
// resolved from its own thread is visibly answered on the page as
|
|
726
|
+
// well as in the badge. `inOut` for the dim, because the marker is
|
|
727
|
+
// not moving - one property changes on a stationary element - and
|
|
728
|
+
// `exit` for the departure, which is a leaving surface.
|
|
729
|
+
//
|
|
730
|
+
// On the BUTTON, with the opacity it belongs to, and never hoisted to
|
|
731
|
+
// the wrapper: a transition on `opacity` up there would composite the
|
|
732
|
+
// thread for 120ms every time a pin resolves, which is issue #23 with
|
|
733
|
+
// a timer on it. See the rule at the head of motion.ts.
|
|
734
|
+
transition: transition(["opacity"], motion.duration.fast, departing ? motion.easing.exit : motion.easing.inOut, reduced),
|
|
735
|
+
cursor: "pointer",
|
|
736
|
+
outline: "revert",
|
|
737
|
+
}, children: [initialsFor(item.author.name, item.author.email), unread ? (_jsx("span", { "data-testid": `pin-unread-${item.id}`, "aria-hidden": "true", style: {
|
|
738
|
+
position: "absolute",
|
|
739
|
+
// 8px of dot inside a 2px ring is a 12px object, hung 4px
|
|
740
|
+
// outside the marker on both axes - all three on the 4px base,
|
|
741
|
+
// where 9px and -3px were on none of it.
|
|
742
|
+
top: `-${spacing.xxs}`,
|
|
743
|
+
right: `-${spacing.xxs}`,
|
|
744
|
+
width: sizes.dot,
|
|
745
|
+
height: sizes.dot,
|
|
746
|
+
borderRadius: radii.pill,
|
|
747
|
+
background: theme.colors.ink,
|
|
748
|
+
border: `2px solid ${theme.colors.markerRing}`,
|
|
749
|
+
boxSizing: "content-box",
|
|
750
|
+
} })) : null] }), thread.mounted ? (_jsx(PinThread, { item: item, theme: theme, client: client, organizationId: organizationId, onChanged: onChanged, outdated: outdated, attachment: attachment, onRequestCapture: onRequestCapture, onClearAttachment: onClearAttachment, apiUrl: apiUrl,
|
|
751
|
+
// Beside the marker rather than over it, so the thread never covers
|
|
752
|
+
// the element the pin is talking about.
|
|
753
|
+
//
|
|
754
|
+
// Still a child of the wrapper, and that is the deliberate half:
|
|
755
|
+
// `data-bitvea-pin-surface` up there is what the outside-pointerdown
|
|
756
|
+
// dismissal matches, and these coordinates are relative to that box.
|
|
757
|
+
// Hoisting the thread out to escape the wrapper's stacking context
|
|
758
|
+
// would need a second dismissal attribute and its own viewport
|
|
759
|
+
// coordinates; moving the wrapper's z-index with `open` gets the same
|
|
760
|
+
// layering with neither.
|
|
761
|
+
//
|
|
762
|
+
// ONE `left`, in the wrapper's coordinates, for all three cases -
|
|
763
|
+
// right of the marker, left of it, and clamped against either
|
|
764
|
+
// gutter. A `right` for the flipped case would have been the shorter
|
|
765
|
+
// spelling and is the wrong shape: two properties that mean the same
|
|
766
|
+
// thing need the origin below to branch with them, and neither can
|
|
767
|
+
// express "as far left as the marker allows, but no further than the
|
|
768
|
+
// gutter" at all. `cardLeft` is a viewport coordinate; the wrapper
|
|
769
|
+
// sits at `at.x`, so the offset is the difference, negative whenever
|
|
770
|
+
// the card ends up left of its own marker.
|
|
771
|
+
position: {
|
|
772
|
+
position: "absolute",
|
|
773
|
+
left: `${cardLeft - at.x}px`,
|
|
774
|
+
top: `${THREAD_ANCHOR_OFFSET}px`,
|
|
775
|
+
},
|
|
776
|
+
// The growth origin is the marker, expressed in the card's own
|
|
777
|
+
// coordinates - so it is the same subtraction the other way round,
|
|
778
|
+
// and it stays right through the flip AND through the clamp without
|
|
779
|
+
// a second copy of either decision. A clamped card grows from a point
|
|
780
|
+
// outside its own box, which is legal CSS and is exactly what
|
|
781
|
+
// "originates at the thing it belongs to" means when the card had to
|
|
782
|
+
// move to stay on screen. Same derivation as the composer's.
|
|
783
|
+
origin: `${at.x - cardLeft}px -${THREAD_ANCHOR_OFFSET}px`, leaving: thread.leaving })) : null] }));
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* One item's thread: the report, who last moved its status and when, the
|
|
787
|
+
* replies, and the box for adding another.
|
|
788
|
+
*
|
|
789
|
+
* Split out of the marker because a thread outlives its marker. A pin whose
|
|
790
|
+
* selector no longer resolves has nothing to hang off, and a general note
|
|
791
|
+
* never had an anchor at all, yet both have to be readable and repliable - so
|
|
792
|
+
* the thread takes its position as a prop and the layer decides whether that
|
|
793
|
+
* is "beside a marker" or "in the middle of the viewport".
|
|
794
|
+
*/
|
|
795
|
+
function PinThread({ item, theme, client, organizationId, onChanged, position, note, onClose, origin, leaving = false, outdated = false, attachment, onRequestCapture, onClearAttachment, apiUrl, }) {
|
|
796
|
+
const [reply, setReply] = useState("");
|
|
797
|
+
const [sending, setSending] = useState(false);
|
|
798
|
+
const [error, setError] = useState(null);
|
|
799
|
+
const [moving, setMoving] = useState(false);
|
|
800
|
+
// Whether the composer band draws its focus ring. The band is a `div`, so
|
|
801
|
+
// the focus state of the field inside it has to be tracked here: there is no
|
|
802
|
+
// stylesheet in this package to hold a `:focus-within` rule, which is the
|
|
803
|
+
// same reason `RowButton` below tracks its own three states.
|
|
804
|
+
const [composing, setComposing] = useState(false);
|
|
805
|
+
// The formatting bar is closed until asked for: it is the rarer need, and a
|
|
806
|
+
// row of markup buttons above an empty box is chrome a reviewer has to read
|
|
807
|
+
// past to write one sentence.
|
|
808
|
+
const [formatting, setFormatting] = useState(false);
|
|
809
|
+
const entered = useEntered();
|
|
810
|
+
const reduced = usePrefersReducedMotion();
|
|
811
|
+
// Which replies were already in this thread when it opened. Only the ones
|
|
812
|
+
// that land while the reviewer is reading get the arrival treatment - see
|
|
813
|
+
// `useNewSinceMount`.
|
|
814
|
+
const isNew = useNewSinceMount(item.replies.map((r) => r.id));
|
|
815
|
+
// The one imperative handle in this file, and auto-growth is why: the height
|
|
816
|
+
// the text wants is a MEASUREMENT (`scrollHeight`), not something a render
|
|
817
|
+
// can derive from state. Touched only in handlers - reading `.current`
|
|
818
|
+
// during render is what `react-hooks/refs` forbids in this repo.
|
|
819
|
+
const replyBox = useRef(null);
|
|
820
|
+
/**
|
|
821
|
+
* Fit the reply box to its text, up to `REPLY_MAX_HEIGHT`.
|
|
822
|
+
*
|
|
823
|
+
* `height` is a LAYOUT property and is set instantly, never transitioned -
|
|
824
|
+
* DESIGN.md's Motion section in as many words, and the reason the composer
|
|
825
|
+
* can grow at all inside a card floating over somebody else's page.
|
|
826
|
+
*
|
|
827
|
+
* The `auto` first is not a flourish: `scrollHeight` reports the height the
|
|
828
|
+
* box already HAS whenever that is the larger of the two, so without it the
|
|
829
|
+
* field could only ever grow and a deleted paragraph left a hole.
|
|
830
|
+
*/
|
|
831
|
+
async function send() {
|
|
832
|
+
const trimmed = reply.trim();
|
|
833
|
+
if (trimmed.length === 0)
|
|
834
|
+
return;
|
|
835
|
+
setSending(true);
|
|
836
|
+
setError(null);
|
|
837
|
+
try {
|
|
838
|
+
await client.reply(organizationId, item.id, trimmed, attachment ?? undefined);
|
|
839
|
+
setReply("");
|
|
840
|
+
// Cleared only after the call resolves. Dropping it optimistically would
|
|
841
|
+
// lose the picture on a failed send, and the picture is the expensive
|
|
842
|
+
// half of what the reviewer just did.
|
|
843
|
+
onClearAttachment();
|
|
844
|
+
// The field shrinks back with its content. React does not own `height`
|
|
845
|
+
// here - it was set imperatively - so clearing the value alone would
|
|
846
|
+
// leave a six-line box holding a placeholder.
|
|
847
|
+
grow(replyBox.current);
|
|
848
|
+
onChanged();
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
setError("Could not post that reply.");
|
|
852
|
+
}
|
|
853
|
+
finally {
|
|
854
|
+
setSending(false);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* The only state change the toolbar offers, and the only one it shows.
|
|
859
|
+
*
|
|
860
|
+
* A reviewer's surface has two states for a comment: it is on the page, or
|
|
861
|
+
* it is resolved. The thread used to render the full triage vocabulary
|
|
862
|
+
* instead - a status chip, plus a mono line naming who moved it and when -
|
|
863
|
+
* and that is a queue-owner's model shown to somebody who is looking at a
|
|
864
|
+
* bug on a page. It cost two rows of chrome above every report and asked a
|
|
865
|
+
* reviewer to distinguish "open" from "in review" with no way to act on the
|
|
866
|
+
* difference.
|
|
867
|
+
*
|
|
868
|
+
* `IN_REVIEW` is not gone: it is still the dashboard's, still in the API,
|
|
869
|
+
* still settable over MCP. It simply reads as unresolved here, which is what
|
|
870
|
+
* a reviewer can actually do something about. That is why this sends `DONE`
|
|
871
|
+
* and `OPEN` rather than a status the caller chose - and why `resolved` is
|
|
872
|
+
* the one derived flag the card renders from.
|
|
873
|
+
*/
|
|
874
|
+
async function moveTo(status) {
|
|
875
|
+
setMoving(true);
|
|
876
|
+
setError(null);
|
|
877
|
+
try {
|
|
878
|
+
await client.setStatus(organizationId, item.id, status);
|
|
879
|
+
onChanged();
|
|
880
|
+
}
|
|
881
|
+
catch (err) {
|
|
882
|
+
setError(err instanceof ToolbarApiError && err.code === "not_invited_to_project"
|
|
883
|
+
? "You are not invited to this project any more."
|
|
884
|
+
: "Could not change the status.");
|
|
885
|
+
}
|
|
886
|
+
finally {
|
|
887
|
+
setMoving(false);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const resolved = item.status === "DONE";
|
|
891
|
+
const canSend = reply.trim().length > 0;
|
|
892
|
+
// When it was written, and nothing else.
|
|
893
|
+
//
|
|
894
|
+
// This line used to carry the viewport and the git branch too. Both are real
|
|
895
|
+
// facts the DTO carries, and both are for the wrong reader: the toolbar is
|
|
896
|
+
// the surface a CUSTOMER's reviewer uses, and "1512x916 · feat/pricing-
|
|
897
|
+
// refresh" is noise to somebody who wants to say a button is in the wrong
|
|
898
|
+
// place. The dashboard is where feedback is acted on, and it keeps every one
|
|
899
|
+
// of them - with the branch and the commit as links, which is a thing only
|
|
900
|
+
// that surface can offer.
|
|
901
|
+
//
|
|
902
|
+
// Not a privacy boundary, and it must never be described as one. The API
|
|
903
|
+
// still sends `gitBranch`, `gitCommit`, `userAgent` and the viewport to any
|
|
904
|
+
// reviewer who can read the item; this is a decision about what is worth a
|
|
905
|
+
// reader's attention, and a curious one can still read the rest in devtools.
|
|
906
|
+
// Withholding them would be an API change, deliberately not made here.
|
|
907
|
+
//
|
|
908
|
+
// The absolute stamp and the browser string stay in the `title` below. They
|
|
909
|
+
// cost nothing until somebody hovers, and "when exactly" is a question a
|
|
910
|
+
// reviewer does occasionally have.
|
|
911
|
+
const rootFacts = [relativeStamp(item.createdAt)];
|
|
912
|
+
const rootTitle = [formatStamp(item.createdAt), item.userAgent]
|
|
913
|
+
.filter((fact) => typeof fact === "string" && fact !== "")
|
|
914
|
+
.join(" · ");
|
|
915
|
+
return (_jsxs("section", { "data-testid": `pin-thread-${item.id}`, style: {
|
|
916
|
+
// `resetBox` first: a `<section>` is a block a host can give margin,
|
|
917
|
+
// padding, a border and a ground, and this one carries every one of
|
|
918
|
+
// those itself.
|
|
919
|
+
...resetBox,
|
|
920
|
+
...position,
|
|
921
|
+
// Issue #20's ~440px content width, from the one token the composer
|
|
922
|
+
// shares - see `sizes.thread`. At 280px every reply wrapped after four
|
|
923
|
+
// or five words and a fourteen-message thread was a very tall, very
|
|
924
|
+
// narrow column.
|
|
925
|
+
width: sizes.thread,
|
|
926
|
+
maxWidth: VIEWPORT_MAX_WIDTH,
|
|
927
|
+
// The list scrolls; the card does not grow past the viewport. See
|
|
928
|
+
// `sizes.threadMaxHeight`.
|
|
929
|
+
maxHeight: sizes.threadMaxHeight,
|
|
930
|
+
// See the note on `boxSizing` in FeedbackToolbar.tsx: the host may
|
|
931
|
+
// ship no CSS reset, and a width plus padding plus a border is a
|
|
932
|
+
// wider card than the one asked for, over the element it is
|
|
933
|
+
// discussing.
|
|
934
|
+
boxSizing: "border-box",
|
|
935
|
+
// Three stacked regions - header, scrolling list, composer footer - so
|
|
936
|
+
// the padding belongs to each of them and the hairline dividers between
|
|
937
|
+
// them can run the full width of the card.
|
|
938
|
+
display: "flex",
|
|
939
|
+
flexDirection: "column",
|
|
940
|
+
background: theme.colors.canvas,
|
|
941
|
+
border: `1px solid ${theme.colors.hairline}`,
|
|
942
|
+
borderRadius: radii.lg,
|
|
943
|
+
boxShadow: theme.shadows.popover,
|
|
944
|
+
...typeScale.bodySm,
|
|
945
|
+
color: theme.colors.body,
|
|
946
|
+
// Grows out of its marker and shrinks back into it. On the thread
|
|
947
|
+
// itself rather than on the wrapper it shares with the marker, because
|
|
948
|
+
// a transform on that wrapper creates a stacking context by exactly the
|
|
949
|
+
// mechanism the old dim did - issue #23 through the other door - and
|
|
950
|
+
// would scale the marker along with the card. Nothing inside this
|
|
951
|
+
// section has to stack independently of it.
|
|
952
|
+
...surfaceMotion({ visible: entered, leaving, reduced, origin }),
|
|
953
|
+
}, children: [onClose ? (_jsx("header", { style: {
|
|
954
|
+
...resetBox,
|
|
955
|
+
flex: "0 0 auto",
|
|
956
|
+
display: "flex",
|
|
957
|
+
justifyContent: "flex-end",
|
|
958
|
+
padding: `${spacing.xs} ${spacing.xs} 0`,
|
|
959
|
+
}, children: _jsx(RowButton, { theme: theme, label: "Close thread", testId: `pin-thread-close-${item.id}`, onClick: onClose, children: _jsx(CloseIcon, {}) }) })) : null, _jsxs("div", { style: {
|
|
960
|
+
flex: "1 1 auto",
|
|
961
|
+
minHeight: 0,
|
|
962
|
+
overflowY: "auto",
|
|
963
|
+
overscrollBehavior: "contain",
|
|
964
|
+
}, children: [note ? (_jsx("p", { "data-testid": `pin-note-${item.id}`, style: {
|
|
965
|
+
...resetBox,
|
|
966
|
+
margin: `${spacing.sm} ${spacing.sm} 0`,
|
|
967
|
+
padding: spacing.xs,
|
|
968
|
+
borderRadius: radii.sm,
|
|
969
|
+
background: theme.colors.canvasSoft2,
|
|
970
|
+
color: theme.colors.body,
|
|
971
|
+
...typeScale.caption,
|
|
972
|
+
}, children: note })) : null, _jsx(MessageRow, { theme: theme, testId: `pin-message-${item.id}`, name: item.author.name, email: item.author.email, body: item.body, facts: rootFacts, factsTitle: rootTitle, badge: outdated ? _jsx(OutdatedChip, { theme: theme, id: item.id }) : undefined,
|
|
973
|
+
// The root's actions are always on, which is issue #20's own split:
|
|
974
|
+
// they are the thread's actions as much as the message's, and
|
|
975
|
+
// resolve in particular is the most consequential control in the
|
|
976
|
+
// card. The replies reveal theirs.
|
|
977
|
+
alwaysShowActions: true, actions: _jsxs(_Fragment, { children: [_jsx(CopyButton, { theme: theme, label: "Copy link to this thread", text: buildDeepLink(item.pageUrl, item.id), icon: _jsx(LinkIcon, {}) }), _jsx(CopyButton, { theme: theme, label: "Copy message", text: item.body, icon: _jsx(CopyIcon, {}) }), _jsx(RowButton, { theme: theme,
|
|
978
|
+
// The label flips rather than the glyph: a check that means
|
|
979
|
+
// "resolve" and a check that means "reopen" are the same
|
|
980
|
+
// picture, and nothing may become unreachable - including a
|
|
981
|
+
// resolution somebody wants back.
|
|
982
|
+
label: resolved ? "Reopen" : "Resolve", srText: resolved ? "Reopen" : "Resolve", testId: `pin-resolve-${item.id}`, tone: resolved ? "ink" : "ghost", disabled: moving, onClick: () => void moveTo(resolved ? "OPEN" : "DONE"), children: _jsx(CheckIcon, {}) })] }) }), item.replies.map((r, index) => (_jsx(MessageRow, { theme: theme, testId: `pin-message-${r.id}`, name: r.author.name, email: r.author.email, body: r.body, facts: [
|
|
983
|
+
// "via agent" before the stamp, because it qualifies WHO is
|
|
984
|
+
// speaking rather than when. Written by an agent through MCP in
|
|
985
|
+
// this person's name, and said plainly rather than hidden:
|
|
986
|
+
// CLAUDE.md section 14, and the dashboard spells it the same way.
|
|
987
|
+
...(r.viaAgent ? ["via agent"] : []),
|
|
988
|
+
relativeStamp(r.createdAt),
|
|
989
|
+
], factsTitle: formatStamp(r.createdAt),
|
|
990
|
+
// Resolved against the API, because the toolbar runs on the
|
|
991
|
+
// CUSTOMER'S origin and the DTO's path is relative to ours - a
|
|
992
|
+
// bare `<img src="/api/...">` would ask the reviewed site for a
|
|
993
|
+
// picture it has never heard of.
|
|
994
|
+
//
|
|
995
|
+
// Only a path is joined. A value that already names its own source
|
|
996
|
+
// is passed through: the preview route's fake transport echoes the
|
|
997
|
+
// captured `data:` URL straight back, having no store to put it
|
|
998
|
+
// in, and prefixing that would produce a nonsense src on the one
|
|
999
|
+
// page built to review this surface.
|
|
1000
|
+
image: r.screenshotUrl
|
|
1001
|
+
? {
|
|
1002
|
+
url: r.screenshotUrl.startsWith("/")
|
|
1003
|
+
? `${apiUrl}${r.screenshotUrl}`
|
|
1004
|
+
: r.screenshotUrl,
|
|
1005
|
+
alt: `Screenshot attached by ${displayNameFor(r.author.name, r.author.email)}`,
|
|
1006
|
+
}
|
|
1007
|
+
: undefined,
|
|
1008
|
+
// A hairline above the first reply, which is the divider between
|
|
1009
|
+
// the root message and the conversation. A border on the first
|
|
1010
|
+
// child rather than a `<hr>` or a bottom border on the root: one
|
|
1011
|
+
// declaration, and it simply does not exist when there are no
|
|
1012
|
+
// replies.
|
|
1013
|
+
divided: index === 0, arriving: isNew(r.id), actions: _jsx(CopyButton, { theme: theme, label: "Copy reply", text: r.body, icon: _jsx(CopyIcon, {}) }) }, r.id)))] }), _jsxs("div", { style: {
|
|
1014
|
+
...resetBox,
|
|
1015
|
+
flex: "0 0 auto",
|
|
1016
|
+
padding: spacing.sm,
|
|
1017
|
+
borderTop: `1px solid ${theme.colors.hairline}`,
|
|
1018
|
+
}, children: [error ? (_jsx("p", { role: "alert", style: {
|
|
1019
|
+
...resetBox,
|
|
1020
|
+
margin: `0 0 ${spacing.xxs}`,
|
|
1021
|
+
color: theme.colors.error,
|
|
1022
|
+
...typeScale.caption,
|
|
1023
|
+
}, children: error })) : null, _jsxs("div", { style: {
|
|
1024
|
+
display: "flex",
|
|
1025
|
+
flexDirection: "column",
|
|
1026
|
+
gap: spacing.xs,
|
|
1027
|
+
padding: spacing.xs,
|
|
1028
|
+
borderRadius: radii.sm,
|
|
1029
|
+
background: theme.colors.canvasSoft2,
|
|
1030
|
+
// The frame is gone, so this ring is the ONLY focus indication the
|
|
1031
|
+
// composer has. Not a nicety: a keyboard reviewer who cannot see
|
|
1032
|
+
// where focus is cannot use the card. Not transitioned, per
|
|
1033
|
+
// DESIGN.md - a ring that fades in reads as lag on the one
|
|
1034
|
+
// interaction that has to feel immediate.
|
|
1035
|
+
boxShadow: composing ? theme.shadows.focusRing : "none",
|
|
1036
|
+
}, children: [attachment ? (_jsxs("div", { "data-testid": `pin-attachment-${item.id}`, style: {
|
|
1037
|
+
position: "relative",
|
|
1038
|
+
alignSelf: "flex-start",
|
|
1039
|
+
borderRadius: radii.sm,
|
|
1040
|
+
border: `1px solid ${theme.colors.hairline}`,
|
|
1041
|
+
overflow: "hidden",
|
|
1042
|
+
lineHeight: 0,
|
|
1043
|
+
}, children: [_jsx("img", { src: attachment, alt: "The element you captured, attached to this reply", style: {
|
|
1044
|
+
display: "block",
|
|
1045
|
+
maxWidth: "120px",
|
|
1046
|
+
maxHeight: "80px",
|
|
1047
|
+
} }), _jsx("button", { type: "button", "aria-label": "Remove screenshot", title: "Remove screenshot", "data-testid": `pin-attachment-remove-${item.id}`, onClick: onClearAttachment, style: {
|
|
1048
|
+
...resetBox,
|
|
1049
|
+
position: "absolute",
|
|
1050
|
+
top: "2px",
|
|
1051
|
+
right: "2px",
|
|
1052
|
+
width: "16px",
|
|
1053
|
+
height: "16px",
|
|
1054
|
+
display: "inline-flex",
|
|
1055
|
+
alignItems: "center",
|
|
1056
|
+
justifyContent: "center",
|
|
1057
|
+
borderRadius: radii.sm,
|
|
1058
|
+
cursor: "pointer",
|
|
1059
|
+
// Its own ground, because it sits on an arbitrary screenshot
|
|
1060
|
+
// and cannot borrow contrast from a colour it does not know.
|
|
1061
|
+
background: theme.colors.ink,
|
|
1062
|
+
color: theme.colors.onInk,
|
|
1063
|
+
lineHeight: 1,
|
|
1064
|
+
}, children: _jsx(CloseIcon, {}) })] })) : null, _jsx("textarea", { ref: replyBox, value: reply, onChange: (e) => {
|
|
1065
|
+
setReply(e.target.value);
|
|
1066
|
+
grow(e.target);
|
|
1067
|
+
}, onFocus: () => setComposing(true), onBlur: () => setComposing(false),
|
|
1068
|
+
// Enter sends; Shift+Enter and Alt+Enter write a newline. The
|
|
1069
|
+
// grammar every comment box a reviewer has ever used already has.
|
|
1070
|
+
//
|
|
1071
|
+
// `isComposing` is the load-bearing guard, not a nicety: an IME
|
|
1072
|
+
// uses Enter to COMMIT a candidate, so a reviewer typing Japanese
|
|
1073
|
+
// or Chinese would have posted a half-finished reply on the first
|
|
1074
|
+
// Enter they pressed.
|
|
1075
|
+
onKeyDown: (event) => {
|
|
1076
|
+
if (event.key !== "Enter")
|
|
1077
|
+
return;
|
|
1078
|
+
if (event.nativeEvent.isComposing)
|
|
1079
|
+
return;
|
|
1080
|
+
if (event.shiftKey || event.altKey)
|
|
1081
|
+
return;
|
|
1082
|
+
event.preventDefault();
|
|
1083
|
+
if (sending || !canSend)
|
|
1084
|
+
return;
|
|
1085
|
+
void send();
|
|
1086
|
+
}, rows: 1, placeholder: "Add a reply\u2026", "aria-label": "Reply", "data-testid": `pin-reply-${item.id}`, maxLength: FEEDBACK_BODY_MAX, style: {
|
|
1087
|
+
// The band draws the ground; this is the text and nothing else.
|
|
1088
|
+
...resetBox,
|
|
1089
|
+
display: "block",
|
|
1090
|
+
width: "100%",
|
|
1091
|
+
// One row at rest, growing with the text. Capped so a pasted
|
|
1092
|
+
// essay scrolls inside the composer instead of pushing the
|
|
1093
|
+
// control row out of the card - which is the whole point of the
|
|
1094
|
+
// row being fixed.
|
|
1095
|
+
minHeight: typeScale.bodySm.lineHeight,
|
|
1096
|
+
maxHeight: `${REPLY_MAX_HEIGHT}px`,
|
|
1097
|
+
overflowY: "auto",
|
|
1098
|
+
// `none`, and `grow` above is the replacement. The UA default is
|
|
1099
|
+
// `both`, and `vertical` was a real handle a reviewer could drag
|
|
1100
|
+
// the box out through the card's border with.
|
|
1101
|
+
resize: "none",
|
|
1102
|
+
...typeScale.bodySm,
|
|
1103
|
+
color: theme.colors.ink,
|
|
1104
|
+
background: "transparent",
|
|
1105
|
+
// The BAND shows focus, so a second ring here would draw the
|
|
1106
|
+
// same state twice.
|
|
1107
|
+
outline: "none",
|
|
1108
|
+
} }), _jsxs("div", { style: {
|
|
1109
|
+
position: "relative",
|
|
1110
|
+
display: "flex",
|
|
1111
|
+
alignItems: "center",
|
|
1112
|
+
gap: spacing.xxs,
|
|
1113
|
+
flex: "0 0 auto",
|
|
1114
|
+
}, children: [formatting ? (_jsx(FormatBar, { theme: theme, onApply: (mark) => {
|
|
1115
|
+
const field = replyBox.current;
|
|
1116
|
+
if (!field)
|
|
1117
|
+
return;
|
|
1118
|
+
const next = toggleMark(reply, field.selectionStart, field.selectionEnd, mark);
|
|
1119
|
+
setReply(next.text);
|
|
1120
|
+
// The selection has to survive the round trip or the button is
|
|
1121
|
+
// unusable twice in a row: React re-renders with the new value
|
|
1122
|
+
// and the browser puts the caret at the end, so it is restored
|
|
1123
|
+
// after the paint rather than before it.
|
|
1124
|
+
requestAnimationFrame(() => {
|
|
1125
|
+
field.focus();
|
|
1126
|
+
field.setSelectionRange(next.selectionStart, next.selectionEnd);
|
|
1127
|
+
grow(field);
|
|
1128
|
+
});
|
|
1129
|
+
} })) : null, _jsx(RowButton, { theme: theme, label: "Take a screenshot", testId: `pin-attach-${item.id}`,
|
|
1130
|
+
// Ink while one is attached, so the row says the reply is
|
|
1131
|
+
// carrying something without the reviewer scrolling up to the
|
|
1132
|
+
// thumbnail to check.
|
|
1133
|
+
tone: attachment ? "ink" : "ghost", onClick: onRequestCapture, children: _jsx(CameraIcon, {}) }), _jsx("span", { "aria-hidden": "true", style: {
|
|
1134
|
+
width: "1px",
|
|
1135
|
+
height: "16px",
|
|
1136
|
+
background: theme.colors.hairline,
|
|
1137
|
+
margin: `0 ${spacing.xxs}`,
|
|
1138
|
+
} }), _jsx(RowButton, { theme: theme, label: formatting ? "Hide formatting" : "Formatting", testId: `pin-format-${item.id}`, tone: formatting ? "ink" : "ghost", onClick: () => setFormatting((on) => !on), children: _jsx("span", { style: { ...typeScale.captionMono, lineHeight: 1 }, children: "Aa" }) }), _jsx("span", { style: { flex: "1 1 auto" } }), _jsx(RowButton, { theme: theme,
|
|
1139
|
+
// "Reply", in both the accessible name and a real text node:
|
|
1140
|
+
// toolbar-pin.spec.ts asks for this control by ROLE AND NAME and
|
|
1141
|
+
// PinLayer.test.tsx asks for it by TEXT, and an `aria-label`
|
|
1142
|
+
// alone answers only the first. See `srOnly`.
|
|
1143
|
+
label: "Reply", srText: "Reply", busy: sending,
|
|
1144
|
+
// Ink the moment there is something to send, quiet before that.
|
|
1145
|
+
// DESIGN.md gives the card exactly one ink primary and this is
|
|
1146
|
+
// it; the reference's blue would be a second accent on a surface
|
|
1147
|
+
// that already has one.
|
|
1148
|
+
tone: canSend ? "ink" : "ghost", disabled: sending || !canSend, onClick: () => void send(), children: _jsx(SendIcon, {}) })] })] })] })] }));
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* One message in a thread - the root report and every reply alike.
|
|
1152
|
+
*
|
|
1153
|
+
* ONE component for both is the point rather than an economy. The root used to
|
|
1154
|
+
* be a bare `<p>` under a header doing double duty as the card's title, and a
|
|
1155
|
+
* reply was two stacked paragraphs with the author, the agent marker and the
|
|
1156
|
+
* timestamp welded into a single 12px mono run - so a thread had no rhythm and
|
|
1157
|
+
* no per-message identity, which is the second defect in issue #20's
|
|
1158
|
+
* screenshot. Avatar, name, metadata line, body: the same anatomy for
|
|
1159
|
+
* everybody who speaks.
|
|
1160
|
+
*
|
|
1161
|
+
* A component also gives a reply that just landed something to mount, which is
|
|
1162
|
+
* what `useEntered` needs. `arriving` is false for the replies that were
|
|
1163
|
+
* already in the thread when it opened - DESIGN.md's first-mount rule, without
|
|
1164
|
+
* which reopening an old thread replays the whole conversation.
|
|
1165
|
+
*/
|
|
1166
|
+
function MessageRow({ theme, testId, name, email, body, facts, factsTitle, actions, badge, image, alwaysShowActions = false, divided = false, arriving = false, }) {
|
|
1167
|
+
const entered = useEntered();
|
|
1168
|
+
const reduced = usePrefersReducedMotion();
|
|
1169
|
+
// Hover AND focus, because "reveal on hover" alone would put a keyboard
|
|
1170
|
+
// reviewer's own focus on a control they cannot see. React's `onFocus` is
|
|
1171
|
+
// `focusin`, so it fires for the buttons inside this row as well as for the
|
|
1172
|
+
// row - which is the `:focus-within` this package has no stylesheet to write.
|
|
1173
|
+
const [active, setActive] = useState(false);
|
|
1174
|
+
const shown = alwaysShowActions || active;
|
|
1175
|
+
return (_jsxs("div", { "data-testid": testId, onPointerEnter: () => setActive(true), onPointerLeave: () => setActive(false), onFocus: () => setActive(true), onBlur: () => setActive(false), style: {
|
|
1176
|
+
...resetBox,
|
|
1177
|
+
display: "flex",
|
|
1178
|
+
gap: spacing.xs,
|
|
1179
|
+
padding: spacing.sm,
|
|
1180
|
+
...(divided ? { borderTop: `1px solid ${theme.colors.hairline}` } : {}),
|
|
1181
|
+
// Fades up from `enterShift` below where it lands, which is the half of
|
|
1182
|
+
// "a sent reply is acknowledged" that happens after the request comes
|
|
1183
|
+
// back. `translateY` and not a height or a margin animation: this row
|
|
1184
|
+
// is inside a card floating over a host page, and a per-frame reflow
|
|
1185
|
+
// there is jank the host gets blamed for.
|
|
1186
|
+
//
|
|
1187
|
+
// Safe as an opacity on the container because a message row is a leaf -
|
|
1188
|
+
// an avatar and three lines of text, nothing that has to stack out of
|
|
1189
|
+
// it. The same question the inbox row answers, and the opposite of the
|
|
1190
|
+
// pin wrapper in issue #23.
|
|
1191
|
+
...(arriving
|
|
1192
|
+
? surfaceMotion({ visible: entered, reduced, slide: "y" })
|
|
1193
|
+
: {}),
|
|
1194
|
+
}, children: [_jsx(Avatar, { theme: theme, name: name, email: email }), _jsxs("div", { style: { flex: "1 1 auto", minWidth: 0 }, children: [_jsxs("div", { style: {
|
|
1195
|
+
display: "flex",
|
|
1196
|
+
alignItems: "center",
|
|
1197
|
+
gap: spacing.xs,
|
|
1198
|
+
// So a row with no actions showing is the same height as one with.
|
|
1199
|
+
minHeight: sizes.action,
|
|
1200
|
+
}, children: [_jsx("span", { style: {
|
|
1201
|
+
minWidth: 0,
|
|
1202
|
+
color: theme.colors.ink,
|
|
1203
|
+
...typeScale.bodySmStrong,
|
|
1204
|
+
overflow: "hidden",
|
|
1205
|
+
textOverflow: "ellipsis",
|
|
1206
|
+
whiteSpace: "nowrap",
|
|
1207
|
+
}, children: displayNameFor(name, email) }), badge === undefined ? null : (_jsx("span", { style: { flex: "0 0 auto", marginLeft: spacing.xxs }, children: badge })), _jsx("span", { style: {
|
|
1208
|
+
marginLeft: "auto",
|
|
1209
|
+
display: "flex",
|
|
1210
|
+
alignItems: "center",
|
|
1211
|
+
gap: spacing.xxs,
|
|
1212
|
+
flex: "0 0 auto",
|
|
1213
|
+
// Opacity alone, never `visibility` or `display`: both of those
|
|
1214
|
+
// take a control out of the tab order, and a hidden action a
|
|
1215
|
+
// keyboard cannot reach to reveal is the accessibility half of
|
|
1216
|
+
// this issue failed rather than met. An invisible button is still
|
|
1217
|
+
// focusable, and focusing it is what makes it visible.
|
|
1218
|
+
opacity: shown ? 1 : 0,
|
|
1219
|
+
transition: transition(["opacity"], motion.duration.instant, motion.easing.inOut, reduced),
|
|
1220
|
+
}, children: actions })] }), _jsx("p", {
|
|
1221
|
+
// The 12px caption-mono role, and on its own line. It used to be the
|
|
1222
|
+
// FIRST line of every reply with 14px body copy immediately beneath
|
|
1223
|
+
// it and nothing between them, which is what made a correctly sized
|
|
1224
|
+
// mono caption read as body-weight metadata.
|
|
1225
|
+
title: factsTitle, style: {
|
|
1226
|
+
...resetBox,
|
|
1227
|
+
...typeScale.captionMono,
|
|
1228
|
+
color: theme.colors.body,
|
|
1229
|
+
overflowWrap: "anywhere",
|
|
1230
|
+
}, children: facts.join(" · ") }), _jsx("div", { style: { marginTop: spacing.xxs }, children: _jsx(RichText, { theme: theme, body: body }) }), image ? (_jsx("img", { src: image.url, alt: image.alt, style: {
|
|
1231
|
+
display: "block",
|
|
1232
|
+
marginTop: spacing.xxs,
|
|
1233
|
+
maxWidth: "100%",
|
|
1234
|
+
borderRadius: radii.sm,
|
|
1235
|
+
border: `1px solid ${theme.colors.hairline}`,
|
|
1236
|
+
} })) : null] })] }));
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* A per-message action: 24px, icon-only, and named.
|
|
1240
|
+
*
|
|
1241
|
+
* Every one of these is an icon, so the accessible name is the whole of what
|
|
1242
|
+
* says what it does - hence `label` being required rather than optional. The
|
|
1243
|
+
* focus, hover and press states are tracked in React state for the reason
|
|
1244
|
+
* `ThreadButton` already states: there is no stylesheet in this package, so
|
|
1245
|
+
* `:hover`, `:active` and `:focus-visible` do not exist here.
|
|
1246
|
+
*/
|
|
1247
|
+
function RowButton({ theme, label, srText, onClick, disabled, testId, tone = "ghost", busy, children, }) {
|
|
1248
|
+
const [focused, setFocused] = useState(false);
|
|
1249
|
+
const [hovered, setHovered] = useState(false);
|
|
1250
|
+
const [pressed, setPressed] = useState(false);
|
|
1251
|
+
const reduced = usePrefersReducedMotion();
|
|
1252
|
+
const ink = tone === "ink";
|
|
1253
|
+
return (_jsxs("button", { type: "button", "aria-label": label,
|
|
1254
|
+
// The same word a screen reader gets, for the reviewer who is pointing at
|
|
1255
|
+
// it. An icon-only control that only a screen reader can name is a
|
|
1256
|
+
// control half the audience has to guess.
|
|
1257
|
+
title: label, ...(busy === undefined ? {} : { "aria-busy": busy }), ...(testId === undefined ? {} : { "data-testid": testId }), onClick: onClick, disabled: disabled, onFocus: () => setFocused(true), onBlur: () => setFocused(false), onPointerEnter: () => setHovered(true), onPointerLeave: () => {
|
|
1258
|
+
setHovered(false);
|
|
1259
|
+
// Dragging off the button cancels the press, which is what every
|
|
1260
|
+
// native control does. Without this the button stays squashed.
|
|
1261
|
+
setPressed(false);
|
|
1262
|
+
}, onPointerDown: () => setPressed(true), onPointerUp: () => setPressed(false), onPointerCancel: () => setPressed(false), style: {
|
|
1263
|
+
// The clipped label is `position: absolute`, so this box has to be the
|
|
1264
|
+
// thing it is positioned against.
|
|
1265
|
+
position: "relative",
|
|
1266
|
+
display: "inline-flex",
|
|
1267
|
+
alignItems: "center",
|
|
1268
|
+
justifyContent: "center",
|
|
1269
|
+
flex: "0 0 auto",
|
|
1270
|
+
width: sizes.action,
|
|
1271
|
+
height: sizes.action,
|
|
1272
|
+
padding: 0,
|
|
1273
|
+
// The host may ship no reset, and a 24px box that measures 26 breaks
|
|
1274
|
+
// the row it sits in. See FeedbackToolbar.tsx's note.
|
|
1275
|
+
boxSizing: "border-box",
|
|
1276
|
+
border: "none",
|
|
1277
|
+
borderRadius: radii.sm,
|
|
1278
|
+
// The ghost's hover fill is the faint shift toward `canvasSoft2` that
|
|
1279
|
+
// DESIGN.md's Known Gaps describes. The ink tone gets none: there is no
|
|
1280
|
+
// ink-hover step in this palette, and inventing one is a decision that
|
|
1281
|
+
// section says to state in a pull request rather than take at a call
|
|
1282
|
+
// site. Its feedback is the press below.
|
|
1283
|
+
background: ink
|
|
1284
|
+
? theme.colors.ink
|
|
1285
|
+
: hovered && disabled !== true
|
|
1286
|
+
? theme.colors.canvasSoft2
|
|
1287
|
+
: "transparent",
|
|
1288
|
+
// `body`, never `mute`: 3.4:1 is not a control, which is the same
|
|
1289
|
+
// argument the dismiss button and the close control already make.
|
|
1290
|
+
color: ink ? theme.colors.onInk : theme.colors.body,
|
|
1291
|
+
cursor: disabled === true ? "not-allowed" : "pointer",
|
|
1292
|
+
outline: "revert",
|
|
1293
|
+
boxShadow: focused ? theme.shadows.focusRing : "none",
|
|
1294
|
+
// A 3% squash under the pointer, at `instant`. A leaf control with no
|
|
1295
|
+
// fixed descendants, so a transform here is safe.
|
|
1296
|
+
transform: motionTransform(pressed && disabled !== true ? `scale(${motion.pressScale})` : "none", reduced),
|
|
1297
|
+
transition: transition(["background-color", "color", "transform"], motion.duration.instant, motion.easing.inOut, reduced),
|
|
1298
|
+
}, children: [children, srText === undefined ? null : _jsx("span", { style: srOnly, children: srText })] }));
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Fit a composer's field to its text, up to `REPLY_MAX_HEIGHT`.
|
|
1302
|
+
*
|
|
1303
|
+
* Module scope because BOTH composers use it now - the thread's reply box and
|
|
1304
|
+
* the pin composer, which are the same box. It was a helper inside the thread
|
|
1305
|
+
* when only the thread grew.
|
|
1306
|
+
*
|
|
1307
|
+
* `height` is a LAYOUT property and is set instantly, never transitioned -
|
|
1308
|
+
* DESIGN.md's Motion section in as many words, and the reason a field can grow
|
|
1309
|
+
* at all inside a card floating over somebody else's page.
|
|
1310
|
+
*
|
|
1311
|
+
* The `auto` first is not a flourish: `scrollHeight` reports the height the box
|
|
1312
|
+
* already HAS whenever that is the larger of the two, so without it the field
|
|
1313
|
+
* could only ever grow and a deleted paragraph left a hole.
|
|
1314
|
+
*/
|
|
1315
|
+
function grow(box) {
|
|
1316
|
+
if (!box)
|
|
1317
|
+
return;
|
|
1318
|
+
box.style.height = "auto";
|
|
1319
|
+
box.style.height = `${Math.min(box.scrollHeight, REPLY_MAX_HEIGHT)}px`;
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* The formatting popover, anchored under the `Aa` button.
|
|
1323
|
+
*
|
|
1324
|
+
* A floating panel rather than a row inside the composer, which is what the
|
|
1325
|
+
* reference does and what makes it read as a menu that belongs to the button
|
|
1326
|
+
* that opened it. An inline row pushed the text area down every time it
|
|
1327
|
+
* opened, so the draft a reviewer was reading moved under their eyes.
|
|
1328
|
+
*
|
|
1329
|
+
* Buttons that rewrite the TEXT rather than an editor that owns a document:
|
|
1330
|
+
* the composer is a `<textarea>` and the body is markdown, so formatting is a
|
|
1331
|
+
* string transformation and `toggleMark` is the whole of it. Rendering is
|
|
1332
|
+
* react-markdown's job.
|
|
1333
|
+
*
|
|
1334
|
+
* `onMouseDown` prevented, not `onClick`: pressing a button blurs the field,
|
|
1335
|
+
* and a blurred field has no selection to format. Preventing the default keeps
|
|
1336
|
+
* the caret where the reviewer left it.
|
|
1337
|
+
*
|
|
1338
|
+
* It is positioned ABOVE the button rather than below it, and that is the one
|
|
1339
|
+
* deliberate departure from the reference. The composer is the bottom-most
|
|
1340
|
+
* band of a card that the pin layer clamps against the viewport floor, so a
|
|
1341
|
+
* panel hanging below the row would be cut off by the window edge exactly when
|
|
1342
|
+
* the card is where it most often sits. Vercel's toolbar floats free of any
|
|
1343
|
+
* card and has room underneath; ours does not.
|
|
1344
|
+
*/
|
|
1345
|
+
function FormatBar({ theme, onApply, }) {
|
|
1346
|
+
const labels = {
|
|
1347
|
+
bold: "Bold",
|
|
1348
|
+
italic: "Italic",
|
|
1349
|
+
strike: "Strikethrough",
|
|
1350
|
+
code: "Code",
|
|
1351
|
+
};
|
|
1352
|
+
const glyphs = {
|
|
1353
|
+
bold: "B",
|
|
1354
|
+
italic: "I",
|
|
1355
|
+
strike: "S",
|
|
1356
|
+
code: "<>",
|
|
1357
|
+
};
|
|
1358
|
+
return (_jsx("div", { role: "group", "aria-label": "Formatting", "data-testid": "pin-format-bar", style: {
|
|
1359
|
+
position: "absolute",
|
|
1360
|
+
// Above the row that opened it, by the gap the card uses everywhere.
|
|
1361
|
+
bottom: `calc(100% + ${spacing.xxs})`,
|
|
1362
|
+
left: 0,
|
|
1363
|
+
display: "flex",
|
|
1364
|
+
alignItems: "center",
|
|
1365
|
+
gap: spacing.xxs,
|
|
1366
|
+
padding: spacing.xxs,
|
|
1367
|
+
borderRadius: radii.md,
|
|
1368
|
+
background: theme.colors.canvas,
|
|
1369
|
+
border: `1px solid ${theme.colors.hairline}`,
|
|
1370
|
+
// A menu floating over the card, so it carries the card's own
|
|
1371
|
+
// elevation rather than sitting flat on it.
|
|
1372
|
+
boxShadow: theme.shadows.popover,
|
|
1373
|
+
// Above the composer it overlaps, and inside the same stacking context
|
|
1374
|
+
// the thread already owns - no new rung in the ladder. See `layers`.
|
|
1375
|
+
zIndex: 1,
|
|
1376
|
+
}, children: Object.keys(MARKDOWN_MARKS).map((mark) => (_jsxs(Fragment, { children: [mark === "code" ? (_jsx("span", { "aria-hidden": "true", style: {
|
|
1377
|
+
width: "1px",
|
|
1378
|
+
height: "16px",
|
|
1379
|
+
background: theme.colors.hairline,
|
|
1380
|
+
margin: `0 ${spacing.xxs}`,
|
|
1381
|
+
} })) : null, _jsx("button", { type: "button", "aria-label": labels[mark], title: labels[mark], "data-testid": `pin-format-${mark}`, onMouseDown: (event) => event.preventDefault(), onClick: () => onApply(mark), style: {
|
|
1382
|
+
...resetBox,
|
|
1383
|
+
width: sizes.action,
|
|
1384
|
+
height: sizes.action,
|
|
1385
|
+
display: "inline-flex",
|
|
1386
|
+
alignItems: "center",
|
|
1387
|
+
justifyContent: "center",
|
|
1388
|
+
borderRadius: radii.sm,
|
|
1389
|
+
cursor: "pointer",
|
|
1390
|
+
color: theme.colors.body,
|
|
1391
|
+
outline: "revert",
|
|
1392
|
+
...typeScale.captionMono,
|
|
1393
|
+
// The one place a mono glyph carries weight, and it is the glyph
|
|
1394
|
+
// that MEANS weight. DESIGN.md gives the mono face one weight; a
|
|
1395
|
+
// bold "B" that is not bold is a button that lies about what it
|
|
1396
|
+
// does, so the exception is stated here rather than taken
|
|
1397
|
+
// quietly. Same argument for the other two.
|
|
1398
|
+
fontWeight: mark === "bold" ? 500 : 400,
|
|
1399
|
+
fontStyle: mark === "italic" ? "italic" : "normal",
|
|
1400
|
+
textDecoration: mark === "strike" ? "line-through" : "none",
|
|
1401
|
+
}, children: glyphs[mark] })] }, mark))) }));
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* Copy one string to the clipboard, and say so.
|
|
1405
|
+
*
|
|
1406
|
+
* The acknowledgement is the reason this is a component rather than an
|
|
1407
|
+
* `onClick`: a copy button with no answer leaves a reviewer pressing it twice.
|
|
1408
|
+
* The glyph swaps to a check for a beat, which costs one timer that only ever
|
|
1409
|
+
* starts on a click.
|
|
1410
|
+
*
|
|
1411
|
+
* `navigator.clipboard` is optional on purpose. It is undefined on a page
|
|
1412
|
+
* served over plain HTTP, and staging sites - which is most of what this
|
|
1413
|
+
* toolbar is pointed at - are not always on TLS. The optional chain makes that
|
|
1414
|
+
* a no-op rather than a `TypeError` thrown inside somebody else's application.
|
|
1415
|
+
*/
|
|
1416
|
+
function CopyButton({ theme, label, text, icon, }) {
|
|
1417
|
+
const [copied, setCopied] = useState(false);
|
|
1418
|
+
useEffect(() => {
|
|
1419
|
+
if (!copied)
|
|
1420
|
+
return;
|
|
1421
|
+
const timer = window.setTimeout(() => setCopied(false), COPIED_FOR_MS);
|
|
1422
|
+
return () => window.clearTimeout(timer);
|
|
1423
|
+
}, [copied]);
|
|
1424
|
+
return (_jsx(RowButton, { theme: theme, label: label, onClick: () => {
|
|
1425
|
+
void navigator.clipboard?.writeText(text).then(() => setCopied(true),
|
|
1426
|
+
// A denied clipboard permission is not something to interrupt a
|
|
1427
|
+
// review over: the button simply does not acknowledge.
|
|
1428
|
+
() => { });
|
|
1429
|
+
}, children: copied ? _jsx(CheckIcon, {}) : icon }));
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Was this comment written about a different build than the one on screen?
|
|
1433
|
+
*
|
|
1434
|
+
* The toolbar knows the commit it is RUNNING on (`config.gitCommit`, from the
|
|
1435
|
+
* host's build environment) and the commit each item was filed against, which
|
|
1436
|
+
* has been captured since the first version and until now was thrown away at
|
|
1437
|
+
* render time. Comparing the two needs no backend, no git access and no new
|
|
1438
|
+
* column - which is why this is a string comparison rather than the
|
|
1439
|
+
* "N commits ago" the request started as.
|
|
1440
|
+
*
|
|
1441
|
+
* That distance is deliberately NOT computed. Counting commits between two
|
|
1442
|
+
* SHAs needs the git graph, and nothing in this product has the repository:
|
|
1443
|
+
* `Project` stores a hostname, there is no remote and no provider token. The
|
|
1444
|
+
* honest answer available here is "same build" or "not the same build", so
|
|
1445
|
+
* that is the only claim the marker makes.
|
|
1446
|
+
*
|
|
1447
|
+
* Both sides are required. An item filed before the environment reported a
|
|
1448
|
+
* commit, or a host that does not set one, yields `false` rather than marking
|
|
1449
|
+
* everything outdated - an unknown is not a difference.
|
|
1450
|
+
*/
|
|
1451
|
+
export function isOutdated(item, config) {
|
|
1452
|
+
const filed = item.gitCommit;
|
|
1453
|
+
const running = config.gitCommit;
|
|
1454
|
+
if (!filed || !running)
|
|
1455
|
+
return false;
|
|
1456
|
+
return filed !== running;
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* The marker a reviewer sees on a comment written about an earlier build.
|
|
1460
|
+
*
|
|
1461
|
+
* "Outdated", not "9f2c1ab" and not "4 commits behind". The toolbar is the
|
|
1462
|
+
* surface a customer's non-technical reviewer uses, so a commit SHA is noise
|
|
1463
|
+
* to them - it stays in the API and in the MCP payload, where an agent can do
|
|
1464
|
+
* something with it. What a person needs is whether the page changed since
|
|
1465
|
+
* somebody complained about it.
|
|
1466
|
+
*
|
|
1467
|
+
* It says the page moved on, never that the report is wrong: a bug filed three
|
|
1468
|
+
* builds ago may be very much still there, and the only thing that closes a
|
|
1469
|
+
* report is a person or an agent acting as one.
|
|
1470
|
+
*/
|
|
1471
|
+
function OutdatedChip({ theme, id }) {
|
|
1472
|
+
return (_jsx("span", { "data-testid": `pin-outdated-${id}`, title: "Written before the latest update to this page. It may already be fixed.", style: {
|
|
1473
|
+
padding: `0 ${spacing.xs}`,
|
|
1474
|
+
// Square-ish, like the chip it replaces: DESIGN.md gives the pill to
|
|
1475
|
+
// marketing CTAs and this is in-product metadata.
|
|
1476
|
+
borderRadius: radii.sm,
|
|
1477
|
+
// Neutral rather than a status tone. This is not a lane in a workflow
|
|
1478
|
+
// and must not read as one - the toolbar has no statuses any more.
|
|
1479
|
+
background: theme.colors.canvasSoft2,
|
|
1480
|
+
color: theme.colors.body,
|
|
1481
|
+
...typeScale.caption,
|
|
1482
|
+
whiteSpace: "nowrap",
|
|
1483
|
+
}, children: "Outdated" }));
|
|
1484
|
+
}
|
|
1485
|
+
/* ── Composing a new pin ──────────────────────────────────────────────── */
|
|
1486
|
+
function PinComposer({ draft, theme, client, config, organizationId, onDone, attachment, onRequestCapture, onClearAttachment, }) {
|
|
1487
|
+
const [body, setBody] = useState("");
|
|
1488
|
+
const [sending, setSending] = useState(false);
|
|
1489
|
+
const [error, setError] = useState(null);
|
|
1490
|
+
const [composing, setComposing] = useState(false);
|
|
1491
|
+
const [formatting, setFormatting] = useState(false);
|
|
1492
|
+
const bodyBox = useRef(null);
|
|
1493
|
+
const trimmed = body.trim();
|
|
1494
|
+
const canSend = trimmed.length > 0 && trimmed.length <= FEEDBACK_BODY_MAX;
|
|
1495
|
+
const entered = useEntered();
|
|
1496
|
+
const reduced = usePrefersReducedMotion();
|
|
1497
|
+
// Where the composer actually lands, hoisted out of the style object because
|
|
1498
|
+
// the clamp is the whole of the "which side of the viewport is this pin on"
|
|
1499
|
+
// information and it was being computed and thrown away.
|
|
1500
|
+
//
|
|
1501
|
+
// The width is what the clamp is made of, so it is READ from the token
|
|
1502
|
+
// rather than restated: the `300` that used to sit here was `280 + padding +
|
|
1503
|
+
// border` hard-coded, and it is the third place issue #20's two `"280px"`
|
|
1504
|
+
// literals had quietly reached. Widening the card without this would have
|
|
1505
|
+
// left the composer clamped 140px inside the right edge its own comment
|
|
1506
|
+
// claims it hugs.
|
|
1507
|
+
//
|
|
1508
|
+
// The width the composer will actually render at, `maxWidth` included, and
|
|
1509
|
+
// then both gutters - the same clamp the anchored thread takes, for the same
|
|
1510
|
+
// reason. `innerWidth - (440 + 16)` alone goes NEGATIVE on any viewport under
|
|
1511
|
+
// 456px and parks the card off the left edge; the old `300` in this position
|
|
1512
|
+
// merely overflowed the right one. A phone-width host is not hypothetical for
|
|
1513
|
+
// a tool pointed at staging sites.
|
|
1514
|
+
const width = Math.min(THREAD_WIDTH, window.innerWidth - VIEWPORT_GUTTER * 2);
|
|
1515
|
+
const left = Math.min(Math.max(draft.x + COMPOSER_ANCHOR_OFFSET, VIEWPORT_GUTTER), window.innerWidth - VIEWPORT_GUTTER - width);
|
|
1516
|
+
// The same floor vertically, for the same arithmetic: the reserve is 200px,
|
|
1517
|
+
// so a viewport shorter than that used to resolve to a negative `top`.
|
|
1518
|
+
const top = Math.max(VIEWPORT_GUTTER, Math.min(draft.y + COMPOSER_ANCHOR_OFFSET, window.innerHeight - COMPOSER_VIEWPORT_RESERVE));
|
|
1519
|
+
async function send() {
|
|
1520
|
+
setSending(true);
|
|
1521
|
+
setError(null);
|
|
1522
|
+
try {
|
|
1523
|
+
// The reviewer's own capture wins. See `attachment`.
|
|
1524
|
+
const screenshot = attachment ?? (await captureScreenshot());
|
|
1525
|
+
await client.submit({
|
|
1526
|
+
organizationId,
|
|
1527
|
+
kind: "PINNED",
|
|
1528
|
+
body: trimmed,
|
|
1529
|
+
metadata: captureMetadata(config),
|
|
1530
|
+
pin: {
|
|
1531
|
+
selector: draft.selector,
|
|
1532
|
+
offsetX: draft.offsetX,
|
|
1533
|
+
offsetY: draft.offsetY,
|
|
1534
|
+
},
|
|
1535
|
+
...(screenshot ? { screenshot } : {}),
|
|
1536
|
+
});
|
|
1537
|
+
onDone();
|
|
1538
|
+
}
|
|
1539
|
+
catch (err) {
|
|
1540
|
+
setSending(false);
|
|
1541
|
+
setError(err instanceof ToolbarApiError && err.code === "not_invited_to_project"
|
|
1542
|
+
? "You are not invited to this project any more."
|
|
1543
|
+
: "Could not save that pin. Your text is still here.");
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return (_jsx("section", { "data-testid": "pin-composer", style: {
|
|
1547
|
+
...resetBox,
|
|
1548
|
+
position: "fixed",
|
|
1549
|
+
left,
|
|
1550
|
+
top,
|
|
1551
|
+
// The same token the thread card takes. Issue #20: "both the pin
|
|
1552
|
+
// popover and the composer-only variant share the 280px constant -
|
|
1553
|
+
// widen both consistently", and one token is how they cannot stop
|
|
1554
|
+
// being consistent.
|
|
1555
|
+
width: sizes.thread,
|
|
1556
|
+
maxWidth: VIEWPORT_MAX_WIDTH,
|
|
1557
|
+
boxSizing: "border-box",
|
|
1558
|
+
// No padding: the composer band below IS this card. The section used
|
|
1559
|
+
// to hold a selector line and a bordered field inside a padded card -
|
|
1560
|
+
// three boxes for one field - and the reference is a single rounded
|
|
1561
|
+
// surface with a placeholder in it.
|
|
1562
|
+
padding: 0,
|
|
1563
|
+
background: theme.colors.canvas,
|
|
1564
|
+
border: `1px solid ${theme.colors.hairline}`,
|
|
1565
|
+
borderRadius: radii.lg,
|
|
1566
|
+
boxShadow: theme.shadows.popover,
|
|
1567
|
+
// The band inside has its own radius; clipping keeps its corners from
|
|
1568
|
+
// squaring off against this one.
|
|
1569
|
+
overflow: "hidden",
|
|
1570
|
+
// `thread`, not `surface`: a composer is a thread that does not exist
|
|
1571
|
+
// yet, and it holds text the reviewer is part-way through typing.
|
|
1572
|
+
// Arming the picker does not close an open thread, so both can be on
|
|
1573
|
+
// screen at once - and left a rung below, the newly opened thread would
|
|
1574
|
+
// paint over the half-written comment. Rendered after the markers, so
|
|
1575
|
+
// it also wins the tie against a thread on the same rung. See `layers`.
|
|
1576
|
+
zIndex: layers.thread,
|
|
1577
|
+
...typeScale.bodySm,
|
|
1578
|
+
color: theme.colors.body,
|
|
1579
|
+
// Grows out of the pixel that was clicked, which is the acceptance
|
|
1580
|
+
// criterion "the composer visibly originates at the anchor". The
|
|
1581
|
+
// origin is the clicked point expressed in the composer's own
|
|
1582
|
+
// coordinates, so it is derived from the SAME clamp that decided where
|
|
1583
|
+
// the composer sits: unclamped it resolves to `-12px -12px`, just
|
|
1584
|
+
// outside the top-left corner; clamped against the right edge it
|
|
1585
|
+
// resolves to roughly +300px, off the composer's right side, and the
|
|
1586
|
+
// surface then grows from the pin's side of the viewport with no
|
|
1587
|
+
// second copy of the branching to keep in step. A negative or
|
|
1588
|
+
// out-of-box origin is legal CSS and is exactly the point.
|
|
1589
|
+
//
|
|
1590
|
+
// No exit. `onDone` reloads the pin list, and a ghost composer fading
|
|
1591
|
+
// over the marker that just replaced it reads as a rendering fault -
|
|
1592
|
+
// and toolbar-pin.spec.ts asserts `pin-composer` reaches zero.
|
|
1593
|
+
...surfaceMotion({
|
|
1594
|
+
visible: entered,
|
|
1595
|
+
reduced,
|
|
1596
|
+
origin: `${draft.x - left}px ${draft.y - top}px`,
|
|
1597
|
+
}),
|
|
1598
|
+
}, children: _jsxs("div", { style: {
|
|
1599
|
+
display: "flex",
|
|
1600
|
+
flexDirection: "column",
|
|
1601
|
+
gap: spacing.xs,
|
|
1602
|
+
padding: spacing.sm,
|
|
1603
|
+
background: theme.colors.canvasSoft2,
|
|
1604
|
+
boxShadow: composing ? theme.shadows.focusRing : "none",
|
|
1605
|
+
}, children: [attachment ? (_jsxs("div", { "data-testid": "pin-composer-attachment", style: {
|
|
1606
|
+
position: "relative",
|
|
1607
|
+
alignSelf: "flex-start",
|
|
1608
|
+
borderRadius: radii.sm,
|
|
1609
|
+
border: `1px solid ${theme.colors.hairline}`,
|
|
1610
|
+
overflow: "hidden",
|
|
1611
|
+
lineHeight: 0,
|
|
1612
|
+
}, children: [_jsx("img", { src: attachment, alt: "The element you captured, attached to this report", style: { display: "block", maxWidth: "120px", maxHeight: "80px" } }), _jsx("button", { type: "button", "aria-label": "Remove screenshot", title: "Remove screenshot", "data-testid": "pin-composer-attachment-remove", onClick: onClearAttachment, style: {
|
|
1613
|
+
...resetBox,
|
|
1614
|
+
position: "absolute",
|
|
1615
|
+
top: "2px",
|
|
1616
|
+
right: "2px",
|
|
1617
|
+
width: "16px",
|
|
1618
|
+
height: "16px",
|
|
1619
|
+
display: "inline-flex",
|
|
1620
|
+
alignItems: "center",
|
|
1621
|
+
justifyContent: "center",
|
|
1622
|
+
borderRadius: radii.sm,
|
|
1623
|
+
cursor: "pointer",
|
|
1624
|
+
background: theme.colors.ink,
|
|
1625
|
+
color: theme.colors.onInk,
|
|
1626
|
+
lineHeight: 1,
|
|
1627
|
+
}, children: _jsx(CloseIcon, {}) })] })) : null, _jsx("textarea", { autoFocus: true, ref: bodyBox, value: body, onChange: (e) => {
|
|
1628
|
+
setBody(e.target.value);
|
|
1629
|
+
grow(e.target);
|
|
1630
|
+
}, onFocus: () => setComposing(true), onBlur: () => setComposing(false),
|
|
1631
|
+
// The thread's grammar, because this is now the thread's composer:
|
|
1632
|
+
// Enter sends, Shift/Alt+Enter writes a newline. It used to take
|
|
1633
|
+
// Cmd/Ctrl+Enter on the argument that a first report is longer prose
|
|
1634
|
+
// than a reply - true, and beside the point once the two boxes are
|
|
1635
|
+
// the same box. Two identical surfaces with different keys is the
|
|
1636
|
+
// worse trade. Cmd/Ctrl+Enter still sends, for the reviewer who
|
|
1637
|
+
// learned that grammar elsewhere.
|
|
1638
|
+
//
|
|
1639
|
+
// `isComposing` is the load-bearing guard: an IME uses Enter to
|
|
1640
|
+
// COMMIT a candidate, so a reviewer typing Japanese would otherwise
|
|
1641
|
+
// post half a sentence on their first Enter.
|
|
1642
|
+
onKeyDown: (event) => {
|
|
1643
|
+
if (event.key !== "Enter")
|
|
1644
|
+
return;
|
|
1645
|
+
if (event.nativeEvent.isComposing)
|
|
1646
|
+
return;
|
|
1647
|
+
if (event.shiftKey || event.altKey)
|
|
1648
|
+
return;
|
|
1649
|
+
event.preventDefault();
|
|
1650
|
+
if (sending || !canSend)
|
|
1651
|
+
return;
|
|
1652
|
+
void send();
|
|
1653
|
+
}, rows: 1, placeholder: "Start a new thread\u2026", "aria-label": "Pinned feedback", "data-testid": "pin-body", maxLength: FEEDBACK_BODY_MAX, style: {
|
|
1654
|
+
...resetBox,
|
|
1655
|
+
display: "block",
|
|
1656
|
+
width: "100%",
|
|
1657
|
+
// Grows with the text and scrolls past the cap, exactly as the
|
|
1658
|
+
// reply box does. The old box opened at three rows with a drag
|
|
1659
|
+
// handle; `resize` is `none` here for the reason it is there - the
|
|
1660
|
+
// handle let a reviewer drag the field out through the card.
|
|
1661
|
+
minHeight: typeScale.bodySm.lineHeight,
|
|
1662
|
+
maxHeight: `${REPLY_MAX_HEIGHT}px`,
|
|
1663
|
+
overflowY: "auto",
|
|
1664
|
+
resize: "none",
|
|
1665
|
+
...typeScale.bodySm,
|
|
1666
|
+
color: theme.colors.ink,
|
|
1667
|
+
background: "transparent",
|
|
1668
|
+
outline: "none",
|
|
1669
|
+
} }), error ? (_jsx("p", { role: "alert", style: {
|
|
1670
|
+
...resetBox,
|
|
1671
|
+
color: theme.colors.error,
|
|
1672
|
+
...typeScale.caption,
|
|
1673
|
+
}, children: error })) : null, _jsxs("div", { style: {
|
|
1674
|
+
position: "relative",
|
|
1675
|
+
display: "flex",
|
|
1676
|
+
alignItems: "center",
|
|
1677
|
+
gap: spacing.xxs,
|
|
1678
|
+
flex: "0 0 auto",
|
|
1679
|
+
}, children: [formatting ? (_jsx(FormatBar, { theme: theme, onApply: (mark) => {
|
|
1680
|
+
const field = bodyBox.current;
|
|
1681
|
+
if (!field)
|
|
1682
|
+
return;
|
|
1683
|
+
const next = toggleMark(body, field.selectionStart, field.selectionEnd, mark);
|
|
1684
|
+
setBody(next.text);
|
|
1685
|
+
requestAnimationFrame(() => {
|
|
1686
|
+
field.focus();
|
|
1687
|
+
field.setSelectionRange(next.selectionStart, next.selectionEnd);
|
|
1688
|
+
grow(field);
|
|
1689
|
+
});
|
|
1690
|
+
} })) : null, _jsx(RowButton, { theme: theme, label: "Take a screenshot", testId: "pin-composer-attach", tone: attachment ? "ink" : "ghost", onClick: onRequestCapture, children: _jsx(CameraIcon, {}) }), _jsx("span", { "aria-hidden": "true", style: {
|
|
1691
|
+
width: "1px",
|
|
1692
|
+
height: "16px",
|
|
1693
|
+
background: theme.colors.hairline,
|
|
1694
|
+
margin: `0 ${spacing.xxs}`,
|
|
1695
|
+
} }), _jsx(RowButton, { theme: theme, label: formatting ? "Hide formatting" : "Formatting", testId: "pin-composer-format", tone: formatting ? "ink" : "ghost", onClick: () => setFormatting((on) => !on), children: _jsx("span", { style: { ...typeScale.captionMono, lineHeight: 1 }, children: "Aa" }) }), _jsx("span", { style: { flex: "1 1 auto" } }), _jsx(RowButton, { theme: theme,
|
|
1696
|
+
// "Pin it", still: the icon is the thread's send control and the
|
|
1697
|
+
// NAME is the verb this surface performs. Both e2e and the unit
|
|
1698
|
+
// tests ask for this button by that name, and so does a screen
|
|
1699
|
+
// reader - `srText` keeps it a real text node as well.
|
|
1700
|
+
label: "Pin it", srText: "Pin it", busy: sending, tone: canSend ? "ink" : "ghost", disabled: sending || !canSend, onClick: () => void send(), children: _jsx(SendIcon, {}) })] })] }) }));
|
|
1701
|
+
}
|
|
1702
|
+
//# sourceMappingURL=PinLayer.js.map
|