@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/motion.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// The React half of DESIGN.md's `## Motion` section. The values live in
|
|
3
|
+
// tokens.ts, which stays importable without React; what needs hooks lives
|
|
4
|
+
// here.
|
|
5
|
+
//
|
|
6
|
+
// THE RULE, BEFORE YOU ADD THE NEXT ANIMATION. An `opacity` below 1 creates a
|
|
7
|
+
// stacking context, and so does any `transform` - mid-flight included, for
|
|
8
|
+
// every frame the transition is running. So both belong on the element being
|
|
9
|
+
// animated, and never on a wrapper that also holds a surface which has to
|
|
10
|
+
// stack independently or stay opaque. Issue #23 was exactly this: the pin
|
|
11
|
+
// marker's wrapper carried `opacity: 0.45` for a resolved pin, the thread it
|
|
12
|
+
// opened was a child of that wrapper, and the thread therefore painted at 45%
|
|
13
|
+
// with the host page's own table rows reading straight through the card - and
|
|
14
|
+
// its `zIndex` resolved inside the wrapper rather than against `layers` in
|
|
15
|
+
// tokens.ts. A transition is the same defect with a timer on it: it is not
|
|
16
|
+
// enough that the resting state is `opacity: 1`, because the 120ms in between
|
|
17
|
+
// composites the subtree just as hard.
|
|
18
|
+
//
|
|
19
|
+
// The three hooks below are deliberately small and deliberately not an
|
|
20
|
+
// animation library. This bundle ships into a customer's application under a
|
|
21
|
+
// gzip ceiling (CLAUDE.md section 8), and everything here is one transition
|
|
22
|
+
// property away from being CSS.
|
|
23
|
+
import { useEffect, useState, useSyncExternalStore, } from "react";
|
|
24
|
+
import { durationMs, exitFor, motion, motionTransform, REDUCED_MOTION_QUERY, transition, } from "./tokens.js";
|
|
25
|
+
/* ── The reviewer's preference ────────────────────────────────────────── */
|
|
26
|
+
function subscribeReducedMotion(listener) {
|
|
27
|
+
if (typeof window.matchMedia !== "function")
|
|
28
|
+
return () => { };
|
|
29
|
+
const query = window.matchMedia(REDUCED_MOTION_QUERY);
|
|
30
|
+
query.addEventListener("change", listener);
|
|
31
|
+
return () => query.removeEventListener("change", listener);
|
|
32
|
+
}
|
|
33
|
+
function prefersReducedMotion() {
|
|
34
|
+
if (typeof window === "undefined" ||
|
|
35
|
+
// A host or a test environment with no `matchMedia` at all. Not
|
|
36
|
+
// hypothetical: jsdom ships without it unless a test adds it, and this
|
|
37
|
+
// hook runs in every surface below.
|
|
38
|
+
typeof window.matchMedia !== "function") {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The server's answer, and the hydrating render's: TRUE, no motion.
|
|
45
|
+
*
|
|
46
|
+
* The opposite of the light/dark default beside it, and for a different
|
|
47
|
+
* reason. "Assume light" is a guess at a value; this is a guess at a
|
|
48
|
+
* PERMISSION, and the safe first paint is the one that moves nothing. It is
|
|
49
|
+
* also the other half of DESIGN.md's "nothing animates on first mount": a
|
|
50
|
+
* surface rendered before the media query can be read has no `from` state to
|
|
51
|
+
* animate out of.
|
|
52
|
+
*/
|
|
53
|
+
function serverPrefersReducedMotion() {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `prefers-reduced-motion: reduce`, read from the host document and kept live.
|
|
58
|
+
*
|
|
59
|
+
* A hook per animating component rather than a field on `ToolbarTheme`. The
|
|
60
|
+
* theme is a colour and elevation object that `themeFor` returns as one of two
|
|
61
|
+
* module constants; widening it to carry a motion preference would make it
|
|
62
|
+
* per-render, which is a real cost for a subscription that is free to
|
|
63
|
+
* duplicate.
|
|
64
|
+
*/
|
|
65
|
+
export function usePrefersReducedMotion() {
|
|
66
|
+
return useSyncExternalStore(subscribeReducedMotion, prefersReducedMotion, serverPrefersReducedMotion);
|
|
67
|
+
}
|
|
68
|
+
/* ── Entering ─────────────────────────────────────────────────────────── */
|
|
69
|
+
function noFrameLoop() {
|
|
70
|
+
return (typeof window === "undefined" ||
|
|
71
|
+
typeof window.requestAnimationFrame !== "function");
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* False on the frame a surface mounts, true on the next one.
|
|
75
|
+
*
|
|
76
|
+
* This is the whole reason an inline `transition` is not enough on its own: a
|
|
77
|
+
* transition never runs on an element's FIRST style computation, so a surface
|
|
78
|
+
* that mounts already at `opacity: 1` has nothing to transition from. The
|
|
79
|
+
* `from` state has to be committed, painted, and only then replaced - which is
|
|
80
|
+
* one `requestAnimationFrame`.
|
|
81
|
+
*
|
|
82
|
+
* `requestAnimationFrame` rather than a layout effect that reads `offsetWidth`
|
|
83
|
+
* to force the same flush. Both work; the reflow version synchronously lays
|
|
84
|
+
* out the HOST page every time a reviewer opens a thread, and this package's
|
|
85
|
+
* standing rule is that it costs the page it is injected into as little as
|
|
86
|
+
* possible. Where there is no frame loop to wait for, the surface simply
|
|
87
|
+
* starts entered - a hard cut is the correct degradation.
|
|
88
|
+
*
|
|
89
|
+
* `settledAtMount` is DESIGN.md's first-mount rule for the one shape that
|
|
90
|
+
* cannot infer it. For a surface that MOUNTS when it opens, the mount is the
|
|
91
|
+
* reviewer's own click and there is nothing to distinguish - it enters, which
|
|
92
|
+
* is the default. For a surface mounted all session and toggled by a prop, an
|
|
93
|
+
* `active` that is already true on the first render is RESTORED STATE, not an
|
|
94
|
+
* action: the inbox sheet's open flag comes out of `localStorage`, so without
|
|
95
|
+
* this the sheet replayed its whole slide-in on every page the reviewer
|
|
96
|
+
* navigated to with it left open - "the toolbar animates itself in on every
|
|
97
|
+
* page load", which is the exact thing DESIGN.md forbids.
|
|
98
|
+
*/
|
|
99
|
+
export function useEntered(active = true, settledAtMount = false) {
|
|
100
|
+
const [entered, setEntered] = useState(() => active && (settledAtMount || noFrameLoop()));
|
|
101
|
+
// `active` is for a surface whose component is mounted for the whole
|
|
102
|
+
// session and only shown some of the time - the inbox sheet, which the
|
|
103
|
+
// toolbar renders unconditionally and toggles with a prop. Mount is the
|
|
104
|
+
// wrong moment there: it happens on page load, so by the time a reviewer
|
|
105
|
+
// opens the sheet it has long since "entered" and there is nothing left to
|
|
106
|
+
// animate. Everything else opens by mounting and takes the default.
|
|
107
|
+
//
|
|
108
|
+
// State rather than a ref for the previous value, and not by preference:
|
|
109
|
+
// `react-hooks/refs` is an error in this repo and reading `.current` during
|
|
110
|
+
// render is what it forbids. This is the shape `PinLayer` already uses for
|
|
111
|
+
// its focus request, and React's own documented one for deriving state from
|
|
112
|
+
// a prop that changed.
|
|
113
|
+
const [wasActive, setWasActive] = useState(active);
|
|
114
|
+
if (wasActive !== active) {
|
|
115
|
+
setWasActive(active);
|
|
116
|
+
// Re-armed on every activation: a sheet opened twice enters twice.
|
|
117
|
+
setEntered(noFrameLoop());
|
|
118
|
+
}
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (entered || !active)
|
|
121
|
+
return;
|
|
122
|
+
const frame = window.requestAnimationFrame(() => setEntered(true));
|
|
123
|
+
return () => window.cancelAnimationFrame(frame);
|
|
124
|
+
}, [entered, active]);
|
|
125
|
+
return entered;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The two-sided sibling of `useEntered`: false for one frame after `form`
|
|
129
|
+
* changes, true otherwise, and true on the very first render.
|
|
130
|
+
*
|
|
131
|
+
* For a surface that is REPLACED rather than opened. `useEntered` takes a
|
|
132
|
+
* boolean, so it can only re-arm in one direction - `active` going false means
|
|
133
|
+
* "this surface is gone" and its effect deliberately stops there. The bar's
|
|
134
|
+
* two forms (issue #22) are not open and closed: each of them is the current
|
|
135
|
+
* one, and whichever arrives has to enter, so the trigger is a value CHANGING
|
|
136
|
+
* rather than a flag going true.
|
|
137
|
+
*
|
|
138
|
+
* Settled on the first render, and here that is not an option a call site
|
|
139
|
+
* passes but the only correct answer: which form the bar is in comes out of
|
|
140
|
+
* `localStorage`, so a reviewer who collapsed it meets it collapsed on the
|
|
141
|
+
* next page - restored state, not an action - and a toolbar that replays its
|
|
142
|
+
* own collapse on every navigation is precisely what DESIGN.md's first-mount
|
|
143
|
+
* rule forbids. That is the bug `useEntered`'s `settledAtMount` was added for,
|
|
144
|
+
* one surface earlier.
|
|
145
|
+
*
|
|
146
|
+
* THE CALLER MUST REMOUNT THE ELEMENT IT STYLES - key it on the same `form`.
|
|
147
|
+
* Everything else in this file is used by a surface that mounts when it opens,
|
|
148
|
+
* which is why a transition can run on it at all: a transition never runs on
|
|
149
|
+
* an element's FIRST style computation, so the `from` state paints and the
|
|
150
|
+
* `to` state animates. A swap has no mount, so an element reused across it
|
|
151
|
+
* receives `opacity: 0` with the transition ALREADY declared, animates 1 -> 0,
|
|
152
|
+
* and gets reversed one frame later - a hard cut wearing a transition. This
|
|
153
|
+
* hook decides WHEN the from-state is committed; only a new element makes that
|
|
154
|
+
* commit instantaneous. See `BarContents` in FeedbackToolbar.tsx.
|
|
155
|
+
*/
|
|
156
|
+
export function useSwapEntered(form) {
|
|
157
|
+
const [entered, setEntered] = useState(true);
|
|
158
|
+
// Derived from a prop that changed, during render: React's own documented
|
|
159
|
+
// shape for this, and the one `react-hooks/refs` leaves available - reading
|
|
160
|
+
// a ref's `.current` during render is an error in this repo. `useEntered`,
|
|
161
|
+
// `usePresence` and `useJustChanged` are all built this way.
|
|
162
|
+
const [seen, setSeen] = useState(form);
|
|
163
|
+
if (seen !== form) {
|
|
164
|
+
setSeen(form);
|
|
165
|
+
// Where there is no frame loop to wait for, the new form is simply already
|
|
166
|
+
// here: a hard cut is the correct degradation, the same one `useEntered`
|
|
167
|
+
// takes.
|
|
168
|
+
setEntered(noFrameLoop());
|
|
169
|
+
}
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (entered)
|
|
172
|
+
return;
|
|
173
|
+
const frame = window.requestAnimationFrame(() => setEntered(true));
|
|
174
|
+
return () => window.cancelAnimationFrame(frame);
|
|
175
|
+
}, [entered]);
|
|
176
|
+
return entered;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* "Was this one already here when the surface opened?", as a predicate.
|
|
180
|
+
*
|
|
181
|
+
* DESIGN.md's first-mount rule applied to a list: the replies already in a
|
|
182
|
+
* thread are pre-existing state and must not animate, or every reopen of an
|
|
183
|
+
* old thread replays the whole conversation arriving. Only the reply that
|
|
184
|
+
* lands while the reviewer is looking is new.
|
|
185
|
+
*
|
|
186
|
+
* The set is captured on the first render and never updated, which is the
|
|
187
|
+
* point - an id that arrives later stays "new" for as long as this surface is
|
|
188
|
+
* mounted, and the row it belongs to plays its enter exactly once because the
|
|
189
|
+
* row itself mounts exactly once.
|
|
190
|
+
*/
|
|
191
|
+
export function useNewSinceMount(ids) {
|
|
192
|
+
// A lazy initialiser rather than a ref filled in on the first render: the
|
|
193
|
+
// set is captured exactly once either way, and `react-hooks/refs` forbids
|
|
194
|
+
// touching `.current` during render.
|
|
195
|
+
const [known] = useState(() => new Set(ids));
|
|
196
|
+
return (id) => !known.has(id);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Keep a surface in the DOM for one exit duration after it closes, then take
|
|
200
|
+
* it out for real.
|
|
201
|
+
*
|
|
202
|
+
* The deferral is DOM REMOVAL ONLY. Whatever state change closed the surface
|
|
203
|
+
* has already been committed by the caller - `openPinId` is null, the sheet's
|
|
204
|
+
* store says closed - so nothing a reviewer did is waiting on an animation,
|
|
205
|
+
* which is DESIGN.md's "an exit never delays the action that triggered it".
|
|
206
|
+
*
|
|
207
|
+
* And the element really does leave. A surface parked at `opacity: 0` forever
|
|
208
|
+
* is markup in a host app's DOM that their own queries and audits trip over,
|
|
209
|
+
* and four e2e specs assert these surfaces reach zero elements - Playwright
|
|
210
|
+
* retries, so a deferred unmount passes and a permanent ghost does not.
|
|
211
|
+
*
|
|
212
|
+
* The latch is set during render rather than in an effect: "a prop changed, so
|
|
213
|
+
* derive some state from it" is React's own documented shape for this, it is
|
|
214
|
+
* what `PinLayer` already does for a focus request, and doing it in an effect
|
|
215
|
+
* would paint one frame of the closed surface still fully open.
|
|
216
|
+
*
|
|
217
|
+
* `exit` is the EXIT duration, and a caller that also drives `surfaceMotion`
|
|
218
|
+
* has to spell it the same way that helper does - `exitFor(<the enter step>)`
|
|
219
|
+
* - or the timer and the transition are two independent literals for the one
|
|
220
|
+
* value DESIGN.md calls "exactly the exit duration".
|
|
221
|
+
*/
|
|
222
|
+
export function usePresence(open, exit) {
|
|
223
|
+
const [leaving, setLeaving] = useState(false);
|
|
224
|
+
const [wasOpen, setWasOpen] = useState(open);
|
|
225
|
+
if (wasOpen !== open) {
|
|
226
|
+
setWasOpen(open);
|
|
227
|
+
// Re-opening mid-exit cancels the exit, which is the same statement.
|
|
228
|
+
setLeaving(!open);
|
|
229
|
+
}
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!leaving)
|
|
232
|
+
return;
|
|
233
|
+
const timer = window.setTimeout(() => setLeaving(false), durationMs(exit));
|
|
234
|
+
return () => window.clearTimeout(timer);
|
|
235
|
+
}, [leaving, exit]);
|
|
236
|
+
return { mounted: open || leaving, leaving };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* True for one `instant` after `value` changes, and false on first mount.
|
|
240
|
+
*
|
|
241
|
+
* For acknowledging a number that moved rather than a surface that arrived:
|
|
242
|
+
* the unread badge, whose colours and position are identical before and after,
|
|
243
|
+
* so without this a count going 3 → 4 is a glyph swap nobody sees.
|
|
244
|
+
*
|
|
245
|
+
* A latch rather than an enter, and the difference matters: the element has
|
|
246
|
+
* been on screen for a while, so its resting state is already painted and a
|
|
247
|
+
* transition out of it runs immediately - no frame of `from` state to arrange,
|
|
248
|
+
* and therefore no `requestAnimationFrame`.
|
|
249
|
+
*/
|
|
250
|
+
export function useJustChanged(value) {
|
|
251
|
+
const [changed, setChanged] = useState(false);
|
|
252
|
+
const [seen, setSeen] = useState(value);
|
|
253
|
+
if (seen !== value) {
|
|
254
|
+
setSeen(value);
|
|
255
|
+
setChanged(true);
|
|
256
|
+
}
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (!changed)
|
|
259
|
+
return;
|
|
260
|
+
const timer = window.setTimeout(() => setChanged(false), durationMs(motion.duration.instant));
|
|
261
|
+
return () => window.clearTimeout(timer);
|
|
262
|
+
}, [changed]);
|
|
263
|
+
return changed;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Every entering and leaving surface in this package, styled from one place.
|
|
267
|
+
*
|
|
268
|
+
* A helper rather than five hand-written style fragments because "the same
|
|
269
|
+
* enter everywhere" is the point of having tokens at all, and because the
|
|
270
|
+
* three things easy to get wrong here - which properties are transitioned,
|
|
271
|
+
* which direction the easing runs, what reduced motion drops - are exactly the
|
|
272
|
+
* three a call site would restate slightly differently each time.
|
|
273
|
+
*
|
|
274
|
+
* Only `opacity` and `transform` appear. Both are handed to the compositor and
|
|
275
|
+
* reflow nothing, which is what lets this package animate at all over a host
|
|
276
|
+
* page nobody here wrote.
|
|
277
|
+
*/
|
|
278
|
+
export function surfaceMotion({ visible, leaving = false, reduced, origin, slide, duration = motion.duration.fast, }) {
|
|
279
|
+
const shown = visible && !leaving;
|
|
280
|
+
const offset = slide === "x"
|
|
281
|
+
? `translateX(${motion.enterShift})`
|
|
282
|
+
: slide === "y"
|
|
283
|
+
? `translateY(${motion.enterShift})`
|
|
284
|
+
: `scale(${motion.enterScale})`;
|
|
285
|
+
return {
|
|
286
|
+
opacity: shown ? 1 : 0,
|
|
287
|
+
// Reduced motion keeps the cross-fade and drops the travel: a surface that
|
|
288
|
+
// has to move is the part of this a vestibular preference is about, and
|
|
289
|
+
// "it appeared" is information rather than decoration. Both halves of that
|
|
290
|
+
// are decided in tokens.ts rather than here - see `motionTransform` and
|
|
291
|
+
// the required `reduced` argument on `transition`.
|
|
292
|
+
transform: motionTransform(shown ? "none" : offset, reduced),
|
|
293
|
+
...(origin === undefined ? {} : { transformOrigin: origin }),
|
|
294
|
+
transition: transition(["opacity", "transform"],
|
|
295
|
+
// One step faster out than in: arriving is information the reader
|
|
296
|
+
// wants, leaving is a surface getting out of their way. Derived from
|
|
297
|
+
// the enter rather than pinned to the floor, so the rule holds for the
|
|
298
|
+
// slow step as well - see `exitFor`.
|
|
299
|
+
leaving ? exitFor(duration) : duration, leaving ? motion.easing.exit : motion.easing.enter, reduced),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
//# sourceMappingURL=motion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"motion.js","sourceRoot":"","sources":["../src/motion.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type ToolbarDock } from "./tokens.js";
|
|
2
|
+
/**
|
|
3
|
+
* Every toolbar setting that belongs to the reviewer rather than to the build.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately not widened to "anything a component wants to remember": the
|
|
6
|
+
* theme and the inbox flag are not here, and the test for admission is whether
|
|
7
|
+
* a second setting would otherwise want its own key next to this one.
|
|
8
|
+
*/
|
|
9
|
+
export interface ToolbarPreferences {
|
|
10
|
+
/** Whether the bar is in its collapsed form. Issue #22. */
|
|
11
|
+
collapsed: boolean;
|
|
12
|
+
/** Which edge or corner the bar is docked to. Issue #21. */
|
|
13
|
+
dock: ToolbarDock;
|
|
14
|
+
}
|
|
15
|
+
export declare const TOOLBAR_PREFERENCES_STORAGE_KEY = "bitvea.feedback-toolbar.preferences";
|
|
16
|
+
/**
|
|
17
|
+
* The answer for a reviewer who has never touched anything, for a host whose
|
|
18
|
+
* storage cannot be read, and for the server.
|
|
19
|
+
*
|
|
20
|
+
* Frozen and shared by identity rather than rebuilt per call, because it is
|
|
21
|
+
* `getServerSnapshot`'s return value and `useSyncExternalStore` compares
|
|
22
|
+
* snapshots with `Object.is` - see the note on `readPreferences`.
|
|
23
|
+
*/
|
|
24
|
+
export declare const DEFAULT_PREFERENCES: ToolbarPreferences;
|
|
25
|
+
/**
|
|
26
|
+
* The reviewer's settings, from storage.
|
|
27
|
+
*
|
|
28
|
+
* Memoised on the raw string, and `useSyncExternalStore` is the reason rather
|
|
29
|
+
* than performance: it compares snapshots with `Object.is` and re-renders
|
|
30
|
+
* whenever they differ, so a freshly parsed object on every call is an
|
|
31
|
+
* infinite render loop that React reports as "The result of getSnapshot should
|
|
32
|
+
* be cached to avoid an infinite loop". The two stores this one is modelled on
|
|
33
|
+
* return booleans and never had to think about it; a record has to.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readPreferences(): ToolbarPreferences;
|
|
36
|
+
/**
|
|
37
|
+
* Change some of the record, leaving the rest of what is stored alone.
|
|
38
|
+
*
|
|
39
|
+
* Read-merge-write, and the merge is over the STORED keys rather than over
|
|
40
|
+
* `ToolbarPreferences`. This issue writes `collapsed` and #21 writes `dock`;
|
|
41
|
+
* either one writing a whole record built from its own fields would erase the
|
|
42
|
+
* other's the first time a reviewer used both. The same spread is what
|
|
43
|
+
* preserves a key this build has never heard of.
|
|
44
|
+
*/
|
|
45
|
+
export declare function writePreferences(patch: Partial<ToolbarPreferences>): void;
|
|
46
|
+
/**
|
|
47
|
+
* The settings as an external store, not as component state read from
|
|
48
|
+
* `localStorage` in a `useState` initialiser.
|
|
49
|
+
*
|
|
50
|
+
* The initialiser is the shape this package already had to fix once, in
|
|
51
|
+
* `INBOX_OPEN_STORE`: on the server the read answers the default and on the
|
|
52
|
+
* client it answers the stored value, so a reviewer who had changed anything
|
|
53
|
+
* got React's "server rendered HTML didn't match the client" in the
|
|
54
|
+
* CUSTOMER's console on every page load. `collapsed` is a harder case than the
|
|
55
|
+
* inbox flag was - it decides which CONTROLS EXIST in the bar - so it takes
|
|
56
|
+
* the same shape: a deterministic server snapshot, and the stored value
|
|
57
|
+
* adopted after hydration rather than during it.
|
|
58
|
+
*/
|
|
59
|
+
export declare const PREFERENCES_STORE: {
|
|
60
|
+
subscribe: (listener: () => void) => (() => void);
|
|
61
|
+
/** The client's answer: storage, else whatever this page last chose. */
|
|
62
|
+
getSnapshot: typeof readPreferences;
|
|
63
|
+
/**
|
|
64
|
+
* The server's answer, and the first client render's. The defaults: a bar
|
|
65
|
+
* that hydrates in the form the server drew and then adopts the reviewer's
|
|
66
|
+
* choice is a paint, and one that hydrates into a different set of buttons
|
|
67
|
+
* than the server rendered is an error in somebody else's application.
|
|
68
|
+
*/
|
|
69
|
+
getServerSnapshot: () => ToolbarPreferences;
|
|
70
|
+
};
|
|
71
|
+
//# sourceMappingURL=preferences.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preferences.d.ts","sourceRoot":"","sources":["../src/preferences.ts"],"names":[],"mappings":"AAuCA,OAAO,EAA+B,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAEzE;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,2DAA2D;IAC3D,SAAS,EAAE,OAAO,CAAC;IACnB,4DAA4D;IAC5D,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,eAAO,MAAM,+BAA+B,wCACL,CAAC;AAExC;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,EAAE,kBAGhC,CAAC;AA2EH;;;;;;;;;GASG;AACH,wBAAgB,eAAe,IAAI,kBAAkB,CAOpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,kBAAkB,CAAC,GAAG,IAAI,CASzE;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB;0BAIN,MAAM,IAAI,KAAG,CAAC,MAAM,IAAI,CAAC;IAM/C,wEAAwE;;IAExE;;;;;OAKG;6BACoB,kBAAkB;CAC1C,CAAC"}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// What this reviewer did to the toolbar itself, as ONE record under ONE key.
|
|
2
|
+
//
|
|
3
|
+
// Two things about the bar are the reviewer's to decide and have to survive a
|
|
4
|
+
// reload: whether it is collapsed (issue #22) and, next, where it is docked
|
|
5
|
+
// (issue #21). Issue #22 says in as many words that those are "one persisted
|
|
6
|
+
// preference object, not two competing ones", and the reason is not tidiness.
|
|
7
|
+
// Two keys written by two independent toggles are two things that can disagree
|
|
8
|
+
// about what the bar looks like, and the second one added is always the one
|
|
9
|
+
// that forgets the first exists. So this is a RECORD from the first field, and
|
|
10
|
+
// #21 landed exactly the way that promised: one more property on
|
|
11
|
+
// `ToolbarPreferences`, one more line in `parsePreferences`, one more key in
|
|
12
|
+
// the frozen defaults, and the import of the guard that validates it. The
|
|
13
|
+
// read-merge-write and the raw-string memoisation below did not have to move.
|
|
14
|
+
//
|
|
15
|
+
// The two single-valued preferences already in this package stay where they
|
|
16
|
+
// are: `THEME_STORAGE_KEY` in tokens.ts and `INBOX_OPEN_STORAGE_KEY` in
|
|
17
|
+
// InboxSheet.tsx, each read by the store that owns it and each named by a
|
|
18
|
+
// unit test. Neither is what #22 is about, so this file is a new home for new
|
|
19
|
+
// settings rather than a migration - and the key below is deliberately NOT on
|
|
20
|
+
// the package's public surface either, unlike `INBOX_OPEN_STORAGE_KEY`, which
|
|
21
|
+
// index.ts exports because a host replacing the inbox has to be able to read
|
|
22
|
+
// the flag the default one wrote.
|
|
23
|
+
//
|
|
24
|
+
// STORAGE HERE BELONGS TO THE HOST, and all three consequences of that are
|
|
25
|
+
// real failure modes rather than hypotheticals:
|
|
26
|
+
//
|
|
27
|
+
// - The accessor itself throws. Safari's private mode and a blocked origin
|
|
28
|
+
// throw on `window.localStorage`, not on `getItem`, so every access sits
|
|
29
|
+
// inside a `try`. An exception out of this file takes the host
|
|
30
|
+
// application's render down with it.
|
|
31
|
+
// - The key sits in the host's own key space, beside whatever they store, so
|
|
32
|
+
// it is namespaced `bitvea.feedback-toolbar.*` like its two neighbours.
|
|
33
|
+
// - The VALUE can be anything. It is the host's origin: their code, a
|
|
34
|
+
// browser extension, or an older or newer build of this toolbar can have
|
|
35
|
+
// written it. So parsing is defensive per field, and unknown keys are
|
|
36
|
+
// preserved on write - which is what lets a browser that has already
|
|
37
|
+
// stored #21's dock keep it through a downgrade to a build that has never
|
|
38
|
+
// heard of one.
|
|
39
|
+
import { DEFAULT_DOCK, isToolbarDock } from "./tokens.js";
|
|
40
|
+
export const TOOLBAR_PREFERENCES_STORAGE_KEY = "bitvea.feedback-toolbar.preferences";
|
|
41
|
+
/**
|
|
42
|
+
* The answer for a reviewer who has never touched anything, for a host whose
|
|
43
|
+
* storage cannot be read, and for the server.
|
|
44
|
+
*
|
|
45
|
+
* Frozen and shared by identity rather than rebuilt per call, because it is
|
|
46
|
+
* `getServerSnapshot`'s return value and `useSyncExternalStore` compares
|
|
47
|
+
* snapshots with `Object.is` - see the note on `readPreferences`.
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_PREFERENCES = Object.freeze({
|
|
50
|
+
collapsed: false,
|
|
51
|
+
dock: DEFAULT_DOCK,
|
|
52
|
+
});
|
|
53
|
+
const listeners = new Set();
|
|
54
|
+
/**
|
|
55
|
+
* The record as it was last written, when it could not be stored - private
|
|
56
|
+
* mode, a blocked origin, a host that disabled storage, a full quota. The
|
|
57
|
+
* choice then holds for this page and no longer, which beats a control that
|
|
58
|
+
* appears not to work at all.
|
|
59
|
+
*/
|
|
60
|
+
let memoryRaw = null;
|
|
61
|
+
/** The last string parsed, and what it parsed to. See `readPreferences`. */
|
|
62
|
+
let cachedRaw = null;
|
|
63
|
+
let cached = DEFAULT_PREFERENCES;
|
|
64
|
+
function readRaw() {
|
|
65
|
+
// Memory FIRST, which is one step further than the theme and inbox stores go
|
|
66
|
+
// - they consult their fallback only when the read throws. A write is what
|
|
67
|
+
// fails on a full quota or a partially blocked origin: `getItem` keeps
|
|
68
|
+
// working there and keeps answering the value from before the reviewer's
|
|
69
|
+
// click, so a storage-first read would honour a preference they had just
|
|
70
|
+
// replaced. `memoryRaw` is non-null only because a write could not be
|
|
71
|
+
// stored, and in that case it is the truth for this page.
|
|
72
|
+
if (memoryRaw !== null)
|
|
73
|
+
return memoryRaw;
|
|
74
|
+
try {
|
|
75
|
+
return window.localStorage.getItem(TOOLBAR_PREFERENCES_STORAGE_KEY);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The stored JSON as a plain object, with every key it happened to carry, or
|
|
83
|
+
* null for anything that is not one - malformed JSON, a string, a number, an
|
|
84
|
+
* array. `Array.isArray` is checked separately because an array IS a
|
|
85
|
+
* `typeof "object"` and spreading one produces index keys.
|
|
86
|
+
*/
|
|
87
|
+
function parseObject(raw) {
|
|
88
|
+
if (raw === null)
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
const value = JSON.parse(raw);
|
|
92
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Per FIELD, and that is the whole of the parsing policy: a stored record with
|
|
103
|
+
* `collapsed: "yes"` in it falls back to the default for `collapsed` and keeps
|
|
104
|
+
* whatever else it holds, rather than being thrown away wholesale. With two
|
|
105
|
+
* fields written by two different issues, discarding the record on one bad
|
|
106
|
+
* value means one bad write erases the other feature's setting.
|
|
107
|
+
*/
|
|
108
|
+
function parsePreferences(raw) {
|
|
109
|
+
const stored = parseObject(raw);
|
|
110
|
+
if (stored === null)
|
|
111
|
+
return DEFAULT_PREFERENCES;
|
|
112
|
+
return {
|
|
113
|
+
collapsed: typeof stored.collapsed === "boolean"
|
|
114
|
+
? stored.collapsed
|
|
115
|
+
: DEFAULT_PREFERENCES.collapsed,
|
|
116
|
+
// Through the same guard `isThemeMode` is: a dock this build has never
|
|
117
|
+
// heard of - a newer one's, or a browser extension's - reads back as the
|
|
118
|
+
// default rather than as a value the layout table has no row for. The
|
|
119
|
+
// stored string itself survives, because the write below merges over the
|
|
120
|
+
// STORED keys and never over this record.
|
|
121
|
+
dock: isToolbarDock(stored.dock) ? stored.dock : DEFAULT_PREFERENCES.dock,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The reviewer's settings, from storage.
|
|
126
|
+
*
|
|
127
|
+
* Memoised on the raw string, and `useSyncExternalStore` is the reason rather
|
|
128
|
+
* than performance: it compares snapshots with `Object.is` and re-renders
|
|
129
|
+
* whenever they differ, so a freshly parsed object on every call is an
|
|
130
|
+
* infinite render loop that React reports as "The result of getSnapshot should
|
|
131
|
+
* be cached to avoid an infinite loop". The two stores this one is modelled on
|
|
132
|
+
* return booleans and never had to think about it; a record has to.
|
|
133
|
+
*/
|
|
134
|
+
export function readPreferences() {
|
|
135
|
+
const raw = readRaw();
|
|
136
|
+
if (raw !== cachedRaw) {
|
|
137
|
+
cachedRaw = raw;
|
|
138
|
+
cached = parsePreferences(raw);
|
|
139
|
+
}
|
|
140
|
+
return cached;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Change some of the record, leaving the rest of what is stored alone.
|
|
144
|
+
*
|
|
145
|
+
* Read-merge-write, and the merge is over the STORED keys rather than over
|
|
146
|
+
* `ToolbarPreferences`. This issue writes `collapsed` and #21 writes `dock`;
|
|
147
|
+
* either one writing a whole record built from its own fields would erase the
|
|
148
|
+
* other's the first time a reviewer used both. The same spread is what
|
|
149
|
+
* preserves a key this build has never heard of.
|
|
150
|
+
*/
|
|
151
|
+
export function writePreferences(patch) {
|
|
152
|
+
const raw = JSON.stringify({ ...(parseObject(readRaw()) ?? {}), ...patch });
|
|
153
|
+
try {
|
|
154
|
+
window.localStorage.setItem(TOOLBAR_PREFERENCES_STORAGE_KEY, raw);
|
|
155
|
+
memoryRaw = null;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
memoryRaw = raw;
|
|
159
|
+
}
|
|
160
|
+
listeners.forEach((listener) => listener());
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The settings as an external store, not as component state read from
|
|
164
|
+
* `localStorage` in a `useState` initialiser.
|
|
165
|
+
*
|
|
166
|
+
* The initialiser is the shape this package already had to fix once, in
|
|
167
|
+
* `INBOX_OPEN_STORE`: on the server the read answers the default and on the
|
|
168
|
+
* client it answers the stored value, so a reviewer who had changed anything
|
|
169
|
+
* got React's "server rendered HTML didn't match the client" in the
|
|
170
|
+
* CUSTOMER's console on every page load. `collapsed` is a harder case than the
|
|
171
|
+
* inbox flag was - it decides which CONTROLS EXIST in the bar - so it takes
|
|
172
|
+
* the same shape: a deterministic server snapshot, and the stored value
|
|
173
|
+
* adopted after hydration rather than during it.
|
|
174
|
+
*/
|
|
175
|
+
export const PREFERENCES_STORE = {
|
|
176
|
+
// Arrow properties rather than shorthand methods: these are handed to
|
|
177
|
+
// `useSyncExternalStore` detached from the object, so nothing here may
|
|
178
|
+
// depend on `this`.
|
|
179
|
+
subscribe: (listener) => {
|
|
180
|
+
listeners.add(listener);
|
|
181
|
+
return () => {
|
|
182
|
+
listeners.delete(listener);
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
/** The client's answer: storage, else whatever this page last chose. */
|
|
186
|
+
getSnapshot: readPreferences,
|
|
187
|
+
/**
|
|
188
|
+
* The server's answer, and the first client render's. The defaults: a bar
|
|
189
|
+
* that hydrates in the form the server drew and then adopts the reviewer's
|
|
190
|
+
* choice is a paint, and one that hydrates into a different set of buttons
|
|
191
|
+
* than the server rendered is an error in somebody else's application.
|
|
192
|
+
*/
|
|
193
|
+
getServerSnapshot: () => DEFAULT_PREFERENCES,
|
|
194
|
+
};
|
|
195
|
+
//# sourceMappingURL=preferences.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preferences.js","sourceRoot":"","sources":["../src/preferences.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,EAAE;AACF,8EAA8E;AAC9E,4EAA4E;AAC5E,6EAA6E;AAC7E,8EAA8E;AAC9E,+EAA+E;AAC/E,4EAA4E;AAC5E,+EAA+E;AAC/E,iEAAiE;AACjE,6EAA6E;AAC7E,0EAA0E;AAC1E,8EAA8E;AAC9E,EAAE;AACF,4EAA4E;AAC5E,wEAAwE;AACxE,0EAA0E;AAC1E,8EAA8E;AAC9E,8EAA8E;AAC9E,8EAA8E;AAC9E,6EAA6E;AAC7E,kCAAkC;AAClC,EAAE;AACF,2EAA2E;AAC3E,gDAAgD;AAChD,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,mEAAmE;AACnE,yCAAyC;AACzC,+EAA+E;AAC/E,4EAA4E;AAC5E,wEAAwE;AACxE,6EAA6E;AAC7E,0EAA0E;AAC1E,yEAAyE;AACzE,8EAA8E;AAC9E,oBAAoB;AAEpB,OAAO,EAAE,YAAY,EAAE,aAAa,EAAoB,MAAM,UAAU,CAAC;AAgBzE,MAAM,CAAC,MAAM,+BAA+B,GAC1C,qCAAqC,CAAC;AAExC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAuB,MAAM,CAAC,MAAM,CAAC;IACnE,SAAS,EAAE,KAAK;IAChB,IAAI,EAAE,YAAY;CACnB,CAAC,CAAC;AAEH,MAAM,SAAS,GAAG,IAAI,GAAG,EAAc,CAAC;AAExC;;;;;GAKG;AACH,IAAI,SAAS,GAAkB,IAAI,CAAC;AAEpC,4EAA4E;AAC5E,IAAI,SAAS,GAAkB,IAAI,CAAC;AACpC,IAAI,MAAM,GAAuB,mBAAmB,CAAC;AAErD,SAAS,OAAO;IACd,6EAA6E;IAC7E,2EAA2E;IAC3E,uEAAuE;IACvE,yEAAyE;IACzE,yEAAyE;IACzE,sEAAsE;IACtE,0DAA0D;IAC1D,IAAI,SAAS,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IACzC,IAAI,CAAC;QACH,OAAO,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,+BAA+B,CAAC,CAAC;IACtE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,SAAS,WAAW,CAAC,GAAkB;IACrC,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC9B,IAAI,CAAC;QACH,MAAM,KAAK,GAAY,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACvC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;YACxE,OAAO,IAAI,CAAC;QACd,CAAC;QACD,OAAO,KAAgC,CAAC;IAC1C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,SAAS,gBAAgB,CAAC,GAAkB;IAC1C,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;IAChC,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,mBAAmB,CAAC;IAChD,OAAO;QACL,SAAS,EACP,OAAO,MAAM,CAAC,SAAS,KAAK,SAAS;YACnC,CAAC,CAAC,MAAM,CAAC,SAAS;YAClB,CAAC,CAAC,mBAAmB,CAAC,SAAS;QACnC,uEAAuE;QACvE,yEAAyE;QACzE,sEAAsE;QACtE,yEAAyE;QACzE,0CAA0C;QAC1C,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,mBAAmB,CAAC,IAAI;KAC1E,CAAC;AACJ,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,eAAe;IAC7B,MAAM,GAAG,GAAG,OAAO,EAAE,CAAC;IACtB,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;QACtB,SAAS,GAAG,GAAG,CAAC;QAChB,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAC;IACjC,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAkC;IACjE,MAAM,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,GAAG,KAAK,EAAE,CAAC,CAAC;IAC5E,IAAI,CAAC;QACH,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,+BAA+B,EAAE,GAAG,CAAC,CAAC;QAClE,SAAS,GAAG,IAAI,CAAC;IACnB,CAAC;IAAC,MAAM,CAAC;QACP,SAAS,GAAG,GAAG,CAAC;IAClB,CAAC;IACD,SAAS,CAAC,OAAO,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,EAAE,CAAC,CAAC;AAC9C,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,sEAAsE;IACtE,uEAAuE;IACvE,oBAAoB;IACpB,SAAS,EAAE,CAAC,QAAoB,EAAgB,EAAE;QAChD,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACxB,OAAO,GAAG,EAAE;YACV,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC7B,CAAC,CAAC;IACJ,CAAC;IACD,wEAAwE;IACxE,WAAW,EAAE,eAAe;IAC5B;;;;;OAKG;IACH,iBAAiB,EAAE,GAAuB,EAAE,CAAC,mBAAmB;CACjE,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rasterise the visible viewport to a PNG data URL, or null if the browser
|
|
3
|
+
* would not produce one.
|
|
4
|
+
*
|
|
5
|
+
* @param maxDataUrlLength Longest data URL the API will accept. The image is
|
|
6
|
+
* re-encoded at half scale, twice, before giving up, so a very large or very
|
|
7
|
+
* dense viewport degrades in quality rather than disappearing.
|
|
8
|
+
*/
|
|
9
|
+
export declare function rasteriseViewport(maxDataUrlLength: number): Promise<string | null>;
|
|
10
|
+
/**
|
|
11
|
+
* Rasterise ONE element to a PNG data URL, or null if the browser would not
|
|
12
|
+
* produce one.
|
|
13
|
+
*
|
|
14
|
+
* A reviewer pointing at a broken button wants a picture of the button, not of
|
|
15
|
+
* the page it is on: the whole-viewport shot puts the thing they are reporting
|
|
16
|
+
* somewhere in a 1512x916 image and leaves the reader to find it. This is the
|
|
17
|
+
* camera in the composer.
|
|
18
|
+
*
|
|
19
|
+
* An element with no box - `display: none`, or detached - yields null rather
|
|
20
|
+
* than a 1x1 image, because a blank attachment is worse than none.
|
|
21
|
+
*/
|
|
22
|
+
export declare function rasteriseElement(element: Element, maxDataUrlLength: number): Promise<string | null>;
|
|
23
|
+
/**
|
|
24
|
+
* Serialise the visible page as an SVG document string, or null if the page is
|
|
25
|
+
* too large to walk. Exported for the unit tests: it is pure DOM work with no
|
|
26
|
+
* canvas or image decoding in it, so it is the part that is provable in jsdom.
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildViewportSvg(width: number, height: number,
|
|
29
|
+
/**
|
|
30
|
+
* Where in the viewport the picture starts. `{x: 0, y: 0}` is the viewport
|
|
31
|
+
* shot; an element shot passes its own top-left, and the body clone shifts
|
|
32
|
+
* by the same amount so the element lands at the picture's origin.
|
|
33
|
+
*
|
|
34
|
+
* Cropping by MOVING THE WHOLE PAGE rather than by cloning only the element
|
|
35
|
+
* is the decision here. An element does not carry its own layout: its width
|
|
36
|
+
* may come from a flex parent, its colour from a `.dark` ancestor, its font
|
|
37
|
+
* from `html`. Cloning it alone reproduces none of that, and the picture
|
|
38
|
+
* that comes back is not the thing the reviewer pointed at. The page is
|
|
39
|
+
* cloned exactly as it is and the viewport is narrowed instead.
|
|
40
|
+
*/
|
|
41
|
+
origin?: {
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
}): string | null;
|
|
45
|
+
//# sourceMappingURL=rasterise.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rasterise.d.ts","sourceRoot":"","sources":["../src/rasterise.ts"],"names":[],"mappings":"AAgNA;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAExB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,OAAO,EAChB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAIxB;AAsDD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM;AACd;;;;;;;;;;;GAWG;AACH,MAAM,GAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAmB,GAChD,MAAM,GAAG,IAAI,CAyCf"}
|