helldots 0.3.0 → 0.5.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
@@ -73,12 +73,34 @@ export function Comments({ user }) {
73
73
  }
74
74
  ```
75
75
 
76
+ ### Single-page apps
77
+
78
+ A client-side router swaps the DOM without a page load, so tell the widget
79
+ when a navigation happened and let its own cross-page jumps use your router:
80
+
81
+ ```js
82
+ const overlay = createCommentOverlay({
83
+ user,
84
+ persistence: "localStorage",
85
+ navigate: (page) => router.push(page), // "view on its page" without a reload
86
+ });
87
+
88
+ // After every route render (React Router, Vue Router, …)
89
+ router.afterEach(() => overlay.notifyNavigation());
90
+ ```
91
+
92
+ `notifyNavigation()` reclassifies every comment against the new URL,
93
+ re-resolves anchors against the new DOM and rebuilds the markers. Calling it
94
+ after a same-path re-render is also the way to re-anchor when your app
95
+ replaced the route's DOM. `autoDetectNavigation: true` additionally covers
96
+ back/forward (popstate) automatically.
97
+
76
98
  ## What gets captured
77
99
 
78
100
  When someone leaves a comment, HellDots records more than the text:
79
101
 
80
102
  **A screenshot of the page as they saw it.** Taken automatically, JPEG at half
81
- scale (~30–100 KB). The widget's own UI is hidden during the capture, so the
103
+ scale (~30–100 KB). The widget's own UI is excluded from the capture, so the
82
104
  toolbar never ends up inside the image. Dragging a region additionally attaches
83
105
  a full-resolution PNG crop of exactly what was selected.
84
106
 
@@ -94,6 +116,31 @@ orphaned rather than silently dropped.
94
116
  Set `autoScreenshot: false` to skip the capture — the render costs a moment
95
117
  on every comment, and some apps would rather not pay it.
96
118
 
119
+ ### Web fonts in screenshots
120
+
121
+ A screenshot is not a screen grab: the browser exposes no way to rasterize
122
+ the painted page from JavaScript, so the capture is a re-render of the DOM.
123
+ Anything the re-render cannot reach is missing from it — web fonts included.
124
+
125
+ A font loaded through a cross-origin `<link>` (Google Fonts and friends) is
126
+ one of those. Reading `cssRules` on such a stylesheet throws `SecurityError`,
127
+ so its `@font-face` never reaches the capture and the text comes out in a
128
+ fallback face. That is not only cosmetic: the fallback's metrics differ, so
129
+ glyphs sit at different positions than on screen, and a drag selection tight
130
+ around a few letters can come back holding the wrong ones.
131
+
132
+ Three ways out, cheapest first:
133
+
134
+ - **Self-host the font**, or add `crossorigin` to the `<link>`. The
135
+ stylesheet becomes readable and the capture matches the page, with no
136
+ extra requests at capture time.
137
+ - **`embedCrossOriginFonts: true`.** HellDots re-fetches those stylesheets
138
+ (the same URLs the page already loaded, cached per session) and hands them
139
+ to the renderer. Off by default: a comment widget making third-party
140
+ requests on your users' behalf should be your call, not ours.
141
+ - **Leave it.** Captures of such a page stay misaligned where text is
142
+ concerned; everything else about them is correct.
143
+
97
144
  ## Triage
98
145
 
99
146
  Comments carry an optional type, priority and free-form tags. All three start
@@ -145,22 +192,48 @@ OS: iOS 17.2
145
192
 
146
193
  ## Options
147
194
 
148
- | Option | Type | Default | |
149
- | ------------------ | -------------------------------- | ---------------- | --------------------------------------------------------- |
150
- | `user` | `{ name: string }` | `"Anonymous"` | Author of new comments and replies |
151
- | `persistence` | `"localStorage"` \| `"none"` | `"none"` | Auto save/restore, or handle it yourself via callbacks |
152
- | `autoScreenshot` | `boolean` | `true` | Capture a screenshot and environment snapshot per comment |
153
- | `locale` | `"en"` \| `"es"` | browser language | UI language, falling back to English |
154
- | `shortcutKey` | `string` | `"c"` | Key that toggles comment mode |
155
- | `shortcutModifier` | `"alt"` \| `"ctrl"` \| `"shift"` | `"alt"` | Modifier for that key |
156
- | `autoInit` | `boolean` | `true` | When `false`, returns an initializer to call yourself |
195
+ | Option | Type | Default | |
196
+ | ----------------------- | -------------------------------- | ------------------- | ----------------------------------------------------------------- |
197
+ | `user` | `{ name: string }` | `"Anonymous"` | Author of new comments and replies |
198
+ | `persistence` | `"localStorage"` \| `"none"` | `"none"` | Auto save/restore, or handle it yourself via callbacks |
199
+ | `autoScreenshot` | `boolean` | `true` | Capture a screenshot and environment snapshot per comment |
200
+ | `embedCrossOriginFonts` | `boolean` | `false` | Fetch unreadable stylesheets so their web fonts reach the capture |
201
+ | `locale` | `string` | browser language | `"en"` and `"es"` ship; anything else falls back per key |
202
+ | `linkParam` | `string` | `"helldotsComment"` | Query param used by "Copy link" URLs |
203
+ | `navigate` | `(page: string) => void` | full page load | SPA router hook for the widget's cross-page jumps |
204
+ | `autoDetectNavigation` | `boolean` | `false` | Run `notifyNavigation()` on popstate (back/forward) |
205
+ | `shortcutKey` | `string` | `"c"` | Key that toggles comment mode |
206
+ | `shortcutModifier` | `"alt"` \| `"ctrl"` \| `"shift"` | `"alt"` | Modifier for that key |
207
+ | `autoInit` | `boolean` | `true` | When `false`, returns an initializer to call yourself |
157
208
 
158
209
  ### Callbacks
159
210
 
211
+ Every change is also available as one stream, which is usually what you want
212
+ when the whole thing syncs to a single endpoint:
213
+
214
+ ```js
215
+ createCommentOverlay({
216
+ onChange: (event) => {
217
+ // "comment:created" | "comment:edited" | "comment:deleted"
218
+ // "comment:status-changed" | "comment:updated" | "comment:anchor-lost"
219
+ // "reply:added" | "reply:deleted" | "reply:edited"
220
+ api.post("/helldots-events", event);
221
+ },
222
+ });
223
+ ```
224
+
225
+ `ChangeEvent` is a discriminated union: switch on `event.type` and
226
+ TypeScript narrows the payload. The specific callbacks below carry the same
227
+ events at the same moments — subscribe either way, or both. A handler that
228
+ throws is caught and warned about, never rolling back the change.
229
+
160
230
  | Callback | Fires when |
161
231
  | --------------------------------- | -------------------------------------------------- |
162
232
  | `onCommentCreated(comment)` | A new comment is saved |
163
233
  | `onReplyAdded(comment, reply)` | A reply is added to any comment |
234
+ | `onReplyDeleted(comment, reply)` | A reply is removed |
235
+ | `onCommentEdited(comment)` | A comment's text is rewritten |
236
+ | `onReplyEdited(comment, reply)` | A reply's text is rewritten |
164
237
  | `onCommentStatusChanged(comment)` | Status moves between open / in progress / resolved |
165
238
  | `onCommentUpdated(comment)` | Type, priority or tags change |
166
239
  | `onCommentDeleted(id)` | A comment is removed |
@@ -174,9 +247,15 @@ const overlay = createCommentOverlay(options);
174
247
  overlay.comments; // Comment[]
175
248
  overlay.commentMode; // boolean
176
249
  overlay.toggleCommentMode();
177
- overlay.addReply(comment, text); // → CommentReply
250
+ overlay.addReply(commentOrId, text, screenshots?); // → CommentReply | null
251
+ overlay.deleteReply(commentId, replyId); // → boolean
252
+ overlay.editComment(id, text); // → boolean
253
+ overlay.editReply(commentId, replyId, text); // → boolean
254
+ overlay.commentLink(id); // → string | null (shareable URL)
178
255
  overlay.serializeComments(); // → SerializedComment[]
179
256
  overlay.loadComments(data); // → { anchored, orphaned, inactive }
257
+ overlay.notifyNavigation(); // re-sync after a client-side navigation
258
+ overlay.clearComments(); // bulk reset, fires no callbacks
180
259
  overlay.deleteComment(id); // → boolean
181
260
  overlay.setCommentStatus(id, status); // → boolean
182
261
  overlay.setCommentType(id, type); // → boolean
@@ -186,7 +265,9 @@ overlay.cleanup(); // remove the widget entirely
186
265
  ```
187
266
 
188
267
  The setters return `false` for an unknown id or an invalid value, and make no
189
- change when they do.
268
+ change when they do. To reconcile against a backend after remote deletions,
269
+ call `clearComments()` and then `loadComments(freshData)` — `loadComments`
270
+ alone replaces by id but never removes.
190
271
 
191
272
  TypeScript definitions ship with the package — no `@types` install needed.
192
273
 
@@ -196,6 +277,13 @@ With `persistence: "localStorage"`, every comment (screenshot included) lives
196
277
  under a single key shared across all pages of your app. Browsers cap that at
197
278
  roughly 5 MB, which is on the order of a hundred comments with screenshots.
198
279
 
280
+ The mode assumes one active tab per page: writes from another tab are
281
+ preserved on the next sync, but two tabs editing the same comment
282
+ concurrently resolve last-write-wins, and a comment deleted in one tab can
283
+ reappear if another tab still holding it in memory saves afterwards. Hosts
284
+ that need real multi-tab editing should persist through the callbacks
285
+ instead.
286
+
199
287
  When the quota is reached, HellDots sheds the _automatic_ screenshots of the
200
288
  oldest comments and retries, so the comments themselves survive. Screenshots a
201
289
  user deliberately attached are never discarded. If you expect heavy use, wire
@@ -209,8 +297,8 @@ page's CSS cannot leak into it and its styles cannot leak out.
209
297
  ## ESM only
210
298
 
211
299
  This package ships ES modules only. `import` works everywhere — bundlers, Vite,
212
- Next.js, native `<script type="module">`. There is no CommonJS build, so
213
- `require("helldots")` will not work.
300
+ Next.js, Node ≥ 18, native `<script type="module">`. There is no CommonJS
301
+ build, so `require("helldots")` will not work.
214
302
 
215
303
  For a plain `<script>` tag with no bundler, a self-contained UMD build is on
216
304
  the CDN: