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 +229 -25
- package/dist/helldots.esm.js +6 -6
- package/dist/helldots.esm.js.map +3 -3
- package/dist/helldots.umd.js +12 -12
- package/dist/index.d.ts +255 -34
- package/package.json +1 -1
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
|
|
378
|
-
| ----------------------- |
|
|
379
|
-
| `user` | `{ name: string, id?: string }`
|
|
380
|
-
| `persistence` | `"localStorage"` \| `"none"`
|
|
381
|
-
| `autoScreenshot` | `boolean`
|
|
382
|
-
| `embedCrossOriginFonts` | `boolean`
|
|
383
|
-
| `locale` | `string`
|
|
384
|
-
| `linkParam` | `string`
|
|
385
|
-
| `navigate` | `(page: string) => void`
|
|
386
|
-
| `
|
|
387
|
-
| `
|
|
388
|
-
| `
|
|
389
|
-
| `
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
|
418
|
-
|
|
|
419
|
-
| `
|
|
420
|
-
| `
|
|
421
|
-
| `
|
|
422
|
-
| `
|
|
423
|
-
| `
|
|
424
|
-
| `
|
|
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`
|