@sable-ai/sdk-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/dist/esm/index.js +2431 -0
- package/dist/sable.iife.js +1486 -0
- package/dist/types/browser-bridge/actions.d.ts +27 -0
- package/dist/types/browser-bridge/dom-state.d.ts +37 -0
- package/dist/types/browser-bridge/index.d.ts +19 -0
- package/dist/types/connection/index.d.ts +26 -0
- package/dist/types/events/index.d.ts +15 -0
- package/dist/types/global.d.ts +26 -0
- package/dist/types/index.d.ts +23 -0
- package/dist/types/rpc.d.ts +22 -0
- package/dist/types/runtime/clipboard.d.ts +14 -0
- package/dist/types/runtime/index.d.ts +36 -0
- package/dist/types/runtime/video-overlay.d.ts +14 -0
- package/dist/types/session/debug-panel.d.ts +29 -0
- package/dist/types/session/index.d.ts +41 -0
- package/dist/types/types/index.d.ts +131 -0
- package/dist/types/version.d.ts +7 -0
- package/dist/types/vision/frame-source.d.ts +34 -0
- package/dist/types/vision/index.d.ts +29 -0
- package/dist/types/vision/publisher.d.ts +44 -0
- package/dist/types/vision/wireframe.d.ts +22 -0
- package/package.json +61 -0
- package/src/assets/visible-dom.js.txt +764 -0
- package/src/assets/wireframe.js.txt +678 -0
- package/src/assets.d.ts +24 -0
- package/src/browser-bridge/actions.ts +161 -0
- package/src/browser-bridge/dom-state.ts +103 -0
- package/src/browser-bridge/index.ts +99 -0
- package/src/connection/index.ts +49 -0
- package/src/events/index.ts +50 -0
- package/src/global.ts +35 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +43 -0
- package/src/rpc.ts +31 -0
- package/src/runtime/clipboard.ts +47 -0
- package/src/runtime/index.ts +138 -0
- package/src/runtime/video-overlay.ts +94 -0
- package/src/session/debug-panel.ts +254 -0
- package/src/session/index.ts +375 -0
- package/src/types/index.ts +176 -0
- package/src/version.ts +8 -0
- package/src/vision/frame-source.ts +111 -0
- package/src/vision/index.ts +70 -0
- package/src/vision/publisher.ts +106 -0
- package/src/vision/wireframe.ts +43 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime: the set of methods the agent can RPC into the page.
|
|
3
|
+
*
|
|
4
|
+
* Historically these were called "UI stubs" because they originated as
|
|
5
|
+
* no-op placeholders for methods parley implements in its call overlay.
|
|
6
|
+
* That framing no longer fits: half of them now do real work, and the
|
|
7
|
+
* public API lets customers replace any of them AND add new ones through
|
|
8
|
+
* the same `Sable.start({ runtime })` surface.
|
|
9
|
+
*
|
|
10
|
+
* The shape:
|
|
11
|
+
*
|
|
12
|
+
* 1. `DEFAULT_RUNTIME` — built-in implementations shipped with the SDK.
|
|
13
|
+
* A few do real work (clipboard copy, video overlay); the rest are
|
|
14
|
+
* no-ops for methods that only make sense in a host-app call UI.
|
|
15
|
+
*
|
|
16
|
+
* 2. `installRuntime(room, userRuntime)` — merges `userRuntime` over the
|
|
17
|
+
* defaults and registers every entry as a LiveKit RPC handler on
|
|
18
|
+
* `room`. Agent RPC calls → run the matching method → return a
|
|
19
|
+
* JSON-encoded result.
|
|
20
|
+
*
|
|
21
|
+
* Customers extend the runtime by passing new keys in `userRuntime`:
|
|
22
|
+
* anything you put in becomes callable by the agent as-is. This means
|
|
23
|
+
* the same surface handles both "override a built-in" and "expose a
|
|
24
|
+
* business-logic tool" — one concept, not two.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { safeParse, type RpcRoom } from "../rpc";
|
|
28
|
+
import type { RuntimeMethod, RuntimeMethods } from "../types";
|
|
29
|
+
import { handleCopyable } from "./clipboard";
|
|
30
|
+
import { mountVideoOverlay, removeViewOverlay } from "./video-overlay";
|
|
31
|
+
|
|
32
|
+
// ── Default runtime ────────────────────────────────────────────────────────
|
|
33
|
+
//
|
|
34
|
+
// Methods that do meaningful work out of the box (clipboard, video overlay)
|
|
35
|
+
// plus no-ops for the set of call-UI methods the agent can call. The no-ops
|
|
36
|
+
// are here so agent tool calls always succeed even if the host app hasn't
|
|
37
|
+
// overridden them — without these, the agent's builtin tools raise
|
|
38
|
+
// "Method not supported at destination" and the conversation derails.
|
|
39
|
+
|
|
40
|
+
function noop(): Promise<{ success: true }> {
|
|
41
|
+
return Promise.resolve({ success: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_RUNTIME: RuntimeMethods = {
|
|
45
|
+
// Real defaults
|
|
46
|
+
sendToolMessage: (payload) => handleCopyable("sendToolMessage", payload),
|
|
47
|
+
sendCopyableText: (payload) => handleCopyable("sendCopyableText", payload),
|
|
48
|
+
switchView: async (payload) => {
|
|
49
|
+
const mode = typeof payload.mode === "string" ? payload.mode : "";
|
|
50
|
+
const url = typeof payload.url === "string" ? payload.url : "";
|
|
51
|
+
if (mode === "video" && url) {
|
|
52
|
+
try {
|
|
53
|
+
mountVideoOverlay(url);
|
|
54
|
+
return { success: true };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
57
|
+
console.warn("[Sable] switchView: failed to mount video overlay", msg);
|
|
58
|
+
return { success: false, error: msg };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
removeViewOverlay();
|
|
62
|
+
return { success: true };
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// No-ops: host-UI-specific methods. Host apps override to render into
|
|
66
|
+
// their own call surface; the SDK's default answer is "ack, did nothing".
|
|
67
|
+
setCallControlsEnabled: noop,
|
|
68
|
+
setUserInputEnabled: noop,
|
|
69
|
+
setAgentInControl: noop,
|
|
70
|
+
showSuggestedReplies: noop,
|
|
71
|
+
hideSuggestedReplies: noop,
|
|
72
|
+
highlightHangup: noop,
|
|
73
|
+
hideVideo: noop,
|
|
74
|
+
showVideo: noop,
|
|
75
|
+
stopScreenShare: noop,
|
|
76
|
+
showSlide: noop,
|
|
77
|
+
hideSlide: noop,
|
|
78
|
+
responseFailed: noop,
|
|
79
|
+
requestContinue: noop,
|
|
80
|
+
greetingComplete: noop,
|
|
81
|
+
speechComplete: noop,
|
|
82
|
+
enableMicrophone: noop,
|
|
83
|
+
requestDisconnect: noop,
|
|
84
|
+
setNickelSession: noop,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ── Registration ───────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Wrap a runtime method in an RPC-compatible handler: decode JSON payload,
|
|
91
|
+
* run the method, re-encode the result. Errors become `{ error: message }`
|
|
92
|
+
* responses — never thrown back to the caller, since LiveKit RPC propagates
|
|
93
|
+
* exceptions to the agent and derails the conversation.
|
|
94
|
+
*/
|
|
95
|
+
function toRpcHandler(
|
|
96
|
+
name: string,
|
|
97
|
+
method: RuntimeMethod,
|
|
98
|
+
): (data: { payload: string }) => Promise<string> {
|
|
99
|
+
return async (data) => {
|
|
100
|
+
const payload = safeParse(data.payload);
|
|
101
|
+
try {
|
|
102
|
+
const result = await method(payload);
|
|
103
|
+
return JSON.stringify(result ?? { success: true });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
106
|
+
console.warn(`[Sable] runtime method "${name}" threw`, msg);
|
|
107
|
+
return JSON.stringify({ success: false, error: msg });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Merge the user-provided runtime over `DEFAULT_RUNTIME` and register every
|
|
114
|
+
* entry as a LiveKit RPC handler on `room`. Later keys win — so passing
|
|
115
|
+
* `{ switchView: myImpl }` replaces the default video-overlay behaviour,
|
|
116
|
+
* while passing `{ activateTrial: ... }` exposes a new method the agent
|
|
117
|
+
* can call without touching the built-ins.
|
|
118
|
+
*/
|
|
119
|
+
export function installRuntime(
|
|
120
|
+
room: RpcRoom,
|
|
121
|
+
userRuntime: RuntimeMethods = {},
|
|
122
|
+
): void {
|
|
123
|
+
const merged: RuntimeMethods = { ...DEFAULT_RUNTIME, ...userRuntime };
|
|
124
|
+
for (const [name, method] of Object.entries(merged)) {
|
|
125
|
+
room.registerRpcMethod(name, toRpcHandler(name, method));
|
|
126
|
+
}
|
|
127
|
+
const overrides = Object.keys(userRuntime).filter(
|
|
128
|
+
(k) => k in DEFAULT_RUNTIME,
|
|
129
|
+
);
|
|
130
|
+
const extensions = Object.keys(userRuntime).filter(
|
|
131
|
+
(k) => !(k in DEFAULT_RUNTIME),
|
|
132
|
+
);
|
|
133
|
+
console.log("[Sable] runtime installed", {
|
|
134
|
+
total: Object.keys(merged).length,
|
|
135
|
+
overrides,
|
|
136
|
+
extensions,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default `switchView({ mode: "video", url })` implementation.
|
|
3
|
+
*
|
|
4
|
+
* Mounts a centred floating video clip in the page. Host apps with their own
|
|
5
|
+
* call UI would override `switchView` in `runtime` to render into their own
|
|
6
|
+
* surface; the standalone SDK uses this simple overlay as the built-in
|
|
7
|
+
* default so agents that call `switchView` Just Work out of the box.
|
|
8
|
+
*
|
|
9
|
+
* Module-level state (`activeViewOverlay`) keeps at most one overlay mounted
|
|
10
|
+
* — calling `mountVideoOverlay` while another is showing tears the old one
|
|
11
|
+
* down first.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let activeViewOverlay: HTMLDivElement | null = null;
|
|
15
|
+
|
|
16
|
+
export function removeViewOverlay(): void {
|
|
17
|
+
if (activeViewOverlay) {
|
|
18
|
+
activeViewOverlay.remove();
|
|
19
|
+
activeViewOverlay = null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function mountVideoOverlay(url: string): void {
|
|
24
|
+
removeViewOverlay();
|
|
25
|
+
|
|
26
|
+
const overlay = document.createElement("div");
|
|
27
|
+
overlay.setAttribute("data-sable", "view-overlay");
|
|
28
|
+
Object.assign(overlay.style, {
|
|
29
|
+
position: "fixed",
|
|
30
|
+
top: "50%",
|
|
31
|
+
left: "50%",
|
|
32
|
+
transform: "translate(-50%, -50%)",
|
|
33
|
+
zIndex: "2147483646",
|
|
34
|
+
background: "rgba(0, 0, 0, 0.85)",
|
|
35
|
+
borderRadius: "12px",
|
|
36
|
+
padding: "8px",
|
|
37
|
+
boxShadow: "0 10px 40px rgba(0, 0, 0, 0.5)",
|
|
38
|
+
maxWidth: "min(80vw, 960px)",
|
|
39
|
+
maxHeight: "80vh",
|
|
40
|
+
display: "flex",
|
|
41
|
+
flexDirection: "column",
|
|
42
|
+
gap: "8px",
|
|
43
|
+
} as Partial<CSSStyleDeclaration>);
|
|
44
|
+
|
|
45
|
+
const closeBtn = document.createElement("button");
|
|
46
|
+
closeBtn.textContent = "✕";
|
|
47
|
+
closeBtn.setAttribute("aria-label", "Close video");
|
|
48
|
+
Object.assign(closeBtn.style, {
|
|
49
|
+
alignSelf: "flex-end",
|
|
50
|
+
background: "rgba(255,255,255,0.15)",
|
|
51
|
+
color: "white",
|
|
52
|
+
border: "none",
|
|
53
|
+
borderRadius: "999px",
|
|
54
|
+
width: "28px",
|
|
55
|
+
height: "28px",
|
|
56
|
+
cursor: "pointer",
|
|
57
|
+
fontSize: "14px",
|
|
58
|
+
lineHeight: "1",
|
|
59
|
+
} as Partial<CSSStyleDeclaration>);
|
|
60
|
+
closeBtn.addEventListener("click", removeViewOverlay);
|
|
61
|
+
|
|
62
|
+
const video = document.createElement("video");
|
|
63
|
+
video.src = url;
|
|
64
|
+
video.controls = false;
|
|
65
|
+
video.autoplay = true;
|
|
66
|
+
video.playsInline = true;
|
|
67
|
+
video.disablePictureInPicture = true;
|
|
68
|
+
video.setAttribute(
|
|
69
|
+
"controlslist",
|
|
70
|
+
"nodownload nofullscreen noremoteplayback noplaybackrate",
|
|
71
|
+
);
|
|
72
|
+
Object.assign(video.style, {
|
|
73
|
+
maxWidth: "100%",
|
|
74
|
+
maxHeight: "70vh",
|
|
75
|
+
borderRadius: "8px",
|
|
76
|
+
display: "block",
|
|
77
|
+
} as Partial<CSSStyleDeclaration>);
|
|
78
|
+
|
|
79
|
+
const onEnded = (): void => {
|
|
80
|
+
if (activeViewOverlay === overlay) {
|
|
81
|
+
removeViewOverlay();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
video.addEventListener("ended", onEnded);
|
|
85
|
+
|
|
86
|
+
overlay.appendChild(closeBtn);
|
|
87
|
+
overlay.appendChild(video);
|
|
88
|
+
document.body.appendChild(overlay);
|
|
89
|
+
activeViewOverlay = overlay;
|
|
90
|
+
|
|
91
|
+
video.play().catch((e) => {
|
|
92
|
+
console.warn("[Sable] switchView video autoplay blocked", e);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating debug panel — "what the agent sees".
|
|
3
|
+
*
|
|
4
|
+
* When debug is on, we mount the vision capture canvas as a draggable
|
|
5
|
+
* preview in the host page. The panel renders the *exact* pixels that get
|
|
6
|
+
* encoded into the LiveKit video track, so "what you see in the panel" is
|
|
7
|
+
* literally "what the agent sees". Position + minimized state persist in
|
|
8
|
+
* `localStorage` so customers don't have to re-place the panel every
|
|
9
|
+
* reload.
|
|
10
|
+
*
|
|
11
|
+
* Opt-in signals (any of these enables the panel):
|
|
12
|
+
* - `Sable.start({ debug: true })`
|
|
13
|
+
* - `?sable-debug=1` anywhere in the page URL
|
|
14
|
+
* - `localStorage.setItem('sable:debug', '1')`
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface DebugPanelState {
|
|
18
|
+
left?: number;
|
|
19
|
+
top?: number;
|
|
20
|
+
minimized?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEBUG_PANEL_STATE_KEY = "sable:debug:panel";
|
|
24
|
+
|
|
25
|
+
function loadState(): DebugPanelState {
|
|
26
|
+
try {
|
|
27
|
+
const raw = window.localStorage?.getItem(DEBUG_PANEL_STATE_KEY);
|
|
28
|
+
if (!raw) return {};
|
|
29
|
+
const parsed = JSON.parse(raw) as DebugPanelState;
|
|
30
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function saveState(state: DebugPanelState): void {
|
|
37
|
+
try {
|
|
38
|
+
window.localStorage?.setItem(DEBUG_PANEL_STATE_KEY, JSON.stringify(state));
|
|
39
|
+
} catch {
|
|
40
|
+
/* ignore */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Opt-in check: does ANY of the debug signals say we should show the panel?
|
|
46
|
+
* Called by the session before deciding whether to mount.
|
|
47
|
+
*/
|
|
48
|
+
export function shouldShowDebugPanel(debugOpt: boolean | undefined): boolean {
|
|
49
|
+
if (debugOpt) return true;
|
|
50
|
+
try {
|
|
51
|
+
if (new URL(window.location.href).searchParams.get("sable-debug") === "1") {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
if (window.localStorage?.getItem("sable:debug") === "1") return true;
|
|
59
|
+
} catch {
|
|
60
|
+
/* ignore */
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mount `canvas` as a floating preview in the page. Returns a teardown that
|
|
67
|
+
* removes the wrapper and detaches listeners.
|
|
68
|
+
*
|
|
69
|
+
* The wrapper is pointer-events:none by default; only the header bar and
|
|
70
|
+
* its minimize button re-enable pointer events, so the panel never blocks
|
|
71
|
+
* clicks on the underlying page.
|
|
72
|
+
*/
|
|
73
|
+
export function mountDebugPanel(canvas: HTMLCanvasElement): () => void {
|
|
74
|
+
const state = loadState();
|
|
75
|
+
|
|
76
|
+
const wrap = document.createElement("div");
|
|
77
|
+
wrap.setAttribute("data-sable-debug", "vision");
|
|
78
|
+
Object.assign(wrap.style, {
|
|
79
|
+
position: "fixed",
|
|
80
|
+
width: "240px",
|
|
81
|
+
zIndex: "2147483647",
|
|
82
|
+
background: "#111",
|
|
83
|
+
color: "#ddd",
|
|
84
|
+
border: "1px solid #444",
|
|
85
|
+
borderRadius: "8px",
|
|
86
|
+
font: "11px/1.3 system-ui, sans-serif",
|
|
87
|
+
boxShadow: "0 8px 24px rgba(0,0,0,.4)",
|
|
88
|
+
pointerEvents: "none",
|
|
89
|
+
userSelect: "none",
|
|
90
|
+
overflow: "hidden",
|
|
91
|
+
} as Partial<CSSStyleDeclaration>);
|
|
92
|
+
|
|
93
|
+
// Initial placement: restored from localStorage, else default top-right.
|
|
94
|
+
if (typeof state.left === "number" && typeof state.top === "number") {
|
|
95
|
+
wrap.style.left = `${state.left}px`;
|
|
96
|
+
wrap.style.top = `${state.top}px`;
|
|
97
|
+
} else {
|
|
98
|
+
wrap.style.right = "12px";
|
|
99
|
+
wrap.style.top = "12px";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Header: drag handle + minimize button ────────────────────────────
|
|
103
|
+
const header = document.createElement("div");
|
|
104
|
+
Object.assign(header.style, {
|
|
105
|
+
display: "flex",
|
|
106
|
+
alignItems: "center",
|
|
107
|
+
justifyContent: "space-between",
|
|
108
|
+
gap: "6px",
|
|
109
|
+
padding: "6px 8px",
|
|
110
|
+
background: "#1a1a1a",
|
|
111
|
+
borderBottom: "1px solid #333",
|
|
112
|
+
cursor: "move",
|
|
113
|
+
pointerEvents: "auto",
|
|
114
|
+
} as Partial<CSSStyleDeclaration>);
|
|
115
|
+
|
|
116
|
+
const title = document.createElement("div");
|
|
117
|
+
title.textContent = "sable: agent vision";
|
|
118
|
+
Object.assign(title.style, {
|
|
119
|
+
opacity: "0.75",
|
|
120
|
+
fontWeight: "600",
|
|
121
|
+
flex: "1",
|
|
122
|
+
pointerEvents: "none",
|
|
123
|
+
} as Partial<CSSStyleDeclaration>);
|
|
124
|
+
header.appendChild(title);
|
|
125
|
+
|
|
126
|
+
const minBtn = document.createElement("button");
|
|
127
|
+
minBtn.setAttribute("aria-label", "Minimize vision panel");
|
|
128
|
+
Object.assign(minBtn.style, {
|
|
129
|
+
background: "transparent",
|
|
130
|
+
color: "#ddd",
|
|
131
|
+
border: "1px solid #444",
|
|
132
|
+
borderRadius: "4px",
|
|
133
|
+
width: "20px",
|
|
134
|
+
height: "20px",
|
|
135
|
+
cursor: "pointer",
|
|
136
|
+
fontSize: "12px",
|
|
137
|
+
lineHeight: "1",
|
|
138
|
+
padding: "0",
|
|
139
|
+
pointerEvents: "auto",
|
|
140
|
+
} as Partial<CSSStyleDeclaration>);
|
|
141
|
+
header.appendChild(minBtn);
|
|
142
|
+
|
|
143
|
+
wrap.appendChild(header);
|
|
144
|
+
|
|
145
|
+
// ── Body: the capture canvas (click-through) ─────────────────────────
|
|
146
|
+
const body = document.createElement("div");
|
|
147
|
+
Object.assign(body.style, {
|
|
148
|
+
padding: "6px",
|
|
149
|
+
background: "#111",
|
|
150
|
+
pointerEvents: "none",
|
|
151
|
+
} as Partial<CSSStyleDeclaration>);
|
|
152
|
+
|
|
153
|
+
canvas.style.width = "100%";
|
|
154
|
+
canvas.style.height = "auto";
|
|
155
|
+
canvas.style.display = "block";
|
|
156
|
+
canvas.style.background = "#fff";
|
|
157
|
+
canvas.style.borderRadius = "4px";
|
|
158
|
+
canvas.style.pointerEvents = "none";
|
|
159
|
+
body.appendChild(canvas);
|
|
160
|
+
|
|
161
|
+
wrap.appendChild(body);
|
|
162
|
+
|
|
163
|
+
// ── Minimize state ───────────────────────────────────────────────────
|
|
164
|
+
let minimized = !!state.minimized;
|
|
165
|
+
const applyMinimized = (): void => {
|
|
166
|
+
body.style.display = minimized ? "none" : "block";
|
|
167
|
+
minBtn.textContent = minimized ? "▢" : "–";
|
|
168
|
+
minBtn.setAttribute(
|
|
169
|
+
"aria-label",
|
|
170
|
+
minimized ? "Restore vision panel" : "Minimize vision panel",
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
applyMinimized();
|
|
174
|
+
|
|
175
|
+
minBtn.addEventListener("click", (ev) => {
|
|
176
|
+
ev.stopPropagation();
|
|
177
|
+
minimized = !minimized;
|
|
178
|
+
applyMinimized();
|
|
179
|
+
saveState({ ...loadState(), minimized });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── Drag behaviour ───────────────────────────────────────────────────
|
|
183
|
+
let dragState: { offsetX: number; offsetY: number } | null = null;
|
|
184
|
+
|
|
185
|
+
const onPointerDown = (ev: PointerEvent): void => {
|
|
186
|
+
if (ev.target instanceof HTMLElement && ev.target.closest("button")) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const rect = wrap.getBoundingClientRect();
|
|
190
|
+
dragState = {
|
|
191
|
+
offsetX: ev.clientX - rect.left,
|
|
192
|
+
offsetY: ev.clientY - rect.top,
|
|
193
|
+
};
|
|
194
|
+
// Convert to left/top anchoring on first drag.
|
|
195
|
+
wrap.style.left = `${rect.left}px`;
|
|
196
|
+
wrap.style.top = `${rect.top}px`;
|
|
197
|
+
wrap.style.right = "auto";
|
|
198
|
+
wrap.style.bottom = "auto";
|
|
199
|
+
header.setPointerCapture(ev.pointerId);
|
|
200
|
+
ev.preventDefault();
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const onPointerMove = (ev: PointerEvent): void => {
|
|
204
|
+
if (!dragState) return;
|
|
205
|
+
// Clamp so the panel can't be dragged fully off-screen (keep 24px of
|
|
206
|
+
// the header visible on every edge so the user can always grab it).
|
|
207
|
+
const vw = window.innerWidth;
|
|
208
|
+
const vh = window.innerHeight;
|
|
209
|
+
const w = wrap.offsetWidth;
|
|
210
|
+
let left = ev.clientX - dragState.offsetX;
|
|
211
|
+
let top = ev.clientY - dragState.offsetY;
|
|
212
|
+
left = Math.min(Math.max(left, -w + 48), vw - 48);
|
|
213
|
+
top = Math.min(Math.max(top, 0), vh - 24);
|
|
214
|
+
wrap.style.left = `${left}px`;
|
|
215
|
+
wrap.style.top = `${top}px`;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const onPointerUp = (ev: PointerEvent): void => {
|
|
219
|
+
if (!dragState) return;
|
|
220
|
+
dragState = null;
|
|
221
|
+
try {
|
|
222
|
+
header.releasePointerCapture(ev.pointerId);
|
|
223
|
+
} catch {
|
|
224
|
+
/* ignore */
|
|
225
|
+
}
|
|
226
|
+
const left = parseFloat(wrap.style.left) || 0;
|
|
227
|
+
const top = parseFloat(wrap.style.top) || 0;
|
|
228
|
+
saveState({ ...loadState(), left, top });
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
header.addEventListener("pointerdown", onPointerDown);
|
|
232
|
+
header.addEventListener("pointermove", onPointerMove);
|
|
233
|
+
header.addEventListener("pointerup", onPointerUp);
|
|
234
|
+
header.addEventListener("pointercancel", onPointerUp);
|
|
235
|
+
|
|
236
|
+
document.body.appendChild(wrap);
|
|
237
|
+
console.log("[Sable] debug vision panel mounted", {
|
|
238
|
+
minimized,
|
|
239
|
+
restoredPosition:
|
|
240
|
+
typeof state.left === "number" && typeof state.top === "number",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return () => {
|
|
244
|
+
try {
|
|
245
|
+
header.removeEventListener("pointerdown", onPointerDown);
|
|
246
|
+
header.removeEventListener("pointermove", onPointerMove);
|
|
247
|
+
header.removeEventListener("pointerup", onPointerUp);
|
|
248
|
+
header.removeEventListener("pointercancel", onPointerUp);
|
|
249
|
+
wrap.remove();
|
|
250
|
+
} catch {
|
|
251
|
+
/* ignore */
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|