@le-space/ui 0.9.0 → 0.9.2
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/package.json +3 -3
- package/react/index.d.ts +24 -0
- package/react/index.js +187 -19
- package/shared/index.d.ts +85 -2
- package/shared/index.js +91 -10
- package/svelte/SponsorRelayFab.svelte +97 -4
- package/svelte/components/LauncherButton.svelte +65 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@le-space/ui",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Floating Aleph Relay Button UI components for Svelte and React.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"url": "https://github.com/NiKrause/relay-button/issues"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@le-space/browser": "0.9.
|
|
48
|
-
"@le-space/core": "0.9.
|
|
47
|
+
"@le-space/browser": "0.9.2",
|
|
48
|
+
"@le-space/core": "0.9.2",
|
|
49
49
|
"viem": "^2.53.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
package/react/index.d.ts
CHANGED
|
@@ -199,6 +199,16 @@ interface DeploymentProgressEvent {
|
|
|
199
199
|
}
|
|
200
200
|
type DeploymentProgressListener = (event: DeploymentProgressEvent) => void;
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Geometry for a launcher the consumer can place and the user can drag.
|
|
204
|
+
*
|
|
205
|
+
* Kept free of DOM access on purpose: the framework layers read rects and write
|
|
206
|
+
* styles, this module only does the arithmetic, which is what makes the awkward
|
|
207
|
+
* parts — the tap/drag threshold, staying on screen after a rotation — testable
|
|
208
|
+
* without a browser.
|
|
209
|
+
*/
|
|
210
|
+
type FabCorner = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
211
|
+
|
|
202
212
|
type SponsorRelayHealthTone = 'ok' | 'caution' | 'error' | 'idle';
|
|
203
213
|
interface SponsorRelayUcanStoreBootstrapInput {
|
|
204
214
|
adminDid: string;
|
|
@@ -233,6 +243,20 @@ interface SponsorRelayProps {
|
|
|
233
243
|
crnSource?: CrnSourcePreference;
|
|
234
244
|
schedulerApiHost?: string;
|
|
235
245
|
twoN6ApiHost?: string;
|
|
246
|
+
/**
|
|
247
|
+
* Which corner the floating launcher starts in. Consumers could not place it
|
|
248
|
+
* at all before: the launcher is `position: fixed`, so a wrapper around it
|
|
249
|
+
* collapses to 0x0 and has no box to position or drag.
|
|
250
|
+
*/
|
|
251
|
+
position?: FabCorner;
|
|
252
|
+
/** Let the user drag the launcher. The panel follows it. */
|
|
253
|
+
draggable?: boolean;
|
|
254
|
+
/**
|
|
255
|
+
* localStorage key for the dragged position. Omit it and nothing is
|
|
256
|
+
* persisted -- a library that writes to storage unasked collides with its
|
|
257
|
+
* host.
|
|
258
|
+
*/
|
|
259
|
+
positionStorageKey?: string;
|
|
236
260
|
}
|
|
237
261
|
interface SponsorRelayWalletState {
|
|
238
262
|
connected: boolean;
|
package/react/index.js
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
// src/react/SponsorRelayFab.tsx
|
|
2
2
|
import React2 from "react";
|
|
3
3
|
|
|
4
|
+
// src/shared/fab-position.ts
|
|
5
|
+
var FAB_EDGE_MARGIN = 22;
|
|
6
|
+
var FAB_DRAG_THRESHOLD = 6;
|
|
7
|
+
function dragExceedsThreshold(start, current, threshold = FAB_DRAG_THRESHOLD) {
|
|
8
|
+
const dx = current.x - start.x;
|
|
9
|
+
const dy = current.y - start.y;
|
|
10
|
+
return Math.hypot(dx, dy) >= threshold;
|
|
11
|
+
}
|
|
12
|
+
function resolveCornerPlacement(corner, size, viewport, margin = FAB_EDGE_MARGIN) {
|
|
13
|
+
const left = corner.endsWith("left") ? margin : viewport.width - size.width - margin;
|
|
14
|
+
const top = corner.startsWith("top") ? margin : viewport.height - size.height - margin;
|
|
15
|
+
return clampToViewport({ left, top }, size, viewport, margin);
|
|
16
|
+
}
|
|
17
|
+
function clampToViewport(placement, size, viewport, margin = FAB_EDGE_MARGIN) {
|
|
18
|
+
const maxLeft = Math.max(margin, viewport.width - size.width - margin);
|
|
19
|
+
const maxTop = Math.max(margin, viewport.height - size.height - margin);
|
|
20
|
+
return {
|
|
21
|
+
left: Math.min(Math.max(placement.left, margin), maxLeft),
|
|
22
|
+
top: Math.min(Math.max(placement.top, margin), maxTop)
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function anchorPanelToLauncher(launcher, panel, viewport, gap = 12, margin = FAB_EDGE_MARGIN) {
|
|
26
|
+
const launcherCentre = launcher.top + launcher.height / 2;
|
|
27
|
+
const side = launcherCentre < viewport.height / 2 ? "below" : "above";
|
|
28
|
+
const top = side === "below" ? launcher.top + launcher.height + gap : launcher.top - gap - panel.height;
|
|
29
|
+
const preferredLeft = launcher.left + launcher.width / 2 < viewport.width / 2 ? launcher.left : launcher.left + launcher.width - panel.width;
|
|
30
|
+
const clamped = clampToViewport({ left: preferredLeft, top }, panel, viewport, margin);
|
|
31
|
+
const available = side === "below" ? viewport.height - clamped.top - margin : launcher.top - gap - margin;
|
|
32
|
+
return {
|
|
33
|
+
...clamped,
|
|
34
|
+
side,
|
|
35
|
+
maxHeight: Math.max(0, available)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function readStoredPosition(key) {
|
|
39
|
+
if (!key) return null;
|
|
40
|
+
try {
|
|
41
|
+
const raw = globalThis.localStorage?.getItem(key);
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
return Number.isFinite(parsed?.left) && Number.isFinite(parsed?.top) ? parsed : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function writeStoredPosition(key, placement, viewport) {
|
|
50
|
+
if (!key) return;
|
|
51
|
+
try {
|
|
52
|
+
globalThis.localStorage?.setItem(key, JSON.stringify({ ...placement, viewport }));
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
4
57
|
// src/shared/constants.ts
|
|
5
58
|
var DEFAULT_INSTANCE_NAME = "sponsor-relay";
|
|
6
59
|
var DEFAULT_MANIFEST_URL = "./rootfs-manifest.json";
|
|
@@ -43,7 +96,7 @@ var RECENT_INSTANCE_RUNTIME_GRACE_MS = 10 * 60 * 1e3;
|
|
|
43
96
|
var DEPLOYMENT_PENDING_WARNING_MS = 3 * 60 * 1e3;
|
|
44
97
|
|
|
45
98
|
// src/shared/package-version.ts
|
|
46
|
-
var UI_PACKAGE_VERSION = "0.9.
|
|
99
|
+
var UI_PACKAGE_VERSION = "0.9.2";
|
|
47
100
|
|
|
48
101
|
// src/shared/format.ts
|
|
49
102
|
function shortHash(value, head = 8, tail = 6) {
|
|
@@ -4116,6 +4169,17 @@ function hostnameOfUrl(value) {
|
|
|
4116
4169
|
return null;
|
|
4117
4170
|
}
|
|
4118
4171
|
}
|
|
4172
|
+
var INSTANCE_ADDRESS_TIMEOUT_MS = 1e4;
|
|
4173
|
+
function describeInstanceAddressFailure(error, sourceUrl) {
|
|
4174
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
4175
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
4176
|
+
return `${sourceUrl} did not answer within ${INSTANCE_ADDRESS_TIMEOUT_MS / 1e3}s. Press Refresh to try again.`;
|
|
4177
|
+
}
|
|
4178
|
+
if (error instanceof TypeError || /failed to fetch|load failed|networkerror/i.test(raw)) {
|
|
4179
|
+
return `${sourceUrl} closed the connection without answering. Relays built before rootfs 0.8.0 do not serve an address document, and their proxy hangs up instead of returning 404 \u2014 redeploying from a current rootfs fixes it. (${raw})`;
|
|
4180
|
+
}
|
|
4181
|
+
return raw;
|
|
4182
|
+
}
|
|
4119
4183
|
function emptyInstanceAddresses() {
|
|
4120
4184
|
return {
|
|
4121
4185
|
status: "idle",
|
|
@@ -6488,14 +6552,21 @@ var SponsorRelayController = class {
|
|
|
6488
6552
|
sourceUrl,
|
|
6489
6553
|
error: null
|
|
6490
6554
|
});
|
|
6555
|
+
const abort = new AbortController();
|
|
6556
|
+
const timeout = globalThis.setTimeout(
|
|
6557
|
+
() => abort.abort(),
|
|
6558
|
+
INSTANCE_ADDRESS_TIMEOUT_MS
|
|
6559
|
+
);
|
|
6491
6560
|
try {
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6561
|
+
let response;
|
|
6562
|
+
try {
|
|
6563
|
+
response = await fetch(sourceUrl, {
|
|
6564
|
+
cache: "no-cache",
|
|
6565
|
+
signal: abort.signal
|
|
6566
|
+
});
|
|
6567
|
+
} finally {
|
|
6568
|
+
globalThis.clearTimeout(timeout);
|
|
6569
|
+
}
|
|
6499
6570
|
if (response.status === 404) {
|
|
6500
6571
|
this.patchInstanceAddresses(instanceHash, {
|
|
6501
6572
|
...emptyInstanceAddresses(),
|
|
@@ -6522,7 +6593,7 @@ var SponsorRelayController = class {
|
|
|
6522
6593
|
...this.state.instanceAddresses[instanceHash] ?? emptyInstanceAddresses(),
|
|
6523
6594
|
status: "error",
|
|
6524
6595
|
sourceUrl,
|
|
6525
|
-
error: error
|
|
6596
|
+
error: describeInstanceAddressFailure(error, sourceUrl)
|
|
6526
6597
|
});
|
|
6527
6598
|
}
|
|
6528
6599
|
}
|
|
@@ -7151,6 +7222,14 @@ function SponsorRelayFab(props) {
|
|
|
7151
7222
|
}
|
|
7152
7223
|
};
|
|
7153
7224
|
const launcherRef = React2.useRef(null);
|
|
7225
|
+
const panelRef = React2.useRef(null);
|
|
7226
|
+
const draggable = props.draggable ?? false;
|
|
7227
|
+
const corner = props.position ?? "bottom-right";
|
|
7228
|
+
const storageKey = props.positionStorageKey;
|
|
7229
|
+
const [placement, setPlacement] = React2.useState(null);
|
|
7230
|
+
const dragOrigin = React2.useRef(null);
|
|
7231
|
+
const dragOffset = React2.useRef(null);
|
|
7232
|
+
const draggedRef = React2.useRef(false);
|
|
7154
7233
|
const [inlinePanelStyle, setInlinePanelStyle] = React2.useState(null);
|
|
7155
7234
|
const [compactInlineLabel, setCompactInlineLabel] = React2.useState(false);
|
|
7156
7235
|
React2.useEffect(() => {
|
|
@@ -7180,6 +7259,67 @@ function SponsorRelayFab(props) {
|
|
|
7180
7259
|
window.clearTimeout(timeout);
|
|
7181
7260
|
};
|
|
7182
7261
|
}, [state.deploymentProgress.stage, state.deploymentProgress.timestamp]);
|
|
7262
|
+
const viewportSize = () => ({
|
|
7263
|
+
width: window.innerWidth,
|
|
7264
|
+
height: window.innerHeight
|
|
7265
|
+
});
|
|
7266
|
+
const launcherSize = () => {
|
|
7267
|
+
const rect = launcherRef.current?.getBoundingClientRect();
|
|
7268
|
+
return { width: rect?.width ?? 180, height: rect?.height ?? 44 };
|
|
7269
|
+
};
|
|
7270
|
+
React2.useEffect(() => {
|
|
7271
|
+
if (launcherMode !== "floating") {
|
|
7272
|
+
setPlacement(null);
|
|
7273
|
+
return;
|
|
7274
|
+
}
|
|
7275
|
+
const stored = readStoredPosition(storageKey);
|
|
7276
|
+
setPlacement(
|
|
7277
|
+
stored ? clampToViewport(stored, launcherSize(), viewportSize()) : resolveCornerPlacement(corner, launcherSize(), viewportSize())
|
|
7278
|
+
);
|
|
7279
|
+
const onViewportChange = () => setPlacement(
|
|
7280
|
+
(current) => current ? clampToViewport(current, launcherSize(), viewportSize()) : current
|
|
7281
|
+
);
|
|
7282
|
+
window.addEventListener("resize", onViewportChange);
|
|
7283
|
+
window.addEventListener("orientationchange", onViewportChange);
|
|
7284
|
+
return () => {
|
|
7285
|
+
window.removeEventListener("resize", onViewportChange);
|
|
7286
|
+
window.removeEventListener("orientationchange", onViewportChange);
|
|
7287
|
+
};
|
|
7288
|
+
}, [launcherMode, corner, storageKey]);
|
|
7289
|
+
const handlePointerDown = (event) => {
|
|
7290
|
+
if (!draggable || launcherMode !== "floating" || event.button > 0 || !placement) return;
|
|
7291
|
+
dragOrigin.current = { x: event.clientX, y: event.clientY };
|
|
7292
|
+
dragOffset.current = { x: event.clientX - placement.left, y: event.clientY - placement.top };
|
|
7293
|
+
draggedRef.current = false;
|
|
7294
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
7295
|
+
};
|
|
7296
|
+
const handlePointerMove = (event) => {
|
|
7297
|
+
const origin = dragOrigin.current;
|
|
7298
|
+
const offset = dragOffset.current;
|
|
7299
|
+
if (!origin || !offset) return;
|
|
7300
|
+
const point = { x: event.clientX, y: event.clientY };
|
|
7301
|
+
if (!draggedRef.current && !dragExceedsThreshold(origin, point)) return;
|
|
7302
|
+
draggedRef.current = true;
|
|
7303
|
+
setPlacement(
|
|
7304
|
+
clampToViewport(
|
|
7305
|
+
{ left: point.x - offset.x, top: point.y - offset.y },
|
|
7306
|
+
launcherSize(),
|
|
7307
|
+
viewportSize()
|
|
7308
|
+
)
|
|
7309
|
+
);
|
|
7310
|
+
};
|
|
7311
|
+
const handlePointerUp = (event) => {
|
|
7312
|
+
if (!dragOrigin.current) return;
|
|
7313
|
+
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
7314
|
+
dragOrigin.current = null;
|
|
7315
|
+
dragOffset.current = null;
|
|
7316
|
+
if (draggedRef.current && placement) {
|
|
7317
|
+
writeStoredPosition(storageKey, placement, viewportSize());
|
|
7318
|
+
} else {
|
|
7319
|
+
controller.toggleOpen();
|
|
7320
|
+
}
|
|
7321
|
+
draggedRef.current = false;
|
|
7322
|
+
};
|
|
7183
7323
|
React2.useEffect(() => {
|
|
7184
7324
|
if (launcherMode !== "inline" || !state.open) {
|
|
7185
7325
|
return;
|
|
@@ -7233,18 +7373,37 @@ function SponsorRelayFab(props) {
|
|
|
7233
7373
|
const inlineButtonBorder = `1px solid ${themedLauncherBorder}`;
|
|
7234
7374
|
const inlineBadgeBackground = themedLauncherBadgeBackground;
|
|
7235
7375
|
const inlineBadgeBorder = `1px solid ${themedLauncherBadgeBorder}`;
|
|
7376
|
+
const floatingPanelStyle = () => {
|
|
7377
|
+
if (!placement || typeof window === "undefined") {
|
|
7378
|
+
return {
|
|
7379
|
+
...basePanelStyle,
|
|
7380
|
+
right: "1.4rem",
|
|
7381
|
+
bottom: "11.5rem",
|
|
7382
|
+
maxHeight: "calc(100vh - 12.5rem)"
|
|
7383
|
+
};
|
|
7384
|
+
}
|
|
7385
|
+
const panelRect = panelRef.current?.getBoundingClientRect();
|
|
7386
|
+
const anchored = anchorPanelToLauncher(
|
|
7387
|
+
{ ...placement, ...launcherSize() },
|
|
7388
|
+
{ width: panelRect?.width ?? 448, height: panelRect?.height ?? 520 },
|
|
7389
|
+
viewportSize()
|
|
7390
|
+
);
|
|
7391
|
+
return {
|
|
7392
|
+
...basePanelStyle,
|
|
7393
|
+
left: anchored.left,
|
|
7394
|
+
top: anchored.top,
|
|
7395
|
+
right: "auto",
|
|
7396
|
+
bottom: "auto",
|
|
7397
|
+
maxHeight: anchored.maxHeight
|
|
7398
|
+
};
|
|
7399
|
+
};
|
|
7236
7400
|
const panelStyle = launcherMode === "inline" ? inlinePanelStyle ?? {
|
|
7237
7401
|
...basePanelStyle,
|
|
7238
7402
|
top: "4.75rem",
|
|
7239
7403
|
right: "1rem",
|
|
7240
7404
|
maxHeight: "calc(100vh - 6rem)",
|
|
7241
7405
|
width: "min(30rem, calc(100vw - 1.5rem))"
|
|
7242
|
-
} :
|
|
7243
|
-
...basePanelStyle,
|
|
7244
|
-
right: "1.4rem",
|
|
7245
|
-
bottom: "11.5rem",
|
|
7246
|
-
maxHeight: "calc(100vh - 12.5rem)"
|
|
7247
|
-
};
|
|
7406
|
+
} : floatingPanelStyle();
|
|
7248
7407
|
const launcherLabel = deploymentLauncherLabel(state, compactInlineLabel, launcherMode);
|
|
7249
7408
|
const rootfsBlocked = state.rootfsHealth.tone === "error";
|
|
7250
7409
|
const deployCallToAction = state.wallet.connected ? state.busy.deploying ? "Deploying\u2026" : rootfsBlocked ? "Deployment blocked \u2014 Rootfs unavailable" : deploymentButtonLabel(state) : "Connect MetaMask";
|
|
@@ -7255,7 +7414,11 @@ function SponsorRelayFab(props) {
|
|
|
7255
7414
|
{
|
|
7256
7415
|
ref: launcherRef,
|
|
7257
7416
|
type: "button",
|
|
7258
|
-
onClick: () => controller.toggleOpen(),
|
|
7417
|
+
onClick: draggable ? void 0 : () => controller.toggleOpen(),
|
|
7418
|
+
onPointerDown: handlePointerDown,
|
|
7419
|
+
onPointerMove: handlePointerMove,
|
|
7420
|
+
onPointerUp: handlePointerUp,
|
|
7421
|
+
onPointerCancel: handlePointerUp,
|
|
7259
7422
|
onMouseEnter: () => setHovered(true),
|
|
7260
7423
|
onMouseLeave: () => setHovered(false),
|
|
7261
7424
|
onFocus: () => setHovered(true),
|
|
@@ -7263,8 +7426,13 @@ function SponsorRelayFab(props) {
|
|
|
7263
7426
|
title: indicator.detail ?? deploymentPanelTitle(state),
|
|
7264
7427
|
style: {
|
|
7265
7428
|
position: launcherMode === "floating" ? "fixed" : "relative",
|
|
7266
|
-
|
|
7267
|
-
|
|
7429
|
+
left: launcherMode === "floating" && placement ? placement.left : void 0,
|
|
7430
|
+
top: launcherMode === "floating" && placement ? placement.top : void 0,
|
|
7431
|
+
right: launcherMode === "floating" && !placement ? "1.4rem" : void 0,
|
|
7432
|
+
bottom: launcherMode === "floating" && !placement ? "5.8rem" : void 0,
|
|
7433
|
+
// Without this the browser scrolls the page instead of moving the button.
|
|
7434
|
+
touchAction: draggable ? "none" : void 0,
|
|
7435
|
+
userSelect: draggable ? "none" : void 0,
|
|
7268
7436
|
zIndex: launcherMode === "floating" ? 1e4 : "auto",
|
|
7269
7437
|
borderRadius: "999px",
|
|
7270
7438
|
border: launcherMode === "floating" ? `1px solid ${themedLauncherBorder}` : inlineButtonBorder,
|
|
@@ -7394,7 +7562,7 @@ function SponsorRelayFab(props) {
|
|
|
7394
7562
|
}
|
|
7395
7563
|
}
|
|
7396
7564
|
),
|
|
7397
|
-
/* @__PURE__ */ jsxs("aside", { style: panelStyle, children: [
|
|
7565
|
+
/* @__PURE__ */ jsxs("aside", { ref: panelRef, style: panelStyle, children: [
|
|
7398
7566
|
/* @__PURE__ */ jsxs(
|
|
7399
7567
|
"div",
|
|
7400
7568
|
{
|
package/shared/index.d.ts
CHANGED
|
@@ -197,6 +197,75 @@ interface DeploymentProgressEvent {
|
|
|
197
197
|
}
|
|
198
198
|
type DeploymentProgressListener = (event: DeploymentProgressEvent) => void;
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Geometry for a launcher the consumer can place and the user can drag.
|
|
202
|
+
*
|
|
203
|
+
* Kept free of DOM access on purpose: the framework layers read rects and write
|
|
204
|
+
* styles, this module only does the arithmetic, which is what makes the awkward
|
|
205
|
+
* parts — the tap/drag threshold, staying on screen after a rotation — testable
|
|
206
|
+
* without a browser.
|
|
207
|
+
*/
|
|
208
|
+
type FabCorner = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
209
|
+
interface FabPoint {
|
|
210
|
+
x: number;
|
|
211
|
+
y: number;
|
|
212
|
+
}
|
|
213
|
+
interface FabSize {
|
|
214
|
+
width: number;
|
|
215
|
+
height: number;
|
|
216
|
+
}
|
|
217
|
+
interface FabViewport {
|
|
218
|
+
width: number;
|
|
219
|
+
height: number;
|
|
220
|
+
}
|
|
221
|
+
/** Absolute top-left of the launcher, in viewport coordinates. */
|
|
222
|
+
interface FabPlacement {
|
|
223
|
+
left: number;
|
|
224
|
+
top: number;
|
|
225
|
+
}
|
|
226
|
+
declare const DEFAULT_FAB_CORNER: FabCorner;
|
|
227
|
+
/** Distance from the viewport edge, matching the previous `1.4rem` inset. */
|
|
228
|
+
declare const FAB_EDGE_MARGIN = 22;
|
|
229
|
+
/**
|
|
230
|
+
* Pointer travel, in pixels, before a press becomes a drag.
|
|
231
|
+
*
|
|
232
|
+
* Not zero: fingers are not steady, so a strict zero turns every tap into a
|
|
233
|
+
* one-pixel drag and the click never fires.
|
|
234
|
+
*/
|
|
235
|
+
declare const FAB_DRAG_THRESHOLD = 6;
|
|
236
|
+
declare function dragExceedsThreshold(start: FabPoint, current: FabPoint, threshold?: number): boolean;
|
|
237
|
+
/** Where a named corner puts a launcher of this size in this viewport. */
|
|
238
|
+
declare function resolveCornerPlacement(corner: FabCorner, size: FabSize, viewport: FabViewport, margin?: number): FabPlacement;
|
|
239
|
+
/**
|
|
240
|
+
* Keep the launcher reachable. A viewport that shrinks — rotation, a desktop
|
|
241
|
+
* window dragged narrow, a mobile keyboard — would otherwise strand a dragged
|
|
242
|
+
* button off-screen with no way to bring it back.
|
|
243
|
+
*/
|
|
244
|
+
declare function clampToViewport(placement: FabPlacement, size: FabSize, viewport: FabViewport, margin?: number): FabPlacement;
|
|
245
|
+
/** Which way the panel should open, given where the launcher sits. */
|
|
246
|
+
type FabPanelSide = 'above' | 'below';
|
|
247
|
+
interface FabPanelPlacement extends FabPlacement {
|
|
248
|
+
side: FabPanelSide;
|
|
249
|
+
maxHeight: number;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Hang the panel off the launcher instead of off its own hardcoded corner.
|
|
253
|
+
*
|
|
254
|
+
* The panel opens away from the nearer edge, so a launcher dragged to the top
|
|
255
|
+
* of the screen drops its panel downward rather than off-screen upward.
|
|
256
|
+
*/
|
|
257
|
+
declare function anchorPanelToLauncher(launcher: FabPlacement & FabSize, panel: FabSize, viewport: FabViewport, gap?: number, margin?: number): FabPanelPlacement;
|
|
258
|
+
interface StoredPosition extends FabPlacement {
|
|
259
|
+
/** Viewport the position was captured in, so a resize can be detected. */
|
|
260
|
+
viewport?: FabViewport;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Persistence is opt-in through a consumer-supplied key: a library that writes
|
|
264
|
+
* to localStorage unasked is a library that collides with its host.
|
|
265
|
+
*/
|
|
266
|
+
declare function readStoredPosition(key: string | null | undefined): StoredPosition | null;
|
|
267
|
+
declare function writeStoredPosition(key: string | null | undefined, placement: FabPlacement, viewport?: FabViewport): void;
|
|
268
|
+
|
|
200
269
|
type SponsorRelayHealthTone = 'ok' | 'caution' | 'error' | 'idle';
|
|
201
270
|
interface SponsorRelayUcanStoreBootstrapInput {
|
|
202
271
|
adminDid: string;
|
|
@@ -231,6 +300,20 @@ interface SponsorRelayProps {
|
|
|
231
300
|
crnSource?: CrnSourcePreference;
|
|
232
301
|
schedulerApiHost?: string;
|
|
233
302
|
twoN6ApiHost?: string;
|
|
303
|
+
/**
|
|
304
|
+
* Which corner the floating launcher starts in. Consumers could not place it
|
|
305
|
+
* at all before: the launcher is `position: fixed`, so a wrapper around it
|
|
306
|
+
* collapses to 0x0 and has no box to position or drag.
|
|
307
|
+
*/
|
|
308
|
+
position?: FabCorner;
|
|
309
|
+
/** Let the user drag the launcher. The panel follows it. */
|
|
310
|
+
draggable?: boolean;
|
|
311
|
+
/**
|
|
312
|
+
* localStorage key for the dragged position. Omit it and nothing is
|
|
313
|
+
* persisted -- a library that writes to storage unasked collides with its
|
|
314
|
+
* host.
|
|
315
|
+
*/
|
|
316
|
+
positionStorageKey?: string;
|
|
234
317
|
}
|
|
235
318
|
interface SponsorRelayWalletState {
|
|
236
319
|
connected: boolean;
|
|
@@ -397,7 +480,7 @@ declare const STALE_INSTANCE_ALLOCATION_COOLDOWN_MS: number;
|
|
|
397
480
|
declare const RECENT_INSTANCE_RUNTIME_GRACE_MS: number;
|
|
398
481
|
declare const DEPLOYMENT_PENDING_WARNING_MS: number;
|
|
399
482
|
|
|
400
|
-
declare const UI_PACKAGE_VERSION = "0.9.
|
|
483
|
+
declare const UI_PACKAGE_VERSION = "0.9.2";
|
|
401
484
|
|
|
402
485
|
interface RootfsRequiredPortForward {
|
|
403
486
|
port: number;
|
|
@@ -520,4 +603,4 @@ interface DeploymentProgressEmitter {
|
|
|
520
603
|
}
|
|
521
604
|
declare function createDeploymentProgressEmitter(): DeploymentProgressEmitter;
|
|
522
605
|
|
|
523
|
-
export { type CompactBootstrapRegistrationRecord, type CompactInstanceDetails, type CompactInstanceRecord, DEFAULT_INSTANCE_NAME, DEFAULT_MANIFEST_URL, DEFAULT_TIER_ID, DEFAULT_UCAN_STORE_ALLOWED_CAPABILITIES, DEFAULT_UCAN_STORE_MAX_DELEGATION_EXPIRATION_SECONDS, DEFAULT_UCAN_STORE_PWA_ORIGIN, DEFAULT_UCAN_STORE_SERVICE_DID, DEFAULT_UCAN_STORE_SERVICE_ORIGIN, DEFAULT_UCAN_STORE_USER_DELEGATION_EXPIRATION_SECONDS, DEPLOYMENT_PENDING_WARNING_MS, type DeploymentProgressEmitter, type EthereumProviderLike, IDLE_DEPLOYMENT_PROGRESS, INSTANCE_RUNTIME_PENDING_REFRESH_MS, INSTANCE_RUNTIME_READY_REFRESH_MS, INSTANCE_RUNTIME_STALE_REFRESH_MS, MANIFEST_SOURCE_REFRESH_DEBOUNCE_MS, RECENT_INSTANCE_RUNTIME_GRACE_MS, REFRESH_INTERVAL_MS, ROOTFS_MISSING_STATE, STALE_INSTANCE_ALLOCATION_COOLDOWN_MS, type SponsorRelayAddressGroup, SponsorRelayController, type SponsorRelayHealthTone, type SponsorRelayInstanceAddresses, type SponsorRelayPricingSummary, type SponsorRelayProps, type SponsorRelayRootfsHealth, type SponsorRelayState, type SponsorRelaySubscriber, type SponsorRelayUcanStoreBootstrapInput, type SponsorRelayWalletState, UI_PACKAGE_VERSION, buildSshCommand, connectWallet, createDeploymentProgressEmitter, createSponsorRelayController, formatDateTime, formatNumber, formatTierSpecLabel, getEthereumProvider, joinMappedPorts, joinRequiredPortForwards, parseInstanceAddressPayload, personalSign, resolveManifestSource, rootfsHealth, shortHash, toChecksumAddress, watchWallet };
|
|
606
|
+
export { type CompactBootstrapRegistrationRecord, type CompactInstanceDetails, type CompactInstanceRecord, DEFAULT_FAB_CORNER, DEFAULT_INSTANCE_NAME, DEFAULT_MANIFEST_URL, DEFAULT_TIER_ID, DEFAULT_UCAN_STORE_ALLOWED_CAPABILITIES, DEFAULT_UCAN_STORE_MAX_DELEGATION_EXPIRATION_SECONDS, DEFAULT_UCAN_STORE_PWA_ORIGIN, DEFAULT_UCAN_STORE_SERVICE_DID, DEFAULT_UCAN_STORE_SERVICE_ORIGIN, DEFAULT_UCAN_STORE_USER_DELEGATION_EXPIRATION_SECONDS, DEPLOYMENT_PENDING_WARNING_MS, type DeploymentProgressEmitter, type EthereumProviderLike, FAB_DRAG_THRESHOLD, FAB_EDGE_MARGIN, type FabCorner, type FabPanelPlacement, type FabPanelSide, type FabPlacement, type FabPoint, type FabSize, type FabViewport, IDLE_DEPLOYMENT_PROGRESS, INSTANCE_RUNTIME_PENDING_REFRESH_MS, INSTANCE_RUNTIME_READY_REFRESH_MS, INSTANCE_RUNTIME_STALE_REFRESH_MS, MANIFEST_SOURCE_REFRESH_DEBOUNCE_MS, RECENT_INSTANCE_RUNTIME_GRACE_MS, REFRESH_INTERVAL_MS, ROOTFS_MISSING_STATE, STALE_INSTANCE_ALLOCATION_COOLDOWN_MS, type SponsorRelayAddressGroup, SponsorRelayController, type SponsorRelayHealthTone, type SponsorRelayInstanceAddresses, type SponsorRelayPricingSummary, type SponsorRelayProps, type SponsorRelayRootfsHealth, type SponsorRelayState, type SponsorRelaySubscriber, type SponsorRelayUcanStoreBootstrapInput, type SponsorRelayWalletState, UI_PACKAGE_VERSION, anchorPanelToLauncher, buildSshCommand, clampToViewport, connectWallet, createDeploymentProgressEmitter, createSponsorRelayController, dragExceedsThreshold, formatDateTime, formatNumber, formatTierSpecLabel, getEthereumProvider, joinMappedPorts, joinRequiredPortForwards, parseInstanceAddressPayload, personalSign, readStoredPosition, resolveCornerPlacement, resolveManifestSource, rootfsHealth, shortHash, toChecksumAddress, watchWallet, writeStoredPosition };
|
package/shared/index.js
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
+
// src/shared/fab-position.ts
|
|
2
|
+
var DEFAULT_FAB_CORNER = "bottom-right";
|
|
3
|
+
var FAB_EDGE_MARGIN = 22;
|
|
4
|
+
var FAB_DRAG_THRESHOLD = 6;
|
|
5
|
+
function dragExceedsThreshold(start, current, threshold = FAB_DRAG_THRESHOLD) {
|
|
6
|
+
const dx = current.x - start.x;
|
|
7
|
+
const dy = current.y - start.y;
|
|
8
|
+
return Math.hypot(dx, dy) >= threshold;
|
|
9
|
+
}
|
|
10
|
+
function resolveCornerPlacement(corner, size, viewport, margin = FAB_EDGE_MARGIN) {
|
|
11
|
+
const left = corner.endsWith("left") ? margin : viewport.width - size.width - margin;
|
|
12
|
+
const top = corner.startsWith("top") ? margin : viewport.height - size.height - margin;
|
|
13
|
+
return clampToViewport({ left, top }, size, viewport, margin);
|
|
14
|
+
}
|
|
15
|
+
function clampToViewport(placement, size, viewport, margin = FAB_EDGE_MARGIN) {
|
|
16
|
+
const maxLeft = Math.max(margin, viewport.width - size.width - margin);
|
|
17
|
+
const maxTop = Math.max(margin, viewport.height - size.height - margin);
|
|
18
|
+
return {
|
|
19
|
+
left: Math.min(Math.max(placement.left, margin), maxLeft),
|
|
20
|
+
top: Math.min(Math.max(placement.top, margin), maxTop)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function anchorPanelToLauncher(launcher, panel, viewport, gap = 12, margin = FAB_EDGE_MARGIN) {
|
|
24
|
+
const launcherCentre = launcher.top + launcher.height / 2;
|
|
25
|
+
const side = launcherCentre < viewport.height / 2 ? "below" : "above";
|
|
26
|
+
const top = side === "below" ? launcher.top + launcher.height + gap : launcher.top - gap - panel.height;
|
|
27
|
+
const preferredLeft = launcher.left + launcher.width / 2 < viewport.width / 2 ? launcher.left : launcher.left + launcher.width - panel.width;
|
|
28
|
+
const clamped = clampToViewport({ left: preferredLeft, top }, panel, viewport, margin);
|
|
29
|
+
const available = side === "below" ? viewport.height - clamped.top - margin : launcher.top - gap - margin;
|
|
30
|
+
return {
|
|
31
|
+
...clamped,
|
|
32
|
+
side,
|
|
33
|
+
maxHeight: Math.max(0, available)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function readStoredPosition(key) {
|
|
37
|
+
if (!key) return null;
|
|
38
|
+
try {
|
|
39
|
+
const raw = globalThis.localStorage?.getItem(key);
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
return Number.isFinite(parsed?.left) && Number.isFinite(parsed?.top) ? parsed : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function writeStoredPosition(key, placement, viewport) {
|
|
48
|
+
if (!key) return;
|
|
49
|
+
try {
|
|
50
|
+
globalThis.localStorage?.setItem(key, JSON.stringify({ ...placement, viewport }));
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
1
55
|
// src/shared/constants.ts
|
|
2
56
|
var DEFAULT_INSTANCE_NAME = "sponsor-relay";
|
|
3
57
|
var DEFAULT_MANIFEST_URL = "./rootfs-manifest.json";
|
|
@@ -42,7 +96,7 @@ var RECENT_INSTANCE_RUNTIME_GRACE_MS = 10 * 60 * 1e3;
|
|
|
42
96
|
var DEPLOYMENT_PENDING_WARNING_MS = 3 * 60 * 1e3;
|
|
43
97
|
|
|
44
98
|
// src/shared/package-version.ts
|
|
45
|
-
var UI_PACKAGE_VERSION = "0.9.
|
|
99
|
+
var UI_PACKAGE_VERSION = "0.9.2";
|
|
46
100
|
|
|
47
101
|
// src/shared/format.ts
|
|
48
102
|
function shortHash(value, head = 8, tail = 6) {
|
|
@@ -4115,6 +4169,17 @@ function hostnameOfUrl(value) {
|
|
|
4115
4169
|
return null;
|
|
4116
4170
|
}
|
|
4117
4171
|
}
|
|
4172
|
+
var INSTANCE_ADDRESS_TIMEOUT_MS = 1e4;
|
|
4173
|
+
function describeInstanceAddressFailure(error, sourceUrl) {
|
|
4174
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
4175
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
4176
|
+
return `${sourceUrl} did not answer within ${INSTANCE_ADDRESS_TIMEOUT_MS / 1e3}s. Press Refresh to try again.`;
|
|
4177
|
+
}
|
|
4178
|
+
if (error instanceof TypeError || /failed to fetch|load failed|networkerror/i.test(raw)) {
|
|
4179
|
+
return `${sourceUrl} closed the connection without answering. Relays built before rootfs 0.8.0 do not serve an address document, and their proxy hangs up instead of returning 404 \u2014 redeploying from a current rootfs fixes it. (${raw})`;
|
|
4180
|
+
}
|
|
4181
|
+
return raw;
|
|
4182
|
+
}
|
|
4118
4183
|
function emptyInstanceAddresses() {
|
|
4119
4184
|
return {
|
|
4120
4185
|
status: "idle",
|
|
@@ -6487,14 +6552,21 @@ var SponsorRelayController = class {
|
|
|
6487
6552
|
sourceUrl,
|
|
6488
6553
|
error: null
|
|
6489
6554
|
});
|
|
6555
|
+
const abort = new AbortController();
|
|
6556
|
+
const timeout = globalThis.setTimeout(
|
|
6557
|
+
() => abort.abort(),
|
|
6558
|
+
INSTANCE_ADDRESS_TIMEOUT_MS
|
|
6559
|
+
);
|
|
6490
6560
|
try {
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6561
|
+
let response;
|
|
6562
|
+
try {
|
|
6563
|
+
response = await fetch(sourceUrl, {
|
|
6564
|
+
cache: "no-cache",
|
|
6565
|
+
signal: abort.signal
|
|
6566
|
+
});
|
|
6567
|
+
} finally {
|
|
6568
|
+
globalThis.clearTimeout(timeout);
|
|
6569
|
+
}
|
|
6498
6570
|
if (response.status === 404) {
|
|
6499
6571
|
this.patchInstanceAddresses(instanceHash, {
|
|
6500
6572
|
...emptyInstanceAddresses(),
|
|
@@ -6521,7 +6593,7 @@ var SponsorRelayController = class {
|
|
|
6521
6593
|
...this.state.instanceAddresses[instanceHash] ?? emptyInstanceAddresses(),
|
|
6522
6594
|
status: "error",
|
|
6523
6595
|
sourceUrl,
|
|
6524
|
-
error: error
|
|
6596
|
+
error: describeInstanceAddressFailure(error, sourceUrl)
|
|
6525
6597
|
});
|
|
6526
6598
|
}
|
|
6527
6599
|
}
|
|
@@ -6706,6 +6778,7 @@ function createSponsorRelayController(props = {}) {
|
|
|
6706
6778
|
return new SponsorRelayController(props);
|
|
6707
6779
|
}
|
|
6708
6780
|
export {
|
|
6781
|
+
DEFAULT_FAB_CORNER,
|
|
6709
6782
|
DEFAULT_INSTANCE_NAME,
|
|
6710
6783
|
DEFAULT_MANIFEST_URL,
|
|
6711
6784
|
DEFAULT_TIER_ID,
|
|
@@ -6716,6 +6789,8 @@ export {
|
|
|
6716
6789
|
DEFAULT_UCAN_STORE_SERVICE_ORIGIN,
|
|
6717
6790
|
DEFAULT_UCAN_STORE_USER_DELEGATION_EXPIRATION_SECONDS,
|
|
6718
6791
|
DEPLOYMENT_PENDING_WARNING_MS,
|
|
6792
|
+
FAB_DRAG_THRESHOLD,
|
|
6793
|
+
FAB_EDGE_MARGIN,
|
|
6719
6794
|
IDLE_DEPLOYMENT_PROGRESS,
|
|
6720
6795
|
INSTANCE_RUNTIME_PENDING_REFRESH_MS,
|
|
6721
6796
|
INSTANCE_RUNTIME_READY_REFRESH_MS,
|
|
@@ -6727,10 +6802,13 @@ export {
|
|
|
6727
6802
|
STALE_INSTANCE_ALLOCATION_COOLDOWN_MS,
|
|
6728
6803
|
SponsorRelayController,
|
|
6729
6804
|
UI_PACKAGE_VERSION,
|
|
6805
|
+
anchorPanelToLauncher,
|
|
6730
6806
|
buildSshCommand,
|
|
6807
|
+
clampToViewport,
|
|
6731
6808
|
connectWallet,
|
|
6732
6809
|
createDeploymentProgressEmitter,
|
|
6733
6810
|
createSponsorRelayController,
|
|
6811
|
+
dragExceedsThreshold,
|
|
6734
6812
|
formatDateTime,
|
|
6735
6813
|
formatNumber,
|
|
6736
6814
|
formatTierSpecLabel,
|
|
@@ -6739,9 +6817,12 @@ export {
|
|
|
6739
6817
|
joinRequiredPortForwards,
|
|
6740
6818
|
parseInstanceAddressPayload,
|
|
6741
6819
|
personalSign,
|
|
6820
|
+
readStoredPosition,
|
|
6821
|
+
resolveCornerPlacement,
|
|
6742
6822
|
resolveManifestSource,
|
|
6743
6823
|
rootfsHealth,
|
|
6744
6824
|
shortHash,
|
|
6745
6825
|
toChecksumAddress,
|
|
6746
|
-
watchWallet
|
|
6826
|
+
watchWallet,
|
|
6827
|
+
writeStoredPosition
|
|
6747
6828
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { onDestroy, onMount } from 'svelte'
|
|
3
3
|
|
|
4
|
-
import { UI_PACKAGE_VERSION, createSponsorRelayController, formatDateTime, formatNumber, formatTierSpecLabel, joinMappedPorts, joinRequiredPortForwards, shortHash } from '../shared/index'
|
|
4
|
+
import { UI_PACKAGE_VERSION, anchorPanelToLauncher, clampToViewport, createSponsorRelayController, readStoredPosition, resolveCornerPlacement, writeStoredPosition, formatDateTime, formatNumber, formatTierSpecLabel, joinMappedPorts, joinRequiredPortForwards, shortHash } from '../shared/index'
|
|
5
5
|
import AccordionSection from './components/AccordionSection.svelte'
|
|
6
6
|
import CopyButton from './components/CopyButton.svelte'
|
|
7
7
|
import LauncherButton from './components/LauncherButton.svelte'
|
|
@@ -23,6 +23,9 @@ export let apiHosts = undefined
|
|
|
23
23
|
export let crnSource = undefined
|
|
24
24
|
export let schedulerApiHost = undefined
|
|
25
25
|
export let twoN6ApiHost = undefined
|
|
26
|
+
export let position = 'bottom-right'
|
|
27
|
+
export let draggable = false
|
|
28
|
+
export let positionStorageKey = undefined
|
|
26
29
|
|
|
27
30
|
const controller = createSponsorRelayController({
|
|
28
31
|
manifestUrl,
|
|
@@ -131,13 +134,93 @@ export let apiHosts = undefined
|
|
|
131
134
|
.join('\n')
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
// Placement lives here rather than in the launcher because the panel has to
|
|
138
|
+
// be anchored to it, and only this component renders both.
|
|
139
|
+
let launcherEl = null
|
|
140
|
+
let panelEl = null
|
|
141
|
+
let placement = null
|
|
142
|
+
let panelPlacement = null
|
|
143
|
+
let dragOffset = null
|
|
144
|
+
|
|
145
|
+
function viewport() {
|
|
146
|
+
return { width: window.innerWidth, height: window.innerHeight }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function launcherSize() {
|
|
150
|
+
const rect = launcherEl?.getBoundingClientRect()
|
|
151
|
+
return { width: rect?.width ?? 180, height: rect?.height ?? 44 }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function initPlacement() {
|
|
155
|
+
if (launcherMode !== 'floating' || typeof window === 'undefined') return
|
|
156
|
+
const stored = readStoredPosition(positionStorageKey)
|
|
157
|
+
placement = stored
|
|
158
|
+
? clampToViewport(stored, launcherSize(), viewport())
|
|
159
|
+
: resolveCornerPlacement(position, launcherSize(), viewport())
|
|
160
|
+
updatePanelPlacement()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function updatePanelPlacement() {
|
|
164
|
+
if (!state.open || !placement || launcherMode !== 'floating') {
|
|
165
|
+
panelPlacement = null
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
const panelRect = panelEl?.getBoundingClientRect()
|
|
169
|
+
panelPlacement = anchorPanelToLauncher(
|
|
170
|
+
{ ...placement, ...launcherSize() },
|
|
171
|
+
{ width: panelRect?.width ?? 448, height: panelRect?.height ?? 520 },
|
|
172
|
+
viewport(),
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function handleDragStart(point) {
|
|
177
|
+
if (!placement) return
|
|
178
|
+
// Remember where inside the button the finger landed, so it does not jump
|
|
179
|
+
// to the pointer on the first move.
|
|
180
|
+
dragOffset = { x: point.x - placement.left, y: point.y - placement.top }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function handleDragMove(point) {
|
|
184
|
+
if (!dragOffset) return
|
|
185
|
+
placement = clampToViewport(
|
|
186
|
+
{ left: point.x - dragOffset.x, top: point.y - dragOffset.y },
|
|
187
|
+
launcherSize(),
|
|
188
|
+
viewport(),
|
|
189
|
+
)
|
|
190
|
+
updatePanelPlacement()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function handleDragEnd(wasDragging) {
|
|
194
|
+
dragOffset = null
|
|
195
|
+
if (wasDragging && placement) writeStoredPosition(positionStorageKey, placement, viewport())
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function handleViewportChange() {
|
|
199
|
+
if (!placement) return
|
|
200
|
+
placement = clampToViewport(placement, launcherSize(), viewport())
|
|
201
|
+
updatePanelPlacement()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
$: if (state.open !== undefined) updatePanelPlacement()
|
|
205
|
+
|
|
206
|
+
$: panelStyle = panelPlacement
|
|
207
|
+
? `left:${panelPlacement.left}px;top:${panelPlacement.top}px;right:auto;bottom:auto;max-height:${panelPlacement.maxHeight}px;`
|
|
208
|
+
: ''
|
|
209
|
+
|
|
134
210
|
onMount(async () => {
|
|
135
211
|
const unsubscribe = controller.subscribe((next) => {
|
|
136
212
|
state = next
|
|
137
213
|
})
|
|
138
214
|
|
|
139
215
|
await controller.init()
|
|
140
|
-
|
|
216
|
+
initPlacement()
|
|
217
|
+
window.addEventListener('resize', handleViewportChange)
|
|
218
|
+
window.addEventListener('orientationchange', handleViewportChange)
|
|
219
|
+
return () => {
|
|
220
|
+
window.removeEventListener('resize', handleViewportChange)
|
|
221
|
+
window.removeEventListener('orientationchange', handleViewportChange)
|
|
222
|
+
unsubscribe()
|
|
223
|
+
}
|
|
141
224
|
})
|
|
142
225
|
|
|
143
226
|
onDestroy(() => {
|
|
@@ -145,14 +228,24 @@ export let apiHosts = undefined
|
|
|
145
228
|
})
|
|
146
229
|
</script>
|
|
147
230
|
|
|
148
|
-
<LauncherButton
|
|
231
|
+
<LauncherButton
|
|
232
|
+
bind:element={launcherEl}
|
|
233
|
+
open={state.open}
|
|
234
|
+
onToggle={() => controller.toggleOpen()}
|
|
235
|
+
mode={launcherMode}
|
|
236
|
+
{placement}
|
|
237
|
+
{draggable}
|
|
238
|
+
onDragStart={handleDragStart}
|
|
239
|
+
onDragMove={handleDragMove}
|
|
240
|
+
onDragEnd={handleDragEnd}
|
|
241
|
+
/>
|
|
149
242
|
|
|
150
243
|
{#if state.open}
|
|
151
244
|
<div class="backdrop" on:click={() => controller.setOpen(false)}></div>
|
|
152
245
|
{/if}
|
|
153
246
|
|
|
154
247
|
{#if state.open}
|
|
155
|
-
<aside class="panel">
|
|
248
|
+
<aside class="panel" bind:this={panelEl} style={panelStyle}>
|
|
156
249
|
<div class="panel-head">
|
|
157
250
|
<div>
|
|
158
251
|
<p class="eyebrow">
|
|
@@ -1,10 +1,68 @@
|
|
|
1
1
|
<script>
|
|
2
|
+
import { dragExceedsThreshold } from '../../shared/index'
|
|
3
|
+
|
|
2
4
|
export let open = false
|
|
3
5
|
export let onToggle = () => {}
|
|
4
6
|
export let mode = 'floating'
|
|
7
|
+
/** Absolute placement from the parent, or null to use the CSS corner default. */
|
|
8
|
+
export let placement = null
|
|
9
|
+
export let draggable = false
|
|
10
|
+
/** Called with the pointer position while dragging; the parent owns the maths. */
|
|
11
|
+
export let onDragStart = () => {}
|
|
12
|
+
export let onDragMove = () => {}
|
|
13
|
+
export let onDragEnd = () => {}
|
|
14
|
+
/** Bound by the parent so it can measure the launcher to anchor the panel. */
|
|
15
|
+
export let element = null
|
|
16
|
+
|
|
17
|
+
let dragging = false
|
|
18
|
+
let pressOrigin = null
|
|
19
|
+
|
|
20
|
+
function handlePointerDown(event) {
|
|
21
|
+
if (!draggable || mode !== 'floating' || event.button > 0) return
|
|
22
|
+
pressOrigin = { x: event.clientX, y: event.clientY }
|
|
23
|
+
dragging = false
|
|
24
|
+
event.currentTarget.setPointerCapture?.(event.pointerId)
|
|
25
|
+
onDragStart(pressOrigin)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function handlePointerMove(event) {
|
|
29
|
+
if (!pressOrigin) return
|
|
30
|
+
const point = { x: event.clientX, y: event.clientY }
|
|
31
|
+
// Only become a drag once the pointer has travelled far enough; below the
|
|
32
|
+
// threshold this is still a press that should open the panel.
|
|
33
|
+
if (!dragging && !dragExceedsThreshold(pressOrigin, point)) return
|
|
34
|
+
dragging = true
|
|
35
|
+
onDragMove(point)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function handlePointerUp(event) {
|
|
39
|
+
if (!pressOrigin) return
|
|
40
|
+
event.currentTarget.releasePointerCapture?.(event.pointerId)
|
|
41
|
+
const wasDragging = dragging
|
|
42
|
+
pressOrigin = null
|
|
43
|
+
dragging = false
|
|
44
|
+
onDragEnd(wasDragging)
|
|
45
|
+
// A drag must not also toggle the panel; a tap must.
|
|
46
|
+
if (!wasDragging) onToggle()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
$: placementStyle =
|
|
50
|
+
placement && mode === 'floating'
|
|
51
|
+
? `left:${placement.left}px;top:${placement.top}px;right:auto;bottom:auto;`
|
|
52
|
+
: ''
|
|
5
53
|
</script>
|
|
6
54
|
|
|
7
|
-
<button
|
|
55
|
+
<button
|
|
56
|
+
bind:this={element}
|
|
57
|
+
class={`launcher ${mode} ${open ? 'open' : ''} ${draggable ? 'draggable' : ''}`}
|
|
58
|
+
style={placementStyle}
|
|
59
|
+
type="button"
|
|
60
|
+
on:pointerdown={handlePointerDown}
|
|
61
|
+
on:pointermove={handlePointerMove}
|
|
62
|
+
on:pointerup={handlePointerUp}
|
|
63
|
+
on:pointercancel={handlePointerUp}
|
|
64
|
+
on:click={draggable ? undefined : onToggle}
|
|
65
|
+
>
|
|
8
66
|
<svg class="mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
|
|
9
67
|
<line x1="17" y1="15.4" x2="19.6" y2="13" class="mark-link" stroke-width="2.4" stroke-linecap="round" />
|
|
10
68
|
<circle cx="11.5" cy="20.5" r="7" class="mark-node" />
|
|
@@ -53,6 +111,12 @@
|
|
|
53
111
|
z-index: 10000;
|
|
54
112
|
}
|
|
55
113
|
|
|
114
|
+
.launcher.draggable {
|
|
115
|
+
/* Without this the browser scrolls the page instead of moving the button. */
|
|
116
|
+
touch-action: none;
|
|
117
|
+
user-select: none;
|
|
118
|
+
}
|
|
119
|
+
|
|
56
120
|
.launcher.inline {
|
|
57
121
|
position: relative;
|
|
58
122
|
z-index: auto;
|