helldots 0.6.0 → 0.7.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,48 @@ 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
+ ### Keeping screenshots out of your database
190
+
191
+ Every image is stored as a base64 data URL inside the record — around 33 KB
192
+ for the automatic capture alone. That is the first thing shed when
193
+ localStorage hits its quota, and in your own backend it means a 33 KB string
194
+ per comment in whatever column holds the JSON.
195
+
196
+ `transformScreenshot` is where you swap it for a URL:
197
+
198
+ ```js
199
+ createCommentOverlay({
200
+ transformScreenshot: async (dataUrl, { kind, commentId }) => {
201
+ const blob = await (await fetch(dataUrl)).blob();
202
+ const { url } = await api.upload(blob, { kind, commentId });
203
+ return url; // stored in place of the data URL
204
+ },
205
+ });
206
+ ```
207
+
208
+ It runs for every image the widget acquires: the automatic capture
209
+ (`kind: "context"`), a drag-crop region, and anything attached through the
210
+ file picker on a comment or a reply (`kind: "attachment"`). The two kinds
211
+ exist so the disposable one and the deliberate one can go to different
212
+ buckets.
213
+
214
+ **It runs at two different moments, and `kind` does not tell them apart.**
215
+ Everything on a comment transforms when the comment is saved; an attachment
216
+ on a _reply_ transforms when the file is picked, because `addReply()` is
217
+ synchronous and cannot wait on your upload. So a reply attachment can be
218
+ uploaded and then never referenced — the user closes the popover without
219
+ sending — and a comment can do the same in a narrower window, by being
220
+ dismissed while its upload is still in flight. Sweep for unreferenced blobs;
221
+ a URL you were handed is not a promise that a record will point at it.
222
+
223
+ It is **fail-open**. If your upload rejects — or resolves to anything that is
224
+ not a non-empty string — the original data URL is kept and you get
225
+ `onError(error, "transform")`. You receive a large record rather than losing
226
+ somebody's comment.
227
+
228
+ Not called for records you pass to `loadComments()`, nor for screenshots you
229
+ hand to `addReply()` yourself: in both cases the strings are already yours.
230
+
144
231
  ## Identity
145
232
 
146
233
  HellDots authenticates nobody. It takes whoever your app says is signed in
@@ -335,6 +422,14 @@ overlay.exportMetricsCsv(); // helldots-metrics.csv — section, key, value
335
422
  overlay.printMetricsReport(); // the browser's print dialog → Save as PDF
336
423
  ```
337
424
 
425
+ Both CSV methods **return the same text they download**, so a host that wanted
426
+ to send those rows somewhere instead of handing the user a file does not have
427
+ to build them a second time:
428
+
429
+ ```js
430
+ await api.post("/reports/comments", { csv: overlay.exportCommentsCsv() });
431
+ ```
432
+
338
433
  The CSVs are RFC 4180 with a UTF-8 BOM, so Excel opens them without turning
339
434
  every accent into mojibake, and a value that would otherwise be evaluated as a
340
435
  formula is neutralised on the way out. Headers are the internal field names
@@ -374,19 +469,25 @@ OS: iOS 17.2
374
469
 
375
470
  ## Options
376
471
 
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 |
472
+ | Option | Type | Default | |
473
+ | ----------------------- | ------------------------------------ | ------------------- | ----------------------------------------------------------------- |
474
+ | `user` | `{ name: string, id?: string }` | `"Anonymous"` | Author of new comments and replies; `id` persists as `authorId` |
475
+ | `persistence` | `"localStorage"` \| `"none"` | `"none"` | Auto save/restore, or handle it yourself via callbacks |
476
+ | `autoScreenshot` | `boolean` | `true` | Capture a screenshot and environment snapshot per comment |
477
+ | `embedCrossOriginFonts` | `boolean` | `false` | Fetch unreadable stylesheets so their web fonts reach the capture |
478
+ | `locale` | `string` | browser language | `"en"` and `"es"` ship; anything else falls back per key |
479
+ | `linkParam` | `string` | `"helldotsComment"` | Query param used by "Copy link" URLs |
480
+ | `navigate` | `(page: string) => void` | full page load | SPA router hook for the widget's cross-page jumps |
481
+ | `onReady` | `(overlay) => void` | | Widget mounted; the safe place to `loadComments()` |
482
+ | `onError` | `(error, context) => void` | | A survivable failure capture, storage, load, link or transform |
483
+ | `onCommentRequested` | `(id) => void \| Promise` | | A link points at a comment the widget does not hold |
484
+ | `transformScreenshot` | `(dataUrl, info) => Promise<string>` | | Swap every image the widget acquires for a string of your own |
485
+ | `onCommentModeChanged` | `(active: boolean) => void` | — | Comment mode turned on or off, however it was flipped |
486
+ | `onCommentOpened` | `(comment) => void` | — | Somebody opened a comment's thread — build unread counts on this |
487
+ | `autoDetectNavigation` | `boolean` | `false` | Run `notifyNavigation()` on popstate (back/forward) |
488
+ | `shortcutKey` | `string` | `"c"` | Key that toggles comment mode |
489
+ | `shortcutModifier` | `"alt"` \| `"ctrl"` \| `"shift"` | `"alt"` | Modifier for that key |
490
+ | `autoInit` | `boolean` | `true` | When `false`, returns an initializer to call yourself |
390
491
 
391
492
  ### Callbacks
392
493
 
@@ -410,18 +511,108 @@ TypeScript narrows the payload. The specific callbacks below carry the same
410
511
  events at the same moments — subscribe either way, or both. A handler that
411
512
  throws is caught and warned about, never rolling back the change.
412
513
 
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) |
514
+ Every one of them ends with a `meta` argument (the same fields are flattened
515
+ onto the `onChange` event), so an existing handler that ignores it keeps
516
+ working unchanged.
517
+
518
+ | Callback | Fires when |
519
+ | ----------------------------------------- | -------------------------------------------------------------- |
520
+ | `onCommentCreated(comment, meta)` | A new comment is saved |
521
+ | `onReplyAdded(comment, reply, meta)` | A reply is added to any comment |
522
+ | `onReplyDeleted(comment, reply, meta)` | A reply is removed |
523
+ | `onCommentEdited(comment, meta)` | A comment's text is rewritten |
524
+ | `onReplyEdited(comment, reply, meta)` | A reply's text is rewritten |
525
+ | `onCommentStatusChanged(comment, meta)` | Status moves along the lifecycle |
526
+ | `onCommentUpdated(comment, meta)` | Type, priority or tags change |
527
+ | `onCommentDeleted(id, meta)` | A comment is removed |
528
+ | `onAnchorLost(comment, meta)` | A comment could not be re-anchored |
529
+ | `onReactionToggled(comment, reply, meta)` | A reaction is added or removed (`reply` is `null` at the root) |
530
+
531
+ Five more do not report a change to a comment:
532
+
533
+ | Callback | Fires when |
534
+ | ------------------------------ | --------------------------------------------------------------- |
535
+ | `onReady(overlay)` | The widget has mounted and every method is safe to call |
536
+ | `onError(error, context)` | Something survivable went wrong — see below |
537
+ | `onCommentRequested(id)` | A link points at a comment the widget does not hold — see below |
538
+ | `onCommentModeChanged(active)` | Comment mode turned on or off — see below |
539
+ | `onCommentOpened(comment)` | Somebody opened a comment's thread — see below |
540
+
541
+ #### `meta.origin` — who caused the change
542
+
543
+ `"user"` is somebody acting inside the widget; `"host"` is your own code
544
+ calling a method. The inbox and the thread popover drive the very same public
545
+ methods you do, so this is the only thing that tells the two apart.
546
+
547
+ It matters as soon as more than one person is looking. Applying a change that
548
+ arrived over a socket means calling `setCommentStatus()` — which emits, which
549
+ sends it straight back to the server:
550
+
551
+ ```js
552
+ createCommentOverlay({
553
+ onChange: (event) => {
554
+ if (event.origin === "host") return; // our own write, echoed back
555
+ api.post("/helldots-events", event);
556
+ },
557
+ });
558
+
559
+ socket.on("comment:resolved", ({ id }) =>
560
+ overlay.setCommentStatus(id, "resolved")
561
+ );
562
+ ```
563
+
564
+ Without the guard that loop runs forever. `comment:anchor-lost` is always
565
+ `"host"` too, so this also silences the repeat every `notifyNavigation()`
566
+ produces for a comment whose element is not on the new page.
567
+
568
+ #### `meta.from` / `meta.to` — what moved
569
+
570
+ `comment:status-changed` carries both ends of the move, and `comment:updated`
571
+ adds `field` to say which of the three it was about:
572
+
573
+ ```js
574
+ onCommentStatusChanged: (comment, { from, to }) => {
575
+ if (from === "resolved") notify(`${comment.author} reopened this`);
576
+ },
577
+ onCommentUpdated: (comment, meta) => {
578
+ if (meta.field === "priority" && meta.to === "high") page(comment);
579
+ },
580
+ ```
581
+
582
+ `field` narrows `from` and `to` for you in TypeScript. Re-applying a value a
583
+ comment already holds is a no-op: no event, no write.
584
+
585
+ #### `onCommentModeChanged` and `onCommentOpened`
586
+
587
+ Comment mode is the one the host cannot observe on its own: the keyboard
588
+ shortcut never reaches your code, so an app that has to stand down while
589
+ somebody is picking an element has no other signal.
590
+
591
+ ```js
592
+ onCommentModeChanged: (active) => {
593
+ carousel.paused = active; // your drag-and-drop would fight the picker
594
+ },
595
+ ```
596
+
597
+ `onCommentOpened` fires when a thread is actually read — from its marker or
598
+ from the inbox detail, the only two places the replies are visible. It does
599
+ not fire when the inbox merely re-renders. HellDots stores no read state of
600
+ its own, because whose "read" it is depends on an identity only you can
601
+ persist:
602
+
603
+ ```js
604
+ onCommentOpened: (comment) => api.post(`/comments/${comment.id}/read`),
605
+ ```
606
+
607
+ #### `onError(error, context)`
608
+
609
+ Failures the widget survives but you would otherwise only find in the
610
+ console: `"capture"` (a screenshot did not render — the comment saves without
611
+ one), `"storage"` (localStorage could not be written, so this browser's copy
612
+ now diverges), `"load"` (a malformed record was skipped), `"link"` (an
613
+ `onCommentRequested` handler threw or rejected), `"transform"` (a
614
+ `transformScreenshot` handler failed, so the data URL was kept). The console
615
+ warning stays either way.
425
616
 
426
617
  ## API
427
618
 
@@ -447,9 +638,22 @@ overlay.setCommentPriority(id, priority); // → boolean
447
638
  overlay.setCommentTags(id, tags); // → boolean
448
639
  overlay.toggleCommentReaction(id, emoji); // → boolean
449
640
  overlay.toggleReplyReaction(commentId, replyId, emoji); // → boolean
641
+ overlay.setUser(user); // → boolean (null returns to the anonymous author)
642
+ overlay.exportCommentsCsv(comments?); // → string (and downloads it)
643
+ overlay.exportMetricsCsv(comments?); // → string (and downloads it)
450
644
  overlay.cleanup(); // remove the widget entirely
451
645
  ```
452
646
 
647
+ Two module-level helpers come with the package, for reading a deep link
648
+ before an overlay exists:
649
+
650
+ ```ts
651
+ import { readCommentLinkParam, DEFAULT_LINK_PARAM } from "helldots";
652
+
653
+ DEFAULT_LINK_PARAM; // "helldotsComment"
654
+ readCommentLinkParam(param?, href?); // → string | null
655
+ ```
656
+
453
657
  The setters return `false` for an unknown id or an invalid value, and make no
454
658
  change when they do. To reconcile against a backend after remote deletions,
455
659
  call `clearComments()` and then `loadComments(freshData)` — `loadComments`