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 +321 -25
- package/dist/helldots.esm.js +6 -6
- package/dist/helldots.esm.js.map +4 -4
- package/dist/helldots.umd.js +12 -12
- package/dist/index.d.ts +317 -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,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
|
|
378
|
-
| ----------------------- |
|
|
379
|
-
| `user` | `{ name: string, id?: string }`
|
|
380
|
-
| `persistence` | `"localStorage"` \| `"none"`
|
|
381
|
-
| `autoScreenshot` | `boolean`
|
|
382
|
-
| `embedCrossOriginFonts` | `boolean`
|
|
383
|
-
| `
|
|
384
|
-
| `
|
|
385
|
-
| `
|
|
386
|
-
| `
|
|
387
|
-
| `
|
|
388
|
-
| `
|
|
389
|
-
| `
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
|
418
|
-
|
|
|
419
|
-
| `
|
|
420
|
-
| `
|
|
421
|
-
| `
|
|
422
|
-
| `
|
|
423
|
-
| `
|
|
424
|
-
| `
|
|
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`
|