backpack-viewer 0.6.0 → 0.7.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/bin/serve.js +159 -396
- package/dist/app/assets/index-D-H7agBH.js +12 -0
- package/dist/app/assets/index-DE73ngo-.css +1 -0
- package/dist/app/assets/index-Lvl7EMM_.js +6 -0
- package/dist/app/index.html +2 -2
- package/dist/bridge.d.ts +22 -0
- package/dist/bridge.js +41 -0
- package/dist/config.js +10 -0
- package/dist/copy-prompt.d.ts +17 -0
- package/dist/copy-prompt.js +81 -0
- package/dist/default-config.json +4 -0
- package/dist/dom-utils.d.ts +46 -0
- package/dist/dom-utils.js +57 -0
- package/dist/empty-state.js +63 -31
- package/dist/extensions/api.d.ts +15 -0
- package/dist/extensions/api.js +185 -0
- package/dist/extensions/chat/backpack-extension.json +23 -0
- package/dist/extensions/chat/src/index.js +32 -0
- package/dist/extensions/chat/src/panel.js +306 -0
- package/dist/extensions/chat/src/providers/anthropic.js +158 -0
- package/dist/extensions/chat/src/providers/types.js +15 -0
- package/dist/extensions/chat/src/tools.js +281 -0
- package/dist/extensions/chat/style.css +147 -0
- package/dist/extensions/event-bus.d.ts +12 -0
- package/dist/extensions/event-bus.js +30 -0
- package/dist/extensions/loader.d.ts +32 -0
- package/dist/extensions/loader.js +71 -0
- package/dist/extensions/manifest.d.ts +54 -0
- package/dist/extensions/manifest.js +116 -0
- package/dist/extensions/panel-mount.d.ts +26 -0
- package/dist/extensions/panel-mount.js +377 -0
- package/dist/extensions/share/backpack-extension.json +20 -0
- package/dist/extensions/share/src/index.js +357 -0
- package/dist/extensions/share/style.css +151 -0
- package/dist/extensions/taskbar.d.ts +29 -0
- package/dist/extensions/taskbar.js +64 -0
- package/dist/extensions/types.d.ts +182 -0
- package/dist/extensions/types.js +8 -0
- package/dist/info-panel.d.ts +2 -1
- package/dist/info-panel.js +78 -87
- package/dist/main.js +189 -29
- package/dist/search.js +1 -1
- package/dist/server-api-routes.d.ts +56 -0
- package/dist/server-api-routes.js +460 -0
- package/dist/server-extensions.d.ts +126 -0
- package/dist/server-extensions.js +272 -0
- package/dist/server-viewer-state.d.ts +18 -0
- package/dist/server-viewer-state.js +33 -0
- package/dist/sidebar.js +19 -7
- package/dist/style.css +356 -74
- package/dist/tools-pane.js +31 -14
- package/package.json +4 -3
- package/dist/app/assets/index-B3z5bBGl.css +0 -1
- package/dist/app/assets/index-BROJmzot.js +0 -35
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { VIEWER_API_VERSION } from "./types.js";
|
|
2
|
+
export class ManifestError extends Error {
|
|
3
|
+
constructor(extensionPath, problem) {
|
|
4
|
+
super(`Invalid manifest at ${extensionPath}: ${problem}`);
|
|
5
|
+
this.name = "ManifestError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validate a parsed manifest object. Throws ManifestError on invalid input.
|
|
10
|
+
* Returns the typed manifest. Stricter than tsc since the file comes from
|
|
11
|
+
* disk; we want clear errors at load time, not silent misbehavior later.
|
|
12
|
+
*/
|
|
13
|
+
export function validateManifest(raw, extensionPath) {
|
|
14
|
+
if (!raw || typeof raw !== "object") {
|
|
15
|
+
throw new ManifestError(extensionPath, "must be a JSON object");
|
|
16
|
+
}
|
|
17
|
+
const obj = raw;
|
|
18
|
+
function requireString(field) {
|
|
19
|
+
const v = obj[field];
|
|
20
|
+
if (typeof v !== "string" || !v) {
|
|
21
|
+
throw new ManifestError(extensionPath, `field "${field}" must be a non-empty string`);
|
|
22
|
+
}
|
|
23
|
+
return v;
|
|
24
|
+
}
|
|
25
|
+
const name = requireString("name");
|
|
26
|
+
const version = requireString("version");
|
|
27
|
+
const viewerApi = requireString("viewerApi");
|
|
28
|
+
const entry = requireString("entry");
|
|
29
|
+
if (viewerApi !== VIEWER_API_VERSION) {
|
|
30
|
+
throw new ManifestError(extensionPath, `viewerApi "${viewerApi}" is not supported (this viewer supports "${VIEWER_API_VERSION}")`);
|
|
31
|
+
}
|
|
32
|
+
if (!/^[a-z0-9][a-z0-9-_]*$/.test(name)) {
|
|
33
|
+
throw new ManifestError(extensionPath, `name "${name}" must be lowercase with hyphens/underscores only — used as a URL path segment and a directory name`);
|
|
34
|
+
}
|
|
35
|
+
// Optional fields
|
|
36
|
+
const displayName = typeof obj.displayName === "string" ? obj.displayName : undefined;
|
|
37
|
+
const description = typeof obj.description === "string" ? obj.description : undefined;
|
|
38
|
+
const stylesheet = typeof obj.stylesheet === "string" ? obj.stylesheet : undefined;
|
|
39
|
+
let permissions;
|
|
40
|
+
if (obj.permissions !== undefined) {
|
|
41
|
+
if (!obj.permissions || typeof obj.permissions !== "object") {
|
|
42
|
+
throw new ManifestError(extensionPath, "permissions must be an object");
|
|
43
|
+
}
|
|
44
|
+
permissions = validatePermissions(obj.permissions, extensionPath);
|
|
45
|
+
}
|
|
46
|
+
return { name, version, viewerApi, displayName, description, entry, stylesheet, permissions };
|
|
47
|
+
}
|
|
48
|
+
function validatePermissions(raw, extensionPath) {
|
|
49
|
+
const out = {};
|
|
50
|
+
if (raw.graph !== undefined) {
|
|
51
|
+
if (!Array.isArray(raw.graph)) {
|
|
52
|
+
throw new ManifestError(extensionPath, "permissions.graph must be an array");
|
|
53
|
+
}
|
|
54
|
+
out.graph = raw.graph.filter((v) => v === "read" || v === "write");
|
|
55
|
+
}
|
|
56
|
+
if (raw.viewer !== undefined) {
|
|
57
|
+
if (!Array.isArray(raw.viewer)) {
|
|
58
|
+
throw new ManifestError(extensionPath, "permissions.viewer must be an array");
|
|
59
|
+
}
|
|
60
|
+
out.viewer = raw.viewer.filter((v) => v === "focus" || v === "pan");
|
|
61
|
+
}
|
|
62
|
+
if (raw.settings !== undefined) {
|
|
63
|
+
out.settings = raw.settings === true;
|
|
64
|
+
}
|
|
65
|
+
if (raw.network !== undefined) {
|
|
66
|
+
if (!Array.isArray(raw.network)) {
|
|
67
|
+
throw new ManifestError(extensionPath, "permissions.network must be an array");
|
|
68
|
+
}
|
|
69
|
+
out.network = raw.network.map((entry, i) => validateNetworkEntry(entry, i, extensionPath));
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function validateNetworkEntry(raw, index, extensionPath) {
|
|
74
|
+
if (!raw || typeof raw !== "object") {
|
|
75
|
+
throw new ManifestError(extensionPath, `permissions.network[${index}] must be an object with { origin, injectHeaders? }`);
|
|
76
|
+
}
|
|
77
|
+
const obj = raw;
|
|
78
|
+
const origin = obj.origin;
|
|
79
|
+
if (typeof origin !== "string" || !origin) {
|
|
80
|
+
throw new ManifestError(extensionPath, `permissions.network[${index}].origin must be a non-empty string`);
|
|
81
|
+
}
|
|
82
|
+
// Reject obviously bad shapes early
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = new URL(origin);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new ManifestError(extensionPath, `permissions.network[${index}].origin "${origin}" is not a valid URL`);
|
|
89
|
+
}
|
|
90
|
+
if (parsed.pathname !== "/" && parsed.pathname !== "") {
|
|
91
|
+
throw new ManifestError(extensionPath, `permissions.network[${index}].origin must be a bare origin (no path); got "${origin}"`);
|
|
92
|
+
}
|
|
93
|
+
let injectHeaders;
|
|
94
|
+
if (obj.injectHeaders !== undefined) {
|
|
95
|
+
if (!obj.injectHeaders || typeof obj.injectHeaders !== "object") {
|
|
96
|
+
throw new ManifestError(extensionPath, `permissions.network[${index}].injectHeaders must be an object`);
|
|
97
|
+
}
|
|
98
|
+
injectHeaders = {};
|
|
99
|
+
for (const [headerName, value] of Object.entries(obj.injectHeaders)) {
|
|
100
|
+
if (!value || typeof value !== "object") {
|
|
101
|
+
throw new ManifestError(extensionPath, `injectHeaders.${headerName} must be an object`);
|
|
102
|
+
}
|
|
103
|
+
const v = value;
|
|
104
|
+
if (typeof v.fromEnv === "string") {
|
|
105
|
+
injectHeaders[headerName] = { fromEnv: v.fromEnv };
|
|
106
|
+
}
|
|
107
|
+
else if (typeof v.literal === "string") {
|
|
108
|
+
injectHeaders[headerName] = { literal: v.literal };
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
throw new ManifestError(extensionPath, `injectHeaders.${headerName} must have either fromEnv (string) or literal (string)`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { origin: parsed.origin, injectHeaders };
|
|
116
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { MountPanelOptions, MountedPanel } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Panel mount surface — wraps an extension-provided body element with
|
|
4
|
+
* standard chrome (title, custom buttons, fullscreen toggle, close X)
|
|
5
|
+
* and makes it draggable + click-to-front + position-persistent.
|
|
6
|
+
*
|
|
7
|
+
* Layout policy:
|
|
8
|
+
* - Each panel positions itself via inline left/top from JS, so
|
|
9
|
+
* drag-to-move is just updating those properties.
|
|
10
|
+
* - The user grabs the title bar to move the panel anywhere within
|
|
11
|
+
* the canvas container. Bounds-clamped so the title bar stays
|
|
12
|
+
* visible.
|
|
13
|
+
* - Click anywhere on a panel raises it above all other panels via
|
|
14
|
+
* a shared z-index counter. The counter is module-global so
|
|
15
|
+
* info-panel and extension panels share the same focus stack.
|
|
16
|
+
* - Position + fullscreen state are persisted to localStorage per
|
|
17
|
+
* panel key, so dragging + maximize survive page refreshes.
|
|
18
|
+
*
|
|
19
|
+
* No region/dock vocabulary, no automatic collision avoidance — if
|
|
20
|
+
* two panels overlap, the user drags one out of the way. The user is
|
|
21
|
+
* the layout coordinator.
|
|
22
|
+
*/
|
|
23
|
+
export interface PanelMount {
|
|
24
|
+
mount(extName: string, body: HTMLElement, opts?: MountPanelOptions): MountedPanel;
|
|
25
|
+
}
|
|
26
|
+
export declare function createPanelMount(parent: HTMLElement): PanelMount;
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { makeSvgIcon } from "../dom-utils";
|
|
2
|
+
const PANEL_TIER_BASE = 30; // matches --z-panel
|
|
3
|
+
const PANEL_TIER_MAX = 39; // last value before --z-floating (40); reset above this
|
|
4
|
+
const FULLSCREEN_Z = 45; // matches --z-panel-fullscreen — above secondary floating, below floating-primary (search bar)
|
|
5
|
+
const DEFAULT_PANEL_WIDTH = 380;
|
|
6
|
+
const DEFAULT_TOP_OFFSET = 70;
|
|
7
|
+
const DEFAULT_RIGHT_MARGIN = 16;
|
|
8
|
+
const MIN_VISIBLE_AFTER_DRAG = 80;
|
|
9
|
+
const STORAGE_PREFIX = "backpack-viewer:panel:";
|
|
10
|
+
const FULLSCREEN_PARENT_CLASS = "has-fullscreen-panel";
|
|
11
|
+
/**
|
|
12
|
+
* Module-global click-to-front counter shared by all panels (info-panel
|
|
13
|
+
* AND extension panels). Click any panel and it bumps it above the
|
|
14
|
+
* other panels in the panel tier. Reset on page reload.
|
|
15
|
+
*
|
|
16
|
+
* If the counter ever exceeds PANEL_TIER_MAX (e.g., the user clicks
|
|
17
|
+
* many panels back and forth), all panels reset to base+1 and the
|
|
18
|
+
* counter resumes — this prevents panels from leaking into the
|
|
19
|
+
* floating tier and overlapping the top bar.
|
|
20
|
+
*/
|
|
21
|
+
let topZ = PANEL_TIER_BASE;
|
|
22
|
+
function bringPanelToFront(panel) {
|
|
23
|
+
topZ++;
|
|
24
|
+
if (topZ > PANEL_TIER_MAX) {
|
|
25
|
+
// Compress: reset every panel in the layer to base+1, then
|
|
26
|
+
// promote this one. Counter resumes from base+2.
|
|
27
|
+
document.querySelectorAll(".extension-panel").forEach((p) => {
|
|
28
|
+
p.style.zIndex = String(PANEL_TIER_BASE + 1);
|
|
29
|
+
});
|
|
30
|
+
topZ = PANEL_TIER_BASE + 2;
|
|
31
|
+
}
|
|
32
|
+
panel.style.zIndex = String(topZ);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Ref-count of panels currently in fullscreen mode. The has-fullscreen
|
|
36
|
+
* class is added to #canvas-container while count > 0 and removed when
|
|
37
|
+
* it drops to 0. CSS uses the class to hide secondary top-bar controls
|
|
38
|
+
* (zoom, theme, copy-prompt, sidebar-expand, taskbar slots) so they
|
|
39
|
+
* don't visually conflict with the fullscreen panel's chrome buttons.
|
|
40
|
+
*/
|
|
41
|
+
let fullscreenCount = 0;
|
|
42
|
+
function notifyFullscreenChange(parent, entering) {
|
|
43
|
+
// Walk up to #canvas-container — that's where the class lives.
|
|
44
|
+
// If parent is the canvas container itself, use it directly.
|
|
45
|
+
const target = parent.id === "canvas-container"
|
|
46
|
+
? parent
|
|
47
|
+
: parent.closest("#canvas-container") ?? parent;
|
|
48
|
+
if (entering) {
|
|
49
|
+
fullscreenCount++;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
fullscreenCount = Math.max(0, fullscreenCount - 1);
|
|
53
|
+
}
|
|
54
|
+
target.classList.toggle(FULLSCREEN_PARENT_CLASS, fullscreenCount > 0);
|
|
55
|
+
}
|
|
56
|
+
function loadPersistedState(key) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = localStorage.getItem(STORAGE_PREFIX + key);
|
|
59
|
+
if (!raw)
|
|
60
|
+
return null;
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
if (parsed && typeof parsed === "object")
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* ignore — best effort */
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function savePersistedState(key, state) {
|
|
71
|
+
try {
|
|
72
|
+
localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(state));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* ignore — quota exceeded etc */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Build a fullscreen-toggle SVG icon (4 corner arrows). */
|
|
79
|
+
function makeFullscreenIcon() {
|
|
80
|
+
return makeSvgIcon({ size: 13, strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" }, [
|
|
81
|
+
{ tag: "polyline", attrs: { points: "4 9 4 4 9 4" } },
|
|
82
|
+
{ tag: "polyline", attrs: { points: "20 9 20 4 15 4" } },
|
|
83
|
+
{ tag: "polyline", attrs: { points: "4 15 4 20 9 20" } },
|
|
84
|
+
{ tag: "polyline", attrs: { points: "20 15 20 20 15 20" } },
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
/** Build a restore-from-fullscreen SVG icon (4 inward arrows). */
|
|
88
|
+
function makeRestoreIcon() {
|
|
89
|
+
return makeSvgIcon({ size: 13, strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" }, [
|
|
90
|
+
{ tag: "polyline", attrs: { points: "9 4 9 9 4 9" } },
|
|
91
|
+
{ tag: "polyline", attrs: { points: "15 4 15 9 20 9" } },
|
|
92
|
+
{ tag: "polyline", attrs: { points: "9 20 9 15 4 15" } },
|
|
93
|
+
{ tag: "polyline", attrs: { points: "15 20 15 15 20 15" } },
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
export function createPanelMount(parent) {
|
|
97
|
+
const layer = document.createElement("div");
|
|
98
|
+
layer.className = "extension-panel-layer";
|
|
99
|
+
parent.appendChild(layer);
|
|
100
|
+
function defaultPosition() {
|
|
101
|
+
const parentRect = parent.getBoundingClientRect();
|
|
102
|
+
const left = Math.max(0, parentRect.width - DEFAULT_PANEL_WIDTH - DEFAULT_RIGHT_MARGIN);
|
|
103
|
+
return { left, top: DEFAULT_TOP_OFFSET };
|
|
104
|
+
}
|
|
105
|
+
function clampPosition(pos, panelRect) {
|
|
106
|
+
const parentRect = parent.getBoundingClientRect();
|
|
107
|
+
const minLeft = MIN_VISIBLE_AFTER_DRAG - panelRect.width;
|
|
108
|
+
const maxLeft = parentRect.width - MIN_VISIBLE_AFTER_DRAG;
|
|
109
|
+
const minTop = 0;
|
|
110
|
+
const maxTop = parentRect.height - 40;
|
|
111
|
+
return {
|
|
112
|
+
left: Math.max(minLeft, Math.min(maxLeft, pos.left)),
|
|
113
|
+
top: Math.max(minTop, Math.min(maxTop, pos.top)),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function mount(extName, body, opts = {}) {
|
|
117
|
+
const persistKey = opts.persistKey ?? extName;
|
|
118
|
+
const persisted = loadPersistedState(persistKey);
|
|
119
|
+
const root = document.createElement("aside");
|
|
120
|
+
root.className = "extension-panel";
|
|
121
|
+
root.dataset.panel = persistKey;
|
|
122
|
+
// --- Header (drag handle + chrome) ---
|
|
123
|
+
const header = document.createElement("div");
|
|
124
|
+
header.className = "extension-panel-header";
|
|
125
|
+
const titleEl = document.createElement("span");
|
|
126
|
+
titleEl.className = "extension-panel-title";
|
|
127
|
+
titleEl.textContent = opts.title ?? extName;
|
|
128
|
+
header.appendChild(titleEl);
|
|
129
|
+
// Custom header buttons get their own container so setHeaderButtons
|
|
130
|
+
// can replace its children without disturbing the built-in controls.
|
|
131
|
+
const customBtnContainer = document.createElement("div");
|
|
132
|
+
customBtnContainer.className = "extension-panel-custom-btns";
|
|
133
|
+
header.appendChild(customBtnContainer);
|
|
134
|
+
function renderCustomButtons(buttons) {
|
|
135
|
+
customBtnContainer.replaceChildren();
|
|
136
|
+
for (const spec of buttons) {
|
|
137
|
+
const btn = document.createElement("button");
|
|
138
|
+
btn.className = "extension-panel-btn";
|
|
139
|
+
btn.title = spec.label;
|
|
140
|
+
btn.setAttribute("aria-label", spec.label);
|
|
141
|
+
btn.textContent = spec.iconText ?? spec.label;
|
|
142
|
+
if (spec.disabled)
|
|
143
|
+
btn.disabled = true;
|
|
144
|
+
btn.addEventListener("click", (e) => {
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
try {
|
|
147
|
+
spec.onClick();
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
console.error(`[backpack-viewer] panel header button "${spec.label}" threw:`, err);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
// Buttons live inside the header (which is the drag handle), so
|
|
154
|
+
// mousedown must not start a drag.
|
|
155
|
+
btn.addEventListener("mousedown", (e) => e.stopPropagation());
|
|
156
|
+
customBtnContainer.appendChild(btn);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
renderCustomButtons(opts.headerButtons ?? []);
|
|
160
|
+
// --- Built-in fullscreen + close buttons ---
|
|
161
|
+
let fullscreen = false;
|
|
162
|
+
let fullscreenBtn = null;
|
|
163
|
+
let fullscreenIconEl = null;
|
|
164
|
+
if (opts.showFullscreenButton !== false) {
|
|
165
|
+
fullscreenBtn = document.createElement("button");
|
|
166
|
+
fullscreenBtn.className = "extension-panel-btn extension-panel-btn-fullscreen";
|
|
167
|
+
fullscreenBtn.title = "Toggle fullscreen";
|
|
168
|
+
fullscreenBtn.setAttribute("aria-label", "Toggle fullscreen");
|
|
169
|
+
fullscreenIconEl = makeFullscreenIcon();
|
|
170
|
+
fullscreenBtn.appendChild(fullscreenIconEl);
|
|
171
|
+
fullscreenBtn.addEventListener("click", (e) => {
|
|
172
|
+
e.stopPropagation();
|
|
173
|
+
setFullscreen(!fullscreen);
|
|
174
|
+
});
|
|
175
|
+
fullscreenBtn.addEventListener("mousedown", (e) => e.stopPropagation());
|
|
176
|
+
header.appendChild(fullscreenBtn);
|
|
177
|
+
}
|
|
178
|
+
let closeBtn = null;
|
|
179
|
+
if (opts.showCloseButton !== false) {
|
|
180
|
+
closeBtn = document.createElement("button");
|
|
181
|
+
closeBtn.className = "extension-panel-btn extension-panel-btn-close";
|
|
182
|
+
closeBtn.title = "Close panel";
|
|
183
|
+
closeBtn.setAttribute("aria-label", "Close panel");
|
|
184
|
+
closeBtn.textContent = "\u00d7";
|
|
185
|
+
closeBtn.addEventListener("click", (e) => {
|
|
186
|
+
e.stopPropagation();
|
|
187
|
+
if (opts.hideOnClose) {
|
|
188
|
+
setVisible(false);
|
|
189
|
+
// Fire onClose for hideOnClose panels too — owners may want
|
|
190
|
+
// to reset state.
|
|
191
|
+
try {
|
|
192
|
+
opts.onClose?.();
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.error("[backpack-viewer] panel onClose threw:", err);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
close();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
closeBtn.addEventListener("mousedown", (e) => e.stopPropagation());
|
|
203
|
+
header.appendChild(closeBtn);
|
|
204
|
+
}
|
|
205
|
+
root.appendChild(header);
|
|
206
|
+
// --- Body ---
|
|
207
|
+
const bodyWrap = document.createElement("div");
|
|
208
|
+
bodyWrap.className = "extension-panel-body";
|
|
209
|
+
bodyWrap.appendChild(body);
|
|
210
|
+
root.appendChild(bodyWrap);
|
|
211
|
+
layer.appendChild(root);
|
|
212
|
+
// --- Initial position ---
|
|
213
|
+
// Priority: persisted > opts.defaultPosition > computed default
|
|
214
|
+
const initial = (persisted && persisted.left != null && persisted.top != null)
|
|
215
|
+
? { left: persisted.left, top: persisted.top }
|
|
216
|
+
: (opts.defaultPosition ?? defaultPosition());
|
|
217
|
+
// Bounds-clamp the restored position in case the viewport shrank
|
|
218
|
+
// since it was saved (otherwise the panel could end up off-screen
|
|
219
|
+
// and unrecoverable).
|
|
220
|
+
const initialRect = { width: DEFAULT_PANEL_WIDTH, height: 200 };
|
|
221
|
+
const clampedInitial = clampPosition(initial, initialRect);
|
|
222
|
+
root.style.left = clampedInitial.left + "px";
|
|
223
|
+
root.style.top = clampedInitial.top + "px";
|
|
224
|
+
bringPanelToFront(root);
|
|
225
|
+
// --- Drag handling ---
|
|
226
|
+
let dragStartX = 0;
|
|
227
|
+
let dragStartY = 0;
|
|
228
|
+
let panelStartLeft = 0;
|
|
229
|
+
let panelStartTop = 0;
|
|
230
|
+
let dragging = false;
|
|
231
|
+
function onMouseMove(e) {
|
|
232
|
+
if (!dragging)
|
|
233
|
+
return;
|
|
234
|
+
const dx = e.clientX - dragStartX;
|
|
235
|
+
const dy = e.clientY - dragStartY;
|
|
236
|
+
const next = clampPosition({ left: panelStartLeft + dx, top: panelStartTop + dy }, root.getBoundingClientRect());
|
|
237
|
+
root.style.left = next.left + "px";
|
|
238
|
+
root.style.top = next.top + "px";
|
|
239
|
+
}
|
|
240
|
+
function onMouseUp() {
|
|
241
|
+
if (!dragging)
|
|
242
|
+
return;
|
|
243
|
+
dragging = false;
|
|
244
|
+
document.removeEventListener("mousemove", onMouseMove);
|
|
245
|
+
document.removeEventListener("mouseup", onMouseUp);
|
|
246
|
+
// Persist the new position
|
|
247
|
+
const rect = root.getBoundingClientRect();
|
|
248
|
+
const parentRect = parent.getBoundingClientRect();
|
|
249
|
+
savePersistedState(persistKey, {
|
|
250
|
+
...loadPersistedState(persistKey),
|
|
251
|
+
left: rect.left - parentRect.left,
|
|
252
|
+
top: rect.top - parentRect.top,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
header.addEventListener("mousedown", (e) => {
|
|
256
|
+
if (fullscreen)
|
|
257
|
+
return; // can't drag while fullscreen
|
|
258
|
+
// Buttons inside the header stop propagation already, so we know
|
|
259
|
+
// this came from the title area or empty space.
|
|
260
|
+
dragging = true;
|
|
261
|
+
dragStartX = e.clientX;
|
|
262
|
+
dragStartY = e.clientY;
|
|
263
|
+
const rect = root.getBoundingClientRect();
|
|
264
|
+
const parentRect = parent.getBoundingClientRect();
|
|
265
|
+
panelStartLeft = rect.left - parentRect.left;
|
|
266
|
+
panelStartTop = rect.top - parentRect.top;
|
|
267
|
+
bringPanelToFront(root);
|
|
268
|
+
document.addEventListener("mousemove", onMouseMove);
|
|
269
|
+
document.addEventListener("mouseup", onMouseUp);
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
});
|
|
272
|
+
// Click anywhere on the panel raises it.
|
|
273
|
+
root.addEventListener("mousedown", () => {
|
|
274
|
+
if (!fullscreen)
|
|
275
|
+
bringPanelToFront(root);
|
|
276
|
+
}, { capture: true });
|
|
277
|
+
// --- Lifecycle ---
|
|
278
|
+
let closed = false;
|
|
279
|
+
let visible = true;
|
|
280
|
+
function close() {
|
|
281
|
+
if (closed)
|
|
282
|
+
return;
|
|
283
|
+
closed = true;
|
|
284
|
+
document.removeEventListener("mousemove", onMouseMove);
|
|
285
|
+
document.removeEventListener("mouseup", onMouseUp);
|
|
286
|
+
// If this panel was in fullscreen, decrement the global counter
|
|
287
|
+
// so the parent class clears properly.
|
|
288
|
+
if (fullscreen) {
|
|
289
|
+
notifyFullscreenChange(parent, false);
|
|
290
|
+
fullscreen = false;
|
|
291
|
+
}
|
|
292
|
+
root.remove();
|
|
293
|
+
try {
|
|
294
|
+
opts.onClose?.();
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
console.error("[backpack-viewer] panel onClose threw:", err);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function setVisible(value) {
|
|
301
|
+
if (value === visible)
|
|
302
|
+
return;
|
|
303
|
+
visible = value;
|
|
304
|
+
root.classList.toggle("is-hidden", !visible);
|
|
305
|
+
if (visible) {
|
|
306
|
+
bringPanelToFront(root);
|
|
307
|
+
// If we were fullscreen when hidden, the global counter was
|
|
308
|
+
// decremented; restore it now that we're visible again.
|
|
309
|
+
if (fullscreen) {
|
|
310
|
+
notifyFullscreenChange(parent, true);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else if (fullscreen) {
|
|
314
|
+
// Hidden while fullscreen — decrement the global counter so
|
|
315
|
+
// other panels and the top bar reappear.
|
|
316
|
+
notifyFullscreenChange(parent, false);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function setFullscreen(value) {
|
|
320
|
+
if (value === fullscreen)
|
|
321
|
+
return;
|
|
322
|
+
fullscreen = value;
|
|
323
|
+
root.classList.toggle("is-fullscreen", fullscreen);
|
|
324
|
+
// Swap the icon between the fullscreen-out and restore-in glyphs
|
|
325
|
+
if (fullscreenBtn && fullscreenIconEl) {
|
|
326
|
+
fullscreenIconEl.remove();
|
|
327
|
+
fullscreenIconEl = fullscreen ? makeRestoreIcon() : makeFullscreenIcon();
|
|
328
|
+
fullscreenBtn.appendChild(fullscreenIconEl);
|
|
329
|
+
}
|
|
330
|
+
// Bump z-index above secondary floating tier when fullscreen so
|
|
331
|
+
// the panel covers zoom/theme/copy-prompt; restore to the
|
|
332
|
+
// panel-tier counter when not. The search bar lives at
|
|
333
|
+
// --z-floating-primary above this and stays visible.
|
|
334
|
+
if (fullscreen) {
|
|
335
|
+
root.style.zIndex = String(FULLSCREEN_Z);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
bringPanelToFront(root);
|
|
339
|
+
}
|
|
340
|
+
// Toggle the parent class so CSS hides secondary controls
|
|
341
|
+
notifyFullscreenChange(parent, fullscreen);
|
|
342
|
+
// Persist
|
|
343
|
+
savePersistedState(persistKey, {
|
|
344
|
+
...loadPersistedState(persistKey),
|
|
345
|
+
fullscreen,
|
|
346
|
+
});
|
|
347
|
+
try {
|
|
348
|
+
opts.onFullscreenChange?.(fullscreen);
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
console.error("[backpack-viewer] panel onFullscreenChange threw:", err);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Restore fullscreen state if persisted
|
|
355
|
+
if (persisted?.fullscreen) {
|
|
356
|
+
setFullscreen(true);
|
|
357
|
+
}
|
|
358
|
+
function setTitle(t) {
|
|
359
|
+
titleEl.textContent = t;
|
|
360
|
+
}
|
|
361
|
+
function setHeaderButtons(buttons) {
|
|
362
|
+
renderCustomButtons(buttons);
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
close,
|
|
366
|
+
setFullscreen,
|
|
367
|
+
isFullscreen: () => fullscreen,
|
|
368
|
+
setTitle,
|
|
369
|
+
setHeaderButtons,
|
|
370
|
+
setVisible,
|
|
371
|
+
isVisible: () => visible,
|
|
372
|
+
bringToFront: () => bringPanelToFront(root),
|
|
373
|
+
element: body,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return { mount };
|
|
377
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "share",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"viewerApi": "1",
|
|
5
|
+
"displayName": "Share",
|
|
6
|
+
"description": "Share encrypted backpacks via a link. Recipients decrypt in-browser — the server never sees your data.",
|
|
7
|
+
"entry": "src/index.js",
|
|
8
|
+
"stylesheet": "style.css",
|
|
9
|
+
"permissions": {
|
|
10
|
+
"graph": ["read"],
|
|
11
|
+
"viewer": [],
|
|
12
|
+
"settings": true,
|
|
13
|
+
"network": [
|
|
14
|
+
{
|
|
15
|
+
"origin": "https://app.backpackontology.com",
|
|
16
|
+
"description": "Share relay for uploading encrypted backpacks"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|