helldots 0.6.0 → 0.8.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 CHANGED
@@ -53,6 +53,51 @@ overlay.loadComments(stored);
53
53
  Every comment is plain JSON — pass `serializeComments()` output straight to
54
54
  your API and hand it back to `loadComments()` later.
55
55
 
56
+ `loadComments()` is safe to call at any point: made before the widget has
57
+ mounted, the data is held and applied at mount — though the counts come back
58
+ as zeroes, because nothing has been resolved against the DOM yet. Use
59
+ `onReady` when you want them:
60
+
61
+ ```js
62
+ createCommentOverlay({
63
+ onReady: async (overlay) => {
64
+ const { orphaned } = overlay.loadComments(await api.get("/comments"));
65
+ if (orphaned) console.info(`${orphaned} comments lost their element`);
66
+ },
67
+ });
68
+ ```
69
+
70
+ ### Loading only the comment in a link
71
+
72
+ A corpus too big to ship on every page load can be fetched per page — but
73
+ then a shared "Copy link" URL points at a comment that is not in the set.
74
+ `onCommentRequested` fires for exactly that case, once per id:
75
+
76
+ ```js
77
+ const overlay = createCommentOverlay({
78
+ onCommentRequested: async (id) => {
79
+ overlay.loadComments([await api.get(`/comments/${id}`)]);
80
+ },
81
+ });
82
+
83
+ overlay.loadComments(await api.get(`/comments?page=${location.pathname}`));
84
+ ```
85
+
86
+ Return a promise and the link is retried once it settles — the inbox opens on
87
+ the comment as soon as it lands. Until then it says the comment was not
88
+ found, rather than doing nothing.
89
+
90
+ To read the id yourself before any of this exists — to fetch that one comment
91
+ and nothing else — the package exports the reader the widget uses, so the
92
+ parameter name never has to be written twice:
93
+
94
+ ```js
95
+ import { readCommentLinkParam, DEFAULT_LINK_PARAM } from "helldots";
96
+
97
+ const id = readCommentLinkParam(); // defaults to DEFAULT_LINK_PARAM
98
+ const only = id ? [await api.get(`/comments/${id}`)] : [];
99
+ ```
100
+
56
101
  ### Server-rendered apps
57
102
 
58
103
  Importing the package on the server is safe — nothing touches the DOM at
@@ -141,6 +186,137 @@ Three ways out, cheapest first:
141
186
  - **Leave it.** Captures of such a page stay misaligned where text is
142
187
  concerned; everything else about them is correct.
143
188
 
189
+ ### When a capture is slow
190
+
191
+ A screenshot is not a screenshot. The browser does not hand that to
192
+ JavaScript, so the renderer re-creates the page instead: it clones the DOM,
193
+ reads every element's computed style, inlines all of it, serialises the
194
+ result into an SVG `<foreignObject>` and rasterises that.
195
+
196
+ Reading the styles is the render. A browser exposes around 527 computed
197
+ properties per element, and the renderer reads all of them — on a page with
198
+ a few thousand live nodes that is hundreds of thousands of property reads,
199
+ and it is ~91% of the cost. Two things follow, in this order:
200
+
201
+ - **Nothing waits for it.** Clicking or dragging places the marker and opens
202
+ the comment box immediately; the render runs behind it and the images drop
203
+ in when they land. A dragged region shows a "Capturing…" slot in the
204
+ attachment strip until its crop arrives, and Send waits for it if you get
205
+ there first. This is always on; there is nothing to configure.
206
+ - **The page no longer freezes while it happens.** The capture hands the
207
+ main thread back to the browser every 8 ms, so the page keeps painting and
208
+ accepting keystrokes even on a render that runs for a second. Also always
209
+ on — and it is what makes the point above worth anything, since a box you
210
+ can see but cannot type into is not much better than no box.
211
+ - **`fastCapture: true`** narrows those reads to a curated list of the
212
+ properties that change a pixel — measured at ~2.7x off that phase on a
213
+ 12 000-element page, and pixel-identical to a full capture on the pages it
214
+ was verified against.
215
+
216
+ `fastCapture` is off by default because the list is a fidelity contract, and
217
+ no list is provably complete for a page this library has never seen: a
218
+ property it does not name is simply absent from the image. Turn it on for a
219
+ heavy page, look at one capture before trusting it, and open an issue if
220
+ something comes out wrong — the fix is one more entry in the list.
221
+
222
+ There is a third lever if your page embeds same-origin iframes.
223
+ **`skipIframeContent: true`** renders them blank instead of cloning what is
224
+ inside. An iframe's cost is invisible from the outside — the renderer walks
225
+ into the frame and clones its whole document, so a page that reports 242
226
+ elements can be a capture of 9 245. Measured at 2374 ms against 82 ms on one
227
+ 9 000-node embedded frame.
228
+
229
+ The `<iframe>` element itself is kept: its box, its border, the space it
230
+ occupies. That matters more than it sounds — removing the element instead
231
+ would slide everything below it up by the frame's height while the crop is
232
+ still taken at live page coordinates, which puts the bottom of every capture
233
+ out of register.
234
+
235
+ A cross-origin frame has nothing to gain here. The renderer cannot read into
236
+ it, so it is already blank in the output, and contrary to a common guess it
237
+ does not stall or wait on one either.
238
+
239
+ What none of them touches is rasterisation, which is a single browser
240
+ operation with no JavaScript inside it. On a very long page that stays a
241
+ few hundred milliseconds of unavoidable work.
242
+
243
+ ### When one dead asset holds a capture up
244
+
245
+ To inline the page's images and fonts the renderer re-fetches them, and gives
246
+ each one 30 seconds before giving up. A URL that never answers stalls the
247
+ capture until that fires. The capture still succeeds — that asset becomes a
248
+ transparent placeholder — but it waits first.
249
+
250
+ The wait is bounded rather than multiplied: one dead asset and ten cost the
251
+ same, because they are waited on concurrently. What it is not is one times the
252
+ timeout. The setting drives two waits in sequence on the same asset — first
253
+ for the image already on the page to finish loading, then for the fetch that
254
+ inlines it — so the real cost is a consistent ~2x. The 30 second default is
255
+ therefore about a minute.
256
+
257
+ **`captureTimeout: 5000`** cuts that to roughly ten seconds. It is left at
258
+ the default because lowering it trades a slow capture for a silently
259
+ incomplete one: an asset that was only slow, rather than dead, gets dropped
260
+ and leaves a hole with nothing to say so. Since these are assets your page
261
+ has already loaded, most come from cache instantly and the tail is exactly
262
+ the large or uncached ones you would be wrong to drop. Set it if you have
263
+ measured your own page and decided which way you would rather it failed.
264
+
265
+ ### Very long pages
266
+
267
+ Browsers cap how large a canvas can be — 65 535 pixels in a dimension in
268
+ Chromium, less in Firefox, and a separate and much lower area cap on mobile
269
+ Safari. A page past that cap cannot be rendered at full scale, so HellDots
270
+ fits the scale to what the browser will actually paint and checks that the
271
+ result holds pixels before using it.
272
+
273
+ Nothing below the cap changes. Past it the capture goes soft in proportion:
274
+ a 68 000px page renders at 0.96, a 140 000px page at 0.47. If even the
275
+ smallest attempt comes back empty, the capture fails through `onError`
276
+ rather than attaching a blank image.
277
+
278
+ ### Keeping screenshots out of your database
279
+
280
+ Every image is stored as a base64 data URL inside the record — around 33 KB
281
+ for the automatic capture alone. That is the first thing shed when
282
+ localStorage hits its quota, and in your own backend it means a 33 KB string
283
+ per comment in whatever column holds the JSON.
284
+
285
+ `transformScreenshot` is where you swap it for a URL:
286
+
287
+ ```js
288
+ createCommentOverlay({
289
+ transformScreenshot: async (dataUrl, { kind, commentId }) => {
290
+ const blob = await (await fetch(dataUrl)).blob();
291
+ const { url } = await api.upload(blob, { kind, commentId });
292
+ return url; // stored in place of the data URL
293
+ },
294
+ });
295
+ ```
296
+
297
+ It runs for every image the widget acquires: the automatic capture
298
+ (`kind: "context"`), a drag-crop region, and anything attached through the
299
+ file picker on a comment or a reply (`kind: "attachment"`). The two kinds
300
+ exist so the disposable one and the deliberate one can go to different
301
+ buckets.
302
+
303
+ **It runs at two different moments, and `kind` does not tell them apart.**
304
+ Everything on a comment transforms when the comment is saved; an attachment
305
+ on a _reply_ transforms when the file is picked, because `addReply()` is
306
+ synchronous and cannot wait on your upload. So a reply attachment can be
307
+ uploaded and then never referenced — the user closes the popover without
308
+ sending — and a comment can do the same in a narrower window, by being
309
+ dismissed while its upload is still in flight. Sweep for unreferenced blobs;
310
+ a URL you were handed is not a promise that a record will point at it.
311
+
312
+ It is **fail-open**. If your upload rejects — or resolves to anything that is
313
+ not a non-empty string — the original data URL is kept and you get
314
+ `onError(error, "transform")`. You receive a large record rather than losing
315
+ somebody's comment.
316
+
317
+ Not called for records you pass to `loadComments()`, nor for screenshots you
318
+ hand to `addReply()` yourself: in both cases the strings are already yours.
319
+
144
320
  ## Identity
145
321
 
146
322
  HellDots authenticates nobody. It takes whoever your app says is signed in
@@ -335,6 +511,14 @@ overlay.exportMetricsCsv(); // helldots-metrics.csv — section, key, value
335
511
  overlay.printMetricsReport(); // the browser's print dialog → Save as PDF
336
512
  ```
337
513
 
514
+ Both CSV methods **return the same text they download**, so a host that wanted
515
+ to send those rows somewhere instead of handing the user a file does not have
516
+ to build them a second time:
517
+
518
+ ```js
519
+ await api.post("/reports/comments", { csv: overlay.exportCommentsCsv() });
520
+ ```
521
+
338
522
  The CSVs are RFC 4180 with a UTF-8 BOM, so Excel opens them without turning
339
523
  every accent into mojibake, and a value that would otherwise be evaluated as a
340
524
  formula is neutralised on the way out. Headers are the internal field names
@@ -374,19 +558,28 @@ OS: iOS 17.2
374
558
 
375
559
  ## Options
376
560
 
377
- | Option | Type | Default | |
378
- | ----------------------- | -------------------------------- | ------------------- | ----------------------------------------------------------------- |
379
- | `user` | `{ name: string, id?: string }` | `"Anonymous"` | Author of new comments and replies; `id` persists as `authorId` |
380
- | `persistence` | `"localStorage"` \| `"none"` | `"none"` | Auto save/restore, or handle it yourself via callbacks |
381
- | `autoScreenshot` | `boolean` | `true` | Capture a screenshot and environment snapshot per comment |
382
- | `embedCrossOriginFonts` | `boolean` | `false` | Fetch unreadable stylesheets so their web fonts reach the capture |
383
- | `locale` | `string` | browser language | `"en"` and `"es"` ship; anything else falls back per key |
384
- | `linkParam` | `string` | `"helldotsComment"` | Query param used by "Copy link" URLs |
385
- | `navigate` | `(page: string) => void` | full page load | SPA router hook for the widget's cross-page jumps |
386
- | `autoDetectNavigation` | `boolean` | `false` | Run `notifyNavigation()` on popstate (back/forward) |
387
- | `shortcutKey` | `string` | `"c"` | Key that toggles comment mode |
388
- | `shortcutModifier` | `"alt"` \| `"ctrl"` \| `"shift"` | `"alt"` | Modifier for that key |
389
- | `autoInit` | `boolean` | `true` | When `false`, returns an initializer to call yourself |
561
+ | Option | Type | Default | |
562
+ | ----------------------- | ------------------------------------ | ------------------- | ----------------------------------------------------------------- |
563
+ | `user` | `{ name: string, id?: string }` | `"Anonymous"` | Author of new comments and replies; `id` persists as `authorId` |
564
+ | `persistence` | `"localStorage"` \| `"none"` | `"none"` | Auto save/restore, or handle it yourself via callbacks |
565
+ | `autoScreenshot` | `boolean` | `true` | Capture a screenshot and environment snapshot per comment |
566
+ | `embedCrossOriginFonts` | `boolean` | `false` | Fetch unreadable stylesheets so their web fonts reach the capture |
567
+ | `fastCapture` | `boolean` | `false` | Read a curated style list instead of all ~527 computed properties |
568
+ | `skipIframeContent` | `boolean` | `false` | Render embedded documents as blank instead of cloning them |
569
+ | `captureTimeout` | `number` | `30000` | Milliseconds one remote asset may hold a capture up |
570
+ | `locale` | `string` | browser language | `"en"` and `"es"` ship; anything else falls back per key |
571
+ | `linkParam` | `string` | `"helldotsComment"` | Query param used by "Copy link" URLs |
572
+ | `navigate` | `(page: string) => void` | full page load | SPA router hook for the widget's cross-page jumps |
573
+ | `onReady` | `(overlay) => void` | | Widget mounted; the safe place to `loadComments()` |
574
+ | `onError` | `(error, context) => void` | — | A survivable failure — capture, storage, load, link or transform |
575
+ | `onCommentRequested` | `(id) => void \| Promise` | — | A link points at a comment the widget does not hold |
576
+ | `transformScreenshot` | `(dataUrl, info) => Promise<string>` | — | Swap every image the widget acquires for a string of your own |
577
+ | `onCommentModeChanged` | `(active: boolean) => void` | — | Comment mode turned on or off, however it was flipped |
578
+ | `onCommentOpened` | `(comment) => void` | — | Somebody opened a comment's thread — build unread counts on this |
579
+ | `autoDetectNavigation` | `boolean` | `false` | Run `notifyNavigation()` on popstate (back/forward) |
580
+ | `shortcutKey` | `string` | `"c"` | Key that toggles comment mode |
581
+ | `shortcutModifier` | `"alt"` \| `"ctrl"` \| `"shift"` | `"alt"` | Modifier for that key |
582
+ | `autoInit` | `boolean` | `true` | When `false`, returns an initializer to call yourself |
390
583
 
391
584
  ### Callbacks
392
585
 
@@ -410,18 +603,108 @@ TypeScript narrows the payload. The specific callbacks below carry the same
410
603
  events at the same moments — subscribe either way, or both. A handler that
411
604
  throws is caught and warned about, never rolling back the change.
412
605
 
413
- | Callback | Fires when |
414
- | ----------------------------------- | -------------------------------------------------------------- |
415
- | `onCommentCreated(comment)` | A new comment is saved |
416
- | `onReplyAdded(comment, reply)` | A reply is added to any comment |
417
- | `onReplyDeleted(comment, reply)` | A reply is removed |
418
- | `onCommentEdited(comment)` | A comment's text is rewritten |
419
- | `onReplyEdited(comment, reply)` | A reply's text is rewritten |
420
- | `onCommentStatusChanged(comment)` | Status moves along the lifecycle |
421
- | `onCommentUpdated(comment)` | Type, priority or tags change |
422
- | `onCommentDeleted(id)` | A comment is removed |
423
- | `onAnchorLost(comment)` | A comment could not be re-anchored on load |
424
- | `onReactionToggled(comment, reply)` | A reaction is added or removed (`reply` is `null` at the root) |
606
+ Every one of them ends with a `meta` argument (the same fields are flattened
607
+ onto the `onChange` event), so an existing handler that ignores it keeps
608
+ working unchanged.
609
+
610
+ | Callback | Fires when |
611
+ | ----------------------------------------- | -------------------------------------------------------------- |
612
+ | `onCommentCreated(comment, meta)` | A new comment is saved |
613
+ | `onReplyAdded(comment, reply, meta)` | A reply is added to any comment |
614
+ | `onReplyDeleted(comment, reply, meta)` | A reply is removed |
615
+ | `onCommentEdited(comment, meta)` | A comment's text is rewritten |
616
+ | `onReplyEdited(comment, reply, meta)` | A reply's text is rewritten |
617
+ | `onCommentStatusChanged(comment, meta)` | Status moves along the lifecycle |
618
+ | `onCommentUpdated(comment, meta)` | Type, priority or tags change |
619
+ | `onCommentDeleted(id, meta)` | A comment is removed |
620
+ | `onAnchorLost(comment, meta)` | A comment could not be re-anchored |
621
+ | `onReactionToggled(comment, reply, meta)` | A reaction is added or removed (`reply` is `null` at the root) |
622
+
623
+ Five more do not report a change to a comment:
624
+
625
+ | Callback | Fires when |
626
+ | ------------------------------ | --------------------------------------------------------------- |
627
+ | `onReady(overlay)` | The widget has mounted and every method is safe to call |
628
+ | `onError(error, context)` | Something survivable went wrong — see below |
629
+ | `onCommentRequested(id)` | A link points at a comment the widget does not hold — see below |
630
+ | `onCommentModeChanged(active)` | Comment mode turned on or off — see below |
631
+ | `onCommentOpened(comment)` | Somebody opened a comment's thread — see below |
632
+
633
+ #### `meta.origin` — who caused the change
634
+
635
+ `"user"` is somebody acting inside the widget; `"host"` is your own code
636
+ calling a method. The inbox and the thread popover drive the very same public
637
+ methods you do, so this is the only thing that tells the two apart.
638
+
639
+ It matters as soon as more than one person is looking. Applying a change that
640
+ arrived over a socket means calling `setCommentStatus()` — which emits, which
641
+ sends it straight back to the server:
642
+
643
+ ```js
644
+ createCommentOverlay({
645
+ onChange: (event) => {
646
+ if (event.origin === "host") return; // our own write, echoed back
647
+ api.post("/helldots-events", event);
648
+ },
649
+ });
650
+
651
+ socket.on("comment:resolved", ({ id }) =>
652
+ overlay.setCommentStatus(id, "resolved")
653
+ );
654
+ ```
655
+
656
+ Without the guard that loop runs forever. `comment:anchor-lost` is always
657
+ `"host"` too, so this also silences the repeat every `notifyNavigation()`
658
+ produces for a comment whose element is not on the new page.
659
+
660
+ #### `meta.from` / `meta.to` — what moved
661
+
662
+ `comment:status-changed` carries both ends of the move, and `comment:updated`
663
+ adds `field` to say which of the three it was about:
664
+
665
+ ```js
666
+ onCommentStatusChanged: (comment, { from, to }) => {
667
+ if (from === "resolved") notify(`${comment.author} reopened this`);
668
+ },
669
+ onCommentUpdated: (comment, meta) => {
670
+ if (meta.field === "priority" && meta.to === "high") page(comment);
671
+ },
672
+ ```
673
+
674
+ `field` narrows `from` and `to` for you in TypeScript. Re-applying a value a
675
+ comment already holds is a no-op: no event, no write.
676
+
677
+ #### `onCommentModeChanged` and `onCommentOpened`
678
+
679
+ Comment mode is the one the host cannot observe on its own: the keyboard
680
+ shortcut never reaches your code, so an app that has to stand down while
681
+ somebody is picking an element has no other signal.
682
+
683
+ ```js
684
+ onCommentModeChanged: (active) => {
685
+ carousel.paused = active; // your drag-and-drop would fight the picker
686
+ },
687
+ ```
688
+
689
+ `onCommentOpened` fires when a thread is actually read — from its marker or
690
+ from the inbox detail, the only two places the replies are visible. It does
691
+ not fire when the inbox merely re-renders. HellDots stores no read state of
692
+ its own, because whose "read" it is depends on an identity only you can
693
+ persist:
694
+
695
+ ```js
696
+ onCommentOpened: (comment) => api.post(`/comments/${comment.id}/read`),
697
+ ```
698
+
699
+ #### `onError(error, context)`
700
+
701
+ Failures the widget survives but you would otherwise only find in the
702
+ console: `"capture"` (a screenshot did not render — the comment saves without
703
+ one), `"storage"` (localStorage could not be written, so this browser's copy
704
+ now diverges), `"load"` (a malformed record was skipped), `"link"` (an
705
+ `onCommentRequested` handler threw or rejected), `"transform"` (a
706
+ `transformScreenshot` handler failed, so the data URL was kept). The console
707
+ warning stays either way.
425
708
 
426
709
  ## API
427
710
 
@@ -447,9 +730,22 @@ overlay.setCommentPriority(id, priority); // → boolean
447
730
  overlay.setCommentTags(id, tags); // → boolean
448
731
  overlay.toggleCommentReaction(id, emoji); // → boolean
449
732
  overlay.toggleReplyReaction(commentId, replyId, emoji); // → boolean
733
+ overlay.setUser(user); // → boolean (null returns to the anonymous author)
734
+ overlay.exportCommentsCsv(comments?); // → string (and downloads it)
735
+ overlay.exportMetricsCsv(comments?); // → string (and downloads it)
450
736
  overlay.cleanup(); // remove the widget entirely
451
737
  ```
452
738
 
739
+ Two module-level helpers come with the package, for reading a deep link
740
+ before an overlay exists:
741
+
742
+ ```ts
743
+ import { readCommentLinkParam, DEFAULT_LINK_PARAM } from "helldots";
744
+
745
+ DEFAULT_LINK_PARAM; // "helldotsComment"
746
+ readCommentLinkParam(param?, href?); // → string | null
747
+ ```
748
+
453
749
  The setters return `false` for an unknown id or an invalid value, and make no
454
750
  change when they do. To reconcile against a backend after remote deletions,
455
751
  call `clearComments()` and then `loadComments(freshData)` — `loadComments`