@tldraw/commenting 5.6.0-next.d6710de420bf → 5.6.0-next.f151f7f3ebd6

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.
Files changed (107) hide show
  1. package/dist-cjs/canvas/cluster-badge.js +7 -1
  2. package/dist-cjs/canvas/cluster-badge.js.map +2 -2
  3. package/dist-cjs/canvas/comment-reactions.js +10 -4
  4. package/dist-cjs/canvas/comment-reactions.js.map +2 -2
  5. package/dist-cjs/canvas/comment-render.js.map +2 -2
  6. package/dist-cjs/canvas/comment-tool.js +35 -15
  7. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  8. package/dist-cjs/canvas/comments-filter-menu.js +13 -6
  9. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  10. package/dist-cjs/canvas/comments-menu-item.js +4 -1
  11. package/dist-cjs/canvas/comments-menu-item.js.map +2 -2
  12. package/dist-cjs/canvas/comments-overflow-menu.js +9 -4
  13. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  14. package/dist-cjs/canvas/comments-sidebar.js +14 -5
  15. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  16. package/dist-cjs/canvas/comments-visibility-toggle.js +5 -1
  17. package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
  18. package/dist-cjs/canvas/options.js.map +1 -1
  19. package/dist-cjs/canvas/pending-composer.js +7 -2
  20. package/dist-cjs/canvas/pending-composer.js.map +2 -2
  21. package/dist-cjs/canvas/thread-pin.js +13 -2
  22. package/dist-cjs/canvas/thread-pin.js.map +2 -2
  23. package/dist-cjs/canvas/thread-preview.js +4 -1
  24. package/dist-cjs/canvas/thread-preview.js.map +2 -2
  25. package/dist-cjs/canvas/thread-stack.js +18 -4
  26. package/dist-cjs/canvas/thread-stack.js.map +2 -2
  27. package/dist-cjs/canvas/thread-view.js +40 -20
  28. package/dist-cjs/canvas/thread-view.js.map +2 -2
  29. package/dist-cjs/index.d.ts +28 -8
  30. package/dist-cjs/index.js +2 -1
  31. package/dist-cjs/index.js.map +2 -2
  32. package/dist-cjs/ui/comment-composer.js +5 -0
  33. package/dist-cjs/ui/comment-composer.js.map +2 -2
  34. package/dist-cjs/ui/gesture-focus.js +37 -0
  35. package/dist-cjs/ui/gesture-focus.js.map +7 -0
  36. package/dist-cjs/ui/reaction-picker.js +5 -2
  37. package/dist-cjs/ui/reaction-picker.js.map +2 -2
  38. package/dist-cjs/ui/reaction.js +15 -5
  39. package/dist-cjs/ui/reaction.js.map +2 -2
  40. package/dist-cjs/ui/reply-count.js +8 -2
  41. package/dist-cjs/ui/reply-count.js.map +2 -2
  42. package/dist-esm/canvas/cluster-badge.mjs +8 -1
  43. package/dist-esm/canvas/cluster-badge.mjs.map +2 -2
  44. package/dist-esm/canvas/comment-reactions.mjs +12 -4
  45. package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
  46. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  47. package/dist-esm/canvas/comment-tool.mjs +37 -16
  48. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  49. package/dist-esm/canvas/comments-filter-menu.mjs +14 -6
  50. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  51. package/dist-esm/canvas/comments-menu-item.mjs +5 -2
  52. package/dist-esm/canvas/comments-menu-item.mjs.map +2 -2
  53. package/dist-esm/canvas/comments-overflow-menu.mjs +10 -4
  54. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  55. package/dist-esm/canvas/comments-sidebar.mjs +21 -6
  56. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  57. package/dist-esm/canvas/comments-visibility-toggle.mjs +6 -2
  58. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
  59. package/dist-esm/canvas/options.mjs.map +1 -1
  60. package/dist-esm/canvas/pending-composer.mjs +8 -2
  61. package/dist-esm/canvas/pending-composer.mjs.map +2 -2
  62. package/dist-esm/canvas/thread-pin.mjs +15 -3
  63. package/dist-esm/canvas/thread-pin.mjs.map +2 -2
  64. package/dist-esm/canvas/thread-preview.mjs +5 -1
  65. package/dist-esm/canvas/thread-preview.mjs.map +2 -2
  66. package/dist-esm/canvas/thread-stack.mjs +19 -4
  67. package/dist-esm/canvas/thread-stack.mjs.map +2 -2
  68. package/dist-esm/canvas/thread-view.mjs +42 -21
  69. package/dist-esm/canvas/thread-view.mjs.map +2 -2
  70. package/dist-esm/index.d.mts +28 -8
  71. package/dist-esm/index.mjs +8 -2
  72. package/dist-esm/index.mjs.map +2 -2
  73. package/dist-esm/ui/comment-composer.mjs +5 -0
  74. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  75. package/dist-esm/ui/gesture-focus.mjs +17 -0
  76. package/dist-esm/ui/gesture-focus.mjs.map +7 -0
  77. package/dist-esm/ui/reaction-picker.mjs +6 -2
  78. package/dist-esm/ui/reaction-picker.mjs.map +2 -2
  79. package/dist-esm/ui/reaction.mjs +16 -8
  80. package/dist-esm/ui/reaction.mjs.map +2 -2
  81. package/dist-esm/ui/reply-count.mjs +8 -2
  82. package/dist-esm/ui/reply-count.mjs.map +2 -2
  83. package/package.json +5 -5
  84. package/src/canvas/cluster-badge.tsx +9 -1
  85. package/src/canvas/comment-reactions.tsx +18 -7
  86. package/src/canvas/comment-render.ts +6 -4
  87. package/src/canvas/comment-tool.test.ts +83 -7
  88. package/src/canvas/comment-tool.tsx +61 -19
  89. package/src/canvas/comments-filter-menu.tsx +15 -6
  90. package/src/canvas/comments-menu-item.tsx +6 -2
  91. package/src/canvas/comments-overflow-menu.tsx +11 -4
  92. package/src/canvas/comments-sidebar.tsx +23 -6
  93. package/src/canvas/comments-visibility-toggle.tsx +7 -2
  94. package/src/canvas/options.ts +1 -1
  95. package/src/canvas/pending-composer.tsx +11 -2
  96. package/src/canvas/thread-pin.tsx +17 -4
  97. package/src/canvas/thread-preview.tsx +6 -1
  98. package/src/canvas/thread-stack.tsx +21 -4
  99. package/src/canvas/thread-view.tsx +44 -22
  100. package/src/index.ts +6 -1
  101. package/src/ui/comment-composer.tsx +9 -0
  102. package/src/ui/gesture-focus.test.ts +45 -0
  103. package/src/ui/gesture-focus.ts +29 -0
  104. package/src/ui/reaction-picker.tsx +7 -2
  105. package/src/ui/reaction.test.tsx +22 -0
  106. package/src/ui/reaction.tsx +25 -14
  107. package/src/ui/reply-count.ts +9 -3
@@ -1,11 +1,11 @@
1
1
  import { memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import {
3
3
  createComment,
4
+ defineMessages,
4
5
  Editor,
5
6
  EditorPortal,
6
7
  TLComment,
7
8
  TLCommentThread,
8
- TLRichText,
9
9
  TldrawUiButton,
10
10
  TldrawUiDropdownMenuContent,
11
11
  TldrawUiDropdownMenuGroup,
@@ -13,6 +13,7 @@ import {
13
13
  TldrawUiDropdownMenuRoot,
14
14
  TldrawUiDropdownMenuTrigger,
15
15
  TldrawUiIcon,
16
+ TLRichText,
16
17
  useContainer,
17
18
  usePassThroughWheelEvents,
18
19
  useTranslation,
@@ -38,7 +39,7 @@ import {
38
39
  resolveThread,
39
40
  } from './comment-mutations'
40
41
  import { CommentReactionPicker, CommentReactions } from './comment-reactions'
41
- import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
42
+ import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
42
43
  import { type CommentingContext } from './context'
43
44
  import { useThreadComments } from './hooks'
44
45
  import { useIsMobileCommenting, useMobilePlacement } from './mobile-placement'
@@ -51,6 +52,28 @@ import {
51
52
  } from './options'
52
53
  import { openThreadId } from './state'
53
54
 
55
+ const messages = defineMessages({
56
+ actionDelete: { id: 'action.delete', defaultMessage: 'Delete' },
57
+ commentsCopyLink: { id: 'comments.copy-link', defaultMessage: 'Copy link' },
58
+ commentsDelete: { id: 'comments.delete', defaultMessage: 'Delete thread' },
59
+ commentsDismiss: { id: 'comments.dismiss', defaultMessage: 'Dismiss' },
60
+ commentsEdit: { id: 'comments.edit', defaultMessage: 'Edit' },
61
+ commentsEditPlaceholder: {
62
+ id: 'comments.edit-placeholder',
63
+ defaultMessage: 'Edit comment…',
64
+ },
65
+ commentsLinkCopied: { id: 'comments.link-copied', defaultMessage: 'Link copied' },
66
+ commentsMoreOptions: { id: 'comments.more-options', defaultMessage: 'More options' },
67
+ commentsReopen: { id: 'comments.reopen', defaultMessage: 'Reopen' },
68
+ commentsReplyPlaceholder: { id: 'comments.reply-placeholder', defaultMessage: 'Reply…' },
69
+ commentsResolve: { id: 'comments.resolve', defaultMessage: 'Resolve' },
70
+ commentsResolvedBy: { id: 'comments.resolved-by', defaultMessage: 'Resolved by {name}' },
71
+ commentsSave: { id: 'comments.save', defaultMessage: 'Save' },
72
+ commentsSend: { id: 'comments.send', defaultMessage: 'Send' },
73
+ commentsThreadTitle: { id: 'comments.thread-title', defaultMessage: 'Comment' },
74
+ commentsUnknownAuthor: { id: 'comments.unknown-author', defaultMessage: 'Someone' },
75
+ })
76
+
54
77
  const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
55
78
 
56
79
  /** The "⋯" dropdown used for both a comment's and the thread's actions. */
@@ -70,12 +93,12 @@ function MoreOptionsMenu({
70
93
  <TldrawUiDropdownMenuTrigger>
71
94
  <TldrawUiButton
72
95
  type="icon"
73
- tooltip={msg('comments.more-options')}
74
- title={msg('comments.more-options')}
96
+ tooltip={msg(messages.commentsMoreOptions)}
97
+ title={msg(messages.commentsMoreOptions)}
75
98
  className="tlui-cmt-thread__action"
76
99
  data-cmt-more-for={moreFor}
77
100
  >
78
- <TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
101
+ <TldrawUiIcon icon="dots-vertical" label={msg(messages.commentsMoreOptions)} small />
79
102
  </TldrawUiButton>
80
103
  </TldrawUiDropdownMenuTrigger>
81
104
  <TldrawUiDropdownMenuContent
@@ -459,11 +482,11 @@ export const ThreadView = memo(function ThreadView({
459
482
  >
460
483
  <CommentComposer
461
484
  author={card.author}
462
- placeholder={msg('comments.edit-placeholder')}
485
+ placeholder={msg(messages.commentsEditPlaceholder)}
463
486
  value={editText}
464
487
  onChange={setEditText}
465
488
  onSubmit={saveEdit}
466
- sendLabel={msg('comments.save')}
489
+ sendLabel={msg(messages.commentsSave)}
467
490
  disabled={isCommentEmpty(editText)}
468
491
  getMentionSuggestions={getMentionSuggestions}
469
492
  renderMentionSuggestion={renderMentionSuggestion}
@@ -491,13 +514,13 @@ export const ThreadView = memo(function ThreadView({
491
514
  <MoreOptionsMenu id={`comment-actions-${comment.id}`} moreFor={comment.id}>
492
515
  {permissions?.edit && (
493
516
  <MenuItem
494
- label={msg('comments.edit')}
517
+ label={msg(messages.commentsEdit)}
495
518
  onClick={() => startEdit(comment, { fromMoreMenu: true })}
496
519
  />
497
520
  )}
498
521
  {permissions?.delete && (
499
522
  <MenuItem
500
- label={msg('action.delete')}
523
+ label={msg(messages.actionDelete)}
501
524
  danger
502
525
  onClick={() => deleteComment(editor, comment)}
503
526
  />
@@ -515,7 +538,7 @@ export const ThreadView = memo(function ThreadView({
515
538
 
516
539
  // Resolve and delete are commenting writes: behind `canComment`, plus the `currentUserId` a
517
540
  // resolve stamps into `resolved.by`.
518
- const resolveLabel = msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')
541
+ const resolveLabel = msg(thread.resolved ? messages.commentsReopen : messages.commentsResolve)
519
542
  const headerActions = (
520
543
  <>
521
544
  {/* Host verbs — assign, link a ticket — sit ahead of the built-in actions. */}
@@ -527,13 +550,13 @@ export const ThreadView = memo(function ThreadView({
527
550
  item can confirm the copy in place. */}
528
551
  {threadHref !== undefined && (
529
552
  <MenuItem
530
- label={msg(linkCopied ? 'comments.link-copied' : 'comments.copy-link')}
553
+ label={msg(linkCopied ? messages.commentsLinkCopied : messages.commentsCopyLink)}
531
554
  noClose
532
555
  onClick={copyThreadLink}
533
556
  />
534
557
  )}
535
558
  {canDeleteThread && (
536
- <MenuItem label={msg('comments.delete')} danger onClick={removeThread} />
559
+ <MenuItem label={msg(messages.commentsDelete)} danger onClick={removeThread} />
537
560
  )}
538
561
  </MoreOptionsMenu>
539
562
  )}
@@ -550,36 +573,35 @@ export const ThreadView = memo(function ThreadView({
550
573
  )}
551
574
  <TldrawUiButton
552
575
  type="icon"
553
- tooltip={msg('comments.dismiss')}
554
- title={msg('comments.dismiss')}
576
+ tooltip={msg(messages.commentsDismiss)}
577
+ title={msg(messages.commentsDismiss)}
555
578
  className="tlui-cmt-thread__action"
556
579
  onClick={() => openThreadId.set(editor, null)}
557
580
  >
558
- <TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
581
+ <TldrawUiIcon icon="cross-2" label={msg(messages.commentsDismiss)} small />
559
582
  </TldrawUiButton>
560
583
  </>
561
584
  )
562
585
 
563
586
  return (
564
587
  <CommentThread
565
- header={msg('comments.thread-title')}
588
+ header={msg(messages.commentsThreadTitle)}
566
589
  headerActions={headerActions}
567
590
  renderComment={renderComment}
568
591
  comments={cards}
569
592
  resolvedBanner={
570
593
  thread.resolved
571
- ? msg('comments.resolved-by').replace(
572
- '{name}',
573
- resolveAuthor(thread.resolved.by)?.name ?? UNKNOWN_AUTHOR
574
- )
594
+ ? msg(messages.commentsResolvedBy, {
595
+ name: resolveAuthor(thread.resolved.by)?.name ?? msg(messages.commentsUnknownAuthor),
596
+ })
575
597
  : undefined
576
598
  }
577
599
  composer={
578
600
  canReply
579
601
  ? {
580
602
  author: me ?? UNKNOWN_COMMENT_AUTHOR,
581
- placeholder: msg('comments.reply-placeholder'),
582
- sendLabel: msg('comments.send'),
603
+ placeholder: msg(messages.commentsReplyPlaceholder),
604
+ sendLabel: msg(messages.commentsSend),
583
605
  value: reply,
584
606
  onChange: (value: TLRichText) => {
585
607
  setReply(value)
package/src/index.ts CHANGED
@@ -80,7 +80,12 @@ export {
80
80
  toggleCommentReaction,
81
81
  useCommentReactions,
82
82
  } from './canvas/comment-reactions'
83
- export { CommentTool, commentToolOverrides, commentTools } from './canvas/comment-tool'
83
+ export {
84
+ CommentTool,
85
+ commentToolOverrides,
86
+ commentTools,
87
+ startCommentAt,
88
+ } from './canvas/comment-tool'
84
89
  export {
85
90
  getCommentReactions,
86
91
  getCommentRecord,
@@ -19,6 +19,7 @@ import {
19
19
  } from 'react'
20
20
  import { isEqual, TLRichText, useMaybeEditor } from 'tldraw'
21
21
  import { commentTipTapExtensions, EMPTY_COMMENT, isCommentEmpty } from './comment-extensions'
22
+ import { focusOnGestureEnd } from './gesture-focus'
22
23
  import { SendButton } from './send-button'
23
24
 
24
25
  /** @public */
@@ -258,6 +259,14 @@ export function CommentComposer({
258
259
  return () => cancelAnimationFrame(raf)
259
260
  }, [autoFocus, editor])
260
261
 
262
+ // The frame-later focus above places the caret but can't raise iOS's software keyboard, leaving
263
+ // the composer looking ready while it swallows every keystroke. Focus once more from the release
264
+ // that ends the placing gesture, which can.
265
+ useEffect(() => {
266
+ if (!autoFocus || !editor) return
267
+ return focusOnGestureEnd(editor.view.dom.ownerDocument, () => editor.commands.focus('end'))
268
+ }, [autoFocus, editor])
269
+
261
270
  // The whole field behaves like the text input: clicking its empty area (the padding, or the
262
271
  // space beside/below a short line) focuses the editor rather than only the text glyphs being
263
272
  // clickable. The input (caret placement) and the send button keep their own click handling.
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { focusOnGestureEnd } from './gesture-focus'
3
+
4
+ const pointer = (type: 'pointerdown' | 'pointerup') =>
5
+ document.dispatchEvent(new Event(type, { bubbles: true }))
6
+
7
+ describe('focusOnGestureEnd', () => {
8
+ it('focuses on the release that ends the gesture the field was mounted during', () => {
9
+ const focus = vi.fn()
10
+ focusOnGestureEnd(document, focus)
11
+ pointer('pointerup')
12
+ expect(focus).toHaveBeenCalledTimes(1)
13
+ })
14
+
15
+ it('only focuses once, so later gestures leave the caret alone', () => {
16
+ const focus = vi.fn()
17
+ focusOnGestureEnd(document, focus)
18
+ pointer('pointerup')
19
+ pointer('pointerdown')
20
+ pointer('pointerup')
21
+ expect(focus).toHaveBeenCalledTimes(1)
22
+ })
23
+
24
+ it('gives up when a press comes first — that release belongs to another gesture', () => {
25
+ const focus = vi.fn()
26
+ focusOnGestureEnd(document, focus)
27
+ pointer('pointerdown')
28
+ pointer('pointerup')
29
+ expect(focus).not.toHaveBeenCalled()
30
+ })
31
+
32
+ it('stops listening once detached', () => {
33
+ const focus = vi.fn()
34
+ const stop = focusOnGestureEnd(document, focus)
35
+ stop()
36
+ pointer('pointerup')
37
+ expect(focus).not.toHaveBeenCalled()
38
+ })
39
+
40
+ it('can be detached more than once', () => {
41
+ const stop = focusOnGestureEnd(document, vi.fn())
42
+ stop()
43
+ expect(() => stop()).not.toThrow()
44
+ })
45
+ })
@@ -0,0 +1,29 @@
1
+ /**
2
+ * iOS opens the software keyboard only for a focus that runs inside a user gesture's own task. A
3
+ * focus scheduled for a later frame still moves the caret, so a field can look ready while
4
+ * swallowing every keystroke until the reader taps it a second time.
5
+ *
6
+ * This covers the case where a field is mounted *during* a press — the comment composer opens on
7
+ * pointer down and settles its anchor on pointer up — by running `focus` from the native pointerup
8
+ * that ends the same gesture. That is late enough to survive the canvas taking focus for itself on
9
+ * pointer down, and still inside the gesture, so iOS raises the keyboard.
10
+ *
11
+ * A pointerdown arriving first means the mounting gesture had already ended (a field opened from a
12
+ * button, say), so the next release belongs to an unrelated gesture: give up rather than pull the
13
+ * caret somewhere the reader didn't ask for.
14
+ *
15
+ * Returns a function that detaches the listeners; safe to call more than once.
16
+ */
17
+ export function focusOnGestureEnd(doc: Document, focus: () => void): () => void {
18
+ const stop = () => {
19
+ doc.removeEventListener('pointerup', onPointerUp, true)
20
+ doc.removeEventListener('pointerdown', stop, true)
21
+ }
22
+ function onPointerUp() {
23
+ stop()
24
+ focus()
25
+ }
26
+ doc.addEventListener('pointerup', onPointerUp, true)
27
+ doc.addEventListener('pointerdown', stop, true)
28
+ return stop
29
+ }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useId, type ComponentType, type KeyboardEvent, type MouseEvent } from 'react'
2
2
  import {
3
+ defineMessages,
3
4
  TldrawUiButton,
4
5
  TldrawUiDropdownMenuContent,
5
6
  TldrawUiDropdownMenuRoot,
@@ -12,6 +13,10 @@ import { EmojiPicker, type EmojiPickerProps } from './emoji-picker'
12
13
  import { SmileyIcon } from './icons'
13
14
  import { RenderReaction } from './reaction'
14
15
 
16
+ const messages = defineMessages({
17
+ commentsAddReaction: { id: 'comments.add-reaction', defaultMessage: 'Add reaction' },
18
+ })
19
+
15
20
  /** @public */
16
21
  export interface ReactionPickerProps {
17
22
  /** The emoji to offer. Defaults to `DEFAULT_REACTION_EMOJI`. */
@@ -92,8 +97,8 @@ export function ReactionPicker({
92
97
  <TldrawUiDropdownMenuTrigger>
93
98
  <TldrawUiButton
94
99
  type="icon"
95
- tooltip={msg('comments.add-reaction')}
96
- title={msg('comments.add-reaction')}
100
+ tooltip={msg(messages.commentsAddReaction)}
101
+ title={msg(messages.commentsAddReaction)}
97
102
  className={className}
98
103
  >
99
104
  <SmileyIcon />
@@ -0,0 +1,22 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { DefaultReactionTooltipContent } from './reaction'
4
+
5
+ function sentence(...names: string[]) {
6
+ return renderToStaticMarkup(
7
+ <DefaultReactionTooltipContent
8
+ reactors={names.map((name) => ({ name, you: name === 'You' }))}
9
+ />
10
+ )
11
+ }
12
+
13
+ describe('DefaultReactionTooltipContent', () => {
14
+ // Rendered outside a translation provider, so this is the shipped English of `comments.reacted`.
15
+ it('picks the phrasing by number of reactors', () => {
16
+ expect(sentence('You')).toBe('You reacted')
17
+ expect(sentence('You', 'Bo')).toBe('You and Bo reacted')
18
+ expect(sentence('Ada', 'Bo', 'Cy')).toBe('Ada, Bo and Cy reacted')
19
+ expect(sentence('Ada', 'Bo', 'Cy', 'Di')).toBe('Ada, Bo, Cy and 1 other reacted')
20
+ expect(sentence('Ada', 'Bo', 'Cy', 'Di', 'Ed', 'Fi')).toBe('Ada, Bo, Cy and 3 others reacted')
21
+ })
22
+ })
@@ -1,7 +1,16 @@
1
1
  import { ComponentType, ReactNode } from 'react'
2
- import { TldrawUiTooltip, useTranslation } from 'tldraw'
2
+ import { defineMessages, TldrawUiTooltip, useTranslation } from 'tldraw'
3
3
  import { ReactionReactor } from './reactions'
4
4
 
5
+ const messages = defineMessages({
6
+ commentsMentionYou: { id: 'comments.mention-you', defaultMessage: 'You' },
7
+ commentsReacted: {
8
+ id: 'comments.reacted',
9
+ defaultMessage:
10
+ '{count, plural, =1 {{a} reacted} =2 {{a} and {b} reacted} =3 {{a}, {b} and {c} reacted} other {{a}, {b}, {c} and {others, plural, one {# other} other {# others}} reacted}}',
11
+ },
12
+ })
13
+
5
14
  /** Render a reaction token to its visual — the emoji glyph by default. @public */
6
15
  export type RenderReaction = (token: string) => ReactNode
7
16
 
@@ -99,25 +108,27 @@ export function DefaultReactionTooltip({ reactors, children }: ReactionTooltipPr
99
108
  /**
100
109
  * The default reactor sentence naming who reacted — up to three names spelled out, then "and N
101
110
  * others" (e.g. "You reacted", "You and Bo reacted", "You, Bo and Ada reacted", "You, Bo, Ada and 2
102
- * others reacted"). The wording lives in the `comments.reacted-*` translation strings so each locale
103
- * controls the grammar. Exported so a custom `ReactionTooltip` can reuse it inside its own box.
111
+ * others reacted"). The wording lives in the `comments.reacted` translation string, whose ICU
112
+ * plural picks the phrasing, so each locale controls both the grammar and how it counts. Exported
113
+ * so a custom `ReactionTooltip` can reuse it inside its own box.
104
114
  * @public @react
105
115
  */
106
116
  export function DefaultReactionTooltipContent({ reactors }: { reactors: ReactionReactor[] }) {
107
117
  const msg = useTranslation()
108
118
  const names = reactors.map((reactor) =>
109
- reactor.you ? msg('comments.mention-you') : reactor.name
119
+ reactor.you ? msg(messages.commentsMentionYou) : reactor.name
110
120
  )
111
121
  if (names.length === 0) return null
112
122
  const [a, b, c] = names
113
- const others = names.length - 3
114
- const key =
115
- names.length <= 3
116
- ? `comments.reacted-${names.length}`
117
- : others === 1
118
- ? 'comments.reacted-more-one'
119
- : 'comments.reacted-more'
120
- // Single pass, so a substituted name containing a placeholder can't be re-substituted.
121
- const vars: Record<string, string | undefined> = { a, b, c, count: String(others) }
122
- return <>{msg(key).replace(/\{(a|b|c|count)\}/g, (_, k) => vars[k] ?? '')}</>
123
+ return (
124
+ <>
125
+ {msg(messages.commentsReacted, {
126
+ count: names.length,
127
+ others: names.length - 3,
128
+ a,
129
+ b,
130
+ c,
131
+ })}
132
+ </>
133
+ )
123
134
  }
@@ -1,4 +1,11 @@
1
- import { useTranslation } from 'tldraw'
1
+ import { defineMessages, useTranslation } from 'tldraw'
2
+
3
+ const messages = defineMessages({
4
+ commentsReplies: {
5
+ id: 'comments.replies',
6
+ defaultMessage: '{count, plural, one {# reply} other {# replies}}',
7
+ },
8
+ })
2
9
 
3
10
  /**
4
11
  * The localized "N replies" label (e.g. "1 reply", "3 replies") for a thread with `replyCount`
@@ -11,6 +18,5 @@ export function replyCountLabel(
11
18
  replyCount: number
12
19
  ): string | null {
13
20
  if (replyCount <= 0) return null
14
- const key = replyCount === 1 ? 'comments.replies-one' : 'comments.replies'
15
- return msg(key).replace('{count}', String(replyCount))
21
+ return msg(messages.commentsReplies, { count: replyCount })
16
22
  }