@omercnet/paseo-shared-browser 1.1.0-next.198.1 → 1.1.0-next.210.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 +8 -4
- package/client/browser.tsx +41 -38
- package/client/sidebar.tsx +134 -0
- package/index.client.tsx +7 -1
- package/index.server.ts +5 -1
- package/package.json +4 -4
- package/shared/browser.ts +67 -0
package/README.md
CHANGED
|
@@ -93,15 +93,19 @@ Before upgrading the plugin on Windows, close active Shared Browser sessions and
|
|
|
93
93
|
daemon; Windows does not allow the installer to replace runtime executables that are still running.
|
|
94
94
|
|
|
95
95
|
Open a workspace, search the Command Center for **Open Shared Browser**, or tap the **Shared
|
|
96
|
-
Browser** composer pill while a workspace session is open.
|
|
96
|
+
Browser** composer pill while a workspace session is open. On Paseo 0.11 or newer, a **Shared
|
|
97
|
+
Browser (n)** row in the sidebar footer appears while browser sessions are open; it lists them and
|
|
98
|
+
jumps to the chosen workspace's browser panel.
|
|
97
99
|
|
|
98
100
|
## Agent MCP access
|
|
99
101
|
|
|
100
102
|
The plugin automatically injects its stdio MCP adapter only when a new, non-internal agent is
|
|
101
103
|
created with a provider that accepts external MCP servers. Agents that already exist, resumed
|
|
102
|
-
sessions, imported sessions, and Paseo's internal agents are not modified. Paseo's OMP
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
sessions, imported sessions, and Paseo's internal agents are not modified. Paseo's built-in OMP
|
|
105
|
+
provider accepts session MCP servers from Paseo 0.11, so new OMP agents receive the adapter there.
|
|
106
|
+
On Paseo 0.9 and 0.10 the built-in OMP adapter rejects external MCP servers, so OMP agents are left
|
|
107
|
+
unchanged. Pi agents continue to receive the adapter, but they require Pi's optional MCP support to
|
|
108
|
+
launch it.
|
|
105
109
|
|
|
106
110
|
The injected MCP server exposes exactly these tools: `shared_browser_status`,
|
|
107
111
|
`shared_browser_capture`, `shared_browser_acquire_control`, `shared_browser_release_control`,
|
package/client/browser.tsx
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
type BrowserInputEvent,
|
|
30
30
|
type BrowserState,
|
|
31
31
|
captureBrowserRpc,
|
|
32
|
+
containedRect,
|
|
33
|
+
createImageSizeLoader,
|
|
32
34
|
DEVICE_PRESETS,
|
|
33
35
|
type DevicePresetId,
|
|
34
36
|
detachBrowserRpc,
|
|
@@ -41,6 +43,7 @@ import {
|
|
|
41
43
|
releaseControlRpc,
|
|
42
44
|
resizeBrowserRpc,
|
|
43
45
|
sendBrowserInputRpc,
|
|
46
|
+
toViewportPoint,
|
|
44
47
|
} from "../shared/browser";
|
|
45
48
|
|
|
46
49
|
const SPACE = {
|
|
@@ -88,12 +91,7 @@ interface Size {
|
|
|
88
91
|
height: number;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
interface
|
|
92
|
-
x: number;
|
|
93
|
-
y: number;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
interface DisplayPoint extends Size {
|
|
94
|
+
interface DisplayPoint {
|
|
97
95
|
x: number;
|
|
98
96
|
y: number;
|
|
99
97
|
}
|
|
@@ -115,21 +113,6 @@ const SPECIAL_KEYS: readonly { key: SpecialKey; label: string }[] = [
|
|
|
115
113
|
{ key: "Space", label: "Space" },
|
|
116
114
|
];
|
|
117
115
|
|
|
118
|
-
function containedRect(container: Size, image: Size): DisplayRect | null {
|
|
119
|
-
if (container.width <= 0 || container.height <= 0 || image.width <= 0 || image.height <= 0) {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
const scale = Math.min(container.width / image.width, container.height / image.height);
|
|
123
|
-
const width = image.width * scale;
|
|
124
|
-
const height = image.height * scale;
|
|
125
|
-
return {
|
|
126
|
-
x: (container.width - width) / 2,
|
|
127
|
-
y: (container.height - height) / 2,
|
|
128
|
-
width,
|
|
129
|
-
height,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
116
|
function errorMessage(error: unknown): string {
|
|
134
117
|
if (error instanceof Error && error.message.trim()) return error.message;
|
|
135
118
|
return "The shared browser request failed.";
|
|
@@ -1253,16 +1236,34 @@ export function SharedBrowserPanel({
|
|
|
1253
1236
|
const currentFrame = frame && state && isFrameCurrent(frame, state) ? frame : null;
|
|
1254
1237
|
const canSendInput = canControl && Boolean(currentFrame) && !inputMutation.isPending;
|
|
1255
1238
|
|
|
1239
|
+
const frameUri = useMemo(
|
|
1240
|
+
() => (currentFrame ? `data:${currentFrame.mimeType};base64,${currentFrame.dataBase64}` : null),
|
|
1241
|
+
[currentFrame],
|
|
1242
|
+
);
|
|
1243
|
+
const [imageSize, setImageSize] = useState<{ uri: string; size: Size } | null>(null);
|
|
1244
|
+
const loadImageSize = useMemo(
|
|
1245
|
+
() =>
|
|
1246
|
+
createImageSizeLoader(
|
|
1247
|
+
(uri, success, failure) => Image.getSize(uri, success, failure),
|
|
1248
|
+
(uri, size) => setImageSize(size ? { uri, size } : null),
|
|
1249
|
+
),
|
|
1250
|
+
[],
|
|
1251
|
+
);
|
|
1252
|
+
useEffect(() => {
|
|
1253
|
+
if (frameUri) loadImageSize(frameUri);
|
|
1254
|
+
}, [frameUri, loadImageSize]);
|
|
1255
|
+
// The bitmap can disagree with the frame metadata; its own aspect ratio decides the rectangle.
|
|
1256
1256
|
const displayRect = useMemo(
|
|
1257
1257
|
() =>
|
|
1258
1258
|
currentFrame
|
|
1259
|
-
? containedRect(
|
|
1259
|
+
? containedRect(
|
|
1260
|
+
containerSize,
|
|
1261
|
+
imageSize?.uri === frameUri
|
|
1262
|
+
? imageSize.size
|
|
1263
|
+
: { width: currentFrame.width, height: currentFrame.height },
|
|
1264
|
+
)
|
|
1260
1265
|
: null,
|
|
1261
|
-
[containerSize, currentFrame],
|
|
1262
|
-
);
|
|
1263
|
-
const frameUri = useMemo(
|
|
1264
|
-
() => (currentFrame ? `data:${currentFrame.mimeType};base64,${currentFrame.dataBase64}` : null),
|
|
1265
|
-
[currentFrame],
|
|
1266
|
+
[containerSize, currentFrame, frameUri, imageSize],
|
|
1266
1267
|
);
|
|
1267
1268
|
|
|
1268
1269
|
const controlContext = useCallback(() => {
|
|
@@ -1329,7 +1330,7 @@ export function SharedBrowserPanel({
|
|
|
1329
1330
|
if (!displayRect) return null;
|
|
1330
1331
|
const x = Math.min(displayRect.width, Math.max(0, event.nativeEvent.locationX));
|
|
1331
1332
|
const y = Math.min(displayRect.height, Math.max(0, event.nativeEvent.locationY));
|
|
1332
|
-
return { x, y
|
|
1333
|
+
return { x, y };
|
|
1333
1334
|
},
|
|
1334
1335
|
[displayRect],
|
|
1335
1336
|
);
|
|
@@ -1338,17 +1339,18 @@ export function SharedBrowserPanel({
|
|
|
1338
1339
|
(event: GestureResponderEvent) => {
|
|
1339
1340
|
const start = dragStartRef.current;
|
|
1340
1341
|
const end = pointFromEvent(event);
|
|
1342
|
+
const viewport = stateRef.current?.viewport;
|
|
1341
1343
|
dragStartRef.current = null;
|
|
1342
|
-
if (!start || !end) return;
|
|
1344
|
+
if (!start || !end || !displayRect || !viewport) return;
|
|
1345
|
+
const toViewport = (point: DisplayPoint) => toViewportPoint(point, displayRect, viewport);
|
|
1343
1346
|
lastPointRef.current = { x: end.x, y: end.y };
|
|
1344
1347
|
const distance = Math.hypot(end.x - start.x, end.y - start.y);
|
|
1345
1348
|
if (distance >= DRAG_THRESHOLD) {
|
|
1346
1349
|
if (swipeMode === "scroll") {
|
|
1347
|
-
const
|
|
1348
|
-
const scale = current ? current.viewport.width / end.width : 1;
|
|
1350
|
+
const scale = viewport.width / displayRect.width;
|
|
1349
1351
|
sendEvent({
|
|
1350
1352
|
kind: "scroll",
|
|
1351
|
-
point:
|
|
1353
|
+
point: toViewport(start),
|
|
1352
1354
|
deltaX: clampScrollDelta((start.x - end.x) * scale),
|
|
1353
1355
|
deltaY: clampScrollDelta((start.y - end.y) * scale),
|
|
1354
1356
|
});
|
|
@@ -1356,20 +1358,20 @@ export function SharedBrowserPanel({
|
|
|
1356
1358
|
}
|
|
1357
1359
|
sendEvent({
|
|
1358
1360
|
kind: "drag",
|
|
1359
|
-
start:
|
|
1360
|
-
end,
|
|
1361
|
+
start: toViewport(start),
|
|
1362
|
+
end: toViewport(end),
|
|
1361
1363
|
button: interactionMode === "right" ? "right" : "left",
|
|
1362
1364
|
});
|
|
1363
1365
|
return;
|
|
1364
1366
|
}
|
|
1365
1367
|
sendEvent({
|
|
1366
1368
|
kind: "click",
|
|
1367
|
-
point: end,
|
|
1369
|
+
point: toViewport(end),
|
|
1368
1370
|
button: interactionMode === "right" ? "right" : "left",
|
|
1369
1371
|
clickCount: interactionMode === "double" ? 2 : 1,
|
|
1370
1372
|
});
|
|
1371
1373
|
},
|
|
1372
|
-
[interactionMode, pointFromEvent, sendEvent, swipeMode],
|
|
1374
|
+
[displayRect, interactionMode, pointFromEvent, sendEvent, swipeMode],
|
|
1373
1375
|
);
|
|
1374
1376
|
|
|
1375
1377
|
const panResponder = useMemo(
|
|
@@ -1474,13 +1476,14 @@ export function SharedBrowserPanel({
|
|
|
1474
1476
|
|
|
1475
1477
|
const scroll = useCallback(
|
|
1476
1478
|
(deltaX: number, deltaY: number) => {
|
|
1477
|
-
|
|
1479
|
+
const viewport = stateRef.current?.viewport;
|
|
1480
|
+
if (!displayRect || !viewport) return;
|
|
1478
1481
|
const previous = lastPointRef.current;
|
|
1479
1482
|
const x = previous && previous.x <= displayRect.width ? previous.x : displayRect.width / 2;
|
|
1480
1483
|
const y = previous && previous.y <= displayRect.height ? previous.y : displayRect.height / 2;
|
|
1481
1484
|
sendEvent({
|
|
1482
1485
|
kind: "scroll",
|
|
1483
|
-
point: { x, y,
|
|
1486
|
+
point: toViewportPoint({ x, y }, displayRect, viewport),
|
|
1484
1487
|
deltaX,
|
|
1485
1488
|
deltaY,
|
|
1486
1489
|
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type PluginClientContext,
|
|
3
|
+
type PluginPopoverProps,
|
|
4
|
+
type PluginSidebarItemProps,
|
|
5
|
+
useRpc,
|
|
6
|
+
useWorkspace,
|
|
7
|
+
} from "@getpaseo/plugin/client";
|
|
8
|
+
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
9
|
+
import { useQuery } from "@tanstack/react-query";
|
|
10
|
+
import { useMemo } from "react";
|
|
11
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
12
|
+
import { listOpenBrowserWorkspacesRpc } from "../shared/browser";
|
|
13
|
+
|
|
14
|
+
const PANEL_ID = "shared-browser";
|
|
15
|
+
const PRESENCE_POLL_MS = 5_000;
|
|
16
|
+
const TOUCH_TARGET = 44;
|
|
17
|
+
const ROW_HEIGHT = 36;
|
|
18
|
+
const RADIUS = 8;
|
|
19
|
+
|
|
20
|
+
function useOpenBrowserWorkspaceIds(): readonly string[] {
|
|
21
|
+
const listOpen = useRpc(listOpenBrowserWorkspacesRpc);
|
|
22
|
+
const query = useQuery({
|
|
23
|
+
queryKey: ["shared-browser", "presence"],
|
|
24
|
+
queryFn: async () => (await listOpen({})).workspaceIds,
|
|
25
|
+
refetchInterval: PRESENCE_POLL_MS,
|
|
26
|
+
retry: false,
|
|
27
|
+
});
|
|
28
|
+
return query.data ?? [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function SessionRow({
|
|
32
|
+
workspaceId,
|
|
33
|
+
theme,
|
|
34
|
+
compact,
|
|
35
|
+
onPress,
|
|
36
|
+
}: {
|
|
37
|
+
workspaceId: string;
|
|
38
|
+
theme: PluginPopoverProps["theme"];
|
|
39
|
+
compact: boolean;
|
|
40
|
+
onPress(): void;
|
|
41
|
+
}) {
|
|
42
|
+
const name = useWorkspace(workspaceId, (workspace) => workspace.title ?? workspace.name);
|
|
43
|
+
const styles = useMemo(
|
|
44
|
+
() =>
|
|
45
|
+
StyleSheet.create({
|
|
46
|
+
row: {
|
|
47
|
+
minHeight: compact ? TOUCH_TARGET : ROW_HEIGHT,
|
|
48
|
+
justifyContent: "center",
|
|
49
|
+
paddingHorizontal: 12,
|
|
50
|
+
borderRadius: RADIUS,
|
|
51
|
+
},
|
|
52
|
+
label: { color: theme.colors.foreground, fontSize: 14 },
|
|
53
|
+
}),
|
|
54
|
+
[theme, compact],
|
|
55
|
+
);
|
|
56
|
+
// A workspace this client has not loaded cannot host the panel; openPanel would throw.
|
|
57
|
+
if (name === null) return null;
|
|
58
|
+
return (
|
|
59
|
+
<Pressable
|
|
60
|
+
accessibilityRole="button"
|
|
61
|
+
accessibilityLabel={`Open Shared Browser for ${name}`}
|
|
62
|
+
onPress={onPress}
|
|
63
|
+
style={({ pressed }) => [
|
|
64
|
+
styles.row,
|
|
65
|
+
pressed ? { backgroundColor: theme.colors.surface1 } : null,
|
|
66
|
+
]}
|
|
67
|
+
>
|
|
68
|
+
<Text numberOfLines={1} style={styles.label}>
|
|
69
|
+
{name}
|
|
70
|
+
</Text>
|
|
71
|
+
</Pressable>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createSessionsPopover(client: PluginClientContext) {
|
|
76
|
+
return function SessionsPopover({ theme, layout, close }: PluginPopoverProps) {
|
|
77
|
+
const workspaceIds = useOpenBrowserWorkspaceIds();
|
|
78
|
+
const styles = useMemo(
|
|
79
|
+
() =>
|
|
80
|
+
StyleSheet.create({
|
|
81
|
+
container: { padding: layout.compact ? 16 : 8, gap: 4, minWidth: 220 },
|
|
82
|
+
title: {
|
|
83
|
+
color: theme.colors.foregroundMuted,
|
|
84
|
+
fontSize: 12,
|
|
85
|
+
paddingHorizontal: 12,
|
|
86
|
+
paddingBottom: 4,
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
[theme, layout.compact],
|
|
90
|
+
);
|
|
91
|
+
return (
|
|
92
|
+
<View style={styles.container}>
|
|
93
|
+
<Text style={styles.title}>Open browser sessions</Text>
|
|
94
|
+
{workspaceIds.map((workspaceId) => (
|
|
95
|
+
<SessionRow
|
|
96
|
+
key={workspaceId}
|
|
97
|
+
workspaceId={workspaceId}
|
|
98
|
+
theme={theme}
|
|
99
|
+
compact={layout.compact}
|
|
100
|
+
onPress={() => {
|
|
101
|
+
close();
|
|
102
|
+
client.openPanel(PANEL_ID, { workspaceId });
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
</View>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createSidebarItem(client: PluginClientContext) {
|
|
112
|
+
const SessionsPopover = createSessionsPopover(client);
|
|
113
|
+
return function SharedBrowserSidebarItem({ openPopover }: PluginSidebarItemProps) {
|
|
114
|
+
const count = useOpenBrowserWorkspaceIds().length;
|
|
115
|
+
if (count === 0) return null;
|
|
116
|
+
return (
|
|
117
|
+
<SidebarRow
|
|
118
|
+
icon="PanelsTopLeft"
|
|
119
|
+
label={`Shared Browser (${count})`}
|
|
120
|
+
onPress={() => openPopover(SessionsPopover)}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Sidebar footer row listing open sessions. Hosts before 0.11 lack the API and get no row. */
|
|
127
|
+
export function contributeSharedBrowserSidebar(client: PluginClientContext): () => void {
|
|
128
|
+
if (typeof client.addSidebarFooterItem !== "function") return () => {};
|
|
129
|
+
return client.addSidebarFooterItem({
|
|
130
|
+
id: "shared-browser-sessions",
|
|
131
|
+
title: "Shared Browser",
|
|
132
|
+
Component: createSidebarItem(client),
|
|
133
|
+
});
|
|
134
|
+
}
|
package/index.client.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
2
|
import { contributeSharedBrowserClient, SharedBrowserPanel } from "./client/browser";
|
|
3
|
+
import { contributeSharedBrowserSidebar } from "./client/sidebar";
|
|
3
4
|
|
|
4
5
|
export default function contribute(client: PluginClientContext) {
|
|
5
6
|
client.addWorkspacePanel({
|
|
@@ -31,5 +32,10 @@ export default function contribute(client: PluginClientContext) {
|
|
|
31
32
|
},
|
|
32
33
|
});
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
const removeSidebar = contributeSharedBrowserSidebar(client);
|
|
36
|
+
const cleanup = contributeSharedBrowserClient(client);
|
|
37
|
+
return () => {
|
|
38
|
+
removeSidebar();
|
|
39
|
+
return cleanup();
|
|
40
|
+
};
|
|
35
41
|
}
|
package/index.server.ts
CHANGED
|
@@ -77,8 +77,12 @@ export default function contribute(server: PluginServerContext) {
|
|
|
77
77
|
server.handle(resizeBrowserRpc, handleResizeBrowser);
|
|
78
78
|
server.handle(applyDevicePresetRpc, handleApplyDevicePreset);
|
|
79
79
|
server.handle(sendBrowserInputRpc, handleSendBrowserInput);
|
|
80
|
+
// OMP accepts session MCP servers from Paseo 0.11.0-beta.1, the release that added
|
|
81
|
+
// registerUsageSource; 0.9/0.10 reject agent creation when an OMP config carries MCP servers.
|
|
82
|
+
const ompAcceptsMcp = typeof server.registerUsageSource === "function";
|
|
80
83
|
server.before("agent.create", async ({ request }) => {
|
|
81
|
-
if (request.config.internal
|
|
84
|
+
if (request.config.internal) return request;
|
|
85
|
+
if (request.config.provider === "omp" && !ompAcceptsMcp) return request;
|
|
82
86
|
const ticket = randomBytes(32).toString("base64url");
|
|
83
87
|
if (!(await issueTicketWithinDeadline(ticket))) return request;
|
|
84
88
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-shared-browser",
|
|
3
|
-
"version": "1.1.0-next.
|
|
3
|
+
"version": "1.1.0-next.210.1",
|
|
4
4
|
"description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"browser",
|
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
"check:write": "biome check --write .",
|
|
44
44
|
"typecheck": "tsc --noEmit",
|
|
45
45
|
"prepare:runtime": "node scripts/prepare-runtime.mjs",
|
|
46
|
-
"test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts",
|
|
47
|
-
"test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts",
|
|
46
|
+
"test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts",
|
|
47
|
+
"test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts",
|
|
48
48
|
"test:smoke": "vitest run tests/browser.smoke.test.ts --testTimeout=30000"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@getpaseo/plugin": "0.
|
|
51
|
+
"@getpaseo/plugin": "0.11.0-beta.3",
|
|
52
52
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
53
53
|
"agent-browser": "0.37.1",
|
|
54
54
|
"esbuild": "0.28.2",
|
package/shared/browser.ts
CHANGED
|
@@ -365,3 +365,70 @@ export function mapDisplayedPoint(
|
|
|
365
365
|
y: Math.min(viewport.height - 1, Math.max(0, (point.y / point.height) * viewport.height)),
|
|
366
366
|
};
|
|
367
367
|
}
|
|
368
|
+
|
|
369
|
+
export interface FrameSize {
|
|
370
|
+
width: number;
|
|
371
|
+
height: number;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface FrameRect extends FrameSize {
|
|
375
|
+
x: number;
|
|
376
|
+
y: number;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function containedRect(container: FrameSize, image: FrameSize): FrameRect | null {
|
|
380
|
+
if (container.width <= 0 || container.height <= 0 || image.width <= 0 || image.height <= 0) {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
const scale = Math.min(container.width / image.width, container.height / image.height);
|
|
384
|
+
const width = image.width * scale;
|
|
385
|
+
const height = image.height * scale;
|
|
386
|
+
return {
|
|
387
|
+
x: (container.width - width) / 2,
|
|
388
|
+
y: (container.height - height) / 2,
|
|
389
|
+
width,
|
|
390
|
+
height,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Converts a point in the displayed image to the canonical viewport, scaling both axes by viewport.width / rect.width. */
|
|
395
|
+
export function toViewportPoint(
|
|
396
|
+
point: MappedPoint,
|
|
397
|
+
rect: FrameSize,
|
|
398
|
+
viewport: Viewport,
|
|
399
|
+
): MappedPoint & FrameSize {
|
|
400
|
+
const scale = viewport.width / rect.width;
|
|
401
|
+
return {
|
|
402
|
+
x: point.x * scale,
|
|
403
|
+
y: Math.min(viewport.height, point.y * scale),
|
|
404
|
+
width: viewport.width,
|
|
405
|
+
height: viewport.height,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
type GetImageSize = (
|
|
410
|
+
uri: string,
|
|
411
|
+
success: (width: number, height: number) => void,
|
|
412
|
+
failure: () => void,
|
|
413
|
+
) => void;
|
|
414
|
+
|
|
415
|
+
/** Looks up the intrinsic size once per distinct URI and drops results superseded by a newer URI. */
|
|
416
|
+
export function createImageSizeLoader(
|
|
417
|
+
getSize: GetImageSize,
|
|
418
|
+
onSize: (uri: string, size: FrameSize | null) => void,
|
|
419
|
+
): (uri: string) => void {
|
|
420
|
+
let current: string | null = null;
|
|
421
|
+
return (uri) => {
|
|
422
|
+
if (uri === current) return;
|
|
423
|
+
current = uri;
|
|
424
|
+
getSize(
|
|
425
|
+
uri,
|
|
426
|
+
(width, height) => {
|
|
427
|
+
if (current === uri) onSize(uri, { width, height });
|
|
428
|
+
},
|
|
429
|
+
() => {
|
|
430
|
+
if (current === uri) onSize(uri, null);
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
};
|
|
434
|
+
}
|