gentle-pi 4.3.0 → 4.5.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 +10 -7
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +138 -155
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +141 -57
- package/lib/gentle-shell-launcher.ts +11 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +227 -27
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +104 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +67 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/vim-editor-adapter.test.ts +2 -2
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -0,0 +1,1497 @@
|
|
|
1
|
+
// Gentle Design canvas: artboards side by side with pan, zoom, live reload,
|
|
2
|
+
// and comments persisted in design/<slug>/comments.json (pins, list, send modes).
|
|
3
|
+
// design/system.html, when present, is the first board of every design; it is
|
|
4
|
+
// kept out of state.boards so comments never apply to it.
|
|
5
|
+
// Artboards run in iframes sandboxed with scripts only, so each one has an opaque
|
|
6
|
+
// origin; messages are trusted only by matching event.source to a known artboard.
|
|
7
|
+
// Navigation mode (the default) covers every artboard with a shield, so the wheel,
|
|
8
|
+
// drags and clicks always reach the canvas; a click on a shield enters focus mode,
|
|
9
|
+
// which shows that artboard at 100% and lets it own the pointer and the keyboard
|
|
10
|
+
// until "Back to canvas" or Shift+Esc restores the previous view.
|
|
11
|
+
// Handoffs (design/<slug>/handoffs.json) show live on each artboard's header: the
|
|
12
|
+
// handoff state, the chosen artboard and its alternatives, and, once implemented
|
|
13
|
+
// with a loopback previewUrl, a Compare view of the artboard next to the running app.
|
|
14
|
+
(() => {
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
const config = JSON.parse(document.getElementById("gd-config").textContent);
|
|
18
|
+
const base = String(config.base);
|
|
19
|
+
const MIN_ZOOM = 0.05;
|
|
20
|
+
const MAX_ZOOM = 4;
|
|
21
|
+
const FIT_PADDING = 48;
|
|
22
|
+
const FOCUS_MARGIN = 16;
|
|
23
|
+
// Room below a focused artboard for the floating "Back to canvas" button.
|
|
24
|
+
const BACK_SPACE = 64;
|
|
25
|
+
const CLICK_SLOP = 4;
|
|
26
|
+
const LIMITS = { selector: 500, excerpt: 300, comment: 2000, pins: 500, screenshotBytes: 5 * 1024 * 1024 };
|
|
27
|
+
const SCREENSHOT_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
28
|
+
const MODE_KEY = "gentle-design:send-mode";
|
|
29
|
+
const SYSTEM_LABEL = { title: "Design system", tradeoff: "Project tokens (design/system.md)" };
|
|
30
|
+
const SYSTEM_NOTE = "Comments apply to design artboards only. To change the system, update the project's design sources and run /gentle:design sync.";
|
|
31
|
+
const HANDOFF_LABELS = { queued: "Queued", sent: "Implementing…", in_progress: "In progress", implemented: "Implemented ✓" };
|
|
32
|
+
const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
33
|
+
const MAX_PREVIEW_URL = 2048;
|
|
34
|
+
// The running app is another loopback origin (previewTarget refuses this server's own
|
|
35
|
+
// port), so the same-origin flag below gives the app its own origin, never the canvas's: it cannot
|
|
36
|
+
// reach this page, its token or its API, and its scripts, storage and forms work as in
|
|
37
|
+
// production. No top-navigation flag is granted, so it can never navigate the canvas away.
|
|
38
|
+
const PREVIEW_SANDBOX = "allow-scripts allow-same-origin allow-forms allow-popups";
|
|
39
|
+
const PREVIEW_TIMEOUT_MS = 6000;
|
|
40
|
+
const PREVIEW_HINT = "If the app refuses to be framed, use Open in new tab.";
|
|
41
|
+
// CSP host sources have no IPv6 literal form, so Chromium drops the [::1] source from frame-src.
|
|
42
|
+
const IPV6_HINT = "Browsers do not frame [::1] under the canvas policy (CSP has no IPv6 sources): use Open in new tab, or report the preview on localhost.";
|
|
43
|
+
const COMPARE_GAP = 24;
|
|
44
|
+
const COMPARE_PADDING = 24;
|
|
45
|
+
const TOAST_MS = 5000;
|
|
46
|
+
|
|
47
|
+
const $ = (id) => document.getElementById(id);
|
|
48
|
+
const els = {
|
|
49
|
+
title: $("gd-title"),
|
|
50
|
+
design: $("gd-design"),
|
|
51
|
+
designField: $("gd-design-field"),
|
|
52
|
+
feature: $("gd-feature"),
|
|
53
|
+
zoomOut: $("gd-zoom-out"),
|
|
54
|
+
zoomIn: $("gd-zoom-in"),
|
|
55
|
+
zoomLevel: $("gd-zoom-level"),
|
|
56
|
+
fit: $("gd-fit"),
|
|
57
|
+
navMode: $("gd-nav-mode"),
|
|
58
|
+
back: $("gd-back"),
|
|
59
|
+
comment: $("gd-comment"),
|
|
60
|
+
status: $("gd-status"),
|
|
61
|
+
viewport: $("gd-viewport"),
|
|
62
|
+
world: $("gd-world"),
|
|
63
|
+
empty: $("gd-empty"),
|
|
64
|
+
popover: $("gd-popover"),
|
|
65
|
+
form: $("gd-popover-form"),
|
|
66
|
+
target: $("gd-popover-target"),
|
|
67
|
+
text: $("gd-popover-text"),
|
|
68
|
+
message: $("gd-popover-message"),
|
|
69
|
+
cancel: $("gd-popover-cancel"),
|
|
70
|
+
send: $("gd-popover-send"),
|
|
71
|
+
shotInput: $("gd-shot-input"),
|
|
72
|
+
shotAttach: $("gd-shot-attach"),
|
|
73
|
+
shotPreview: $("gd-shot-preview"),
|
|
74
|
+
shotImage: $("gd-shot-image"),
|
|
75
|
+
shotRemove: $("gd-shot-remove"),
|
|
76
|
+
modeSend: $("gd-mode-send"),
|
|
77
|
+
modeCollect: $("gd-mode-collect"),
|
|
78
|
+
sendAll: $("gd-send-all"),
|
|
79
|
+
panelToggle: $("gd-panel-toggle"),
|
|
80
|
+
panel: $("gd-panel"),
|
|
81
|
+
filterOpen: $("gd-filter-open"),
|
|
82
|
+
filterResolved: $("gd-filter-resolved"),
|
|
83
|
+
panelEmpty: $("gd-panel-empty"),
|
|
84
|
+
list: $("gd-comment-list"),
|
|
85
|
+
toast: $("gd-toast"),
|
|
86
|
+
compare: $("gd-compare"),
|
|
87
|
+
compareTitle: $("gd-compare-title"),
|
|
88
|
+
compareFit: $("gd-compare-fit"),
|
|
89
|
+
compareFitField: $("gd-compare-fit-field"),
|
|
90
|
+
compareOpen: $("gd-compare-open"),
|
|
91
|
+
compareBack: $("gd-compare-back"),
|
|
92
|
+
compareMessage: $("gd-compare-message"),
|
|
93
|
+
compareStage: $("gd-compare-stage"),
|
|
94
|
+
compareSizer: $("gd-compare-sizer"),
|
|
95
|
+
comparePanes: $("gd-compare-panes"),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function storedMode() {
|
|
99
|
+
try {
|
|
100
|
+
return localStorage.getItem(MODE_KEY) === "collect" ? "collect" : "send";
|
|
101
|
+
} catch {
|
|
102
|
+
return "send";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const state = {
|
|
107
|
+
slug: typeof config.slug === "string" ? config.slug : null,
|
|
108
|
+
manifest: null,
|
|
109
|
+
boards: [],
|
|
110
|
+
system: null,
|
|
111
|
+
systemBoard: null,
|
|
112
|
+
zoom: 1,
|
|
113
|
+
x: FIT_PADDING,
|
|
114
|
+
y: FIT_PADDING,
|
|
115
|
+
commenting: false,
|
|
116
|
+
pick: null,
|
|
117
|
+
// The one screenshot of the open popover: { mimeType, data: base64 }, or null.
|
|
118
|
+
shot: null,
|
|
119
|
+
shotToken: 0,
|
|
120
|
+
closeTimer: 0,
|
|
121
|
+
comments: [],
|
|
122
|
+
filter: "open",
|
|
123
|
+
mode: storedMode(),
|
|
124
|
+
activeId: null,
|
|
125
|
+
designCount: 0,
|
|
126
|
+
// The focused board, and the view to restore when focus mode ends.
|
|
127
|
+
focus: null,
|
|
128
|
+
savedView: null,
|
|
129
|
+
savedPanel: null,
|
|
130
|
+
hoverBoard: null,
|
|
131
|
+
// The handoffs.json of design `slug`; boards of another design ignore it.
|
|
132
|
+
handoffs: { slug: null, chosen: null, handoffs: {} },
|
|
133
|
+
// The open Compare view: { board, preview, artboardFrame, timer }.
|
|
134
|
+
compare: null,
|
|
135
|
+
toastTimer: 0,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function setStatus(text, isError) {
|
|
139
|
+
els.status.textContent = text;
|
|
140
|
+
els.status.classList.toggle("gd-error", Boolean(isError));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A confirmation that stays on screen for a few seconds, beyond the status line. */
|
|
144
|
+
function showToast(text, isError) {
|
|
145
|
+
clearTimeout(state.toastTimer);
|
|
146
|
+
els.toast.textContent = text;
|
|
147
|
+
els.toast.classList.toggle("gd-error", Boolean(isError));
|
|
148
|
+
els.toast.hidden = false;
|
|
149
|
+
state.toastTimer = setTimeout(() => { els.toast.hidden = true; }, TOAST_MS);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fileUrl(slug, file) {
|
|
153
|
+
return `${base}files/${encodeURIComponent(slug)}/${file.split("/").map(encodeURIComponent).join("/")}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function getJson(path) {
|
|
157
|
+
const response = await fetch(base + path, { headers: { Accept: "application/json" }, cache: "no-store" });
|
|
158
|
+
let body = null;
|
|
159
|
+
try { body = await response.json(); } catch { body = null; }
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
const reason = body && (body.reason || body.error);
|
|
162
|
+
throw new Error(reason ? String(reason) : `HTTP ${response.status}`);
|
|
163
|
+
}
|
|
164
|
+
return body;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- view transform -------------------------------------------------
|
|
168
|
+
|
|
169
|
+
function clampZoom(value) {
|
|
170
|
+
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function applyView() {
|
|
174
|
+
els.world.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.zoom})`;
|
|
175
|
+
els.world.style.setProperty("--gd-inv-zoom", String(1 / state.zoom));
|
|
176
|
+
els.zoomLevel.textContent = `${Math.round(state.zoom * 100)}%`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Zoom so the world point under (px, py) in viewport coordinates stays put. */
|
|
180
|
+
function zoomAt(nextZoom, px, py) {
|
|
181
|
+
const zoom = clampZoom(nextZoom);
|
|
182
|
+
state.x = px - (px - state.x) * (zoom / state.zoom);
|
|
183
|
+
state.y = py - (py - state.y) * (zoom / state.zoom);
|
|
184
|
+
state.zoom = zoom;
|
|
185
|
+
applyView();
|
|
186
|
+
closePopover();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function zoomAtCenter(factor) {
|
|
190
|
+
const rect = els.viewport.getBoundingClientRect();
|
|
191
|
+
zoomAt(state.zoom * factor, rect.width / 2, rect.height / 2);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function panBy(dx, dy) {
|
|
195
|
+
state.x += dx;
|
|
196
|
+
state.y += dy;
|
|
197
|
+
applyView();
|
|
198
|
+
closePopover();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function fit() {
|
|
202
|
+
if (state.focus) return;
|
|
203
|
+
const rect = els.viewport.getBoundingClientRect();
|
|
204
|
+
const width = els.world.offsetWidth;
|
|
205
|
+
const height = els.world.offsetHeight;
|
|
206
|
+
if (!width || !height) return;
|
|
207
|
+
const zoom = clampZoom(Math.min((rect.width - FIT_PADDING * 2) / width, (rect.height - FIT_PADDING * 2) / height, 1));
|
|
208
|
+
state.zoom = zoom;
|
|
209
|
+
state.x = (rect.width - width * zoom) / 2;
|
|
210
|
+
state.y = Math.max(FIT_PADDING, (rect.height - height * zoom) / 2);
|
|
211
|
+
applyView();
|
|
212
|
+
closePopover();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function wheelPixels(delta, mode) {
|
|
216
|
+
return mode === 1 ? delta * 16 : mode === 2 ? delta * 400 : delta;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Shared by native wheel events and wheel events forwarded from artboards. */
|
|
220
|
+
function handleWheel(deltaX, deltaY, deltaMode, zooming, shift, clientX, clientY) {
|
|
221
|
+
if (state.focus) return;
|
|
222
|
+
const dx = wheelPixels(deltaX, deltaMode);
|
|
223
|
+
const dy = wheelPixels(deltaY, deltaMode);
|
|
224
|
+
if (zooming) {
|
|
225
|
+
const rect = els.viewport.getBoundingClientRect();
|
|
226
|
+
const step = Math.max(-50, Math.min(50, dy));
|
|
227
|
+
zoomAt(state.zoom * Math.exp(-step * 0.01), clientX - rect.left, clientY - rect.top);
|
|
228
|
+
} else if (shift && dx === 0) {
|
|
229
|
+
panBy(-dy, 0);
|
|
230
|
+
} else {
|
|
231
|
+
panBy(-dx, -dy);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
els.viewport.addEventListener("wheel", (event) => {
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
handleWheel(event.deltaX, event.deltaY, event.deltaMode, event.ctrlKey || event.metaKey, event.shiftKey, event.clientX, event.clientY);
|
|
238
|
+
}, { passive: false });
|
|
239
|
+
|
|
240
|
+
// A press on a shield pans like the background; released without moving, it is a
|
|
241
|
+
// click on that artboard: a routed pick in comment mode, else focus mode.
|
|
242
|
+
let drag = null;
|
|
243
|
+
els.viewport.addEventListener("pointerdown", (event) => {
|
|
244
|
+
if (state.focus || event.button !== 0 || event.target.closest("button")) return;
|
|
245
|
+
const shield = event.target.closest(".gd-shield");
|
|
246
|
+
if (!shield && event.target !== els.viewport && event.target !== els.world && !event.target.closest(".gd-artboard-label")) return;
|
|
247
|
+
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, startX: event.clientX, startY: event.clientY, moved: false, board: shield ? boardOfShield(shield) : null };
|
|
248
|
+
els.viewport.setPointerCapture(event.pointerId);
|
|
249
|
+
els.viewport.classList.add("gd-panning");
|
|
250
|
+
});
|
|
251
|
+
els.viewport.addEventListener("pointermove", (event) => {
|
|
252
|
+
if (!drag || drag.id !== event.pointerId) {
|
|
253
|
+
if (!drag) hoverAt(event);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
panBy(event.clientX - drag.x, event.clientY - drag.y);
|
|
257
|
+
drag.x = event.clientX;
|
|
258
|
+
drag.y = event.clientY;
|
|
259
|
+
if (Math.abs(event.clientX - drag.startX) > CLICK_SLOP || Math.abs(event.clientY - drag.startY) > CLICK_SLOP) drag.moved = true;
|
|
260
|
+
});
|
|
261
|
+
const endDrag = (event) => {
|
|
262
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
263
|
+
const ended = drag;
|
|
264
|
+
drag = null;
|
|
265
|
+
els.viewport.classList.remove("gd-panning");
|
|
266
|
+
if (event.type === "pointerup" && ended.board && !ended.moved) activateBoard(ended.board, event.clientX, event.clientY);
|
|
267
|
+
};
|
|
268
|
+
els.viewport.addEventListener("pointerup", endDrag);
|
|
269
|
+
els.viewport.addEventListener("pointercancel", endDrag);
|
|
270
|
+
els.viewport.addEventListener("pointerleave", () => setHoverBoard(null));
|
|
271
|
+
|
|
272
|
+
els.viewport.addEventListener("keydown", (event) => {
|
|
273
|
+
if (event.target !== els.viewport || state.focus) return;
|
|
274
|
+
const step = event.shiftKey ? 200 : 50;
|
|
275
|
+
const actions = {
|
|
276
|
+
ArrowLeft: () => panBy(step, 0),
|
|
277
|
+
ArrowRight: () => panBy(-step, 0),
|
|
278
|
+
ArrowUp: () => panBy(0, step),
|
|
279
|
+
ArrowDown: () => panBy(0, -step),
|
|
280
|
+
"+": () => zoomAtCenter(1.25),
|
|
281
|
+
"=": () => zoomAtCenter(1.25),
|
|
282
|
+
"-": () => zoomAtCenter(0.8),
|
|
283
|
+
"0": fit,
|
|
284
|
+
};
|
|
285
|
+
const action = actions[event.key];
|
|
286
|
+
if (!action) return;
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
action();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
els.zoomIn.addEventListener("click", () => zoomAtCenter(1.25));
|
|
292
|
+
els.zoomOut.addEventListener("click", () => zoomAtCenter(0.8));
|
|
293
|
+
els.fit.addEventListener("click", fit);
|
|
294
|
+
|
|
295
|
+
// ---- navigation and focus modes ---------------------------------------
|
|
296
|
+
|
|
297
|
+
function allBoards() {
|
|
298
|
+
return state.systemBoard ? [state.systemBoard, ...state.boards] : state.boards;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function boardOfShield(shield) {
|
|
302
|
+
return allBoards().find((board) => board.shield === shield) ?? null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A viewport point in the artboard's own CSS pixels. */
|
|
306
|
+
function framePoint(board, clientX, clientY) {
|
|
307
|
+
const rect = board.frame.getBoundingClientRect();
|
|
308
|
+
const scale = rect.width / board.artboard.width || 1;
|
|
309
|
+
return { x: (clientX - rect.left) / scale, y: (clientY - rect.top) / scale };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function activateBoard(board, clientX, clientY) {
|
|
313
|
+
if (!state.commenting) enterFocus(board);
|
|
314
|
+
else if (!board.system) board.frame.contentWindow?.postMessage({ type: "gd:pick-at", ...framePoint(board, clientX, clientY) }, "*");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function setHoverBoard(board) {
|
|
318
|
+
if (state.hoverBoard && state.hoverBoard !== board) state.hoverBoard.frame.contentWindow?.postMessage({ type: "gd:hover-at", x: null, y: null }, "*");
|
|
319
|
+
state.hoverBoard = board;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Comment mode in navigation: the shield routes the hover to the inspector so it can outline the element. */
|
|
323
|
+
function hoverAt(event) {
|
|
324
|
+
if (!state.commenting || state.focus) return setHoverBoard(null);
|
|
325
|
+
const shield = event.target.closest(".gd-shield");
|
|
326
|
+
const board = shield ? boardOfShield(shield) : null;
|
|
327
|
+
setHoverBoard(board && !board.system ? board : null);
|
|
328
|
+
if (state.hoverBoard) state.hoverBoard.frame.contentWindow?.postMessage({ type: "gd:hover-at", ...framePoint(board, event.clientX, event.clientY) }, "*");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function sendFocus(board) {
|
|
332
|
+
board.frame.contentWindow?.postMessage({ type: "gd:focus", enabled: board === state.focus }, "*");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The artboard's visible height: its manifest height, or the viewport's while focused on a taller one. */
|
|
336
|
+
function setFrameHeight(board, height) {
|
|
337
|
+
board.viewHeight = height;
|
|
338
|
+
board.frame.style.height = `${height}px`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 100% zoom with the focused artboard centered and its label (Implement this) in view.
|
|
343
|
+
* A taller artboard is top-aligned and its frame is clipped to the viewport, so it
|
|
344
|
+
* scrolls inside like a browser window instead of leaving its bottom out of reach
|
|
345
|
+
* while the canvas cannot pan.
|
|
346
|
+
*/
|
|
347
|
+
function centerFocus() {
|
|
348
|
+
const board = state.focus;
|
|
349
|
+
if (!board) return;
|
|
350
|
+
const viewport = els.viewport.getBoundingClientRect();
|
|
351
|
+
// Layout offsets inside the world ignore its transform, so re-centering is idempotent
|
|
352
|
+
// (a scaled bounding rect carries sub-pixel error that shifted the board by 1 px).
|
|
353
|
+
const box = board.frame.parentElement;
|
|
354
|
+
const left = box.offsetLeft;
|
|
355
|
+
const top = box.offsetTop;
|
|
356
|
+
const labelSpace = box.offsetTop - board.wrapper.offsetTop;
|
|
357
|
+
const { width, height } = board.artboard;
|
|
358
|
+
const minTop = FOCUS_MARGIN + labelSpace;
|
|
359
|
+
const available = viewport.height - minTop - BACK_SPACE;
|
|
360
|
+
const tall = height > available;
|
|
361
|
+
setFrameHeight(board, tall ? Math.max(120, Math.floor(available)) : height);
|
|
362
|
+
state.zoom = 1;
|
|
363
|
+
state.x = Math.round(width + FOCUS_MARGIN * 2 <= viewport.width ? (viewport.width - width) / 2 - left : FOCUS_MARGIN - left);
|
|
364
|
+
state.y = Math.round((tall ? minTop : Math.max(minTop, (viewport.height - height) / 2)) - top);
|
|
365
|
+
applyView();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Shields, dimming, inert boards, toolbar controls and the mode label follow state.focus. */
|
|
369
|
+
function applyFocusState() {
|
|
370
|
+
const focused = state.focus;
|
|
371
|
+
els.viewport.classList.toggle("gd-focused", Boolean(focused));
|
|
372
|
+
for (const board of allBoards()) {
|
|
373
|
+
const active = board === focused;
|
|
374
|
+
board.wrapper.classList.toggle("gd-focus-board", active);
|
|
375
|
+
board.wrapper.inert = Boolean(focused) && !active;
|
|
376
|
+
board.shield.hidden = active;
|
|
377
|
+
if (!active && board.viewHeight !== board.artboard.height) setFrameHeight(board, board.artboard.height);
|
|
378
|
+
sendFocus(board);
|
|
379
|
+
}
|
|
380
|
+
for (const control of [els.zoomOut, els.zoomIn, els.fit]) control.disabled = Boolean(focused);
|
|
381
|
+
els.design.disabled = Boolean(focused) || state.designCount === 0;
|
|
382
|
+
els.back.hidden = !focused;
|
|
383
|
+
els.navMode.textContent = focused ? `Using: ${focused.artboard.title} · Shift+Esc to exit` : "Navigating";
|
|
384
|
+
els.navMode.classList.toggle("gd-using", Boolean(focused));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function enterFocus(board) {
|
|
388
|
+
if (state.focus === board) return;
|
|
389
|
+
if (!state.focus) {
|
|
390
|
+
state.savedView = { zoom: state.zoom, x: state.x, y: state.y };
|
|
391
|
+
// The artboard keeps its designed width at 100%; hiding the panel gains room for it.
|
|
392
|
+
state.savedPanel = !els.panel.hidden;
|
|
393
|
+
setPanel(false);
|
|
394
|
+
}
|
|
395
|
+
closePopover();
|
|
396
|
+
setHoverBoard(null);
|
|
397
|
+
state.focus = board;
|
|
398
|
+
centerFocus();
|
|
399
|
+
applyFocusState();
|
|
400
|
+
board.frame.focus();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function exitFocus() {
|
|
404
|
+
if (!state.focus) return;
|
|
405
|
+
closePopover();
|
|
406
|
+
state.focus = null;
|
|
407
|
+
if (state.savedPanel !== null) setPanel(state.savedPanel);
|
|
408
|
+
state.savedPanel = null;
|
|
409
|
+
if (state.savedView) Object.assign(state, state.savedView);
|
|
410
|
+
state.savedView = null;
|
|
411
|
+
applyView();
|
|
412
|
+
applyFocusState();
|
|
413
|
+
els.viewport.focus();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
els.back.addEventListener("click", exitFocus);
|
|
417
|
+
|
|
418
|
+
// ---- designs and artboards -------------------------------------------
|
|
419
|
+
|
|
420
|
+
function showEmpty(text) {
|
|
421
|
+
exitFocus();
|
|
422
|
+
els.world.replaceChildren();
|
|
423
|
+
state.boards = [];
|
|
424
|
+
els.empty.textContent = text;
|
|
425
|
+
els.empty.hidden = false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function renderDesignOptions(designs) {
|
|
429
|
+
const options = designs.map((design) => {
|
|
430
|
+
const option = document.createElement("option");
|
|
431
|
+
option.value = design.slug;
|
|
432
|
+
option.textContent = design.valid ? design.title : `${design.slug} (invalid design.json)`;
|
|
433
|
+
return option;
|
|
434
|
+
});
|
|
435
|
+
els.design.replaceChildren(...options);
|
|
436
|
+
state.designCount = designs.length;
|
|
437
|
+
els.design.disabled = designs.length === 0 || Boolean(state.focus);
|
|
438
|
+
els.designField.hidden = designs.length === 0;
|
|
439
|
+
if (state.slug) els.design.value = state.slug;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** pickLatest: with no design selected, select the most recent one (not for the system-only view). */
|
|
443
|
+
async function refreshDesigns(pickLatest = true) {
|
|
444
|
+
const { designs } = await getJson("api/designs");
|
|
445
|
+
if (pickLatest && !state.slug && designs.length > 0) state.slug = designs[0].slug;
|
|
446
|
+
renderDesignOptions(designs);
|
|
447
|
+
return designs;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function sendMode(board) {
|
|
451
|
+
board.frame.contentWindow?.postMessage({ type: "gd:mode", enabled: state.commenting && !board.system }, "*");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function boardUrl(board) {
|
|
455
|
+
return board.system ? `${base}system.html` : fileUrl(board.slug, board.artboard.file);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function createBoard(slug, artboard, system = false) {
|
|
459
|
+
const wrapper = document.createElement("section");
|
|
460
|
+
wrapper.className = system ? "gd-artboard gd-system-board" : "gd-artboard";
|
|
461
|
+
wrapper.setAttribute("aria-label", artboard.title);
|
|
462
|
+
const label = document.createElement("div");
|
|
463
|
+
label.className = "gd-artboard-label";
|
|
464
|
+
const title = document.createElement("h2");
|
|
465
|
+
title.className = "gd-artboard-title";
|
|
466
|
+
title.textContent = artboard.title;
|
|
467
|
+
label.append(title);
|
|
468
|
+
if (artboard.tradeoff) {
|
|
469
|
+
const tradeoff = document.createElement("p");
|
|
470
|
+
tradeoff.className = "gd-artboard-tradeoff";
|
|
471
|
+
tradeoff.textContent = artboard.tradeoff;
|
|
472
|
+
label.append(tradeoff);
|
|
473
|
+
}
|
|
474
|
+
// The handoff state, the chosen badge and Compare (rendered by renderHandoff) stack above
|
|
475
|
+
// Implement this, at screen size, so neither covers the other at any zoom.
|
|
476
|
+
const handoff = document.createElement("div");
|
|
477
|
+
handoff.className = "gd-handoff";
|
|
478
|
+
handoff.hidden = true;
|
|
479
|
+
if (!system) {
|
|
480
|
+
const actions = document.createElement("div");
|
|
481
|
+
actions.className = "gd-board-actions";
|
|
482
|
+
actions.append(handoff, createImplementControl(slug, artboard));
|
|
483
|
+
label.append(actions);
|
|
484
|
+
}
|
|
485
|
+
if (system) {
|
|
486
|
+
label.title = SYSTEM_NOTE;
|
|
487
|
+
const note = document.createElement("p");
|
|
488
|
+
note.className = "gd-system-note";
|
|
489
|
+
note.title = SYSTEM_NOTE;
|
|
490
|
+
note.textContent = "Comments off on this board";
|
|
491
|
+
label.append(note);
|
|
492
|
+
}
|
|
493
|
+
const frame = document.createElement("iframe");
|
|
494
|
+
frame.className = "gd-artboard-frame";
|
|
495
|
+
frame.setAttribute("sandbox", "allow-scripts");
|
|
496
|
+
frame.setAttribute("referrerpolicy", "no-referrer");
|
|
497
|
+
frame.title = `${artboard.title} artboard`;
|
|
498
|
+
frame.style.width = `${artboard.width}px`;
|
|
499
|
+
const box = document.createElement("div");
|
|
500
|
+
box.className = "gd-frame-box";
|
|
501
|
+
const shield = document.createElement("div");
|
|
502
|
+
shield.className = "gd-shield";
|
|
503
|
+
shield.setAttribute("role", "button");
|
|
504
|
+
shield.tabIndex = 0;
|
|
505
|
+
shield.setAttribute("aria-label", system ? `View ${artboard.title} (read-only)` : `Use ${artboard.title}`);
|
|
506
|
+
const hint = document.createElement("span");
|
|
507
|
+
hint.className = "gd-shield-hint";
|
|
508
|
+
hint.setAttribute("aria-hidden", "true");
|
|
509
|
+
hint.textContent = system ? "Click to view the design system" : "Click to use this design";
|
|
510
|
+
shield.append(hint);
|
|
511
|
+
const pins = document.createElement("div");
|
|
512
|
+
pins.className = "gd-pins";
|
|
513
|
+
// rects: null until the artboard reports where each commented element is.
|
|
514
|
+
const board = { slug, artboard, frame, wrapper, shield, pins, handoff, rects: null, system, viewHeight: 0 };
|
|
515
|
+
setFrameHeight(board, artboard.height);
|
|
516
|
+
renderHandoff(board);
|
|
517
|
+
shield.addEventListener("keydown", (event) => {
|
|
518
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
519
|
+
event.preventDefault();
|
|
520
|
+
enterFocus(board);
|
|
521
|
+
});
|
|
522
|
+
frame.addEventListener("load", () => {
|
|
523
|
+
board.rects = null;
|
|
524
|
+
sendMode(board);
|
|
525
|
+
sendFocus(board);
|
|
526
|
+
if (!system) sendPins(board);
|
|
527
|
+
// A live reload of the focused artboard keeps focus mode and its keyboard focus.
|
|
528
|
+
if (state.focus === board && els.popover.hidden && (document.activeElement === frame || document.activeElement === document.body)) frame.focus();
|
|
529
|
+
});
|
|
530
|
+
frame.src = boardUrl(board);
|
|
531
|
+
box.append(frame, shield, pins);
|
|
532
|
+
wrapper.append(label, box);
|
|
533
|
+
return board;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** "Implement this" with an inline confirm step; the system board never gets one. */
|
|
537
|
+
function createImplementControl(slug, artboard) {
|
|
538
|
+
const box = document.createElement("div");
|
|
539
|
+
box.className = "gd-implement";
|
|
540
|
+
const start = document.createElement("button");
|
|
541
|
+
start.type = "button";
|
|
542
|
+
start.className = "gd-implement-start";
|
|
543
|
+
start.textContent = "Implement this";
|
|
544
|
+
start.setAttribute("aria-label", `Implement ${artboard.title} in the project`);
|
|
545
|
+
const confirm = document.createElement("div");
|
|
546
|
+
confirm.className = "gd-implement-confirm";
|
|
547
|
+
confirm.setAttribute("role", "group");
|
|
548
|
+
confirm.setAttribute("aria-label", `Confirm implementing ${artboard.title}`);
|
|
549
|
+
confirm.hidden = true;
|
|
550
|
+
const question = document.createElement("span");
|
|
551
|
+
question.textContent = "Send it to the session to implement?";
|
|
552
|
+
const yes = document.createElement("button");
|
|
553
|
+
yes.type = "button";
|
|
554
|
+
yes.className = "gd-primary";
|
|
555
|
+
yes.textContent = "Send";
|
|
556
|
+
const no = document.createElement("button");
|
|
557
|
+
no.type = "button";
|
|
558
|
+
no.textContent = "Cancel";
|
|
559
|
+
confirm.append(question, yes, no);
|
|
560
|
+
const close = (focus) => {
|
|
561
|
+
confirm.hidden = true;
|
|
562
|
+
start.hidden = false;
|
|
563
|
+
if (focus) start.focus();
|
|
564
|
+
};
|
|
565
|
+
start.addEventListener("click", () => {
|
|
566
|
+
start.hidden = true;
|
|
567
|
+
confirm.hidden = false;
|
|
568
|
+
yes.disabled = false;
|
|
569
|
+
yes.focus();
|
|
570
|
+
});
|
|
571
|
+
no.addEventListener("click", () => close(true));
|
|
572
|
+
confirm.addEventListener("keydown", (event) => {
|
|
573
|
+
if (event.key !== "Escape") return;
|
|
574
|
+
event.preventDefault();
|
|
575
|
+
event.stopPropagation();
|
|
576
|
+
close(true);
|
|
577
|
+
});
|
|
578
|
+
yes.addEventListener("click", async () => {
|
|
579
|
+
yes.disabled = true;
|
|
580
|
+
const reply = await postJson(`api/designs/${encodeURIComponent(slug)}/implement`, { file: artboard.file }).catch((error) => ({ ok: false, body: { error: error.message } }));
|
|
581
|
+
if (reply.ok) {
|
|
582
|
+
const queued = reply.body.queued === true;
|
|
583
|
+
const text = queued
|
|
584
|
+
? `Queued: the session will implement ${artboard.title} after its current task.`
|
|
585
|
+
: `Sent: the session is implementing ${artboard.title} now.`;
|
|
586
|
+
setStatus(text, false);
|
|
587
|
+
showToast(text, false);
|
|
588
|
+
// The server recorded it before answering; the handoffs.json change event confirms it.
|
|
589
|
+
noteHandoff(slug, artboard.file, queued);
|
|
590
|
+
} else {
|
|
591
|
+
const text = `Not sent: ${reply.body.reason || reply.body.error || "error"}`;
|
|
592
|
+
setStatus(text, true);
|
|
593
|
+
showToast(text, true);
|
|
594
|
+
}
|
|
595
|
+
close(true);
|
|
596
|
+
});
|
|
597
|
+
box.append(start, confirm);
|
|
598
|
+
return box;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ---- handoffs ------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
function ownPort() {
|
|
604
|
+
const location = window.location;
|
|
605
|
+
return location && typeof location.port === "string" ? location.port : "";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The preview URL Compare may frame: http(s) on localhost, 127.0.0.1 or [::1], no
|
|
610
|
+
* credentials, and never this canvas server's port (the server validates it too).
|
|
611
|
+
*/
|
|
612
|
+
function previewTarget(value) {
|
|
613
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_PREVIEW_URL) return null;
|
|
614
|
+
let url;
|
|
615
|
+
try { url = new URL(value); } catch { return null; }
|
|
616
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
617
|
+
if (url.username || url.password || !LOOPBACK_HOSTS.includes(url.hostname)) return null;
|
|
618
|
+
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
|
619
|
+
return port === ownPort() ? null : url.href;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function handoffOf(board) {
|
|
623
|
+
const data = state.handoffs;
|
|
624
|
+
if (board.system || data.slug !== board.slug) return { chosen: null, entry: null };
|
|
625
|
+
const file = board.artboard.file;
|
|
626
|
+
const entry = Object.prototype.hasOwnProperty.call(data.handoffs, file) ? data.handoffs[file] : null;
|
|
627
|
+
return { chosen: data.chosen, entry: entry && typeof entry === "object" ? entry : null };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function handoffKind(entry) {
|
|
631
|
+
if (entry.status === "sent") return entry.queued === true ? "queued" : "sent";
|
|
632
|
+
return entry.status === "in_progress" || entry.status === "implemented" ? entry.status : null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function chip(className, text) {
|
|
636
|
+
const element = document.createElement("span");
|
|
637
|
+
element.className = className;
|
|
638
|
+
element.textContent = text;
|
|
639
|
+
return element;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** The header row of one artboard: Chosen or Alternative, the handoff badge, its feature document, note and Compare. */
|
|
643
|
+
function renderHandoff(board) {
|
|
644
|
+
if (board.system || !board.handoff) return;
|
|
645
|
+
const { chosen, entry } = handoffOf(board);
|
|
646
|
+
const isChosen = typeof chosen === "string" && chosen === board.artboard.file;
|
|
647
|
+
board.wrapper.classList.toggle("gd-chosen", isChosen);
|
|
648
|
+
const parts = [];
|
|
649
|
+
if (typeof chosen === "string") parts.push(chip(`gd-choice ${isChosen ? "gd-choice-chosen" : "gd-choice-alternative"}`, isChosen ? "Chosen" : "Alternative"));
|
|
650
|
+
const kind = entry ? handoffKind(entry) : null;
|
|
651
|
+
let compare = null;
|
|
652
|
+
if (kind) {
|
|
653
|
+
const badge = chip("gd-handoff-badge", HANDOFF_LABELS[kind]);
|
|
654
|
+
badge.dataset.status = kind;
|
|
655
|
+
parts.push(badge);
|
|
656
|
+
if (typeof entry.feature === "string" && entry.feature) {
|
|
657
|
+
const feature = chip("gd-handoff-feature", entry.feature);
|
|
658
|
+
feature.title = "Tracked in this ODD feature document";
|
|
659
|
+
parts.push(feature);
|
|
660
|
+
}
|
|
661
|
+
if (typeof entry.note === "string" && entry.note) {
|
|
662
|
+
const note = chip("gd-handoff-note", entry.note);
|
|
663
|
+
note.title = entry.note;
|
|
664
|
+
parts.push(note);
|
|
665
|
+
}
|
|
666
|
+
const preview = kind === "implemented" ? previewTarget(entry.previewUrl) : null;
|
|
667
|
+
if (preview) {
|
|
668
|
+
compare = document.createElement("button");
|
|
669
|
+
compare.type = "button";
|
|
670
|
+
compare.className = "gd-compare-start";
|
|
671
|
+
compare.textContent = "Compare";
|
|
672
|
+
compare.setAttribute("aria-label", `Compare ${board.artboard.title} with the running app`);
|
|
673
|
+
compare.addEventListener("click", () => enterCompare(board, preview));
|
|
674
|
+
parts.push(compare);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const hadFocus = board.handoff.contains(document.activeElement);
|
|
678
|
+
board.handoff.replaceChildren(...parts);
|
|
679
|
+
board.handoff.hidden = parts.length === 0;
|
|
680
|
+
board.wrapper.classList.toggle("gd-has-handoff", parts.length > 0);
|
|
681
|
+
if (hadFocus && compare) compare.focus();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function renderHandoffs() {
|
|
685
|
+
state.boards.forEach(renderHandoff);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function setHandoffs(slug, data) {
|
|
689
|
+
const valid = Boolean(data && typeof data === "object" && data.handoffs && typeof data.handoffs === "object" && !Array.isArray(data.handoffs));
|
|
690
|
+
state.handoffs = { slug, chosen: valid && typeof data.chosen === "string" ? data.chosen : null, handoffs: valid ? data.handoffs : {} };
|
|
691
|
+
renderHandoffs();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function loadHandoffs() {
|
|
695
|
+
const slug = state.slug;
|
|
696
|
+
if (!slug) return;
|
|
697
|
+
let data = null;
|
|
698
|
+
try {
|
|
699
|
+
data = await getJson(`api/designs/${encodeURIComponent(slug)}/handoffs`);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
setStatus(`handoffs.json for ${slug} cannot be read: ${error.message}`, true);
|
|
702
|
+
}
|
|
703
|
+
if (slug === state.slug) setHandoffs(slug, data);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Shows a handoff the server just accepted, before its handoffs.json change event arrives. */
|
|
707
|
+
function noteHandoff(slug, file, queued) {
|
|
708
|
+
const current = state.handoffs.slug === slug ? state.handoffs.handoffs : {};
|
|
709
|
+
const feature = state.manifest && state.manifest.slug === slug && typeof state.manifest.feature === "string" ? state.manifest.feature : undefined;
|
|
710
|
+
const entry = { status: "sent", queued, sentAt: new Date().toISOString(), startedAt: null, implementedAt: null };
|
|
711
|
+
if (feature) entry.feature = feature;
|
|
712
|
+
const handoffs = Object.assign({}, current);
|
|
713
|
+
handoffs[file] = entry;
|
|
714
|
+
state.handoffs = { slug, chosen: file, handoffs };
|
|
715
|
+
renderHandoffs();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ---- compare -------------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
function comparePane(caption, frame) {
|
|
721
|
+
const figure = document.createElement("figure");
|
|
722
|
+
figure.className = "gd-compare-pane";
|
|
723
|
+
const label = document.createElement("figcaption");
|
|
724
|
+
label.className = "gd-compare-caption";
|
|
725
|
+
label.textContent = caption;
|
|
726
|
+
figure.append(label, frame);
|
|
727
|
+
return figure;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function compareFrame(width, height, title) {
|
|
731
|
+
const frame = document.createElement("iframe");
|
|
732
|
+
frame.className = "gd-compare-frame";
|
|
733
|
+
frame.setAttribute("referrerpolicy", "no-referrer");
|
|
734
|
+
frame.title = title;
|
|
735
|
+
frame.style.width = `${width}px`;
|
|
736
|
+
frame.style.height = `${height}px`;
|
|
737
|
+
return frame;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function setCompareMessage(text, isError) {
|
|
741
|
+
els.compareMessage.textContent = text;
|
|
742
|
+
els.compareMessage.classList.toggle("gd-error", Boolean(isError));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** 100% by default; past the window width, "Scale to fit" shrinks the pair to fit. */
|
|
746
|
+
function layoutCompare() {
|
|
747
|
+
const compare = state.compare;
|
|
748
|
+
if (!compare) return;
|
|
749
|
+
const { width } = compare.board.artboard;
|
|
750
|
+
const needed = width * 2 + COMPARE_GAP;
|
|
751
|
+
const available = els.compareStage.getBoundingClientRect().width - COMPARE_PADDING * 2;
|
|
752
|
+
const wide = needed > available;
|
|
753
|
+
els.compareFitField.hidden = !wide;
|
|
754
|
+
const scale = wide && els.compareFit.checked ? Math.max(0.05, available / needed) : 1;
|
|
755
|
+
els.comparePanes.style.transform = scale === 1 ? "none" : `scale(${scale.toFixed(3)})`;
|
|
756
|
+
// The sizer keeps the scroll area at the scaled size.
|
|
757
|
+
els.compareSizer.style.width = scale === 1 ? "" : `${Math.ceil(needed * scale)}px`;
|
|
758
|
+
els.compareSizer.style.height = scale === 1 ? "" : `${Math.ceil(els.comparePanes.offsetHeight * scale)}px`;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** The artboard (sandboxed as on the canvas) next to the running app, both at the artboard's size. */
|
|
762
|
+
function enterCompare(board, preview) {
|
|
763
|
+
exitCompare(false);
|
|
764
|
+
exitFocus();
|
|
765
|
+
closePopover();
|
|
766
|
+
setHoverBoard(null);
|
|
767
|
+
const { width, height, title } = board.artboard;
|
|
768
|
+
const artboardFrame = compareFrame(width, height, `${title} artboard`);
|
|
769
|
+
artboardFrame.setAttribute("sandbox", "allow-scripts");
|
|
770
|
+
// Like focus mode: the artboard owns its scroll, and Shift+Esc inside it asks to leave.
|
|
771
|
+
artboardFrame.addEventListener("load", () => artboardFrame.contentWindow?.postMessage({ type: "gd:focus", enabled: true }, "*"));
|
|
772
|
+
artboardFrame.src = boardUrl(board);
|
|
773
|
+
const app = compareFrame(width, height, `Running app at ${preview}`);
|
|
774
|
+
app.setAttribute("sandbox", PREVIEW_SANDBOX);
|
|
775
|
+
const compare = { board, preview, artboardFrame, timer: 0, loaded: false };
|
|
776
|
+
const failed = (text) => {
|
|
777
|
+
if (state.compare === compare) setCompareMessage(text, true);
|
|
778
|
+
};
|
|
779
|
+
// A frame refused by X-Frame-Options or frame-ancestors may still fire load, so the
|
|
780
|
+
// Open in new tab fallback is always shown; a missing load also says so.
|
|
781
|
+
app.addEventListener("load", () => { compare.loaded = true; });
|
|
782
|
+
app.addEventListener("error", () => failed(`The app at ${preview} could not be loaded in the frame. Use Open in new tab.`));
|
|
783
|
+
compare.timer = setTimeout(() => {
|
|
784
|
+
if (!compare.loaded) failed(`The app at ${preview} has not loaded; it may be down or refuse to be framed. Use Open in new tab.`);
|
|
785
|
+
}, PREVIEW_TIMEOUT_MS);
|
|
786
|
+
app.src = preview;
|
|
787
|
+
state.compare = compare;
|
|
788
|
+
els.compareTitle.textContent = `Compare: ${title}`;
|
|
789
|
+
// A new tab with no opener and no Referer: the canvas URL carries the server token.
|
|
790
|
+
els.compareOpen.setAttribute("target", "_blank");
|
|
791
|
+
els.compareOpen.setAttribute("rel", "noopener noreferrer");
|
|
792
|
+
els.compareOpen.href = preview;
|
|
793
|
+
setCompareMessage(new URL(preview).hostname === "[::1]" ? IPV6_HINT : PREVIEW_HINT, false);
|
|
794
|
+
els.comparePanes.replaceChildren(comparePane(`Design: ${board.artboard.file}`, artboardFrame), comparePane(`Implementation: ${preview}`, app));
|
|
795
|
+
els.compareFit.checked = false;
|
|
796
|
+
els.compare.hidden = false;
|
|
797
|
+
els.viewport.inert = true;
|
|
798
|
+
els.panel.inert = true;
|
|
799
|
+
for (const control of [els.zoomOut, els.zoomIn, els.fit, els.design]) control.disabled = true;
|
|
800
|
+
layoutCompare();
|
|
801
|
+
els.compareBack.focus();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function exitCompare(restoreFocus) {
|
|
805
|
+
const compare = state.compare;
|
|
806
|
+
if (!compare) return;
|
|
807
|
+
clearTimeout(compare.timer);
|
|
808
|
+
state.compare = null;
|
|
809
|
+
els.comparePanes.replaceChildren();
|
|
810
|
+
els.compareOpen.removeAttribute("href");
|
|
811
|
+
els.compare.hidden = true;
|
|
812
|
+
els.viewport.inert = false;
|
|
813
|
+
els.panel.inert = false;
|
|
814
|
+
applyFocusState();
|
|
815
|
+
if (!restoreFocus) return;
|
|
816
|
+
const board = state.boards.find((candidate) => candidate.artboard.file === compare.board.artboard.file);
|
|
817
|
+
const button = board ? Array.from(board.handoff.children).find((child) => child.classList.contains("gd-compare-start")) : null;
|
|
818
|
+
(button ?? els.viewport).focus();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
els.compareBack.addEventListener("click", () => exitCompare(true));
|
|
822
|
+
els.compareFit.addEventListener("change", layoutCompare);
|
|
823
|
+
|
|
824
|
+
function showFeature(manifest) {
|
|
825
|
+
const feature = manifest && typeof manifest.feature === "string" ? manifest.feature : "";
|
|
826
|
+
els.feature.textContent = feature ? `Feature: ${feature}` : "";
|
|
827
|
+
els.feature.hidden = !feature;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** No design selected: only the system board, or the empty notice. */
|
|
831
|
+
function showSystemOnly() {
|
|
832
|
+
state.manifest = null;
|
|
833
|
+
state.boards = [];
|
|
834
|
+
showFeature(null);
|
|
835
|
+
els.title.textContent = "Gentle Design";
|
|
836
|
+
if (!state.systemBoard) {
|
|
837
|
+
showEmpty("No designs yet. Run /gentle:design with a brief to create one.");
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
els.empty.hidden = true;
|
|
841
|
+
placeBoards();
|
|
842
|
+
setStatus("", false);
|
|
843
|
+
requestAnimationFrame(fit);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function loadManifest(slug, keepView) {
|
|
847
|
+
let manifest;
|
|
848
|
+
try {
|
|
849
|
+
manifest = await getJson(`api/designs/${encodeURIComponent(slug)}`);
|
|
850
|
+
} catch (error) {
|
|
851
|
+
showEmpty(`Design "${slug}" cannot be shown: ${error.message}`);
|
|
852
|
+
setStatus(`design.json for ${slug} is invalid`, true);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (slug !== state.slug) return;
|
|
856
|
+
closePopover();
|
|
857
|
+
state.manifest = manifest;
|
|
858
|
+
showFeature(manifest);
|
|
859
|
+
els.title.textContent = manifest.title;
|
|
860
|
+
document.title = `${manifest.title} · Gentle Design`;
|
|
861
|
+
history.replaceState(null, "", `${base}d/${encodeURIComponent(slug)}/`);
|
|
862
|
+
if (manifest.artboards.length === 0 && !state.systemBoard) {
|
|
863
|
+
showEmpty("This design has no artboards yet.");
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
els.empty.hidden = true;
|
|
867
|
+
const focusFile = state.focus && !state.focus.system ? state.focus.artboard.file : null;
|
|
868
|
+
state.boards = manifest.artboards.map((artboard) => createBoard(slug, artboard));
|
|
869
|
+
// A manifest reload keeps focus mode on the same artboard file while it still exists.
|
|
870
|
+
if (focusFile) state.focus = state.boards.find((board) => board.artboard.file === focusFile) ?? state.focus;
|
|
871
|
+
placeBoards();
|
|
872
|
+
if (state.focus) requestAnimationFrame(centerFocus);
|
|
873
|
+
const warnings = Array.isArray(manifest.warnings) ? manifest.warnings.filter((warning) => typeof warning === "string") : [];
|
|
874
|
+
setStatus(warnings.length > 0 ? `design.json: ${warnings.join("; ")}` : state.boards.length === 0 ? "This design has no artboards yet." : "", warnings.length > 0);
|
|
875
|
+
if (!keepView) requestAnimationFrame(fit);
|
|
876
|
+
await loadHandoffs();
|
|
877
|
+
await loadComments();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function reloadBoard(board) {
|
|
881
|
+
board.rects = null;
|
|
882
|
+
board.frame.src = `${boardUrl(board)}?v=${Date.now()}`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** The system board first, then the design's artboards. */
|
|
886
|
+
function placeBoards() {
|
|
887
|
+
const boards = allBoards();
|
|
888
|
+
els.world.replaceChildren(...boards.map((board) => board.wrapper));
|
|
889
|
+
if (state.focus && !boards.includes(state.focus)) exitFocus();
|
|
890
|
+
else applyFocusState();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Reads api/system and creates or drops the system board; true when it appeared or went away. */
|
|
894
|
+
async function refreshSystem() {
|
|
895
|
+
let info = null;
|
|
896
|
+
try { info = await getJson("api/system"); } catch { info = null; }
|
|
897
|
+
const exists = Boolean(info && info.exists === true && isFiniteNumber(info.width) && isFiniteNumber(info.height));
|
|
898
|
+
state.system = exists ? info : null;
|
|
899
|
+
if (exists === Boolean(state.systemBoard)) return false;
|
|
900
|
+
state.systemBoard = exists ? createBoard(null, { file: String(info.file), ...SYSTEM_LABEL, width: info.width, height: info.height }, true) : null;
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
async function onSystemChange() {
|
|
905
|
+
if (!(await refreshSystem())) {
|
|
906
|
+
if (state.systemBoard) reloadBoard(state.systemBoard);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (state.focus && !allBoards().includes(state.focus)) exitFocus();
|
|
910
|
+
if (!state.slug) {
|
|
911
|
+
showSystemOnly();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (!state.manifest || state.manifest.slug !== state.slug) return;
|
|
915
|
+
if (state.boards.length === 0 && !state.systemBoard) showEmpty("This design has no artboards yet.");
|
|
916
|
+
else {
|
|
917
|
+
els.empty.hidden = true;
|
|
918
|
+
placeBoards();
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
els.design.addEventListener("change", () => {
|
|
923
|
+
exitCompare(false);
|
|
924
|
+
state.slug = els.design.value;
|
|
925
|
+
loadManifest(state.slug, false);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The server's show event (S14): `open` while this page is connected switches it to
|
|
930
|
+
* that design (null: the system-only view) and reloads every board from the server, so
|
|
931
|
+
* a change event this page missed is caught up. The same design refreshes in place and
|
|
932
|
+
* keeps zoom, pan and focus mode on the same artboard; another design leaves focus mode
|
|
933
|
+
* first, since its artboards differ.
|
|
934
|
+
*/
|
|
935
|
+
async function showDesign(slug) {
|
|
936
|
+
const same = slug === state.slug;
|
|
937
|
+
if (!same) {
|
|
938
|
+
exitCompare(false);
|
|
939
|
+
exitFocus();
|
|
940
|
+
state.slug = slug;
|
|
941
|
+
state.comments = [];
|
|
942
|
+
state.activeId = null;
|
|
943
|
+
renderComments();
|
|
944
|
+
}
|
|
945
|
+
if (!(await refreshSystem()) && state.systemBoard) reloadBoard(state.systemBoard);
|
|
946
|
+
if (slug !== state.slug) return;
|
|
947
|
+
try {
|
|
948
|
+
await refreshDesigns(false);
|
|
949
|
+
} catch (error) {
|
|
950
|
+
setStatus(`Designs cannot be listed: ${error.message}`, true);
|
|
951
|
+
}
|
|
952
|
+
if (slug !== state.slug) return;
|
|
953
|
+
if (slug === null) {
|
|
954
|
+
history.replaceState(null, "", base);
|
|
955
|
+
document.title = "Gentle Design";
|
|
956
|
+
if (!same || !state.systemBoard) showSystemOnly();
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
await loadManifest(slug, same);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function start() {
|
|
963
|
+
applyView();
|
|
964
|
+
await refreshSystem();
|
|
965
|
+
try {
|
|
966
|
+
await refreshDesigns();
|
|
967
|
+
} catch (error) {
|
|
968
|
+
showEmpty(`Designs cannot be listed: ${error.message}`);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (!state.slug) {
|
|
972
|
+
showSystemOnly();
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
await loadManifest(state.slug, false);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ---- live reload -------------------------------------------------------
|
|
979
|
+
|
|
980
|
+
function connectEvents() {
|
|
981
|
+
const source = new EventSource(`${base}events`);
|
|
982
|
+
source.addEventListener("open", () => {
|
|
983
|
+
if (els.status.classList.contains("gd-error")) setStatus("", false);
|
|
984
|
+
});
|
|
985
|
+
source.addEventListener("error", () => setStatus("Live reload disconnected, retrying…", true));
|
|
986
|
+
source.addEventListener("show", (event) => {
|
|
987
|
+
let request;
|
|
988
|
+
try { request = JSON.parse(event.data); } catch { return; }
|
|
989
|
+
if (!request || typeof request !== "object") return;
|
|
990
|
+
if (request.slug !== null && !(typeof request.slug === "string" && /^[a-z0-9][a-z0-9-]*$/.test(request.slug))) return;
|
|
991
|
+
showDesign(request.slug).catch(() => {});
|
|
992
|
+
});
|
|
993
|
+
source.addEventListener("change", (event) => {
|
|
994
|
+
let change;
|
|
995
|
+
try { change = JSON.parse(event.data); } catch { return; }
|
|
996
|
+
if (!change || typeof change.file !== "string") return;
|
|
997
|
+
if (change.slug === null) {
|
|
998
|
+
if (change.file === "system.html") onSystemChange().catch(() => {});
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (typeof change.slug !== "string") return;
|
|
1002
|
+
if (change.file === "design.json") {
|
|
1003
|
+
refreshDesigns().then(() => {
|
|
1004
|
+
if (change.slug === state.slug) return loadManifest(state.slug, true);
|
|
1005
|
+
if (!state.manifest && state.slug) return loadManifest(state.slug, false);
|
|
1006
|
+
return undefined;
|
|
1007
|
+
}).catch(() => {});
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (change.slug !== state.slug) return;
|
|
1011
|
+
if (change.file === "comments.json") {
|
|
1012
|
+
loadComments();
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (change.file === "handoffs.json") {
|
|
1016
|
+
loadHandoffs();
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (state.compare && state.compare.board.artboard.file === change.file) state.compare.artboardFrame.src = `${boardUrl(state.compare.board)}?v=${Date.now()}`;
|
|
1020
|
+
const matching = state.boards.filter((board) => board.artboard.file === change.file);
|
|
1021
|
+
// A shared asset (CSS, image, font) can affect every artboard of the design.
|
|
1022
|
+
const targets = matching.length > 0 ? matching : change.file.endsWith(".html") ? [] : state.boards;
|
|
1023
|
+
targets.forEach(reloadBoard);
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ---- comments ----------------------------------------------------------
|
|
1028
|
+
|
|
1029
|
+
function commentsForBoard(board) {
|
|
1030
|
+
return state.comments.filter((comment) => comment.file === board.artboard.file);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function sendPins(board) {
|
|
1034
|
+
const pins = commentsForBoard(board).slice(0, LIMITS.pins).map((comment) => ({ id: comment.id, selector: comment.selector }));
|
|
1035
|
+
board.frame.contentWindow?.postMessage({ type: "gd:pins", pins }, "*");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** Detached: the artboard is gone, or it reported that the selector matches nothing. */
|
|
1039
|
+
function isDetached(comment) {
|
|
1040
|
+
const board = state.boards.find((candidate) => candidate.artboard.file === comment.file);
|
|
1041
|
+
if (!board) return true;
|
|
1042
|
+
return Boolean(board.rects && board.rects.has(comment.id) && board.rects.get(comment.id) === null);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function detachedKey() {
|
|
1046
|
+
return state.comments.filter(isDetached).map((comment) => comment.id).join(",");
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function commentNumber(comment) {
|
|
1050
|
+
return state.comments.indexOf(comment) + 1;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function renderPins(board) {
|
|
1054
|
+
const pins = [];
|
|
1055
|
+
for (const comment of commentsForBoard(board)) {
|
|
1056
|
+
const rect = board.rects?.get(comment.id);
|
|
1057
|
+
if (!rect) continue;
|
|
1058
|
+
if (rect.x + rect.width < 0 || rect.y + rect.height < 0 || rect.x > board.artboard.width || rect.y > board.viewHeight) continue;
|
|
1059
|
+
const pin = document.createElement("button");
|
|
1060
|
+
pin.type = "button";
|
|
1061
|
+
pin.className = "gd-pin";
|
|
1062
|
+
pin.dataset.id = comment.id;
|
|
1063
|
+
pin.dataset.status = comment.status;
|
|
1064
|
+
pin.classList.toggle("gd-pin-active", comment.id === state.activeId);
|
|
1065
|
+
pin.textContent = String(commentNumber(comment));
|
|
1066
|
+
pin.setAttribute("aria-label", `Comment ${commentNumber(comment)} (${comment.status}): ${comment.comment}`);
|
|
1067
|
+
pin.style.left = `${Math.max(0, rect.x)}px`;
|
|
1068
|
+
pin.style.top = `${Math.max(0, rect.y)}px`;
|
|
1069
|
+
pin.addEventListener("click", (event) => {
|
|
1070
|
+
event.stopPropagation();
|
|
1071
|
+
selectComment(comment.id, { scroll: false, focusList: true });
|
|
1072
|
+
});
|
|
1073
|
+
pins.push(pin);
|
|
1074
|
+
}
|
|
1075
|
+
const focused = board.pins.contains(document.activeElement) ? document.activeElement.dataset.id : null;
|
|
1076
|
+
board.pins.replaceChildren(...pins);
|
|
1077
|
+
if (focused) pins.find((pin) => pin.dataset.id === focused)?.focus();
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function renderList() {
|
|
1081
|
+
const open = state.comments.filter((comment) => comment.status === "open");
|
|
1082
|
+
const resolved = state.comments.filter((comment) => comment.status === "resolved");
|
|
1083
|
+
els.filterOpen.textContent = `Open (${open.length})`;
|
|
1084
|
+
els.filterResolved.textContent = `Resolved (${resolved.length})`;
|
|
1085
|
+
els.filterOpen.setAttribute("aria-pressed", String(state.filter === "open"));
|
|
1086
|
+
els.filterResolved.setAttribute("aria-pressed", String(state.filter === "resolved"));
|
|
1087
|
+
const unsent = open.filter((comment) => comment.sentAt === null).length;
|
|
1088
|
+
els.sendAll.textContent = `Send all (${unsent})`;
|
|
1089
|
+
els.sendAll.disabled = unsent === 0;
|
|
1090
|
+
const shown = state.filter === "open" ? open : resolved;
|
|
1091
|
+
els.panelEmpty.hidden = shown.length > 0;
|
|
1092
|
+
els.panelEmpty.textContent = state.comments.length === 0
|
|
1093
|
+
? "No comments yet. Turn on Comment mode and click an element."
|
|
1094
|
+
: state.filter === "open" ? "No open comments." : "No resolved comments.";
|
|
1095
|
+
els.list.replaceChildren(...shown.map((comment) => {
|
|
1096
|
+
const item = document.createElement("li");
|
|
1097
|
+
item.className = "gd-comment-item";
|
|
1098
|
+
item.dataset.id = comment.id;
|
|
1099
|
+
item.classList.toggle("gd-active", comment.id === state.activeId);
|
|
1100
|
+
const show = document.createElement("button");
|
|
1101
|
+
show.type = "button";
|
|
1102
|
+
show.className = "gd-comment-show";
|
|
1103
|
+
show.setAttribute("aria-label", `Show comment ${commentNumber(comment)} on ${comment.file}`);
|
|
1104
|
+
const meta = document.createElement("span");
|
|
1105
|
+
meta.className = "gd-comment-meta";
|
|
1106
|
+
const number = document.createElement("span");
|
|
1107
|
+
number.className = "gd-comment-number";
|
|
1108
|
+
number.textContent = `#${commentNumber(comment)}`;
|
|
1109
|
+
const file = document.createElement("span");
|
|
1110
|
+
file.textContent = comment.file;
|
|
1111
|
+
meta.append(number, file);
|
|
1112
|
+
const badge = (text, kind) => {
|
|
1113
|
+
const element = document.createElement("span");
|
|
1114
|
+
element.className = `gd-badge gd-badge-${kind}`;
|
|
1115
|
+
element.textContent = text;
|
|
1116
|
+
meta.append(element);
|
|
1117
|
+
};
|
|
1118
|
+
badge(comment.sentAt ? "Sent" : "Not sent", comment.sentAt ? "sent" : "unsent");
|
|
1119
|
+
if (isDetached(comment)) badge("Detached", "detached");
|
|
1120
|
+
const text = document.createElement("span");
|
|
1121
|
+
text.className = "gd-comment-text";
|
|
1122
|
+
text.style.display = "block";
|
|
1123
|
+
text.textContent = comment.comment;
|
|
1124
|
+
show.append(meta, text);
|
|
1125
|
+
if (comment.screenshot) {
|
|
1126
|
+
const shot = document.createElement("img");
|
|
1127
|
+
shot.className = "gd-comment-shot";
|
|
1128
|
+
shot.alt = `Screenshot of comment ${commentNumber(comment)}`;
|
|
1129
|
+
shot.loading = "lazy";
|
|
1130
|
+
shot.src = `${base}api/designs/${encodeURIComponent(state.slug)}/comments/${encodeURIComponent(comment.id)}/screenshot`;
|
|
1131
|
+
show.append(shot);
|
|
1132
|
+
}
|
|
1133
|
+
show.addEventListener("click", () => selectComment(comment.id, { scroll: true, focusList: false }));
|
|
1134
|
+
const actions = document.createElement("div");
|
|
1135
|
+
actions.className = "gd-comment-actions";
|
|
1136
|
+
const toggle = document.createElement("button");
|
|
1137
|
+
toggle.type = "button";
|
|
1138
|
+
const next = comment.status === "open" ? "resolved" : "open";
|
|
1139
|
+
toggle.textContent = next === "resolved" ? "Resolve" : "Reopen";
|
|
1140
|
+
toggle.setAttribute("aria-label", `${toggle.textContent} comment ${commentNumber(comment)}`);
|
|
1141
|
+
toggle.addEventListener("click", () => setCommentStatus(comment.id, next));
|
|
1142
|
+
actions.append(toggle);
|
|
1143
|
+
item.append(show, actions);
|
|
1144
|
+
return item;
|
|
1145
|
+
}));
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function renderComments() {
|
|
1149
|
+
renderList();
|
|
1150
|
+
state.boards.forEach(renderPins);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function loadComments() {
|
|
1154
|
+
const slug = state.slug;
|
|
1155
|
+
if (!slug) return;
|
|
1156
|
+
let comments = [];
|
|
1157
|
+
try {
|
|
1158
|
+
comments = (await getJson(`api/designs/${encodeURIComponent(slug)}/comments`)).comments;
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
setStatus(`comments.json for ${slug} cannot be read: ${error.message}`, true);
|
|
1161
|
+
}
|
|
1162
|
+
if (slug !== state.slug || !Array.isArray(comments)) return;
|
|
1163
|
+
state.comments = comments;
|
|
1164
|
+
if (state.activeId && !comments.some((comment) => comment.id === state.activeId)) state.activeId = null;
|
|
1165
|
+
state.boards.forEach(sendPins);
|
|
1166
|
+
renderComments();
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function selectComment(id, options) {
|
|
1170
|
+
state.activeId = id;
|
|
1171
|
+
renderComments();
|
|
1172
|
+
const comment = state.comments.find((candidate) => candidate.id === id);
|
|
1173
|
+
if (!comment) return;
|
|
1174
|
+
if (comment.status !== state.filter && options.focusList) {
|
|
1175
|
+
state.filter = comment.status;
|
|
1176
|
+
renderList();
|
|
1177
|
+
}
|
|
1178
|
+
const board = state.boards.find((candidate) => candidate.artboard.file === comment.file);
|
|
1179
|
+
board?.frame.contentWindow?.postMessage({ type: "gd:highlight", id }, "*");
|
|
1180
|
+
if (options.focusList) els.list.querySelector(`[data-id="${CSS.escape(id)}"] .gd-comment-show`)?.focus();
|
|
1181
|
+
if (!options.scroll) return;
|
|
1182
|
+
// The focused artboard is already at 1:1; another board's comment leaves focus mode first.
|
|
1183
|
+
if (state.focus) {
|
|
1184
|
+
if (state.focus === board) return;
|
|
1185
|
+
exitFocus();
|
|
1186
|
+
}
|
|
1187
|
+
const pin = board?.pins.querySelector(`[data-id="${CSS.escape(id)}"]`);
|
|
1188
|
+
const target = pin ?? board?.frame;
|
|
1189
|
+
if (!target) return;
|
|
1190
|
+
const viewport = els.viewport.getBoundingClientRect();
|
|
1191
|
+
const rect = target.getBoundingClientRect();
|
|
1192
|
+
panBy(viewport.left + viewport.width / 2 - (rect.left + rect.width / 2), viewport.top + viewport.height / 2 - (rect.top + rect.height / 2));
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
async function postJson(path, body) {
|
|
1196
|
+
const response = await fetch(base + path, {
|
|
1197
|
+
method: "POST",
|
|
1198
|
+
headers: { "Content-Type": "application/json" },
|
|
1199
|
+
cache: "no-store",
|
|
1200
|
+
body: JSON.stringify(body),
|
|
1201
|
+
});
|
|
1202
|
+
let reply = null;
|
|
1203
|
+
try { reply = await response.json(); } catch { reply = null; }
|
|
1204
|
+
return { status: response.status, ok: response.ok, body: reply || {} };
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
async function setCommentStatus(id, status) {
|
|
1208
|
+
const slug = state.slug;
|
|
1209
|
+
const reply = await postJson(`api/designs/${encodeURIComponent(slug)}/comments/${encodeURIComponent(id)}/status`, { status }).catch((error) => ({ ok: false, body: { error: error.message } }));
|
|
1210
|
+
if (!reply.ok) setStatus(`Comment not updated: ${reply.body.reason || reply.body.error || "error"}`, true);
|
|
1211
|
+
else setStatus(status === "resolved" ? "Comment resolved" : "Comment reopened", false);
|
|
1212
|
+
await loadComments();
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
els.sendAll.addEventListener("click", async () => {
|
|
1216
|
+
const slug = state.slug;
|
|
1217
|
+
if (!slug) return;
|
|
1218
|
+
els.sendAll.disabled = true;
|
|
1219
|
+
const reply = await postJson(`api/designs/${encodeURIComponent(slug)}/comments/send-all`, {}).catch((error) => ({ ok: false, body: { error: error.message } }));
|
|
1220
|
+
if (!reply.ok) setStatus(`Comments not sent: ${reply.body.reason || reply.body.error || "error"}`, true);
|
|
1221
|
+
else setStatus(reply.body.sent ? `Sent ${reply.body.sent} comment${reply.body.sent === 1 ? "" : "s"} to the session` : "No unsent comments", false);
|
|
1222
|
+
await loadComments();
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
function setMode(mode) {
|
|
1226
|
+
state.mode = mode === "collect" ? "collect" : "send";
|
|
1227
|
+
els.modeSend.checked = state.mode === "send";
|
|
1228
|
+
els.modeCollect.checked = state.mode === "collect";
|
|
1229
|
+
els.send.textContent = state.mode === "collect" ? "Save" : "Send";
|
|
1230
|
+
try { localStorage.setItem(MODE_KEY, state.mode); } catch { /* storage may be unavailable */ }
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
els.modeSend.addEventListener("change", () => setMode("send"));
|
|
1234
|
+
els.modeCollect.addEventListener("change", () => setMode("collect"));
|
|
1235
|
+
els.filterOpen.addEventListener("click", () => { state.filter = "open"; renderList(); });
|
|
1236
|
+
els.filterResolved.addEventListener("click", () => { state.filter = "resolved"; renderList(); });
|
|
1237
|
+
function setPanel(show) {
|
|
1238
|
+
els.panel.hidden = !show;
|
|
1239
|
+
els.panelToggle.setAttribute("aria-pressed", String(show));
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
els.panelToggle.addEventListener("click", () => setPanel(els.panel.hidden));
|
|
1243
|
+
|
|
1244
|
+
function readRects(data) {
|
|
1245
|
+
if (!Array.isArray(data.rects) || data.rects.length > LIMITS.pins) return null;
|
|
1246
|
+
const rects = new Map();
|
|
1247
|
+
for (const entry of data.rects) {
|
|
1248
|
+
if (!entry || typeof entry.id !== "string" || entry.id.length > 32) return null;
|
|
1249
|
+
const rect = entry.rect;
|
|
1250
|
+
if (rect === null) rects.set(entry.id, null);
|
|
1251
|
+
else if (rect && ["x", "y", "width", "height"].every((key) => isFiniteNumber(rect[key]))) rects.set(entry.id, { x: rect.x, y: rect.y, width: rect.width, height: rect.height });
|
|
1252
|
+
else return null;
|
|
1253
|
+
}
|
|
1254
|
+
return rects;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function setCommenting(enabled) {
|
|
1258
|
+
state.commenting = enabled;
|
|
1259
|
+
els.comment.setAttribute("aria-pressed", String(enabled));
|
|
1260
|
+
els.world.classList.toggle("gd-commenting", enabled);
|
|
1261
|
+
state.boards.forEach(sendMode);
|
|
1262
|
+
setStatus(enabled ? "Comment mode: click an element in an artboard" : "", false);
|
|
1263
|
+
if (!enabled) {
|
|
1264
|
+
setHoverBoard(null);
|
|
1265
|
+
closePopover();
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
els.comment.addEventListener("click", () => setCommenting(!state.commenting));
|
|
1270
|
+
|
|
1271
|
+
function isFiniteNumber(value) {
|
|
1272
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function readPick(data) {
|
|
1276
|
+
if (typeof data.selector !== "string") return null;
|
|
1277
|
+
const selector = data.selector.trim();
|
|
1278
|
+
if (!selector || selector.length > LIMITS.selector) return null;
|
|
1279
|
+
const excerpt = typeof data.excerpt === "string" ? data.excerpt.slice(0, LIMITS.excerpt) : "";
|
|
1280
|
+
const rect = data.rect && typeof data.rect === "object" ? data.rect : {};
|
|
1281
|
+
const box = ["x", "y", "width", "height"].every((key) => isFiniteNumber(rect[key])) ? rect : { x: 0, y: 0, width: 0, height: 0 };
|
|
1282
|
+
return { selector, excerpt, rect: { x: box.x, y: box.y, width: box.width, height: box.height } };
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function openPopover(board, pick) {
|
|
1286
|
+
clearTimeout(state.closeTimer);
|
|
1287
|
+
state.pick = { board, ...pick };
|
|
1288
|
+
els.target.textContent = `${board.artboard.file} · ${pick.selector}`;
|
|
1289
|
+
els.message.textContent = "";
|
|
1290
|
+
els.message.classList.remove("gd-error");
|
|
1291
|
+
els.send.disabled = false;
|
|
1292
|
+
els.popover.hidden = false;
|
|
1293
|
+
const frameRect = board.frame.getBoundingClientRect();
|
|
1294
|
+
const scale = frameRect.width / board.artboard.width || 1;
|
|
1295
|
+
const width = els.popover.offsetWidth;
|
|
1296
|
+
const height = els.popover.offsetHeight;
|
|
1297
|
+
let left = frameRect.left + pick.rect.x * scale;
|
|
1298
|
+
let top = frameRect.top + (pick.rect.y + pick.rect.height) * scale + 8;
|
|
1299
|
+
if (top + height > window.innerHeight - 12) top = frameRect.top + pick.rect.y * scale - height - 8;
|
|
1300
|
+
left = Math.min(Math.max(12, left), window.innerWidth - width - 12);
|
|
1301
|
+
top = Math.min(Math.max(12, top), window.innerHeight - height - 12);
|
|
1302
|
+
els.popover.style.left = `${left}px`;
|
|
1303
|
+
els.popover.style.top = `${top}px`;
|
|
1304
|
+
els.text.focus();
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function clearShot() {
|
|
1308
|
+
state.shot = null;
|
|
1309
|
+
state.shotToken += 1;
|
|
1310
|
+
els.shotInput.value = "";
|
|
1311
|
+
els.shotImage.removeAttribute("src");
|
|
1312
|
+
els.shotPreview.hidden = true;
|
|
1313
|
+
els.shotAttach.hidden = false;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function shotError(message) {
|
|
1317
|
+
els.message.textContent = message;
|
|
1318
|
+
els.message.classList.add("gd-error");
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** Reads one image file into the popover; a later attach or a clear discards a read still running. */
|
|
1322
|
+
function attachShot(file) {
|
|
1323
|
+
if (!file || els.popover.hidden) return;
|
|
1324
|
+
if (!SCREENSHOT_TYPES.includes(file.type)) return shotError("Attach a PNG, JPEG, WebP or GIF image.");
|
|
1325
|
+
if (file.size === 0 || file.size > LIMITS.screenshotBytes) return shotError("The screenshot must be at most 5 MB.");
|
|
1326
|
+
const token = ++state.shotToken;
|
|
1327
|
+
const reader = new FileReader();
|
|
1328
|
+
reader.addEventListener("load", () => {
|
|
1329
|
+
if (token !== state.shotToken || typeof reader.result !== "string") return;
|
|
1330
|
+
const url = reader.result;
|
|
1331
|
+
state.shot = { mimeType: file.type, data: url.slice(url.indexOf(",") + 1) };
|
|
1332
|
+
els.shotImage.src = url;
|
|
1333
|
+
els.shotPreview.hidden = false;
|
|
1334
|
+
els.shotAttach.hidden = true;
|
|
1335
|
+
els.message.textContent = "";
|
|
1336
|
+
els.message.classList.remove("gd-error");
|
|
1337
|
+
});
|
|
1338
|
+
reader.addEventListener("error", () => { if (token === state.shotToken) shotError("The screenshot could not be read."); });
|
|
1339
|
+
reader.readAsDataURL(file);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function imageFile(list) {
|
|
1343
|
+
return Array.from(list || []).find((file) => file.type.startsWith("image/")) || (list && list[0]) || null;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
els.shotAttach.addEventListener("click", () => els.shotInput.click());
|
|
1347
|
+
els.shotInput.addEventListener("change", () => attachShot(els.shotInput.files && els.shotInput.files[0]));
|
|
1348
|
+
els.shotRemove.addEventListener("click", () => {
|
|
1349
|
+
clearShot();
|
|
1350
|
+
els.shotAttach.focus();
|
|
1351
|
+
});
|
|
1352
|
+
els.popover.addEventListener("paste", (event) => {
|
|
1353
|
+
const items = Array.from(event.clipboardData ? event.clipboardData.items : []);
|
|
1354
|
+
const item = items.find((entry) => entry.kind === "file" && entry.type.startsWith("image/"));
|
|
1355
|
+
if (!item) return;
|
|
1356
|
+
event.preventDefault();
|
|
1357
|
+
attachShot(item.getAsFile());
|
|
1358
|
+
});
|
|
1359
|
+
els.popover.addEventListener("dragover", (event) => {
|
|
1360
|
+
if (!event.dataTransfer || !Array.from(event.dataTransfer.types).includes("Files")) return;
|
|
1361
|
+
event.preventDefault();
|
|
1362
|
+
event.dataTransfer.dropEffect = "copy";
|
|
1363
|
+
els.popover.classList.add("gd-dropping");
|
|
1364
|
+
});
|
|
1365
|
+
els.popover.addEventListener("dragleave", (event) => {
|
|
1366
|
+
if (!els.popover.contains(event.relatedTarget)) els.popover.classList.remove("gd-dropping");
|
|
1367
|
+
});
|
|
1368
|
+
els.popover.addEventListener("drop", (event) => {
|
|
1369
|
+
els.popover.classList.remove("gd-dropping");
|
|
1370
|
+
if (!event.dataTransfer || event.dataTransfer.files.length === 0) return;
|
|
1371
|
+
event.preventDefault();
|
|
1372
|
+
attachShot(imageFile(event.dataTransfer.files));
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
function closePopover() {
|
|
1376
|
+
if (els.popover.hidden) return;
|
|
1377
|
+
clearTimeout(state.closeTimer);
|
|
1378
|
+
const board = state.pick && state.pick.board;
|
|
1379
|
+
state.pick = null;
|
|
1380
|
+
clearShot();
|
|
1381
|
+
els.popover.hidden = true;
|
|
1382
|
+
board?.frame.contentWindow?.postMessage({ type: "gd:clear" }, "*");
|
|
1383
|
+
if (els.popover.contains(document.activeElement) || document.activeElement === document.body) (state.focus ? state.focus.frame : els.comment).focus();
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
els.cancel.addEventListener("click", closePopover);
|
|
1387
|
+
els.popover.addEventListener("keydown", (event) => {
|
|
1388
|
+
if (event.key === "Escape") {
|
|
1389
|
+
event.preventDefault();
|
|
1390
|
+
closePopover();
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
document.addEventListener("keydown", (event) => {
|
|
1394
|
+
if (event.defaultPrevented || event.key !== "Escape") return;
|
|
1395
|
+
if (event.shiftKey) {
|
|
1396
|
+
if (state.compare) {
|
|
1397
|
+
event.preventDefault();
|
|
1398
|
+
exitCompare(true);
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
if (!state.focus) return;
|
|
1402
|
+
event.preventDefault();
|
|
1403
|
+
exitFocus();
|
|
1404
|
+
} else if (state.commenting && els.popover.hidden && event.target === document.body) setCommenting(false);
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
els.form.addEventListener("submit", async (event) => {
|
|
1408
|
+
event.preventDefault();
|
|
1409
|
+
const pick = state.pick;
|
|
1410
|
+
if (!pick) return;
|
|
1411
|
+
const comment = els.text.value.trim();
|
|
1412
|
+
if (!comment || comment.length > LIMITS.comment) {
|
|
1413
|
+
els.message.textContent = `Write a comment of 1 to ${LIMITS.comment} characters.`;
|
|
1414
|
+
els.message.classList.add("gd-error");
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
els.send.disabled = true;
|
|
1418
|
+
els.message.classList.remove("gd-error");
|
|
1419
|
+
els.message.textContent = "Sending…";
|
|
1420
|
+
const shot = state.shot;
|
|
1421
|
+
try {
|
|
1422
|
+
const response = await fetch(`${base}api/comments`, {
|
|
1423
|
+
method: "POST",
|
|
1424
|
+
headers: { "Content-Type": "application/json" },
|
|
1425
|
+
cache: "no-store",
|
|
1426
|
+
body: JSON.stringify({ slug: pick.board.slug, file: pick.board.artboard.file, selector: pick.selector, excerpt: pick.excerpt, comment, mode: state.mode, ...(shot ? { screenshot: shot } : {}) }),
|
|
1427
|
+
});
|
|
1428
|
+
let reply = {};
|
|
1429
|
+
try { reply = await response.json(); } catch { /* keep the status */ }
|
|
1430
|
+
// Delivery can fail after the comment was saved; it then waits for "Send all".
|
|
1431
|
+
const savedOnly = response.status === 500 && reply.saved === true;
|
|
1432
|
+
if (response.status !== 202 && response.status !== 201 && !savedOnly) throw new Error(reply.reason || reply.error || `HTTP ${response.status}`);
|
|
1433
|
+
loadComments();
|
|
1434
|
+
if (state.pick !== pick) return;
|
|
1435
|
+
els.text.value = "";
|
|
1436
|
+
if (state.shot === shot) clearShot();
|
|
1437
|
+
const done = savedOnly ? "Saved, but not sent to the session" : response.status === 201 ? "Saved; use Send all to send it" : "Sent to the session";
|
|
1438
|
+
els.message.textContent = done;
|
|
1439
|
+
els.message.classList.toggle("gd-error", savedOnly);
|
|
1440
|
+
setStatus(done, savedOnly);
|
|
1441
|
+
state.closeTimer = setTimeout(closePopover, savedOnly ? 2500 : 1200);
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
if (state.pick !== pick) return;
|
|
1444
|
+
els.send.disabled = false;
|
|
1445
|
+
els.message.textContent = `Not sent: ${error.message}`;
|
|
1446
|
+
els.message.classList.add("gd-error");
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
window.addEventListener("message", (event) => {
|
|
1451
|
+
// Shift+Esc inside the compared artboard leaves Compare; nothing else from it is trusted.
|
|
1452
|
+
if (state.compare && event.source === state.compare.artboardFrame.contentWindow) {
|
|
1453
|
+
if (event.data && typeof event.data === "object" && event.data.type === "gd:exit-focus") exitCompare(true);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const system = state.systemBoard && state.systemBoard.frame.contentWindow === event.source ? state.systemBoard : null;
|
|
1457
|
+
const board = system ?? state.boards.find((candidate) => candidate.frame.contentWindow === event.source);
|
|
1458
|
+
if (!board || !event.data || typeof event.data !== "object") return;
|
|
1459
|
+
const data = event.data;
|
|
1460
|
+
if (data.type === "gd:pick") {
|
|
1461
|
+
// The system board never takes comments, even if its page posts a pick.
|
|
1462
|
+
if (!state.commenting || board.system || (state.focus && board !== state.focus)) return;
|
|
1463
|
+
const pick = readPick(data);
|
|
1464
|
+
if (pick) openPopover(board, pick);
|
|
1465
|
+
} else if (data.type === "gd:wheel") {
|
|
1466
|
+
if (![data.deltaX, data.deltaY, data.deltaMode, data.x, data.y].every(isFiniteNumber)) return;
|
|
1467
|
+
const frameRect = board.frame.getBoundingClientRect();
|
|
1468
|
+
const scale = frameRect.width / board.artboard.width || 1;
|
|
1469
|
+
handleWheel(data.deltaX, data.deltaY, data.deltaMode, data.zoom === true, data.shift === true, frameRect.left + data.x * scale, frameRect.top + data.y * scale);
|
|
1470
|
+
} else if (data.type === "gd:pin-rects") {
|
|
1471
|
+
if (board.system) return;
|
|
1472
|
+
const rects = readRects(data);
|
|
1473
|
+
if (!rects) return;
|
|
1474
|
+
const detachedBefore = detachedKey();
|
|
1475
|
+
board.rects = rects;
|
|
1476
|
+
renderPins(board);
|
|
1477
|
+
// Re-render the list only when a badge changes, so keyboard focus in it survives.
|
|
1478
|
+
if (detachedKey() !== detachedBefore) renderList();
|
|
1479
|
+
} else if (data.type === "gd:escape") {
|
|
1480
|
+
if (!els.popover.hidden) closePopover();
|
|
1481
|
+
else if (state.commenting) setCommenting(false);
|
|
1482
|
+
} else if (data.type === "gd:exit-focus") {
|
|
1483
|
+
// Only the focused artboard may end its own focus mode.
|
|
1484
|
+
if (board === state.focus) exitFocus();
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
window.addEventListener("resize", closePopover);
|
|
1489
|
+
window.addEventListener("resize", layoutCompare);
|
|
1490
|
+
// The viewport also changes size when the comments panel opens or closes.
|
|
1491
|
+
if (typeof ResizeObserver === "function") new ResizeObserver(() => centerFocus()).observe(els.viewport);
|
|
1492
|
+
else window.addEventListener("resize", () => centerFocus());
|
|
1493
|
+
|
|
1494
|
+
setMode(state.mode);
|
|
1495
|
+
start();
|
|
1496
|
+
connectEvents();
|
|
1497
|
+
})();
|