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/dist/index.d.ts CHANGED
@@ -191,42 +191,104 @@ export interface SerializedComment {
191
191
  reactions: Record<string, string[]> | null;
192
192
  }
193
193
 
194
+ /**
195
+ * Who caused a change.
196
+ *
197
+ * `"user"` is somebody acting inside the widget — a marker, the thread
198
+ * popover, the inbox. `"host"` is the app's own code calling a method.
199
+ *
200
+ * The distinction exists for multi-user apps. Applying a change that arrived
201
+ * over a socket means calling the same method the UI calls, so without this
202
+ * every host has to wrap its own writes in a flag to avoid echoing them
203
+ * straight back to the server. `if (meta.origin === "host") return;` is that
204
+ * flag, and it is now the library's job to provide it.
205
+ */
206
+ export type ChangeOrigin = "user" | "host";
207
+
208
+ /** Which part of the widget a reported failure came from. */
209
+ export type ErrorContext =
210
+ /** A screenshot render failed; the comment saves without one. */
211
+ | "capture"
212
+ /** localStorage could not be written; this browser's copy now diverges. */
213
+ | "storage"
214
+ /** A record handed to loadComments was malformed and was skipped. */
215
+ | "load"
216
+ /** An `onCommentRequested` handler threw or rejected. */
217
+ | "link"
218
+ /** A `transformScreenshot` handler failed; the data URL was kept. */
219
+ | "transform";
220
+
221
+ /**
222
+ * What every change carries, whatever its type. Delivered as one trailing
223
+ * argument to the specific callbacks, and flattened onto the `onChange`
224
+ * event next to `comment`/`reply`/`id`.
225
+ */
226
+ export interface ChangeMeta {
227
+ origin: ChangeOrigin;
228
+ }
229
+
230
+ /** `comment:status-changed` — both ends of the move along the lifecycle. */
231
+ export interface StatusChangeMeta extends ChangeMeta {
232
+ from: CommentStatus;
233
+ to: CommentStatus;
234
+ }
235
+
236
+ /**
237
+ * `comment:updated` — which of the three fields moved, and both ends of the
238
+ * move. Discriminated on `field`, so narrowing gives you correctly typed
239
+ * `from`/`to` for each.
240
+ */
241
+ export type UpdateMeta = ChangeMeta &
242
+ (
243
+ | { field: "type"; from: CommentType | null; to: CommentType | null }
244
+ | {
245
+ field: "priority";
246
+ from: CommentPriority | null;
247
+ to: CommentPriority | null;
248
+ }
249
+ | { field: "tags"; from: string[]; to: string[] }
250
+ );
251
+
194
252
  /**
195
253
  * Everything that can change, as one discriminated union. Switch on `type`
196
254
  * and TypeScript narrows the rest of the fields for you.
197
255
  *
198
256
  * The ten specific callbacks below carry exactly the same events at
199
- * exactly the same moments; subscribe either way, or both.
257
+ * exactly the same moments, with the same metadata; subscribe either way,
258
+ * or both.
200
259
  */
201
260
  export type ChangeEvent =
202
- | { type: "comment:created"; comment: SerializedComment }
203
- | { type: "comment:edited"; comment: SerializedComment }
204
- | { type: "comment:deleted"; id: CommentId }
205
- | { type: "comment:status-changed"; comment: SerializedComment }
206
- /** Type, priority or tags changed. */
207
- | { type: "comment:updated"; comment: SerializedComment }
208
- | { type: "comment:anchor-lost"; comment: SerializedComment }
209
- | {
261
+ | ({ type: "comment:created"; comment: SerializedComment } & ChangeMeta)
262
+ | ({ type: "comment:edited"; comment: SerializedComment } & ChangeMeta)
263
+ | ({ type: "comment:deleted"; id: CommentId } & ChangeMeta)
264
+ | ({
265
+ type: "comment:status-changed";
266
+ comment: SerializedComment;
267
+ } & StatusChangeMeta)
268
+ /** Type, priority or tags changed; `field` says which. */
269
+ | ({ type: "comment:updated"; comment: SerializedComment } & UpdateMeta)
270
+ | ({ type: "comment:anchor-lost"; comment: SerializedComment } & ChangeMeta)
271
+ | ({
210
272
  type: "reply:added";
211
273
  comment: SerializedComment;
212
274
  reply: CommentReply;
213
- }
214
- | {
275
+ } & ChangeMeta)
276
+ | ({
215
277
  type: "reply:deleted";
216
278
  comment: SerializedComment;
217
279
  reply: CommentReply;
218
- }
219
- | {
280
+ } & ChangeMeta)
281
+ | ({
220
282
  type: "reply:edited";
221
283
  comment: SerializedComment;
222
284
  reply: CommentReply;
223
- }
285
+ } & ChangeMeta)
224
286
  /** `reply` is null when the reaction is on the root comment. */
225
- | {
287
+ | ({
226
288
  type: "reaction:toggled";
227
289
  comment: SerializedComment;
228
290
  reply: CommentReply | null;
229
- };
291
+ } & ChangeMeta);
230
292
 
231
293
  export interface CommentOverlayOptions {
232
294
  shortcutKey?: string;
@@ -261,6 +323,68 @@ export interface CommentOverlayOptions {
261
323
  * font or adding `crossorigin` to the `<link>`.
262
324
  */
263
325
  embedCrossOriginFonts?: boolean;
326
+ /**
327
+ * Narrows the screenshot renderer's computed-style enumeration to a
328
+ * curated allow-list instead of every property the browser exposes.
329
+ *
330
+ * The enumeration IS the render: it is ~91% of a capture's cost and it
331
+ * scales with element count, so on a page with a few thousand live nodes
332
+ * it is the difference between a capture that finishes and one that
333
+ * visibly stalls. Measured at ~2.7x off that phase.
334
+ *
335
+ * Off by default because the list is a fidelity contract, and no list can
336
+ * be complete for a page this library has never seen: a property it does
337
+ * not name is simply absent from the image. Turn it on for a heavy page,
338
+ * then look at a capture before trusting it — and report anything that
339
+ * comes out wrong, since the fix is one more entry in the list.
340
+ */
341
+ fastCapture?: boolean;
342
+ /**
343
+ * Renders embedded documents as blank instead of cloning their contents.
344
+ *
345
+ * An iframe's cost is invisible from the outside: the renderer walks into
346
+ * a same-origin frame and clones its whole document, so a page reporting
347
+ * 242 elements can be a capture of 9 245. Measured at 2374 ms against
348
+ * 82 ms on one 9 000-node embedded frame.
349
+ *
350
+ * The `<iframe>` element itself is kept — its box, its border and the
351
+ * space it occupies. Removing the element instead would slide everything
352
+ * below it up by the frame's height and misalign the crop.
353
+ *
354
+ * Nothing to gain on a cross-origin frame: the renderer cannot read it,
355
+ * so it is already blank in the output (it does not stall or wait on it
356
+ * either). This is for same-origin frames, where the trade is real —
357
+ * their content is what you lose.
358
+ */
359
+ skipIframeContent?: boolean;
360
+ /**
361
+ * Milliseconds a single remote asset may hold a capture up.
362
+ *
363
+ * The renderer re-fetches the page's images, fonts and `@import`s so it can
364
+ * inline them, and gives each one an `AbortController` set to 30 000 ms by
365
+ * default. A URL that never answers stalls the capture until that fires —
366
+ * the capture still succeeds, with that asset replaced by a transparent
367
+ * placeholder, but it waits first.
368
+ *
369
+ * The wait is **bounded, not multiplied**: measured at the same ~2x the
370
+ * timeout whether one asset is dead or ten, because they are waited on
371
+ * concurrently. It is 2x rather than 1x because this one number drives two
372
+ * waits in sequence on the same asset — first for the image already on the
373
+ * page to finish loading, then for the fetch that inlines it. Budget
374
+ * accordingly: 5000 here means roughly ten seconds.
375
+ *
376
+ * Left at the renderer's default because lowering it trades a slow capture
377
+ * for a silently incomplete one: an asset that was merely slow, rather than
378
+ * dead, is dropped and leaves a hole in the image with nothing to say so.
379
+ * Set it only if you have measured your own page and decided which way you
380
+ * want that to fail.
381
+ *
382
+ * Only a finite positive number is honoured. The two values a host would
383
+ * reach for to mean "no deadline" both do the opposite and are ignored: the
384
+ * renderer reads 0 as "never give up", and `Infinity` is coerced by
385
+ * `setTimeout` to 0, aborting every asset immediately.
386
+ */
387
+ captureTimeout?: number;
264
388
  /**
265
389
  * Identity used as the author of new comments and replies.
266
390
  *
@@ -293,39 +417,155 @@ export interface CommentOverlayOptions {
293
417
  * `notifyNavigation()` call from the router's hook. Default: false.
294
418
  */
295
419
  autoDetectNavigation?: boolean;
420
+ /**
421
+ * Called once the widget has mounted and every method on it is safe to
422
+ * drive. Receives the instance, because when the document is already
423
+ * parsed the mount happens inside the constructor — before
424
+ * `createCommentOverlay()` has returned anything to assign.
425
+ *
426
+ * This is the right place to `loadComments()`: a call made earlier is held
427
+ * and replayed here, but it can only return zeroes until then.
428
+ */
429
+ onReady?: (overlay: CommentOverlay) => void;
430
+ /**
431
+ * Called when something the widget survived nonetheless went wrong: a
432
+ * screenshot that failed to render, a localStorage write that could not be
433
+ * made, a malformed record skipped on load, a rejected
434
+ * `onCommentRequested`. Each of these already warns on the console and the
435
+ * widget carries on regardless — this is how they reach the host's own
436
+ * logging instead of only a devtools panel nobody has open.
437
+ */
438
+ onError?: (error: unknown, context: ErrorContext) => void;
439
+ /**
440
+ * Called when a "Copy link" URL points at a comment the widget does not
441
+ * hold — once per id, not once per attempt.
442
+ *
443
+ * This is what makes lazy loading work: fetch that one comment and hand it
444
+ * to `loadComments()`, and the inbox opens on it. Return a promise and the
445
+ * link is retried once it settles. Without a handler the inbox still opens
446
+ * and says the comment was not found.
447
+ */
448
+ onCommentRequested?: (id: CommentId) => void | Promise<unknown>;
449
+ /**
450
+ * Called for every image the widget acquires — the automatic viewport
451
+ * capture, a drag-crop region, and anything attached through the file
452
+ * picker. Return the string to store in its place, typically a URL into
453
+ * your own object storage.
454
+ *
455
+ * Without it, a ~33KB base64 data URL travels inside every comment: into
456
+ * localStorage, where it is the first thing shed under quota pressure, and
457
+ * into whatever your backend persists.
458
+ *
459
+ * Everything on a comment is transformed as the comment is saved; an
460
+ * attachment on a *reply* is transformed when the file is picked, because
461
+ * `addReply()` is synchronous. Either way the record may never arrive —
462
+ * an abandoned reply draft, or a comment box dismissed while its upload is
463
+ * in flight — so treat a URL you hand back as an upload, not as a record,
464
+ * and sweep for unreferenced blobs.
465
+ *
466
+ * `kind` separates the automatic capture ("context" — disposable and
467
+ * regenerable) from something a person chose to include ("attachment"), so
468
+ * the two can go to different buckets with different retention. It does
469
+ * not distinguish the two moments above: both arrive as "attachment".
470
+ * `commentId` is the comment the image will belong to — for an attachment
471
+ * on a reply, the parent comment.
472
+ *
473
+ * Fail-open: a rejection, a throw, or a resolved value that is not a
474
+ * non-empty string leaves the original data URL in place and reports
475
+ * `onError(error, "transform")`. Losing the user's comment would be worse
476
+ * than sending you a large one.
477
+ *
478
+ * Not called for records passed to `loadComments()`, nor for screenshots
479
+ * you hand to `addReply()` yourself — in both cases the strings are
480
+ * already yours.
481
+ */
482
+ transformScreenshot?: (
483
+ dataUrl: string,
484
+ info: { kind: "context" | "attachment"; commentId: CommentId }
485
+ ) => Promise<string>;
486
+ /**
487
+ * Called whenever comment mode turns on or off, however it was flipped —
488
+ * the toolbar button, the keyboard shortcut, the inbox empty state, or the
489
+ * automatic switch-off after a comment is saved.
490
+ *
491
+ * The shortcut is the reason this exists: the host never sees that
492
+ * keystroke, so an app that needs to stand down while the user is picking
493
+ * an element — pause a carousel, disable its own drag-and-drop, dim a
494
+ * layer — has no other way to know.
495
+ */
496
+ onCommentModeChanged?: (active: boolean) => void;
497
+ /**
498
+ * Called when somebody opens a comment's full thread, from its marker or
499
+ * from the inbox detail — the only two places the replies are readable.
500
+ *
501
+ * This is what an unread count is built on. HellDots keeps no read state
502
+ * of its own: whose "read" it is depends on an identity only the host can
503
+ * persist.
504
+ */
505
+ onCommentOpened?: (comment: SerializedComment) => void;
296
506
  /**
297
507
  * Single subscription point: fires for every change, alongside whichever
298
508
  * specific callback below carries the same event. Handy when a host syncs
299
509
  * everything to one endpoint instead of wiring nine functions. A handler
300
510
  * that throws is caught and warned about — it never rolls back the
301
511
  * mutation that emitted it.
512
+ *
513
+ * The event carries `origin` (and, where there is one, the transition that
514
+ * caused it) flattened onto it — see `ChangeMeta`.
302
515
  */
303
516
  onChange?: (event: ChangeEvent) => void;
304
517
  /** Fired after a new comment is saved. */
305
- onCommentCreated?: (comment: SerializedComment) => void;
518
+ onCommentCreated?: (comment: SerializedComment, meta: ChangeMeta) => void;
306
519
  /** Fired after a reply is added to any comment. */
307
- onReplyAdded?: (comment: SerializedComment, reply: CommentReply) => void;
520
+ onReplyAdded?: (
521
+ comment: SerializedComment,
522
+ reply: CommentReply,
523
+ meta: ChangeMeta
524
+ ) => void;
308
525
  /** Fired after deleteReply removes a reply. */
309
- onReplyDeleted?: (comment: SerializedComment, reply: CommentReply) => void;
526
+ onReplyDeleted?: (
527
+ comment: SerializedComment,
528
+ reply: CommentReply,
529
+ meta: ChangeMeta
530
+ ) => void;
310
531
  /** Fired after editComment rewrites a comment's text. */
311
- onCommentEdited?: (comment: SerializedComment) => void;
532
+ onCommentEdited?: (comment: SerializedComment, meta: ChangeMeta) => void;
312
533
  /** Fired after editReply rewrites a reply's text. */
313
- onReplyEdited?: (comment: SerializedComment, reply: CommentReply) => void;
314
- /** Fired for each comment that could not be re-anchored by loadComments. */
315
- onAnchorLost?: (comment: SerializedComment) => void;
534
+ onReplyEdited?: (
535
+ comment: SerializedComment,
536
+ reply: CommentReply,
537
+ meta: ChangeMeta
538
+ ) => void;
539
+ /**
540
+ * Fired for each comment that could not be re-anchored — by loadComments,
541
+ * and again by every notifyNavigation that lands somewhere its element
542
+ * does not exist. Those repeats always carry `origin: "host"`.
543
+ */
544
+ onAnchorLost?: (comment: SerializedComment, meta: ChangeMeta) => void;
316
545
  /** Fired after deleteComment removes a comment. */
317
- onCommentDeleted?: (id: CommentId) => void;
318
- /** Fired after setCommentStatus changes a comment's lifecycle state. */
319
- onCommentStatusChanged?: (comment: SerializedComment) => void;
320
- /** Fired after type, priority or tags change on any comment. */
321
- onCommentUpdated?: (comment: SerializedComment) => void;
546
+ onCommentDeleted?: (id: CommentId, meta: ChangeMeta) => void;
547
+ /**
548
+ * Fired after setCommentStatus changes a comment's lifecycle state.
549
+ * `meta.from`/`meta.to` are both ends of the move, so "reopened" and
550
+ * "resolved" are told apart without diffing against a previous copy.
551
+ */
552
+ onCommentStatusChanged?: (
553
+ comment: SerializedComment,
554
+ meta: StatusChangeMeta
555
+ ) => void;
556
+ /**
557
+ * Fired after type, priority or tags change on any comment. `meta.field`
558
+ * says which one moved, and narrows `meta.from`/`meta.to` with it.
559
+ */
560
+ onCommentUpdated?: (comment: SerializedComment, meta: UpdateMeta) => void;
322
561
  /**
323
562
  * Fired after a reaction is added to or removed from a comment or a reply.
324
563
  * `reply` is null when the reaction is on the root comment.
325
564
  */
326
565
  onReactionToggled?: (
327
566
  comment: SerializedComment,
328
- reply: CommentReply | null
567
+ reply: CommentReply | null,
568
+ meta: ChangeMeta
329
569
  ) => void;
330
570
  }
331
571
 
@@ -451,6 +691,12 @@ export declare class CommentOverlay {
451
691
  /** The shareable URL for a comment, or null when the id is unknown. */
452
692
  commentLink(id: CommentId): string | null;
453
693
  serializeComments(): SerializedComment[];
694
+ /**
695
+ * Called before the widget has mounted — possible when a fetch resolves
696
+ * while the document is still parsing — the data is held and applied at
697
+ * mount, and the counts come back as zeroes because nothing has been
698
+ * resolved yet. Load from `onReady` when the counts matter.
699
+ */
454
700
  loadComments(data: SerializedComment[]): {
455
701
  anchored: number;
456
702
  orphaned: number;
@@ -481,21 +727,40 @@ export declare class CommentOverlay {
481
727
  */
482
728
  getMetrics(): CommentMetrics;
483
729
  /**
484
- * Downloads the corpus as CSV, one row per comment. Screenshots and the
485
- * automatic context capture stay out. Defaults to every comment.
730
+ * Downloads the corpus as CSV, one row per comment, and returns the same
731
+ * text. Screenshots and the automatic context capture stay out. Defaults
732
+ * to every comment.
733
+ *
734
+ * The return value is for a host that wanted to POST those rows somewhere
735
+ * or attach them to a message: a browser download is a dead end, and
736
+ * building the CSV a second time is the only alternative.
486
737
  */
487
- exportCommentsCsv(comments?: SerializedComment[]): void;
738
+ exportCommentsCsv(comments?: SerializedComment[]): string;
488
739
  /**
489
740
  * Downloads the aggregate figures as CSV in long format — `section, key,
490
- * value` — so the column count does not change with the corpus.
741
+ * value` — so the column count does not change with the corpus. Returns
742
+ * the same text, for the same reason as above.
491
743
  */
492
- exportMetricsCsv(comments?: SerializedComment[]): void;
744
+ exportMetricsCsv(comments?: SerializedComment[]): string;
493
745
  /**
494
746
  * Opens the browser's print dialog on a report of the figures, which is
495
747
  * where "save as PDF" lives. The report is built in its own document, so
496
748
  * what prints is the report rather than the host page.
497
749
  */
498
750
  printMetricsReport(comments?: SerializedComment[], scope?: string): void;
751
+ /**
752
+ * Replaces the identity new comments, replies and reactions are attributed
753
+ * to. Everything already recorded keeps the author it was written with.
754
+ *
755
+ * For the common case where identity resolves after the widget mounts, or
756
+ * where the user switches account or workspace — the alternative was
757
+ * `cleanup()` and a rebuild, which throws away every loaded comment and
758
+ * whatever panel was open. `null` returns to the anonymous author.
759
+ *
760
+ * Returns false, changing nothing, for anything that is neither null nor
761
+ * an object with a non-blank `name`.
762
+ */
763
+ setUser(user: { name: string; id?: string } | null): boolean;
499
764
  setCommentStatus(id: CommentId, status: CommentStatus): boolean;
500
765
  setCommentType(id: CommentId, type: CommentType | null): boolean;
501
766
  setCommentPriority(id: CommentId, priority: CommentPriority | null): boolean;
@@ -530,4 +795,22 @@ export declare function createCommentOverlay(
530
795
  options: CommentOverlayOptions & { autoInit: false }
531
796
  ): () => CommentOverlay;
532
797
 
798
+ /**
799
+ * Default name of the query parameter carrying a comment id in "Copy link"
800
+ * URLs. Exported so a host reading the id itself — before the widget is up,
801
+ * to fetch just that comment — does not have to hardcode a second copy of
802
+ * it. Override it per instance with the `linkParam` option.
803
+ */
804
+ export declare const DEFAULT_LINK_PARAM: string;
805
+
806
+ /**
807
+ * The comment id the given URL asks for, or null. Pass the same `param` the
808
+ * widget was configured with; both default to `DEFAULT_LINK_PARAM`. A
809
+ * malformed URL yields null rather than throwing.
810
+ */
811
+ export declare function readCommentLinkParam(
812
+ param?: string,
813
+ href?: string
814
+ ): string | null;
815
+
533
816
  export default createCommentOverlay;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helldots",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Drop-in comment overlay for web apps — click anywhere to leave a comment anchored to that element, with an automatic screenshot and environment capture",
5
5
  "keywords": [
6
6
  "comments",