@tobq/boardclip 0.0.0-stage → 2.1.108

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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -2
  3. package/assets/boardclip-icon.png +0 -0
  4. package/bin/boardclip.js +43 -0
  5. package/editor-preload.js +71 -0
  6. package/editor.html +220 -0
  7. package/icon.png +0 -0
  8. package/icon@2x.png +0 -0
  9. package/iconTemplate.png +0 -0
  10. package/iconTemplate@2x.png +0 -0
  11. package/index.html +1339 -0
  12. package/lib/appearance.js +183 -0
  13. package/lib/auto-update.js +306 -0
  14. package/lib/backup-worker.js +46 -0
  15. package/lib/backup.js +275 -0
  16. package/lib/blob-field-store.js +275 -0
  17. package/lib/blob-store.js +68 -0
  18. package/lib/build-info.js +89 -0
  19. package/lib/clip-blob-store.js +161 -0
  20. package/lib/clip-revision.js +81 -0
  21. package/lib/clipboard-capture.js +95 -0
  22. package/lib/clipboard-follow.js +98 -0
  23. package/lib/clipboard-model.js +1235 -0
  24. package/lib/cloud-accounts.js +250 -0
  25. package/lib/conflict-model.js +186 -0
  26. package/lib/control-client.js +87 -0
  27. package/lib/control-server.js +149 -0
  28. package/lib/diagnostics.js +121 -0
  29. package/lib/drag-files.js +48 -0
  30. package/lib/ensure-directory.js +56 -0
  31. package/lib/fork-names.js +35 -0
  32. package/lib/fs-probe.js +132 -0
  33. package/lib/history-feed.js +48 -0
  34. package/lib/hmac-auth.js +78 -0
  35. package/lib/macos-hotkey.js +297 -0
  36. package/lib/macos-paste.js +112 -0
  37. package/lib/mcp-core.js +270 -0
  38. package/lib/mcp-installers.js +284 -0
  39. package/lib/mcp-paths.js +99 -0
  40. package/lib/p2p-crypto.js +54 -0
  41. package/lib/p2p-discovery.js +191 -0
  42. package/lib/quick-paste.js +187 -0
  43. package/lib/retention.js +43 -0
  44. package/lib/sync-delta.js +227 -0
  45. package/lib/sync-journal.js +181 -0
  46. package/lib/sync-paths.js +73 -0
  47. package/lib/tailscale.js +80 -0
  48. package/lib/text-blob-store.js +38 -0
  49. package/lib/windows-clipboard.js +213 -0
  50. package/lib/windows-console.js +63 -0
  51. package/lib/windows-dwm.js +183 -0
  52. package/lib/windows-hook-worker.js +236 -0
  53. package/lib/windows-hook.js +91 -0
  54. package/lib/windows-paste.js +185 -0
  55. package/main.js +7540 -0
  56. package/mcp/boardclip-mcp.js +255 -0
  57. package/mcp-approval-preload.js +22 -0
  58. package/mcp-approval.html +218 -0
  59. package/package.json +55 -4
  60. package/preload.js +106 -0
  61. package/site/index.html +1624 -0
  62. package/site/shared/clip-search.js +2330 -0
  63. package/site/shared/clipboard-popup.css +1046 -0
  64. package/site/shared/clipboard-tokens.css +326 -0
  65. package/site/shared/clipboard-ui-core.js +6921 -0
  66. package/site/shared/clipboard-window.css +36 -0
  67. package/site/shared/vendor/cm5/README.md +52 -0
  68. package/site/shared/vendor/cm5/codemirror.css +344 -0
  69. package/site/shared/vendor/cm5/codemirror.js +9884 -0
  70. package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
  71. package/site/shared/vendor/cm5/merge.css +119 -0
  72. package/site/shared/vendor/cm5/merge.js +1182 -0
  73. package/site/shared/vendor/fonts/README.md +19 -0
  74. package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
  75. package/viewer-preload.js +51 -0
  76. package/viewer.html +129 -0
@@ -0,0 +1,1624 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>BoardClip - clipboard history for Windows and macOS</title>
7
+ <meta name="description" content="BoardClip is a free, open-source clipboard history app for Windows and macOS: search with filters, pins and groups, numpad quick-paste, image clips, a built-in editor, and sync between your computers without an account.">
8
+ <link rel="canonical" href="https://boardclip.app/">
9
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
10
+ <link rel="icon" type="image/png" href="favicon.png">
11
+ <link rel="apple-touch-icon" href="favicon.png">
12
+ <!-- Cookieless, anonymous visit counts (Ahrefs Web Analytics). Downloads count as
13
+ outbound clicks to GitHub; install_copied and demo_used are custom events. -->
14
+ <script src="https://analytics.ahrefs.com/analytics.js" data-key="uRa9KP7kXOw0b5xS3h5Dxg" async></script>
15
+ <script>
16
+ // The page follows the system theme through the shared token layer
17
+ // (:root[data-theme]); the demo popup carries its own data-theme.
18
+ (() => {
19
+ const dark = matchMedia("(prefers-color-scheme: dark)");
20
+ const apply = () => { document.documentElement.dataset.theme = dark.matches ? "dark" : "light"; };
21
+ apply();
22
+ dark.addEventListener("change", apply);
23
+ })();
24
+ </script>
25
+ <!-- Vendored CodeMirror 5 merge view (shared with the desktop app's editor
26
+ window); powers the demo's unify/reconcile overlay. -->
27
+ <link rel="stylesheet" href="shared/vendor/cm5/codemirror.css">
28
+ <link rel="stylesheet" href="shared/vendor/cm5/merge.css">
29
+ <script src="shared/vendor/cm5/codemirror.js" defer></script>
30
+ <script src="shared/vendor/cm5/diff-match-patch.js" defer></script>
31
+ <script src="shared/vendor/cm5/merge.js" defer></script>
32
+ <link rel="stylesheet" href="shared/clipboard-popup.css">
33
+ <link rel="stylesheet" href="styles.css">
34
+ </head>
35
+ <body>
36
+ <header class="topbar wrap">
37
+ <a class="brand" href="./" aria-label="BoardClip home">
38
+ <img src="favicon.svg" width="20" height="20" alt="">
39
+ <span>BoardClip</span>
40
+ </a>
41
+ <nav class="nav" aria-label="Site">
42
+ <a href="#features">Features</a>
43
+ <a href="#privacy">Privacy</a>
44
+ <a href="#download">Download</a>
45
+ <a class="cli-only" href="#update" hidden>Update</a>
46
+ <a href="https://github.com/tobq/boardclip">
47
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
48
+ <path d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.39 0-.19-.01-.84-.01-1.53-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.15-.28-.16-.68-.55-.01-.56.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.1-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.19-.08-.21-.36-1.04.08-2.16 0 0 .67-.22 2.2.84A7.37 7.37 0 0 1 8 3.99c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.12.16 1.95.08 2.16.51.57.82 1.3.82 2.19 0 3.14-1.87 3.83-3.65 4.04.29.26.54.75.54 1.52 0 1.1-.01 1.98-.01 2.25 0 .21.15.47.55.39A8.07 8.07 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"/>
49
+ </svg>
50
+ <span>GitHub</span>
51
+ </a>
52
+ </nav>
53
+ </header>
54
+
55
+ <main>
56
+ <div class="demo-dim" id="demo-dim" aria-hidden="true"></div>
57
+ <section class="hero wrap" aria-labelledby="hero-title">
58
+ <div class="hero-copy">
59
+ <h1 id="hero-title">Everything you copy, <span class="hero-accent">one keystroke away.</span></h1>
60
+ <p class="lede">BoardClip keeps the text and images you copy in a small popup you can search and paste from. It is free, open source, and your history stays on your own disk.</p>
61
+
62
+ <dl class="keys" aria-label="Keys">
63
+ <div>
64
+ <dt><kbd>Win</kbd><kbd>V</kbd></dt>
65
+ <dd>Open the popup. <span class="dim">On a Mac: <kbd>Cmd</kbd><kbd>Shift</kbd><kbd>V</kbd></span></dd>
66
+ </div>
67
+ <div>
68
+ <dt class="dim">Type</dt>
69
+ <dd>Search titles and text, or filter with <code>group:</code> <code>is:</code> <code>since:</code></dd>
70
+ </div>
71
+ <div>
72
+ <dt><kbd>Enter</kbd></dt>
73
+ <dd>Paste the selected clip into the app you were using</dd>
74
+ </div>
75
+ <div>
76
+ <dt><kbd>Numpad 1-9</kbd></dt>
77
+ <dd>Paste a numpad slot without opening the popup. <span class="dim">On a Mac: <kbd>Cmd</kbd><kbd>Option</kbd><kbd>1-9</kbd></span></dd>
78
+ </div>
79
+ </dl>
80
+
81
+ <div class="get" id="download">
82
+ <div class="get-head">
83
+ <h2 class="get-title" id="download-title">Download for Windows</h2>
84
+ <div class="seg" role="group" aria-label="Install method">
85
+ <button class="seg-btn active" type="button" data-install-mode="installer">Installer</button>
86
+ <button class="seg-btn" type="button" data-install-mode="cli">Command line</button>
87
+ </div>
88
+ </div>
89
+
90
+ <div class="get-panel" data-mode-panel="installer">
91
+ <div class="get-row">
92
+ <a class="btn primary" id="download-link" href="https://github.com/tobq/boardclip/releases/latest">Download</a>
93
+ <p class="note" id="download-note">Installer builds are published from the open-source repo.</p>
94
+ </div>
95
+ <div class="get-builds" role="group" aria-label="Other builds">
96
+ <button class="btn quiet sm" type="button" data-download-os="win">Windows</button>
97
+ <button class="btn quiet sm" type="button" data-download-os="mac">Mac</button>
98
+ <button class="btn quiet sm" type="button" data-download-os="linux">Linux</button>
99
+ </div>
100
+ </div>
101
+
102
+ <div class="get-panel" data-mode-panel="cli" hidden>
103
+ <div class="get-head">
104
+ <h3 class="get-sub" id="install-title">Recommended command</h3>
105
+ <div class="seg" role="group" aria-label="Operating system">
106
+ <button class="seg-btn cli-os active" type="button" data-os="mac">macOS</button>
107
+ <button class="seg-btn cli-os" type="button" data-os="win">Windows</button>
108
+ <button class="seg-btn cli-os" type="button" data-os="linux">Linux</button>
109
+ </div>
110
+ </div>
111
+ <div class="command-wrap">
112
+ <button class="btn sm command-copy" type="button" data-copy-command="install-command">Copy</button>
113
+ <pre class="command-line" data-copy-command="install-command" tabindex="0" title="Click to copy"><code id="install-command">curl -fsSL https://boardclip.sh/install.sh | sh</code></pre>
114
+ </div>
115
+ <p class="note">Installs from the open-source repo. If BoardClip is already installed, this runs the normal update.</p>
116
+ </div>
117
+ </div>
118
+ </div>
119
+
120
+ <div class="demo" aria-label="Interactive BoardClip demo">
121
+ <p class="demo-live"><span class="demo-live-dot" aria-hidden="true"></span><strong>Live demo</strong> Copy any text on this page and it lands here. Click a clip to copy it.</p>
122
+ <div class="demo-stage">
123
+ <div class="demo-window bc-popup" id="demo-window"></div>
124
+ <div class="demo-window demo-clipwin bc-popup" id="demo-clipwin" hidden><div class="demo-clipwin-body" id="demo-clipwin-body"></div><div class="toast" id="demo-clip-toast"></div></div>
125
+ </div>
126
+ <div class="demo-dock-slot" id="demo-dock-slot" inert>
127
+ <div class="demo-dock-clip">
128
+ <div class="demo-dock" id="demo-dock" role="toolbar" aria-label="Open windows">
129
+ <button class="dock-item running" id="demo-dock-app" type="button" title="BoardClip" aria-label="BoardClip"><img src="favicon.svg" width="28" height="28" alt=""></button>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ <div class="demo-guide" id="demo-guide" aria-hidden="true">
134
+ <span class="demo-guide-pulse"></span>
135
+ <svg class="demo-guide-cursor" viewBox="0 0 24 32"><path d="M3 2.5v24l6.2-6.1 4.2 8.7 4.4-2.1-4.2-8.3h8.8L3 2.5Z"/></svg>
136
+ <span class="demo-guide-tip" id="demo-guide-tip"></span>
137
+ </div>
138
+ <p class="demo-caption">The real popup with sample clips. Right-click a clip for its menu, or try a search:
139
+ <button class="try" type="button" data-try="invoice">invoice</button>
140
+ <button class="try" type="button" data-try="group:work">group:work</button>
141
+ <button class="try" type="button" data-try="is:image">is:image</button>
142
+ </p>
143
+ </div>
144
+ </section>
145
+
146
+ <section class="features wrap" id="features" aria-labelledby="features-title">
147
+ <h2 class="overline" id="features-title">What it does</h2>
148
+ <dl class="spec">
149
+ <div>
150
+ <dt><span class="mi spec-icon" aria-hidden="true">search</span>Search with filters</dt>
151
+ <dd>Plain words match titles and text, exactly as typed. Filters narrow the list, and a right-click excludes one. Turn on Regex and what you type becomes a /pattern/; OR, brackets and every filter work as text too.
152
+ <span class="tries">
153
+ <button class="try" type="button" data-try="group:work">group:work</button>
154
+ <button class="try" type="button" data-try="is:pinned">is:pinned</button>
155
+ <button class="try" type="button" data-try="is:image">is:image</button>
156
+ <button class="try" type="button" data-try="since:7d">since:7d</button>
157
+ <button class="try" type="button" data-try="len:>200">len:&gt;200</button>
158
+ <button class="try" type="button" data-try="-group:code">-group:code</button>
159
+ </span></dd>
160
+ </div>
161
+ <div>
162
+ <dt><span class="mi spec-icon" aria-hidden="true">tag</span>Numpad quick-paste</dt>
163
+ <dd>Give up to nine clips a numpad slot from their menu. Pressing the key pastes that clip straight into the app you are in, and whatever was on your clipboard is put back afterwards.</dd>
164
+ </div>
165
+ <div>
166
+ <dt><span class="mi spec-icon" aria-hidden="true">star</span>Pins, groups and titles</dt>
167
+ <dd>Pinned clips never expire. Groups nest with a slash, like work/api, and any clip can carry a title, so a snippet you keep reads as a note rather than a fragment.</dd>
168
+ </div>
169
+ <div>
170
+ <dt><span class="mi spec-icon" aria-hidden="true">image</span>Images</dt>
171
+ <dd>Screenshots and copied images are kept next to your text. Open one in the image viewer, or drag it out of the list into another app as a file.</dd>
172
+ </div>
173
+ <div>
174
+ <dt><span class="mi spec-icon" aria-hidden="true">edit_note</span>Editor and Unify</dt>
175
+ <dd>Text clips open in a built-in editor that saves as you go. Select a few near-identical clips and Unify folds them into one, with a three-pane merge view for the parts that differ.</dd>
176
+ </div>
177
+ <div>
178
+ <dt><span class="mi spec-icon" aria-hidden="true">sync</span>Sync between your computers</dt>
179
+ <dd>Computers on the same network, or on the same Tailscale network, sync with each other directly over an encrypted connection. A Google Drive, OneDrive or iCloud folder you already use carries changes too. There is no BoardClip account and no BoardClip server.</dd>
180
+ </div>
181
+ <div>
182
+ <dt><span class="mi spec-icon" aria-hidden="true">lock</span>AI access, off by default</dt>
183
+ <dd>Turn it on and AI apps such as Claude, Cursor or VS Code can read the groups you choose to share, through an MCP server that runs on your own computer. Reading anything else, editing or deleting asks you first.</dd>
184
+ </div>
185
+ <div>
186
+ <dt><span class="mi spec-icon" aria-hidden="true">devices</span>Windows and macOS</dt>
187
+ <dd>Windows, and macOS on Apple Silicon and Intel. A Linux AppImage is built from the same source. MIT licensed; your history is plain files in the app's folder.</dd>
188
+ </div>
189
+ </dl>
190
+ </section>
191
+
192
+ <section class="features privacy wrap" id="privacy" aria-labelledby="privacy-title">
193
+ <h2 class="overline" id="privacy-title">Private by design</h2>
194
+ <p class="lede privacy-lede">A clipboard holds passwords, addresses and half-written messages, so none of it goes anywhere you did not choose.</p>
195
+ <dl class="spec">
196
+ <div>
197
+ <dt><span class="mi spec-icon" aria-hidden="true">dns</span>No BoardClip server, no account</dt>
198
+ <dd>Your history is plain files on your own disk. The app never talks to a server of ours: the only request it makes is asking GitHub whether there is an update.</dd>
199
+ </div>
200
+ <div>
201
+ <dt><span class="mi spec-icon" aria-hidden="true">folder_shared</span>Synced through your own drives</dt>
202
+ <dd>Your computers reach each other directly, encrypted, over your network or your Tailscale network, or through a Google Drive, OneDrive or iCloud folder you already use. Nothing passes through us.</dd>
203
+ </div>
204
+ <div>
205
+ <dt><span class="mi spec-icon" aria-hidden="true">code</span>Open source</dt>
206
+ <dd>MIT licensed. Every line that touches your clipboard is on GitHub for anyone to read, build and check.</dd>
207
+ </div>
208
+ <div>
209
+ <dt><span class="mi spec-icon" aria-hidden="true">travel_explore</span>Coming soon: your clips in any browser</dt>
210
+ <dd>Sign in with Google on this site and read your clips straight from your own Drive. It runs in your browser, with still no server of ours in between.</dd>
211
+ </div>
212
+ </dl>
213
+ <p class="note privacy-note">This website counts visits anonymously, with no cookies (Ahrefs Web Analytics). The app itself has no analytics or tracking at all.</p>
214
+ </section>
215
+
216
+ <section class="update wrap cli-only" id="update" hidden aria-labelledby="update-label">
217
+ <h2 class="overline" id="update-label">Update</h2>
218
+ <div class="get">
219
+ <div class="get-head">
220
+ <h3 class="get-sub" id="update-title">Recommended command</h3>
221
+ <div class="seg" role="group" aria-label="Update operating system">
222
+ <button class="seg-btn update-os active" type="button" data-os="mac">macOS</button>
223
+ <button class="seg-btn update-os" type="button" data-os="win">Windows</button>
224
+ <button class="seg-btn update-os" type="button" data-os="linux">Linux</button>
225
+ </div>
226
+ </div>
227
+ <div class="command-wrap">
228
+ <button class="btn sm command-copy" type="button" data-copy-command="update-command">Copy</button>
229
+ <pre class="command-line" data-copy-command="update-command" tabindex="0" title="Click to copy"><code id="update-command">curl -fsSL https://boardclip.sh/update.sh | sh</code></pre>
230
+ </div>
231
+ <p class="note">Pulls the latest source, refreshes dependencies and relaunches the tray app.</p>
232
+ </div>
233
+ </section>
234
+ </main>
235
+
236
+ <footer class="foot wrap">
237
+ <span>Open source, MIT licensed. No account, no hosted clipboard database.</span>
238
+ <a href="https://github.com/tobq/boardclip">github.com/tobq/boardclip</a>
239
+ </footer>
240
+
241
+ <script>
242
+ // The example searches in the copy run in the live demo: they set the demo's
243
+ // search field and fire the same input event typing would.
244
+ document.addEventListener("click", (event) => {
245
+ const button = event.target.closest("[data-try]");
246
+ const search = button && document.getElementById("demo-search");
247
+ if (!search) return;
248
+ search.value = button.dataset.try;
249
+ search.dispatchEvent(new Event("input", { bubbles: true }));
250
+ const demo = document.getElementById("demo-window");
251
+ const rect = demo.getBoundingClientRect();
252
+ if (rect.top < 0 || rect.bottom > window.innerHeight) demo.scrollIntoView({ block: "nearest" });
253
+ });
254
+ </script>
255
+
256
+ <script src="shared/clip-search.js"></script>
257
+ <script src="shared/clipboard-ui-core.js"></script>
258
+ <script>
259
+ const Core = window.BoardClipCore;
260
+ const escapeHtml = Core.escapeHtml;
261
+ // The demo renders the EXACT same shell + settings body as the desktop app
262
+ // (one shared source). Only the data and the in-browser hooks differ.
263
+ const demoWindowEl = document.getElementById("demo-window");
264
+ demoWindowEl.innerHTML = Core.renderPopupShell({
265
+ ids: {
266
+ mainView: "demo-main-view",
267
+ count: "clip-count",
268
+ syncHeaderBtn: "demo-sync-header",
269
+ settingsBtn: "demo-settings-button",
270
+ closeBtn: "demo-close-button",
271
+ search: "demo-search",
272
+ searchClear: "demo-clear-search",
273
+ regexBtn: "demo-regex",
274
+ sortBtn: "demo-sort",
275
+ groupFilters: "filter-chips",
276
+ list: "clip-list",
277
+ settingsView: "demo-settings",
278
+ settingsBack: "demo-settings-back",
279
+ settingsCloseBtn: "demo-settings-close",
280
+ },
281
+ settingsNote: "Demo",
282
+ showCloseButtons: true,
283
+ headerActionsHtml: '<button class="icon-btn" id="demo-new-note" type="button" title="New note" aria-label="New note"><span class="mi">edit_note</span></button>',
284
+ settingsBodyHtml: Core.renderSettingsBody(),
285
+ });
286
+ // The toast lives INSIDE the demo window so it inherits the popup theme
287
+ // variables. The confirm/prompt dialogs come from the SHARED
288
+ // Core.createDialogs (same implementation the desktop app uses). The clip
289
+ // window (editor, image viewer, Unify) is its own window beside it, as in
290
+ // the app, and both are themed alike (demoRoots).
291
+ demoWindowEl.insertAdjacentHTML("beforeend", `
292
+ <div class="toast" id="demo-toast">Copied</div>
293
+ `);
294
+ const clipWinEl = document.getElementById("demo-clipwin");
295
+ const demoRoots = [demoWindowEl, clipWinEl];
296
+ // The window an action came from (the active one; the clip window while
297
+ // the popup is closed): its dialogs and toasts open there, as each of the
298
+ // app's windows has its own.
299
+ function activeDemoWindow() {
300
+ if (!clipWinEl.hidden && (clipWinEl.dataset.active !== undefined || demoWindowEl.hidden)) return clipWinEl;
301
+ return demoWindowEl;
302
+ }
303
+ const dialogs = Core.createDialogs(activeDemoWindow);
304
+ const osLabels = {
305
+ mac: "macOS terminal",
306
+ linux: "Linux terminal",
307
+ win: "Windows PowerShell"
308
+ };
309
+ const commands = {
310
+ install: {
311
+ mac: "curl -fsSL https://boardclip.sh/install.sh | sh",
312
+ linux: "curl -fsSL https://boardclip.sh/install.sh | sh",
313
+ win: "irm https://boardclip.sh/install.ps1 | iex"
314
+ },
315
+ update: {
316
+ mac: "curl -fsSL https://boardclip.sh/update.sh | sh",
317
+ linux: "curl -fsSL https://boardclip.sh/update.sh | sh",
318
+ win: "irm https://boardclip.sh/update.ps1 | iex"
319
+ }
320
+ };
321
+ const releaseBase = "https://github.com/tobq/boardclip/releases/latest/download/";
322
+ const downloads = {
323
+ win: {
324
+ title: "Download for Windows",
325
+ label: "Download installer",
326
+ note: "Downloads the Windows installer directly.",
327
+ url: `${releaseBase}BoardClip-Setup-Windows.exe`
328
+ },
329
+ // The .dmg is greyed until it opens without a security prompt. Homebrew
330
+ // (tobq/homebrew-tap clears the quarantine flag) and the one-line
331
+ // install (Command line) need none, so Macs start on Command line.
332
+ mac: {
333
+ title: "Download for Mac",
334
+ label: "Download coming soon",
335
+ note: "Get the Mac app with Homebrew, no security prompt: brew install --cask tobq/tap/boardclip. Or use Command line.",
336
+ url: `${releaseBase}BoardClip-macOS.dmg`,
337
+ disabled: true
338
+ },
339
+ linux: {
340
+ title: "Download for Linux",
341
+ label: "Download AppImage",
342
+ note: "Downloads the Linux AppImage directly.",
343
+ url: `${releaseBase}BoardClip-Linux.AppImage`
344
+ }
345
+ };
346
+ const DEMO_SHOTS = ["assets/demo-shot-waveform.svg", "assets/demo-shot-dashboard.svg", "assets/demo-shot-architecture.svg"];
347
+ let labels = ["work", "work/api", "work/release", "code", "code/review", "support", "ideas", "legal", "sales", "design", "finance", "ops", "personal", "release", "meeting", "writing", "bug", "social"];
348
+ const numpadSlots = new Map([
349
+ [0, 1],
350
+ [5, 4],
351
+ [8, 7],
352
+ [22, 2],
353
+ [47, 9]
354
+ ]);
355
+ const examples = [
356
+ { text: "npm run build && git push origin main" },
357
+ { text: "Release notes v2.1.0\n- Shared popup renderer between the app and the website demo\n- Numpad quick-paste reliability on Windows\n- Cloud sync conflict resolution by newest updatedAt then stable id\n- Inline edit, image lightbox, and theme toggle in the live demo" },
358
+ { text: "Yep, found the shape of it. Live prod mitigations first, then clean patch." },
359
+ { text: "https://github.com/tobq/boardclip/issues/new" },
360
+ { text: "Invoice #BC-2048 paid via card ending 1234" },
361
+ { text: "Audio visualizer reference", image: "assets/demo-shot-waveform.svg" },
362
+ { text: "curl -fsSL https://boardclip.sh/install.sh | sh" },
363
+ { text: "Meeting notes: launch site, DNS, update scripts, README polish" },
364
+ { text: "SELECT id, content, created_at FROM clips WHERE pinned = 1;" },
365
+ { text: "Can you send over the repro steps and expected behavior?" },
366
+ { text: "BOARDCLIP_APP_DIR=$HOME/.local/share/boardclip" },
367
+ { text: "Screenshot 2026-05-17 at 13.15.png", image: "assets/demo-shot-dashboard.svg" },
368
+ { text: "API key rotated; update local env before deploy" },
369
+ { text: "OneDrive sync conflict resolved by newest updatedAt then stable id" },
370
+ { text: "Customer reply: happy to extend the trial while we fix this" },
371
+ { text: "Reddit draft: I built a tiny clipboard tray app because I kept losing useful snippets" },
372
+ { text: "C:\\Users\\Tobi\\AppData\\Local\\BoardClip" },
373
+ { text: "Product mockup crop", image: "assets/demo-shot-dashboard.svg" },
374
+ { text: "Brand copy: fast clipboard memory without another cloud account" },
375
+ { text: "Launch checklist: domain, Netlify, TLS, scripts, README" },
376
+ { text: "macOS accessibility permissions need to be granted once" },
377
+ { text: "Fix: Windows key release should not open Win+V after BoardClip handles it" },
378
+ { text: "Architecture diagram", image: "assets/demo-shot-architecture.svg" },
379
+ { text: "Support macro: Could you try updating to the latest build and relaunching?" },
380
+ { text: "PR summary: unify provider detection and remove duplicated sync paths" },
381
+ { text: "npx --yes netlify-cli@latest deploy --prod --dir site" }
382
+ ];
383
+
384
+ // The labels the two rules below miss, on a fitting clip each, so every
385
+ // demo chip opens on clips and never on an empty group.
386
+ const DEMO_EXTRA_GROUPS = new Map([[45, "ideas"], [67, "legal"], [59, "meeting"], [42, "social"]]);
387
+ const seedClips = Array.from({ length: 100 }, (_, index) => {
388
+ const example = examples[index % examples.length];
389
+ const text = example.text;
390
+ const isImage = Boolean(example.image) || /screenshot|image|diagram|png/i.test(text) || index % 17 === 0;
391
+ const pin = {};
392
+ if (numpadSlots.has(index)) pin.number = numpadSlots.get(index);
393
+ if (index % 11 === 0 || index < 4) pin.groups = [labels[index % labels.length]];
394
+ if (index % 23 === 0) pin.groups = [...new Set([...(pin.groups || []), labels[(index * 3 + 4) % labels.length]])];
395
+ if (DEMO_EXTRA_GROUPS.has(index)) pin.groups = [...new Set([...(pin.groups || []), DEMO_EXTRA_GROUPS.get(index)])];
396
+ const hasPin = index % 19 === 0 || index < 2 || Object.keys(pin).length > 0;
397
+ return {
398
+ id: `demo:${index + 1}`,
399
+ title: !isImage && index % 9 === 0 ? ["Release note", "Support reply", "API reminder"][index % 3] : undefined,
400
+ text: isImage && !/png|screenshot|image/i.test(text) ? `Image clip: product-mockup-${String(index + 1).padStart(2, "0")}.png` : text,
401
+ imageSrc: example.image || (isImage ? DEMO_SHOTS[index % DEMO_SHOTS.length] : null),
402
+ width: isImage ? 240 : undefined, // the demo-shot SVGs are 240x86
403
+ height: isImage ? 86 : undefined,
404
+ pin: hasPin ? pin : null,
405
+ type: isImage ? "image" : "text",
406
+ ts: Math.floor(Date.now() / 1000) - (index < 8 ? index : (index + 2) * 60)
407
+ };
408
+ });
409
+ let clips = Core.sortItems(seedClips);
410
+
411
+ // The search box TEXT is the single source of truth (same model as the app): facets live
412
+ // as group:/is:/num:/since: tokens inside `query`; chip state is DERIVED from it.
413
+ const state = { query: "", sortMode: null };
414
+ const facetState = () => Core.search.facetState(Core.search.parseQuery(state.query));
415
+ const installState = { mode: "installer", downloadOS: "win" };
416
+ const clipList = document.getElementById("clip-list");
417
+ const clipCount = document.getElementById("clip-count");
418
+ const search = document.getElementById("demo-search");
419
+ const clearSearch = document.getElementById("demo-clear-search");
420
+ const filterChips = document.getElementById("filter-chips");
421
+ const settingsButton = document.getElementById("demo-settings-button");
422
+ const settingsBack = document.getElementById("demo-settings-back");
423
+ const settingsPanel = document.getElementById("demo-settings");
424
+ const demoMainView = document.getElementById("demo-main-view");
425
+ const demoUsage = document.getElementById("usage");
426
+ const demoNumpadSlots = document.getElementById("numpadSlots");
427
+ const demoGroupSlots = document.getElementById("groupSlots");
428
+ const demoToast = document.getElementById("demo-toast");
429
+ const demoSharedGroups = new Set(["work"]);
430
+ const aiSampleClients = [
431
+ { label: "Claude Desktop", detected: true, enabled: true },
432
+ { label: "Cursor", detected: true, enabled: true },
433
+ { label: "VS Code", detected: true, enabled: false },
434
+ { label: "Claude Code", detected: false, enabled: false },
435
+ { label: "Codex", detected: false, enabled: false },
436
+ ];
437
+ let demoSettingsInit = false;
438
+ let toastTimer = 0;
439
+
440
+ function detectOS() {
441
+ const ua = navigator.userAgent.toLowerCase();
442
+ if (ua.includes("windows")) return "win";
443
+ if (ua.includes("linux")) return "linux";
444
+ return "mac";
445
+ }
446
+
447
+ function detectedDownloadOS() {
448
+ return detectOS();
449
+ }
450
+
451
+ function setDownload(os) {
452
+ const selected = downloads[os] || downloads.win;
453
+ installState.downloadOS = downloads[os] ? os : "win";
454
+ if (installState.mode === "installer") {
455
+ document.getElementById("download-title").textContent = selected.title;
456
+ document.getElementById("download-note").textContent = selected.note;
457
+ }
458
+ const link = document.getElementById("download-link");
459
+ if (selected.disabled) link.removeAttribute("href"); else link.href = selected.url;
460
+ link.toggleAttribute("aria-disabled", !!selected.disabled);
461
+ link.textContent = selected.label;
462
+ document.querySelectorAll("[data-download-os]").forEach((button) => {
463
+ button.classList.toggle("active", button.dataset.downloadOs === os);
464
+ });
465
+ }
466
+
467
+ function setInstallMode(mode) {
468
+ const isCli = mode === "cli";
469
+ installState.mode = isCli ? "cli" : "installer";
470
+ document.querySelectorAll("[data-install-mode]").forEach((button) => {
471
+ button.classList.toggle("active", button.dataset.installMode === installState.mode);
472
+ });
473
+ document.querySelectorAll("[data-mode-panel]").forEach((panel) => {
474
+ panel.hidden = panel.dataset.modePanel !== installState.mode;
475
+ });
476
+ document.querySelectorAll(".cli-only").forEach((element) => {
477
+ element.hidden = !isCli;
478
+ });
479
+ if (isCli) {
480
+ document.getElementById("download-title").textContent = "Install from CLI";
481
+ } else {
482
+ setDownload(installState.downloadOS);
483
+ }
484
+ }
485
+
486
+ function setCommand(kind, os) {
487
+ const scope = kind === "update" ? ".update-os" : ".cli-os";
488
+ document.querySelectorAll(scope).forEach((button) => button.classList.toggle("active", button.dataset.os === os));
489
+ document.getElementById(`${kind}-command`).textContent = commands[kind][os];
490
+ document.getElementById(`${kind}-title`).textContent = osLabels[os];
491
+ }
492
+
493
+ // Rows render through the SAME shared list as the app (Core.createClipList,
494
+ // created below): it keeps your place across every rebuild.
495
+ let visibleRows = [];
496
+ function renderClipRow(clip) {
497
+ return Core.renderClipItem(clip, {
498
+ imageSrc: item => item.imageSrc,
499
+ query: state.query,
500
+ selection: controller ? controller.selection() : null,
501
+ actionsHtml: Core.renderClipActions(clip),
502
+ });
503
+ }
504
+ function renderClips() {
505
+ const visible = Core.filterItems(clips, state);
506
+ clipCount.textContent = Core.itemCountLabel(clips.length, visible.length, state);
507
+ updateClearControls();
508
+ updateDemoSortButton(); // defined below; renderClips first runs at the bottom of this script
509
+ renderFilterChips();
510
+ renderDemoSettings();
511
+ visibleRows = visible;
512
+ demoList.update({
513
+ ids: visible.map((clip) => Core.itemId(clip)),
514
+ tsAt: (i) => Number(visible[i] && visible[i].ts) || 0,
515
+ mode: Core.search.rankMode(Core.search.parseQuery(state.query), state.sortMode),
516
+ queryKey: state.query,
517
+ emptyHtml: () => Core.renderEmptyState({ total: clips.length, query: state.query, groups: labels, nudgeHtml: Core.renderRelaxNudge(Core.search.bestRelaxation(demoSearchDocs(), state.query, { cache: demoRelaxCache })) }),
518
+ });
519
+ }
520
+
521
+ function renderDemoSettings() {
522
+ if (!demoSettingsInit) initDemoSettings();
523
+ const nmap = Core.numpadMap(clips);
524
+ const bytes = clips.reduce((sum, clip) => sum + (clip.text || "").length, 0);
525
+ Core.setSettingHelp(demoUsage, `${clips.length} clips, ${(bytes / 1048576).toFixed(1)} MB used`);
526
+ document.getElementById("conflictsSection").classList.add("hidden");
527
+ // The same shared rows as the app (slot click copies, x unassigns, the
528
+ // switch shares a group with AI: the shared controller's dispatch).
529
+ demoNumpadSlots.innerHTML = Core.renderNumpadSlotRows(nmap, (id) => clips.find((item) => Core.itemId(item) === id));
530
+ document.getElementById("numpadHelp").classList.toggle("hidden", Object.keys(nmap).length >= 9);
531
+ const counts = new Map(labels.map((group) => [group, clips.filter((clip) => Core.isInGroup(clip, group)).length]));
532
+ demoGroupSlots.innerHTML = Core.renderGroupRows(labels, { counts, shared: demoSharedGroups });
533
+ }
534
+
535
+ function aiClientRowHtml(client) {
536
+ return Core.renderSettingsItem({
537
+ title: client.label, dim: !client.detected, sub: client.detected ? "" : "Not detected on this machine",
538
+ toggle: { checked: client.enabled, label: `Install BoardClip in ${client.label}` },
539
+ });
540
+ }
541
+
542
+ // One-time sample data for the OS-only settings sections (sync providers, AI
543
+ // clients, shortcuts, build info, updates). Inert in the browser, but the
544
+ // panel looks exactly like the real app's.
545
+ function initDemoSettings() {
546
+ demoSettingsInit = true;
547
+ const isMac = detectOS() === "mac";
548
+ const mod = isMac ? "⌘" : "Ctrl";
549
+ document.getElementById("autoLaunch").checked = true;
550
+ document.getElementById("quickPasteRestore").checked = true;
551
+ document.getElementById("maxAge").value = 30;
552
+ document.getElementById("maxSize").value = 1.0;
553
+ demoZoom.bindInput(document.getElementById("imagePreviewHeight"), document.getElementById("imagePreviewHeightReset"));
554
+ document.getElementById("shortcutRecord").textContent = `${mod} + Shift + V`;
555
+ document.getElementById("quickPasteRecord").textContent = `${mod} + Shift + B`;
556
+ const providers = [
557
+ { name: "Google Drive", path: isMac ? "~/Library/CloudStorage/GoogleDrive/BoardClip" : "G:\\My Drive\\BoardClip" },
558
+ { name: "OneDrive", path: isMac ? "~/Library/CloudStorage/OneDrive/BoardClip" : "C:\\Users\\you\\OneDrive\\BoardClip" },
559
+ { name: "iCloud Drive", path: isMac ? "~/Library/Mobile Documents/com~apple~CloudDocs/BoardClip" : "C:\\Users\\you\\iCloudDrive\\BoardClip" },
560
+ ];
561
+ document.getElementById("syncAccounts").innerHTML = providers.map((p) => Core.renderSettingsItem({
562
+ title: p.name, sub: p.path, subMono: true, toggle: { checked: true, label: `Sync with ${p.name}` },
563
+ })).join("");
564
+ Core.setSettingHelp(document.getElementById("syncStatus"), "3 of 3 on, syncs on every change and every 30 s", "active");
565
+ document.getElementById("updateBuild").textContent = "Build v2.1.0";
566
+ document.getElementById("updateDetail").textContent = "Up to date";
567
+ document.getElementById("aiAccessEnabled").checked = true;
568
+ document.getElementById("aiAccessBody").classList.remove("hidden");
569
+ document.getElementById("aiTimeout").value = 30;
570
+ document.getElementById("aiClients").innerHTML = aiSampleClients.filter((c) => c.detected).map(aiClientRowHtml).join("");
571
+ const undetected = aiSampleClients.filter((c) => !c.detected);
572
+ document.getElementById("aiClientsMore").innerHTML = undetected.map(aiClientRowHtml).join("");
573
+ const moreBtn = document.getElementById("aiMoreClients");
574
+ moreBtn.classList.remove("hidden");
575
+ moreBtn.textContent = `Show all clients (${undetected.length} more)`;
576
+ Core.setSettingHelp(document.getElementById("aiAccessStatus"), "Running, assistants can connect", "active");
577
+ document.getElementById("buildInfo").textContent = isMac ? "~/Applications/BoardClip" : "C:\\Users\\you\\AppData\\Local\\BoardClip";
578
+ wireDemoSettingsHandlers();
579
+ }
580
+
581
+ function wireDemoSettingsHandlers() {
582
+ const syncToast = () => showToast("Synced");
583
+ document.getElementById("syncNow").addEventListener("click", syncToast);
584
+ document.getElementById("demo-sync-header").addEventListener("click", syncToast);
585
+ document.getElementById("addSyncFolder").addEventListener("click", () => showToast("Pick a folder in the app"));
586
+ document.getElementById("updateNow").addEventListener("click", () => showToast("You're up to date"));
587
+ document.getElementById("copyDiagnostics").addEventListener("click", () => showToast("Diagnostics copied"));
588
+ document.getElementById("aiMoreClients").addEventListener("click", (event) => {
589
+ const more = document.getElementById("aiClientsMore");
590
+ more.classList.toggle("hidden");
591
+ event.currentTarget.textContent = more.classList.contains("hidden") ? `Show all clients (${more.children.length} more)` : "Show fewer clients";
592
+ });
593
+ document.getElementById("aiAccessEnabled").addEventListener("change", (event) => {
594
+ const on = event.target.checked;
595
+ document.getElementById("aiAccessBody").classList.toggle("hidden", !on);
596
+ Core.setSettingHelp(document.getElementById("aiAccessStatus"), on ? "Running, assistants can connect" : "Reads clips in shared groups; deletes and edits ask you first", on ? "active" : "");
597
+ });
598
+ document.getElementById("clearAll").addEventListener("click", () => controller.clearAll());
599
+ document.getElementById("addGroupBtn").addEventListener("click", () => controller.addGroup(null));
600
+ }
601
+
602
+ // Search docs for the demo's clips (rebuilt when `clips` is replaced) feed
603
+ // the same availability census and empty-result nudge as the app.
604
+ let demoDocs = [];
605
+ let demoDocsFor = null;
606
+ const demoRelaxCache = {}; // the nudge refines as words are typed (as in the app)
607
+ function demoSearchDocs() {
608
+ if (demoDocsFor !== clips) { demoDocs = clips.map(Core.search.clipToDoc); demoDocsFor = clips; }
609
+ return demoDocs;
610
+ }
611
+ const demoCensus = Core.createCensusCache({ docs: demoSearchDocs, groups: () => labels, joins: () => demoSearchBox.getJoins() });
612
+ function renderFilterChips() {
613
+ const fs = facetState();
614
+ // A chip that joins or leaves the selection glides to its place.
615
+ Core.flipChildren(filterChips, () => {
616
+ filterChips.innerHTML = Core.renderFilterBar({
617
+ items: clips,
618
+ groups: labels,
619
+ activeFilters: fs.active,
620
+ excludedFilters: fs.excluded,
621
+ query: state.query,
622
+ census: demoCensus.get(state.query),
623
+ joins: demoSearchBox.getJoins(),
624
+ });
625
+ });
626
+ }
627
+
628
+ const activeToast = () => (activeDemoWindow() === clipWinEl ? document.getElementById("demo-clip-toast") : demoToast);
629
+ function showToast(message) {
630
+ const toast = activeToast();
631
+ toast.textContent = message || "Copied";
632
+ toast.classList.add("show");
633
+ window.clearTimeout(toastTimer);
634
+ toastTimer = window.setTimeout(() => toast.classList.remove("show"), 1200);
635
+ }
636
+
637
+ async function copyCommand(id, trigger) {
638
+ const text = document.getElementById(id).textContent.trim();
639
+ if (navigator.clipboard && window.isSecureContext) {
640
+ await navigator.clipboard.writeText(text);
641
+ } else {
642
+ const textarea = document.createElement("textarea");
643
+ textarea.value = text;
644
+ textarea.setAttribute("readonly", "");
645
+ textarea.style.position = "fixed";
646
+ textarea.style.left = "-9999px";
647
+ document.body.appendChild(textarea);
648
+ textarea.select();
649
+ document.execCommand("copy");
650
+ textarea.remove();
651
+ }
652
+ const button = trigger.closest(".command-wrap").querySelector(".command-copy");
653
+ const original = button.textContent;
654
+ button.textContent = "Copied";
655
+ window.setTimeout(() => {
656
+ button.textContent = original;
657
+ }, 1200);
658
+ }
659
+
660
+ function loadImage(src) {
661
+ return new Promise((resolve, reject) => {
662
+ const image = new Image();
663
+ image.crossOrigin = "anonymous";
664
+ image.onload = () => resolve(image);
665
+ image.onerror = reject;
666
+ image.src = src;
667
+ });
668
+ }
669
+
670
+ function canvasToBlob(canvas) {
671
+ return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
672
+ }
673
+
674
+ async function imageBlobForClip(clip) {
675
+ const canvas = document.createElement("canvas");
676
+ canvas.width = 480;
677
+ canvas.height = 280;
678
+ const ctx = canvas.getContext("2d");
679
+ const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
680
+ gradient.addColorStop(0, "#111827");
681
+ gradient.addColorStop(1, "#2563eb");
682
+ ctx.fillStyle = gradient;
683
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
684
+
685
+ try {
686
+ if (clip.imageSrc) {
687
+ const image = await loadImage(clip.imageSrc);
688
+ const scale = Math.max(canvas.width / image.width, canvas.height / image.height);
689
+ const width = image.width * scale;
690
+ const height = image.height * scale;
691
+ ctx.drawImage(image, (canvas.width - width) / 2, (canvas.height - height) / 2, width, height);
692
+ ctx.fillStyle = "rgba(0,0,0,.48)";
693
+ ctx.fillRect(0, canvas.height - 58, canvas.width, 58);
694
+ }
695
+ } catch {
696
+ ctx.fillStyle = "rgba(255,255,255,.12)";
697
+ for (let x = -canvas.height; x < canvas.width; x += 42) {
698
+ ctx.fillRect(x, 0, 18, canvas.height);
699
+ }
700
+ }
701
+
702
+ ctx.fillStyle = "#ffffff";
703
+ ctx.font = "700 22px Segoe UI, sans-serif";
704
+ ctx.fillText("BoardClip image clip", 24, canvas.height - 30);
705
+ ctx.font = "14px Consolas, monospace";
706
+ ctx.fillText((clip.text || "Image").slice(0, 54), 24, canvas.height - 12);
707
+ return canvasToBlob(canvas);
708
+ }
709
+
710
+ async function writeImageClipboard(clip) {
711
+ if (!navigator.clipboard || typeof ClipboardItem === "undefined" || !window.isSecureContext) return false;
712
+ const blob = await imageBlobForClip(clip);
713
+ if (!blob) return false;
714
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
715
+ return true;
716
+ }
717
+
718
+ async function writeBrowserClipboard(text) {
719
+ if (navigator.clipboard && window.isSecureContext) {
720
+ await navigator.clipboard.writeText(text);
721
+ return true;
722
+ }
723
+ const textarea = document.createElement("textarea");
724
+ textarea.value = text;
725
+ textarea.setAttribute("readonly", "");
726
+ textarea.style.position = "fixed";
727
+ textarea.style.left = "-9999px";
728
+ document.body.appendChild(textarea);
729
+ textarea.select();
730
+ const ok = document.execCommand("copy");
731
+ textarea.remove();
732
+ return ok;
733
+ }
734
+
735
+ function addCopiedText(text) {
736
+ const before = clips.length;
737
+ clips = Core.addClipboardText(clips, text);
738
+ if (clips.length !== before || clips[0]?.text === text) renderClips();
739
+ }
740
+
741
+ let relativeTimeTimer = 0;
742
+ function scheduleRelativeTimeRefresh() {
743
+ window.clearTimeout(relativeTimeTimer);
744
+ const delay = Core.updateRelativeTimes(clipList);
745
+ if (delay) relativeTimeTimer = window.setTimeout(scheduleRelativeTimeRefresh, delay);
746
+ }
747
+
748
+ // The clear / sort / mode / options buttons slide in and out with the field's
749
+ // state (Core.attachSearchBox owns that); the demo only keeps their titles.
750
+ function updateClearControls() {
751
+ clearSearch.title = Core.hasActiveFilters({ query: state.query }) ? "Clear search and filters" : "Clear search";
752
+ }
753
+
754
+ function clearSearchAndFilters() {
755
+ state.query = ""; // clears content AND facet tokens (both live in the query)
756
+ state.sortMode = null;
757
+ if (typeof demoSearchBox !== "undefined" && demoSearchBox) demoSearchBox.setQuery("");
758
+ controller.onQueryChange();
759
+ updateClearControls();
760
+ renderClips();
761
+ }
762
+
763
+ document.querySelectorAll(".cli-os").forEach((button) => {
764
+ button.addEventListener("click", () => setCommand("install", button.dataset.os));
765
+ });
766
+ document.querySelectorAll(".update-os").forEach((button) => {
767
+ button.addEventListener("click", () => setCommand("update", button.dataset.os));
768
+ });
769
+ document.querySelectorAll("[data-download-os]").forEach((button) => {
770
+ button.addEventListener("click", () => setDownload(button.dataset.downloadOs));
771
+ });
772
+ document.querySelectorAll("[data-install-mode]").forEach((button) => {
773
+ button.addEventListener("click", () => setInstallMode(button.dataset.installMode));
774
+ });
775
+ const countCopy = (id) => { try { window.AhrefsAnalytics?.sendEvent(id === "update-command" ? "update_copied" : "install_copied"); } catch {} };
776
+ document.querySelectorAll("[data-copy-command]").forEach((control) => {
777
+ control.addEventListener("click", async () => {
778
+ countCopy(control.dataset.copyCommand);
779
+ await copyCommand(control.dataset.copyCommand, control);
780
+ addCopiedText(document.getElementById(control.dataset.copyCommand).textContent.trim());
781
+ });
782
+ control.addEventListener("keydown", (event) => {
783
+ if (event.key === "Enter" || event.key === " ") {
784
+ event.preventDefault();
785
+ countCopy(control.dataset.copyCommand);
786
+ copyCommand(control.dataset.copyCommand, control).then(() => addCopiedText(document.getElementById(control.dataset.copyCommand).textContent.trim()));
787
+ }
788
+ });
789
+ });
790
+
791
+ document.addEventListener("copy", () => {
792
+ const text = String(getSelection() || "").trim();
793
+ if (text) window.setTimeout(() => addCopiedText(text), 0);
794
+ });
795
+
796
+ // ---- Interaction layer ----
797
+ // The demo drives the SAME shared Core.createClipController the desktop app
798
+ // uses, via an adapter backed by the in-memory clips + browser APIs. Click
799
+ // routing, confirm/prompt dialogs, numpad-replace, add-group and keyboard nav
800
+ // therefore behave identically to the app and cannot drift.
801
+ function stripGroupFromClips(group) {
802
+ clips = clips.map((clip) => {
803
+ if (!clip.pin || !Array.isArray(clip.pin.groups)) return clip;
804
+ const groups = clip.pin.groups.filter((label) => label !== group);
805
+ const nextPin = { ...clip.pin };
806
+ if (groups.length) nextPin.groups = groups; else delete nextPin.groups;
807
+ return { ...clip, pin: Object.keys(nextPin).length ? nextPin : null };
808
+ });
809
+ }
810
+ function closeDemoSettings() {
811
+ settingsPanel.classList.remove("show");
812
+ demoMainView.classList.remove("hidden");
813
+ settingsButton.classList.remove("active");
814
+ settingsButton.setAttribute("aria-expanded", "false");
815
+ search.focus();
816
+ }
817
+ // In-memory restore buffer for the demo's delete Undo (mirrors the app's
818
+ // tombstone-clearing restore, but purely local).
819
+ const demoAdapter = {
820
+ dialogs,
821
+ menuHost: demoWindowEl,
822
+ // A menu opens in the window it was opened from (the active one: a press
823
+ // or the keyboard's focus marks it), like its dialogs and toasts.
824
+ menuHostAt: () => activeDemoWindow(),
825
+ deletedToast: "Deleted",
826
+ itemById: (id) => clips.find((item) => Core.itemId(item) === id),
827
+ numpadMap: () => Core.numpadMap(clips),
828
+ protectedGroups: () => new Set(),
829
+ getQuery: () => state.query,
830
+ applyFacet: (token, intent) => demoSearchBox.applyFacet(token, intent),
831
+ setConnective: (dim, conn) => demoSearchBox.setConnective(dim, conn),
832
+ clearFilters: () => clearSearchAndFilters(),
833
+ // The empty-result nudge rewrites the whole query (one filter dropped).
834
+ setQuery: (next) => {
835
+ state.query = String(next || "");
836
+ if (typeof demoSearchBox !== "undefined" && demoSearchBox) demoSearchBox.setQuery(state.query);
837
+ updateClearControls();
838
+ controller.onQueryChange();
839
+ },
840
+ focusSearch: () => search.focus(),
841
+ closeSearchOptions: () => demoSearchBox.closeOptions(),
842
+ pin: (id) => { clips = Core.togglePin(clips, id); },
843
+ numpadAssign: (id, slot) => { clips = Core.assignNumpad(clips, id, slot); },
844
+ numpadUnassign: (slot) => {
845
+ clips = clips.map((clip) => {
846
+ if (Core.numpadOf(clip) !== slot) return clip;
847
+ const pin = { ...clip.pin }; delete pin.number;
848
+ return { ...clip, pin: Object.keys(pin).length ? pin : null };
849
+ });
850
+ },
851
+ toggleGroup: (id, group) => { if (!labels.includes(group)) labels.push(group); clips = Core.toggleGroup(clips, id, group); }, // matches the app's applyGroupAssign (toggles)
852
+ createGroup: (name) => { if (!labels.includes(name)) labels.push(name); },
853
+ deleteGroup: (group) => {
854
+ labels = labels.filter((l) => l !== group);
855
+ state.query = Core.search.stripFacet(state.query, { kind: "group", value: group });
856
+ if (typeof demoSearchBox !== "undefined" && demoSearchBox) demoSearchBox.setQuery(state.query);
857
+ stripGroupFromClips(group);
858
+ },
859
+ deleteClip: (id) => { clips = Core.deleteItem(clips, id); },
860
+ clearUnpinned: () => { clips = clips.filter((clip) => Core.isPinned(clip)); },
861
+ setGroupSharedAi: (group) => { if (demoSharedGroups.has(group)) demoSharedGroups.delete(group); else demoSharedGroups.add(group); },
862
+ copyNumpadSlot: async (id) => {
863
+ const clip = demoAdapter.itemById(id);
864
+ if (!clip) return;
865
+ if (clip.type === "image") { const ok = await writeImageClipboard(clip).catch(() => false); if (!ok) await writeBrowserClipboard(clip.text || ""); }
866
+ else await writeBrowserClipboard(clip.text || "");
867
+ },
868
+ activateClip: async (id) => {
869
+ const clip = demoAdapter.itemById(id);
870
+ if (!clip) return;
871
+ if (clip.type === "image") { const ok = await writeImageClipboard(clip).catch(() => false); if (!ok) await writeBrowserClipboard(clip.text || ""); }
872
+ else await writeBrowserClipboard(clip.text || "");
873
+ clips = Core.touchItem(clips, id);
874
+ showToast("Copied");
875
+ renderClips();
876
+ },
877
+ editClip: (id, _itemEl, options) => openDemoEditor(id, { ...Core.editorFindFor(state.query), ...(options || {}) }),
878
+ // The open editor's title field is authoritative for its clip (it rides
879
+ // every commit), so a rename from its menu goes through it, as in the app.
880
+ setClipTitle: (id, title) => {
881
+ if (demoEditor && id === demoEditorClipId) { demoEditor.setTitle(title); demoEditor.commit(); return; }
882
+ const t = (title || "").trim(); clips = clips.map((item) => Core.itemId(item) === id ? { ...item, title: t || undefined, titleUpdatedAt: Date.now() } : item);
883
+ },
884
+ // The editor menu's "Revert to original": the editor's own revert (Undo in its toast).
885
+ revertClip: () => { if (demoEditor) demoEditor.revert(); },
886
+ openImage: (item) => openDemoViewer(item),
887
+ openImageExternal: (item) => { if (item && item.imageSrc) window.open(item.imageSrc, "_blank"); },
888
+ // A web page cannot hand the OS a file: drag the image's URL (most apps
889
+ // and browsers fetch it on drop) plus Chrome's DownloadURL (drop on the
890
+ // desktop/a folder saves it).
891
+ dragImages: (ids, event) => {
892
+ const shots = ids.map((id) => demoAdapter.itemById(id)).filter((clip) => clip && clip.imageSrc);
893
+ if (!shots.length || !event.dataTransfer) return false;
894
+ const urls = shots.map((clip) => new URL(clip.imageSrc, location.href).href);
895
+ const name = (shots[0].title || shots[0].text || "boardclip-image").replace(/\.[a-z0-9]{1,5}$/i, "").replace(/[^\w.-]+/g, "-").slice(0, 40);
896
+ event.dataTransfer.setData("text/uri-list", urls.join("\r\n"));
897
+ event.dataTransfer.setData("text/plain", urls.join("\n"));
898
+ event.dataTransfer.setData("DownloadURL", `image/svg+xml:${name}.svg:${urls[0]}`);
899
+ event.dataTransfer.effectAllowed = "copy";
900
+ return true;
901
+ },
902
+ saveImage: (item) => {
903
+ if (!item || !item.imageSrc) return null;
904
+ const link = document.createElement("a");
905
+ link.href = item.imageSrc;
906
+ link.download = (item.text || "boardclip-image").replace(/[^\w.-]+/g, "-").slice(0, 40) + ".svg";
907
+ document.body.appendChild(link); link.click(); link.remove();
908
+ return "Saved to Downloads";
909
+ },
910
+ isSettingsOpen: () => settingsPanel.classList.contains("show"),
911
+ closeSettings: () => closeDemoSettings(),
912
+ hidePopup: () => {},
913
+ // --- Multi-select contract (selection state lives in the controller) ---
914
+ visibleIds: () => demoList.ids(),
915
+ firstVisibleId: () => demoList.firstVisibleId(),
916
+ allItems: () => clips,
917
+ groupNames: () => labels,
918
+ renderSelection: (selState, opts) => demoList.paintSelection(selState, opts),
919
+ deleteClips: (ids) => {
920
+ const set = new Set(ids);
921
+ // Deleting the open editor's clip closes it (no closing commit), as the app's window does.
922
+ if (demoEditor && set.has(demoEditorClipId)) closeDemoEditor();
923
+ if (demoViewerId && set.has(demoViewerId)) closeDemoViewer();
924
+ const snaps = clips.filter((clip) => set.has(Core.itemId(clip))).map((clip) => ({ ...clip }));
925
+ clips = clips.filter((clip) => !set.has(Core.itemId(clip)));
926
+ return snaps;
927
+ },
928
+ restoreClips: (snaps) => {
929
+ const present = new Set(clips.map((clip) => Core.itemId(clip)));
930
+ clips = Core.sortItems([...clips, ...snaps.filter((snap) => !present.has(Core.itemId(snap)))]);
931
+ },
932
+ groupAssignMany: (ids, group, shouldHave) => {
933
+ if (!labels.includes(group)) labels.push(group);
934
+ for (const id of ids) {
935
+ const clip = clips.find((item) => Core.itemId(item) === id);
936
+ if (clip && Core.isInGroup(clip, group) !== shouldHave) clips = Core.toggleGroup(clips, id, group);
937
+ }
938
+ },
939
+ pasteMany: async (ids) => {
940
+ // Text-only join, matching the app's pasteMany (images can't concatenate).
941
+ const texts = ids
942
+ .map((id) => clips.find((item) => Core.itemId(item) === id))
943
+ .filter((clip) => clip && clip.type !== "image")
944
+ .map((clip) => clip.text || "");
945
+ try { await writeBrowserClipboard(texts.join("\n")); } catch {}
946
+ showToast(`Pasted ${texts.length}`);
947
+ },
948
+ startUnify: (ids) => runDemoUnify(ids),
949
+ offerUndo: ({ count, undo }) => Core.showActionToast(activeToast(), {
950
+ message: `Deleted ${count} clip${count === 1 ? "" : "s"}`,
951
+ actionLabel: "Undo",
952
+ onAction: undo,
953
+ }),
954
+ toast: (msg) => showToast(msg),
955
+ // Commit-on-add for the demo editor's tag strip on a NEW note: commit the
956
+ // draft through the shared editor commit (which records the created id).
957
+ ensureClipId: async () => {
958
+ if (!demoEditor) return null;
959
+ await Promise.resolve(demoEditor.commit());
960
+ return demoEditorClipId;
961
+ },
962
+ render: () => renderClips(),
963
+ // Mutations repaint the list AND the open editor's tag strip (no-op when closed).
964
+ refresh: () => { renderClips(); refreshClipWindowTags(); },
965
+ };
966
+ const controller = Core.createClipController(demoAdapter);
967
+ const demoList = Core.createClipList({
968
+ listEl: clipList,
969
+ newestEl: document.getElementById("listNewest"),
970
+ barEl: document.getElementById("selectionBar"),
971
+ renderRow: (i) => renderClipRow(visibleRows[i]),
972
+ controller,
973
+ onRendered: () => scheduleRelativeTimeRefresh(),
974
+ });
975
+ // Image preview height (Ctrl+wheel / Ctrl+=/-/0 / the Settings row), kept
976
+ // per visitor in localStorage like the demo's other display choices.
977
+ const DEMO_IMAGE_HEIGHT_KEY = "boardclip-demo-image-height";
978
+ let demoImageHeight = null;
979
+ try { demoImageHeight = localStorage.getItem(DEMO_IMAGE_HEIGHT_KEY); } catch {}
980
+ const demoZoom = Core.createImageZoom({
981
+ root: demoWindowEl,
982
+ clipList: demoList,
983
+ initial: demoImageHeight == null ? undefined : Number(demoImageHeight),
984
+ save: (px) => { try { localStorage.setItem(DEMO_IMAGE_HEIGHT_KEY, String(px)); } catch {} },
985
+ toast: (msg) => showToast(msg),
986
+ });
987
+ demoAdapter.imageZoom = demoZoom;
988
+
989
+ // One delegated click + contextmenu binding for the WHOLE popup window (list,
990
+ // filters, settings, AND the popover menu which mounts inside the window),
991
+ // mirroring the app's document-level binding so the shared dispatch handles
992
+ // everything once.
993
+ demoWindowEl.addEventListener("click", (event) => controller.onClick(event));
994
+ demoWindowEl.addEventListener("mousedown", (event) => controller.onMousedown(event));
995
+ demoWindowEl.addEventListener("mouseup", (event) => controller.onMouseup(event));
996
+ demoWindowEl.addEventListener("auxclick", (event) => controller.onAuxclick(event));
997
+ demoWindowEl.addEventListener("contextmenu", (event) => controller.onContextmenu(event));
998
+ demoWindowEl.addEventListener("wheel", (event) => controller.onWheel(event), { passive: false });
999
+ demoWindowEl.addEventListener("dragstart", (event) => controller.onDragstart(event));
1000
+ demoWindowEl.addEventListener("mouseover", (event) => controller.onMouseover(event));
1001
+ demoWindowEl.addEventListener("mouseout", (event) => controller.onMouseout(event));
1002
+
1003
+ // The shared search box, same as the app popup: live highlighting,
1004
+ // autocomplete, the options panel and the Regex toggle (it types /.../ for
1005
+ // you). The panel height, the Regex toggle and the remembered or / and are
1006
+ // kept per visitor in localStorage (the app keeps them per device).
1007
+ const DEMO_OPTIONS_HEIGHT_KEY = "boardclip-demo-options-height";
1008
+ const DEMO_REGEX_KEY = "boardclip-demo-regex";
1009
+ const DEMO_JOINS_KEY = "boardclip-demo-joins";
1010
+ const DEMO_FIND_PREFS_KEY = "boardclip-demo-find";
1011
+ let demoOptionsHeight = 0;
1012
+ let demoRegex = false;
1013
+ let demoJoins = null;
1014
+ try { demoOptionsHeight = Number(localStorage.getItem(DEMO_OPTIONS_HEIGHT_KEY)) || 0; } catch {}
1015
+ try {
1016
+ // The hour-old search mode folds into the Regex toggle (then goes).
1017
+ const oldMode = localStorage.getItem("boardclip-demo-search-mode");
1018
+ if (oldMode !== null) {
1019
+ if (oldMode === "regex" && localStorage.getItem(DEMO_REGEX_KEY) === null) localStorage.setItem(DEMO_REGEX_KEY, "1");
1020
+ localStorage.removeItem("boardclip-demo-search-mode");
1021
+ }
1022
+ demoRegex = localStorage.getItem(DEMO_REGEX_KEY) === "1";
1023
+ demoJoins = JSON.parse(localStorage.getItem(DEMO_JOINS_KEY) || "null");
1024
+ } catch {}
1025
+ const demoSearchBox = Core.attachSearchBox(search, {
1026
+ regex: demoRegex,
1027
+ saveRegex: (on) => { try { localStorage.setItem(DEMO_REGEX_KEY, on ? "1" : "0"); } catch {} },
1028
+ joins: demoJoins,
1029
+ saveJoins: (joins) => { try { localStorage.setItem(DEMO_JOINS_KEY, JSON.stringify(joins)); } catch {} },
1030
+ getGroups: () => labels,
1031
+ getCensus: (parsed) => demoCensus.get(parsed),
1032
+ // The Best / Recent override ends with the search (an emptied box, however it was emptied).
1033
+ onChange: (v) => { state.query = v; if (!v.trim()) state.sortMode = null; controller.onQueryChange(); renderClips(); },
1034
+ optionsHeight: demoOptionsHeight,
1035
+ saveOptionsHeight: (px) => { try { localStorage.setItem(DEMO_OPTIONS_HEIGHT_KEY, String(px)); } catch {} },
1036
+ });
1037
+ // The popup is a window on the page (the shared Core.attachWebWindow): its
1038
+ // header moves it (a click focuses the search, as in the app), its edges
1039
+ // resize it, its close leaves the dock's BoardClip icon to open it again,
1040
+ // as the app's tray icon does. No maximise: the app's popup has none.
1041
+ const popupWin = Core.attachWebWindow(demoWindowEl, { minWidth: 320, minHeight: 320 });
1042
+ popupWin.handle(demoMainView.querySelector(".sticky"), { onClick: () => search.focus() });
1043
+ popupWin.handle(settingsPanel.querySelector(".settings-hdr"), {});
1044
+ // The dock (the demo's taskbar) shows only when it has something to
1045
+ // offer: the popup is closed (its BoardClip icon opens it again) or a clip
1046
+ // window is open or minimised (its button brings it to the front or back
1047
+ // from the dock). With just the open popup there is nothing to show.
1048
+ const demoDock = document.getElementById("demo-dock");
1049
+ const demoDockSlot = document.getElementById("demo-dock-slot");
1050
+ const dockApp = document.getElementById("demo-dock-app");
1051
+ demoDock.dataset.os = Core.webWindowOs();
1052
+ let popupShown = true;
1053
+ let clipShown = false;
1054
+ function updateDock() {
1055
+ const show = !popupShown || clipShown;
1056
+ demoDockSlot.classList.toggle("show", show);
1057
+ demoDockSlot.toggleAttribute("inert", !show);
1058
+ dockApp.classList.toggle("running", popupShown);
1059
+ if (clipDockItem) clipDockItem.classList.toggle("running", clipShown && !clipWin.isMinimized());
1060
+ }
1061
+ function closePopup() {
1062
+ controller.closeMenu();
1063
+ popupShown = false;
1064
+ updateDock();
1065
+ popupWin.close();
1066
+ }
1067
+ function openPopup() {
1068
+ popupShown = true;
1069
+ updateDock();
1070
+ if (popupWin.isOpen()) popupWin.focus(); else popupWin.open();
1071
+ search.focus({ preventScroll: true });
1072
+ }
1073
+ document.getElementById("demo-close-button").addEventListener("click", closePopup);
1074
+ document.getElementById("demo-settings-close").addEventListener("click", closePopup);
1075
+ dockApp.addEventListener("click", openPopup);
1076
+ // An example search on a closed popup opens it first.
1077
+ document.addEventListener("click", (event) => {
1078
+ if (event.target.closest && event.target.closest("[data-try]") && !popupWin.isOpen()) openPopup();
1079
+ }, true);
1080
+ clearSearch.addEventListener("click", () => {
1081
+ clearSearchAndFilters();
1082
+ search.focus();
1083
+ });
1084
+ // Best⇄Recent toggle: same behavior as the app (visible while searching).
1085
+ const demoSortBtn = document.getElementById("demo-sort");
1086
+ function updateDemoSortButton() {
1087
+ if (!state.query.trim()) return; // hidden without a query (Core.attachSearchBox)
1088
+ Core.paintSortButton(demoSortBtn, state.sortMode || "best");
1089
+ }
1090
+ demoSortBtn.addEventListener("click", () => {
1091
+ state.sortMode = (state.sortMode || "best") === "best" ? "new" : "best";
1092
+ renderClips();
1093
+ search.focus();
1094
+ });
1095
+ settingsButton.addEventListener("click", () => {
1096
+ const open = !settingsPanel.classList.contains("show");
1097
+ settingsPanel.classList.toggle("show", open);
1098
+ demoMainView.classList.toggle("hidden", open);
1099
+ settingsButton.classList.toggle("active", open);
1100
+ settingsButton.setAttribute("aria-expanded", String(open));
1101
+ });
1102
+ settingsBack.addEventListener("click", closeDemoSettings);
1103
+
1104
+ // Keyboard nav (arrows/Enter/Escape + multi-select) when focus is inside the
1105
+ // demo window, so the demo honors its own "type, press Enter, and paste" pitch.
1106
+ demoWindowEl.addEventListener("keydown", (event) => controller.onKeydown(event));
1107
+
1108
+ // ONE clip window (the editor, the image viewer, Unify), a window on the
1109
+ // page like the app's own windows: the shared Core.attachWebWindow moves,
1110
+ // resizes, maximises, minimises (into the dock) and closes it, with the
1111
+ // visitor's OS controls and motion. Opening another clip replaces what it
1112
+ // shows; it opens where the visitor last left it.
1113
+ const clipBody = document.getElementById("demo-clipwin-body");
1114
+ let clipDockItem = null;
1115
+ let clipView = { icon: "edit_note", title: "" };
1116
+ let clipMounts = 0; // a late close never clears a newer view
1117
+ let clipPlaced = false;
1118
+ const clipWin = Core.attachWebWindow(clipWinEl, {
1119
+ minWidth: 320,
1120
+ minHeight: 240,
1121
+ maximizable: true,
1122
+ dockTarget: () => clipDockItem,
1123
+ onMinimize: () => updateDock(),
1124
+ onRestore: () => updateDock(),
1125
+ });
1126
+ clipWinEl.addEventListener("click", (event) => controller.onClick(event));
1127
+ // The viewer's Ctrl/Cmd + = / - / 0 zoom the image (the app's main claims them), never the page.
1128
+ clipWinEl.addEventListener("keydown", (event) => {
1129
+ const act = demoViewer ? Core.imageZoomKey(event) : null;
1130
+ if (act) { event.preventDefault(); demoViewer.zoomKey(act); }
1131
+ });
1132
+ Core.installSubmenuAutoflip(clipWinEl);
1133
+ // The clip window's dock button lives while the window does (open or
1134
+ // minimised): it brings the window back from the dock, or to the front.
1135
+ function syncClipDockItem() {
1136
+ if (!clipDockItem) {
1137
+ clipDockItem = document.createElement("button");
1138
+ clipDockItem.type = "button";
1139
+ clipDockItem.className = "dock-item dock-win";
1140
+ clipDockItem.addEventListener("click", () => { if (clipWin.isMinimized()) clipWin.restore(); else clipWin.focus(); });
1141
+ demoDock.appendChild(clipDockItem);
1142
+ }
1143
+ clipDockItem.title = clipView.title;
1144
+ clipDockItem.setAttribute("aria-label", clipView.title);
1145
+ clipDockItem.innerHTML = `<span class="mi lg">${clipView.icon}</span>`;
1146
+ }
1147
+ function removeClipDockItem() {
1148
+ if (clipDockItem) clipDockItem.remove();
1149
+ clipDockItem = null;
1150
+ }
1151
+ function showClipWindow(viewEl, view) {
1152
+ clipMounts += 1;
1153
+ clipView = view;
1154
+ clipBody.innerHTML = "";
1155
+ clipBody.appendChild(viewEl);
1156
+ clipWin.handle(viewEl.querySelector(".bc-bar"), { ignore: "input.bc-bar-title" });
1157
+ clipShown = true;
1158
+ syncClipDockItem();
1159
+ updateDock();
1160
+ if (clipWin.isMinimized()) { clipWin.restore(); return; }
1161
+ if (clipWin.isOpen()) { clipWin.focus(); return; }
1162
+ clipWin.open();
1163
+ if (clipPlaced) return;
1164
+ clipPlaced = true; // first open: cascaded from the popup, as an OS places a new window
1165
+ const p = popupWin.bounds();
1166
+ const width = Math.min(480, window.innerWidth - 32);
1167
+ const stageLeft = clipWinEl.parentElement.getBoundingClientRect().left;
1168
+ const left = Math.max(16 - stageLeft, Math.min(p.left + 36, window.innerWidth - stageLeft - width - 16));
1169
+ clipWin.place({ left, top: p.top + 36, width, height: 420 });
1170
+ }
1171
+ function hideClipWindow() {
1172
+ const mounts = clipMounts;
1173
+ clipShown = false;
1174
+ removeClipDockItem();
1175
+ updateDock();
1176
+ if (popupWin.isOpen()) search.focus({ preventScroll: true });
1177
+ clipWin.close().then(() => { if (mounts === clipMounts) clipBody.innerHTML = ""; });
1178
+ }
1179
+ // What the window shows now goes (its edits saved) before another clip opens there.
1180
+ function retireClipView() {
1181
+ if (demoEditor) { demoEditor.commit(); demoEditor.destroy(); }
1182
+ demoEditor = null;
1183
+ demoEditorClipId = null;
1184
+ demoViewer = null;
1185
+ demoViewerId = null;
1186
+ }
1187
+ let demoViewer = null;
1188
+ let demoViewerId = null;
1189
+ function closeDemoViewer() {
1190
+ demoViewer = null;
1191
+ demoViewerId = null;
1192
+ hideClipWindow();
1193
+ }
1194
+ // Images open in the shared image viewer (Core.createImageViewer), as the app's viewer window.
1195
+ function openDemoViewer(item) {
1196
+ if (!item || !item.imageSrc) return;
1197
+ retireClipView();
1198
+ const id = Core.itemId(item);
1199
+ const title = Core.titleOf(item) || "Image";
1200
+ demoViewer = Core.createImageViewer({
1201
+ src: item.imageSrc,
1202
+ title,
1203
+ onMenu: (x, y) => controller.openClipMenu(id, x, y, "viewer"),
1204
+ onClose: closeDemoViewer,
1205
+ });
1206
+ demoViewerId = id;
1207
+ showClipWindow(demoViewer.el, { icon: "image", title });
1208
+ demoViewer.setTags(item);
1209
+ demoViewer.focus();
1210
+ }
1211
+ function refreshClipWindowTags() {
1212
+ refreshDemoEditorTags();
1213
+ if (demoViewer && demoViewerId) {
1214
+ const clip = clips.find((item) => Core.itemId(item) === demoViewerId);
1215
+ if (clip) demoViewer.setTags(clip);
1216
+ }
1217
+ }
1218
+ // Right-click on the editor chrome (not the text, where the browser menu
1219
+ // belongs) opens the same clip menu as its "...", like the app editor window.
1220
+ clipBody.addEventListener("contextmenu", (event) => {
1221
+ if (!demoEditor || demoEditorClipId == null || !event.target.closest(".bc-editor")) return;
1222
+ if (event.target.closest(".bc-editor-area, input, textarea")) return;
1223
+ event.preventDefault();
1224
+ event.stopPropagation();
1225
+ controller.openClipMenu(demoEditorClipId, event.clientX, event.clientY, "editor");
1226
+ });
1227
+ let demoEditor = null;
1228
+ let demoEditorClipId = null; // current clip behind the open editor (null = uncommitted new note)
1229
+ function closeDemoEditor() {
1230
+ if (demoEditor) demoEditor.destroy();
1231
+ demoEditor = null;
1232
+ demoEditorClipId = null;
1233
+ hideClipWindow();
1234
+ }
1235
+ // Title-bar tag strip on the demo editor - same shared strip as the app's
1236
+ // editor window; commit-on-add works because ensureClipId (below) commits
1237
+ // the draft through the same shared editor commit path.
1238
+ function refreshDemoEditorTags() {
1239
+ if (!demoEditor) return;
1240
+ const clip = demoEditorClipId != null ? clips.find((item) => Core.itemId(item) === demoEditorClipId) : null;
1241
+ demoEditor.setTags(clip, { allowAdd: true });
1242
+ }
1243
+ function openDemoEditor(id, options = {}) {
1244
+ const clip = id != null ? clips.find((item) => Core.itemId(item) === id) : null;
1245
+ if (id != null && (!clip || clip.type === "image")) return;
1246
+ let currentId = id;
1247
+ let currentText = clip ? (clip.text || "") : "";
1248
+ let currentTitle = clip ? Core.titleOf(clip) : "";
1249
+ const sourceGroups = id == null ? Core.sourceGroupsFromFilters(facetState().active) : [];
1250
+ sourceGroups.forEach((group) => { if (!labels.includes(group)) labels.push(group); });
1251
+ retireClipView();
1252
+ demoEditorClipId = id;
1253
+ demoEditor = Core.createEditor({
1254
+ initialText: currentText,
1255
+ initialTitle: currentTitle,
1256
+ initialFind: options.find || "",
1257
+ initialFindRegex: options.findRegex,
1258
+ // (a visitor's old { mode: 'regex' } reads as Regex on)
1259
+ findPrefs: (() => { try { const p = JSON.parse(localStorage.getItem(DEMO_FIND_PREFS_KEY) || "{}") || {}; return { regex: !!p.regex || p.mode === "regex", caseSensitive: !!p.caseSensitive }; } catch { return {}; } })(),
1260
+ saveFindPrefs: (prefs) => { try { localStorage.setItem(DEMO_FIND_PREFS_KEY, JSON.stringify(prefs)); } catch {} },
1261
+ initialFocusTitle: !!options.focusTitle,
1262
+ windowDrag: clipWin.move,
1263
+ title: clip ? "Edit clip" : "New clip",
1264
+ onCommit: (payload) => {
1265
+ const nextText = String(payload && payload.text || "");
1266
+ const nextTitle = Core.titleOf({ title: payload && payload.title });
1267
+ if (nextText === currentText && nextTitle === currentTitle) return;
1268
+ if (currentId != null) {
1269
+ clips = clips.map((item) => Core.itemId(item) === currentId ? { ...item, text: nextText, title: nextTitle || undefined, titleUpdatedAt: Date.now() } : item);
1270
+ clips = Core.touchItem(clips, currentId);
1271
+ } else if (nextText.trim()) {
1272
+ clips = Core.addClipboardText(clips, nextText);
1273
+ const created = clips.find((item) => item.text === nextText);
1274
+ if (created) {
1275
+ created.title = nextTitle || undefined;
1276
+ created.titleUpdatedAt = Date.now();
1277
+ sourceGroups.forEach((group) => { if (!Core.groupsOf(created).includes(group)) clips = Core.toggleGroup(clips, Core.itemId(created), group); });
1278
+ }
1279
+ if (created) currentId = Core.itemId(created);
1280
+ }
1281
+ currentText = nextText;
1282
+ currentTitle = nextTitle;
1283
+ demoEditorClipId = currentId;
1284
+ refreshDemoEditorTags();
1285
+ renderClips();
1286
+ },
1287
+ onClose: closeDemoEditor,
1288
+ toastEl: document.getElementById("demo-clip-toast"),
1289
+ // "..." (and a right-click on the bar) = the app editor's clip menu,
1290
+ // through the popup's controller in the editor context.
1291
+ ...(id != null ? { onMenu: (x, y) => { if (demoEditorClipId != null) controller.openClipMenu(demoEditorClipId, x, y, "editor"); } } : {}),
1292
+ });
1293
+ showClipWindow(demoEditor.el, { icon: "edit_note", title: currentTitle || (clip ? "Edit clip" : "New note") });
1294
+ demoEditor.focus();
1295
+ refreshDemoEditorTags();
1296
+ }
1297
+ document.getElementById("demo-new-note").addEventListener("click", () => openDemoEditor(null));
1298
+
1299
+ // Unify (demo): fold the selected text clips oldest->newest through the SAME
1300
+ // shared reconciliation view the app uses, in the editor overlay. Atomic -
1301
+ // closing any step aborts with zero changes; the final step replaces all
1302
+ // sources with one merged clip carrying the union of their groups.
1303
+ function clipSnapshot(clip) {
1304
+ return { id: Core.itemId(clip), type: "text", title: Core.titleOf(clip), text: clip.text || "", groups: Core.groupsOf(clip) };
1305
+ }
1306
+ function runDemoUnify(ids) {
1307
+ const sources = ids.map((id) => clips.find((c) => Core.itemId(c) === id)).filter(Boolean)
1308
+ .filter((c) => c.type !== "image")
1309
+ .sort((a, b) => (a.ts || 0) - (b.ts || 0));
1310
+ if (sources.length < 2) return;
1311
+ const total = sources.length - 1;
1312
+ const unionGroups = new Set(sources.flatMap(Core.groupsOf));
1313
+ let acc = clipSnapshot(sources[0]);
1314
+ let step = 0;
1315
+ retireClipView();
1316
+ const abort = () => hideClipWindow();
1317
+ function commit() {
1318
+ const srcIds = new Set(sources.map((c) => Core.itemId(c)));
1319
+ clips = clips.filter((c) => !srcIds.has(Core.itemId(c)));
1320
+ clips = Core.addClipboardText(clips, acc.text);
1321
+ const created = clips.find((c) => c.text === acc.text);
1322
+ if (created) {
1323
+ created.title = acc.title || undefined;
1324
+ created.titleUpdatedAt = Date.now();
1325
+ for (const g of unionGroups) { if (!labels.includes(g)) labels.push(g); if (!Core.groupsOf(created).includes(g)) clips = Core.toggleGroup(clips, Core.itemId(created), g); }
1326
+ }
1327
+ abort();
1328
+ renderClips();
1329
+ showToast(`Unified ${sources.length} clips`);
1330
+ }
1331
+ function mountStep() {
1332
+ const right = clipSnapshot(sources[step + 1]);
1333
+ const view = Core.createReconciliationView({
1334
+ record: {
1335
+ id: `demo-unify-${step}`,
1336
+ unify: true,
1337
+ title: "Unify clips",
1338
+ step: { at: step + 1, of: total },
1339
+ saveLabel: step + 1 >= total ? "Merge & finish" : "Merge & continue",
1340
+ left: acc,
1341
+ right,
1342
+ result: { title: acc.title || right.title, text: acc.text },
1343
+ },
1344
+ onResolve: (resolution) => {
1345
+ acc = { ...acc, text: resolution.text, title: resolution.title };
1346
+ step += 1;
1347
+ if (step >= total) commit(); else mountStep();
1348
+ },
1349
+ onClose: abort,
1350
+ windowDrag: clipWin.move,
1351
+ });
1352
+ showClipWindow(view.el, { icon: "merge", title: "Unify clips" });
1353
+ }
1354
+ mountStep();
1355
+ }
1356
+
1357
+
1358
+
1359
+ const detectedOS = detectOS();
1360
+ const themeQuery = matchMedia("(prefers-color-scheme: dark)");
1361
+ let demoThemeMode = "system"; // system | light | dark - chosen via Settings > Theme
1362
+ function applyDemoTheme() {
1363
+ for (const root of demoRoots) Core.applyTheme(root, demoThemeMode, themeQuery.matches);
1364
+ Core.setActiveThemeSeg(document.getElementById("themeMode"), demoThemeMode);
1365
+ }
1366
+ document.getElementById("themeMode").addEventListener("click", (event) => {
1367
+ const btn = event.target.closest("[data-theme-mode]");
1368
+ if (!btn) return;
1369
+ demoThemeMode = btn.dataset.themeMode;
1370
+ applyDemoTheme();
1371
+ });
1372
+ // Appearance: the app's Settings > Appearance controls, bound by the same
1373
+ // Core.mountSettings and painted by the same Core.applyAppearance, kept per
1374
+ // visitor in localStorage. "System" is the browser's accent where it shares
1375
+ // one (CSS AccentColor), else Blue, like an OS that has none.
1376
+ const DEMO_VARIANT_KEY = "boardclip-demo-variants";
1377
+ const DEMO_LOOK_KEYS = {
1378
+ surface_style: ["surfaceStyle", ["auto", "glass", "solid"]],
1379
+ glass_scope: ["glassScope", ["popup", "all"]],
1380
+ ui_density: ["uiDensity", ["normal", "compact"]],
1381
+ ui_corners: ["uiCorners", ["soft", "sharp"]],
1382
+ accent_mode: ["accentMode", ["system", "blue", "teal", "mono", "custom"]],
1383
+ };
1384
+ const demoLook = { surfaceStyle: "auto", glassScope: "popup", uiDensity: "normal", uiCorners: "soft", accentMode: "system", accentCustom: "" };
1385
+ try {
1386
+ const stored = JSON.parse(localStorage.getItem(DEMO_VARIANT_KEY) || "{}") || {};
1387
+ for (const [field, allowed] of Object.values(DEMO_LOOK_KEYS)) if (allowed.includes(stored[field])) demoLook[field] = stored[field];
1388
+ demoLook.accentCustom = Core.normalizeHexColor(stored.accentCustom) || "";
1389
+ } catch {}
1390
+ function browserAccent() {
1391
+ try {
1392
+ if (!window.CSS || !CSS.supports("color", "AccentColor")) return null;
1393
+ const probe = document.createElement("span");
1394
+ probe.style.color = "AccentColor";
1395
+ document.body.appendChild(probe);
1396
+ const rgb = (getComputedStyle(probe).color.match(/\d+/g) || []).slice(0, 3);
1397
+ probe.remove();
1398
+ return rgb.length === 3 ? Core.normalizeHexColor(rgb.map((n) => Number(n).toString(16).padStart(2, "0")).join("")) : null;
1399
+ } catch { return null; }
1400
+ }
1401
+ const demoSystemAccent = browserAccent();
1402
+ function demoAppearance() {
1403
+ const mode = demoLook.accentMode;
1404
+ return {
1405
+ surfaceStyle: demoLook.surfaceStyle,
1406
+ accentVariant: ["blue", "teal", "mono"].includes(mode) ? mode : "blue",
1407
+ accentColor: mode === "custom" ? demoLook.accentCustom || null : mode === "system" ? demoSystemAccent : null,
1408
+ uiDensity: demoLook.uiDensity,
1409
+ uiCorners: demoLook.uiCorners,
1410
+ glassScope: demoLook.glassScope,
1411
+ };
1412
+ }
1413
+ const demoSettingsUi = Core.mountSettings(settingsPanel, {
1414
+ save(body) {
1415
+ for (const [key, value] of Object.entries(body)) {
1416
+ if (key === "accent_custom") demoLook.accentCustom = Core.normalizeHexColor(value) || "";
1417
+ else if (DEMO_LOOK_KEYS[key] && DEMO_LOOK_KEYS[key][1].includes(value)) demoLook[DEMO_LOOK_KEYS[key][0]] = value;
1418
+ }
1419
+ try { localStorage.setItem(DEMO_VARIANT_KEY, JSON.stringify(demoLook)); } catch {}
1420
+ for (const root of demoRoots) Core.applyAppearance(root, demoAppearance());
1421
+ demoSettingsUi.update(demoLook);
1422
+ },
1423
+ preview: (look) => { for (const root of demoRoots) Core.applyAppearance(root, { ...demoAppearance(), ...look }); },
1424
+ });
1425
+ demoSettingsUi.update({ ...demoLook, systemAccent: demoSystemAccent, glassSupported: true });
1426
+ for (const root of demoRoots) Core.applyAppearance(root, demoAppearance());
1427
+ themeQuery.addEventListener("change", applyDemoTheme);
1428
+ applyDemoTheme();
1429
+ setDownload(detectedDownloadOS());
1430
+ setInstallMode(location.hash === "#update" || detectedDownloadOS() === "mac" ? "cli" : "installer");
1431
+ setCommand("install", detectedOS);
1432
+ setCommand("update", detectedOS);
1433
+ if (location.hash === "#update") {
1434
+ requestAnimationFrame(() => document.getElementById("update")?.scrollIntoView());
1435
+ }
1436
+ // ── The guide: when a visitor moves the mouse while the demo is in view, a
1437
+ // cursor shows it working (types a search, clicks a filter, right-clicks a
1438
+ // clip for its menu, clears it) through the demo's own controls, so what it
1439
+ // shows is what a click does. The rest of the page dims as it starts and
1440
+ // eases back over 10 s. Any input of the visitor's (or any click it did
1441
+ // not make) stops a run, and it comes back, waiting longer each time,
1442
+ // until the visitor has done a few things in the demo themselves. No tour
1443
+ // on touch screens or under reduced motion.
1444
+ const demoColumn = document.querySelector(".demo");
1445
+ const demoGuide = document.getElementById("demo-guide");
1446
+ const demoGuideTip = document.getElementById("demo-guide-tip");
1447
+ const demoDim = document.getElementById("demo-dim");
1448
+ const PLAYED_ENOUGH = 3; // the visitor's own presses / keys in the demo
1449
+ const TOUR_AGAIN_MS = 12000; // the wait before a second run (doubles each run)
1450
+ const TOUR_IDLE_MS = 8000; // nor while the visitor is busy in the demo
1451
+ const tour = { timer: 0, wake: null, run: 0, runs: 0, running: false, pending: false, acting: false, inView: false, left: "", played: 0, lastOwn: 0, nextAt: 0 };
1452
+ const tourAllowed = () => !matchMedia("(prefers-reduced-motion: reduce)").matches && matchMedia("(hover: hover) and (pointer: fine)").matches;
1453
+ const tourWait = (ms) => {
1454
+ const run = tour.run;
1455
+ return new Promise((resolve) => { tour.wake = resolve; tour.timer = setTimeout(resolve, ms); }).then(() => tour.running && tour.run === run);
1456
+ };
1457
+ // The spotlight follows the popup while it shows (a scroll or a resize moves it).
1458
+ let spotUntil = 0;
1459
+ let focusAnim = null;
1460
+ function aimSpot() {
1461
+ const r = demoWindowEl.getBoundingClientRect();
1462
+ demoDim.style.setProperty("--spot-x", `${Math.round(r.left + r.width / 2)}px`);
1463
+ demoDim.style.setProperty("--spot-y", `${Math.round(r.top + r.height / 2)}px`);
1464
+ demoDim.style.setProperty("--spot-r", `${Math.round(Math.max(r.width, r.height) * 0.95)}px`);
1465
+ }
1466
+ const reaim = () => { if (Date.now() < spotUntil) aimSpot(); };
1467
+ window.addEventListener("scroll", reaim, { passive: true });
1468
+ window.addEventListener("resize", reaim);
1469
+ // The popup comes forward with the light: it grows a little, holds, then
1470
+ // settles back with the page over the same 10 s. A transform on the
1471
+ // popup itself, never on an ancestor of the windows (a maximised window is
1472
+ // position: fixed, which a transformed ancestor would capture).
1473
+ function focusDemo() {
1474
+ if (focusAnim) focusAnim.cancel();
1475
+ focusAnim = demoWindowEl.animate([
1476
+ { transform: "none", easing: "cubic-bezier(.2, .8, .2, 1)" },
1477
+ { transform: "scale(1.06)", offset: 0.09 },
1478
+ { transform: "scale(1.06)", offset: 0.13, easing: "cubic-bezier(.4, 0, .2, 1)" },
1479
+ { transform: "none" },
1480
+ ], { duration: 11400 });
1481
+ const mine = focusAnim;
1482
+ mine.finished.then(() => { if (focusAnim === mine) focusAnim = null; }, () => {});
1483
+ }
1484
+ // A handover settles it quickly but smoothly: a click already on its way
1485
+ // must still land on the control it was aimed at.
1486
+ function unfocusDemo() {
1487
+ if (!focusAnim) return;
1488
+ const from = getComputedStyle(demoWindowEl).transform;
1489
+ focusAnim.cancel();
1490
+ focusAnim = from && from !== "none" ? demoWindowEl.animate([{ transform: from }, { transform: "none" }], { duration: 600, easing: "cubic-bezier(.2, .8, .2, 1)" }) : null;
1491
+ }
1492
+ function dimPage() {
1493
+ aimSpot();
1494
+ spotUntil = Date.now() + 12000;
1495
+ demoDim.classList.remove("quick");
1496
+ demoDim.classList.add("on");
1497
+ focusDemo();
1498
+ setTimeout(() => demoDim.classList.remove("on"), 1400); // then its 10 s ease back
1499
+ }
1500
+ function undimPage() {
1501
+ demoDim.classList.add("quick");
1502
+ demoDim.classList.remove("on");
1503
+ spotUntil = Date.now() + 800;
1504
+ unfocusDemo();
1505
+ }
1506
+ // Glide the cursor to a point of el (at: offset in el, else its centre), with
1507
+ // a tip under it; returns that point in viewport coordinates.
1508
+ function guideTo(el, tip, at) {
1509
+ const host = demoColumn.getBoundingClientRect();
1510
+ const r = el.getBoundingClientRect();
1511
+ const x = r.left + (at ? at.x : r.width / 2);
1512
+ const y = r.top + (at ? at.y : r.height / 2);
1513
+ demoGuide.style.transform = `translate(${Math.round(x - host.left)}px, ${Math.round(y - host.top)}px)`;
1514
+ demoGuideTip.textContent = tip;
1515
+ return { x, y };
1516
+ }
1517
+ function guideClick(run) {
1518
+ demoGuide.classList.remove("click");
1519
+ void demoGuide.offsetWidth; // restart the pulse
1520
+ demoGuide.classList.add("click");
1521
+ tour.acting = true;
1522
+ try { run(); } finally { tour.acting = false; }
1523
+ tour.left = state.query; // what the tour has left in the demo
1524
+ }
1525
+ // A run ends: the cursor goes, and a demo the tour left mid-way (a
1526
+ // half-typed search, a filter, an open menu) is put back, so the visitor's
1527
+ // own input lands on a clean demo (it runs after this: a capture
1528
+ // listener). handover = the visitor took over: the page undims at once.
1529
+ // The next run waits twice as long as the last.
1530
+ function endTour(handover) {
1531
+ if (!tour.running) return;
1532
+ tour.running = false;
1533
+ tour.run += 1;
1534
+ clearTimeout(tour.timer);
1535
+ if (tour.wake) tour.wake();
1536
+ demoGuide.classList.remove("show");
1537
+ if (handover) undimPage();
1538
+ if (tour.left && state.query === tour.left) {
1539
+ tour.acting = true;
1540
+ try { controller.closeMenu(); clearSearchAndFilters(); } finally { tour.acting = false; }
1541
+ }
1542
+ tour.left = "";
1543
+ tour.nextAt = Date.now() + TOUR_AGAIN_MS * 2 ** Math.max(0, tour.runs - 1);
1544
+ }
1545
+ async function runTour(from) {
1546
+ tour.running = true;
1547
+ tour.runs += 1;
1548
+ popupWin.focus(); // in front of a clip window, so what it shows is seen
1549
+ dimPage();
1550
+ // Appear where the visitor's mouse is (kept inside the demo column), then glide.
1551
+ const host = demoColumn.getBoundingClientRect();
1552
+ demoGuide.classList.add("jump");
1553
+ demoGuide.style.transform = `translate(${Math.round(Math.min(Math.max(from.x - host.left, 0), host.width - 40))}px, ${Math.round(Math.min(Math.max(from.y - host.top, 0), host.height - 40))}px)`;
1554
+ demoGuideTip.textContent = "";
1555
+ void demoGuide.offsetWidth;
1556
+ demoGuide.classList.remove("jump");
1557
+ demoGuide.classList.add("show");
1558
+ if (!(await tourWait(200))) return;
1559
+ guideTo(search, "Type to search", { x: 30, y: search.getBoundingClientRect().height / 2 });
1560
+ if (!(await tourWait(1100))) return;
1561
+ let typed = "";
1562
+ for (const ch of "group:work") {
1563
+ typed += ch;
1564
+ guideClick(() => { search.value = typed; search.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ch })); });
1565
+ if (!(await tourWait(90))) return;
1566
+ }
1567
+ if (!(await tourWait(1300))) return;
1568
+ const chip = document.querySelector('#filter-chips [data-filter="__images__"]:not([aria-disabled="true"])');
1569
+ if (chip) {
1570
+ guideTo(chip, "Click a filter");
1571
+ if (!(await tourWait(1000))) return;
1572
+ guideClick(() => chip.click());
1573
+ if (!(await tourWait(1500))) return;
1574
+ }
1575
+ const row = document.querySelector("#clip-list .item");
1576
+ if (row) {
1577
+ const at = guideTo(row, "Right-click a clip for its menu", { x: Math.min(150, row.getBoundingClientRect().width / 2), y: 20 });
1578
+ if (!(await tourWait(1100))) return;
1579
+ guideClick(() => row.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2, clientX: at.x, clientY: at.y })));
1580
+ demoGuideTip.textContent = ""; // the menu opens under the cursor: the tip would cover it
1581
+ if (!(await tourWait(2200))) return;
1582
+ guideClick(() => controller.closeMenu());
1583
+ }
1584
+ guideTo(clearSearch, "Now you try");
1585
+ if (!(await tourWait(1000))) return;
1586
+ guideClick(() => clearSearchAndFilters()); // the X's own action, minus its focus (the tour never takes the keyboard)
1587
+ if (!(await tourWait(1600))) return;
1588
+ endTour(false);
1589
+ }
1590
+ new IntersectionObserver((entries) => { tour.inView = entries.some((e) => e.isIntersecting); }, { threshold: 0.6 }).observe(demoWindowEl);
1591
+ window.addEventListener("pointermove", (event) => {
1592
+ if (tour.running || tour.pending || tour.played >= PLAYED_ENOUGH || !event.isTrusted || event.pointerType !== "mouse") return;
1593
+ const now = Date.now();
1594
+ if (now < tour.nextAt || now - tour.lastOwn < TOUR_IDLE_MS || !tour.inView || !tourAllowed() || !popupWin.isOpen() || clipWin.isMaximized()) return;
1595
+ tour.pending = true;
1596
+ const from = { x: event.clientX, y: event.clientY };
1597
+ tour.timer = setTimeout(() => {
1598
+ tour.pending = false;
1599
+ if (tour.inView && popupWin.isOpen() && tour.played < PLAYED_ENOUGH && Date.now() - tour.lastOwn >= TOUR_IDLE_MS) runTour(from);
1600
+ }, 600);
1601
+ }, { passive: true });
1602
+ // Hands off: the visitor's own input in the demo (or on an example search)
1603
+ // counts toward having played and ends a run; so does any click the tour
1604
+ // did not make, or leaving the page.
1605
+ for (const type of ["pointerdown", "keydown", "contextmenu", "click", "wheel", "focusin"]) {
1606
+ document.addEventListener(type, (event) => {
1607
+ if (tour.acting || !event.isTrusted) return;
1608
+ const inDemo = demoColumn.contains(event.target) || !!(event.target.closest && event.target.closest("[data-try]"));
1609
+ if (inDemo) {
1610
+ tour.lastOwn = Date.now();
1611
+ if (type === "pointerdown" || type === "keydown") {
1612
+ tour.played += 1;
1613
+ if (tour.played === 1) { try { window.AhrefsAnalytics?.sendEvent("demo_used"); } catch {} } // once per visit
1614
+ }
1615
+ if (tour.pending) { clearTimeout(tour.timer); tour.pending = false; }
1616
+ }
1617
+ if (tour.running && (inDemo || (type !== "wheel" && type !== "focusin"))) endTour(true);
1618
+ }, { capture: true, passive: true });
1619
+ }
1620
+ document.addEventListener("visibilitychange", () => { if (document.hidden) endTour(true); });
1621
+ renderClips();
1622
+ </script>
1623
+ </body>
1624
+ </html>