@tldraw/commenting 5.6.0-next.d6710de420bf → 5.6.0-next.fb83f87b7388
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-cjs/canvas/cluster-badge.js +7 -1
- package/dist-cjs/canvas/cluster-badge.js.map +2 -2
- package/dist-cjs/canvas/comment-reactions.js +10 -4
- package/dist-cjs/canvas/comment-reactions.js.map +2 -2
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-tool.js +35 -15
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +13 -6
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-menu-item.js +4 -1
- package/dist-cjs/canvas/comments-menu-item.js.map +2 -2
- package/dist-cjs/canvas/comments-overflow-menu.js +9 -4
- package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-sidebar.js +14 -5
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/comments-visibility-toggle.js +5 -1
- package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
- package/dist-cjs/canvas/options.js.map +1 -1
- package/dist-cjs/canvas/pending-composer.js +7 -2
- package/dist-cjs/canvas/pending-composer.js.map +2 -2
- package/dist-cjs/canvas/thread-pin.js +13 -2
- package/dist-cjs/canvas/thread-pin.js.map +2 -2
- package/dist-cjs/canvas/thread-preview.js +4 -1
- package/dist-cjs/canvas/thread-preview.js.map +2 -2
- package/dist-cjs/canvas/thread-stack.js +18 -4
- package/dist-cjs/canvas/thread-stack.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +40 -20
- package/dist-cjs/canvas/thread-view.js.map +2 -2
- package/dist-cjs/index.d.ts +28 -8
- package/dist-cjs/index.js +2 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/ui/comment-composer.js +5 -0
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/gesture-focus.js +37 -0
- package/dist-cjs/ui/gesture-focus.js.map +7 -0
- package/dist-cjs/ui/reaction-picker.js +5 -2
- package/dist-cjs/ui/reaction-picker.js.map +2 -2
- package/dist-cjs/ui/reaction.js +15 -5
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-cjs/ui/reply-count.js +8 -2
- package/dist-cjs/ui/reply-count.js.map +2 -2
- package/dist-esm/canvas/cluster-badge.mjs +8 -1
- package/dist-esm/canvas/cluster-badge.mjs.map +2 -2
- package/dist-esm/canvas/comment-reactions.mjs +12 -4
- package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-tool.mjs +37 -16
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +14 -6
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-menu-item.mjs +5 -2
- package/dist-esm/canvas/comments-menu-item.mjs.map +2 -2
- package/dist-esm/canvas/comments-overflow-menu.mjs +10 -4
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-sidebar.mjs +21 -6
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs +6 -2
- package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
- package/dist-esm/canvas/options.mjs.map +1 -1
- package/dist-esm/canvas/pending-composer.mjs +8 -2
- package/dist-esm/canvas/pending-composer.mjs.map +2 -2
- package/dist-esm/canvas/thread-pin.mjs +15 -3
- package/dist-esm/canvas/thread-pin.mjs.map +2 -2
- package/dist-esm/canvas/thread-preview.mjs +5 -1
- package/dist-esm/canvas/thread-preview.mjs.map +2 -2
- package/dist-esm/canvas/thread-stack.mjs +19 -4
- package/dist-esm/canvas/thread-stack.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +42 -21
- package/dist-esm/canvas/thread-view.mjs.map +2 -2
- package/dist-esm/index.d.mts +28 -8
- package/dist-esm/index.mjs +8 -2
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/ui/comment-composer.mjs +5 -0
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/gesture-focus.mjs +17 -0
- package/dist-esm/ui/gesture-focus.mjs.map +7 -0
- package/dist-esm/ui/reaction-picker.mjs +6 -2
- package/dist-esm/ui/reaction-picker.mjs.map +2 -2
- package/dist-esm/ui/reaction.mjs +16 -8
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/dist-esm/ui/reply-count.mjs +8 -2
- package/dist-esm/ui/reply-count.mjs.map +2 -2
- package/package.json +5 -5
- package/src/canvas/cluster-badge.tsx +9 -1
- package/src/canvas/comment-reactions.tsx +18 -7
- package/src/canvas/comment-render.ts +6 -4
- package/src/canvas/comment-tool.test.ts +83 -7
- package/src/canvas/comment-tool.tsx +61 -19
- package/src/canvas/comments-filter-menu.tsx +15 -6
- package/src/canvas/comments-menu-item.tsx +6 -2
- package/src/canvas/comments-overflow-menu.tsx +11 -4
- package/src/canvas/comments-sidebar.tsx +23 -6
- package/src/canvas/comments-visibility-toggle.tsx +7 -2
- package/src/canvas/options.ts +1 -1
- package/src/canvas/pending-composer.tsx +11 -2
- package/src/canvas/thread-pin.tsx +17 -4
- package/src/canvas/thread-preview.tsx +6 -1
- package/src/canvas/thread-stack.tsx +21 -4
- package/src/canvas/thread-view.tsx +44 -22
- package/src/index.ts +6 -1
- package/src/ui/comment-composer.tsx +9 -0
- package/src/ui/gesture-focus.test.ts +45 -0
- package/src/ui/gesture-focus.ts +29 -0
- package/src/ui/reaction-picker.tsx +7 -2
- package/src/ui/reaction.test.tsx +22 -0
- package/src/ui/reaction.tsx +25 -14
- package/src/ui/reply-count.ts +9 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/ui/comment-composer.tsx"],
|
|
4
|
-
"sourcesContent": ["import { JSONContent } from '@tiptap/core'\nimport { EditorContent, useEditor } from '@tiptap/react'\nimport {\n\tAvatar,\n\ttype CommentAuthor,\n\tcreateMentionExtension,\n\tcreateMentionSuggestion,\n\tisMentionPickerOpen,\n\tMentionMember,\n} from '@tldraw/mentions'\nimport {\n\ttype MouseEvent as ReactMouseEvent,\n\tReactNode,\n\tuseEffect,\n\tuseLayoutEffect,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n} from 'react'\nimport { isEqual, TLRichText, useMaybeEditor } from 'tldraw'\nimport { commentTipTapExtensions, EMPTY_COMMENT, isCommentEmpty } from './comment-extensions'\nimport { SendButton } from './send-button'\n\n/** @public */\nexport interface CommentComposerProps {\n\tauthor: CommentAuthor\n\tplaceholder: string\n\t/** Controlled rich-text value. Omit for the presentational (display-only) composer. */\n\tvalue?: TLRichText\n\tonChange?(value: TLRichText): void\n\t/** Called on Send click or Enter. When set, the composer is interactive. */\n\tonSubmit?(): void\n\tsendLabel?: string\n\t/** Called when Up is pressed in an empty composer \u2014 e.g. to start editing the comment above,\n\t * the way chat apps edit your last message. With content in the field, Up moves the cursor. */\n\tonArrowUpWhenEmpty?(): void\n\tdisabled?: boolean\n\tautoFocus?: boolean\n\t/** The leading element before the field. Defaults to the author's avatar. */\n\tleading?: ReactNode\n\t/** Resolve the members matching an `@`-query (sync or async). Provide to enable mentions. */\n\tgetMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>\n\t/** Override a picker row's content. Defaults to avatar + name (+ secondary). */\n\trenderMentionSuggestion?(member: MentionMember): ReactNode\n}\n\n/**\n * The input for writing a comment: a TipTap rich-text editor restricted to the comment extension\n * set (bold, italic, lists, links, code, highlight \u2014 no headings), with a Send button. Formatting\n * is applied through markdown and keyboard shortcuts (e.g. `**bold**`, `- `, Cmd+B); there's no\n * floating toolbar. Presentational by default; pass value/onChange/onSubmit to drive it as a form.\n * @public @react\n */\nexport function CommentComposer({\n\tauthor,\n\tplaceholder,\n\tvalue,\n\tonChange,\n\tonSubmit,\n\tsendLabel = 'Send',\n\tonArrowUpWhenEmpty,\n\tdisabled,\n\tautoFocus,\n\tleading,\n\tgetMentionSuggestions,\n\trenderMentionSuggestion,\n}: CommentComposerProps) {\n\tconst interactive = !!onChange || !!onSubmit\n\t// Lets the mention popup track the camera. Null outside a tldraw editor.\n\tconst tlEditor = useMaybeEditor()\n\n\t// Read through refs so the editor isn't recreated when callback identity changes.\n\tconst onChangeRef = useRef(onChange)\n\tonChangeRef.current = onChange\n\tconst onSubmitRef = useRef(onSubmit)\n\tonSubmitRef.current = onSubmit\n\tconst onArrowUpWhenEmptyRef = useRef(onArrowUpWhenEmpty)\n\tonArrowUpWhenEmptyRef.current = onArrowUpWhenEmpty\n\tconst disabledRef = useRef(disabled)\n\tdisabledRef.current = disabled\n\tconst getMentionSuggestionsRef = useRef(getMentionSuggestions)\n\tgetMentionSuggestionsRef.current = getMentionSuggestions\n\tconst renderMentionSuggestionRef = useRef(renderMentionSuggestion)\n\trenderMentionSuggestionRef.current = renderMentionSuggestion\n\n\tconst [isEmpty, setIsEmpty] = useState(() => !value || isCommentEmpty(value))\n\t// Whether the field has grown to two rows: input across the full width, send button below.\n\t// Flips when the (single-line) content width reaches the space left beside the send button.\n\tconst [expanded, setExpanded] = useState(false)\n\tconst expandedRef = useRef(expanded)\n\texpandedRef.current = expanded\n\tconst inputWrapRef = useRef<HTMLDivElement>(null)\n\tconst mirrorRef = useRef<HTMLDivElement>(null)\n\n\t// Measure whether the content still fits on one line beside the send button, against a hidden\n\t// nowrap clone so marks and mention chips measure at their true width. The gap between the\n\t// expand and collapse thresholds is a dead zone, so the layout can't flap at the boundary.\n\tconst remeasure = () => {\n\t\tconst wrap = inputWrapRef.current\n\t\tconst mirror = mirrorRef.current\n\t\tconst editor = editorRef.current\n\t\tif (!wrap || !mirror || !editor) return\n\t\t// Empty always fits \u2014 and measuring before TipTap has laid out reads zero widths, flashing\n\t\t// the expanded layout for a frame on mount.\n\t\tif (editor.isEmpty) {\n\t\t\tif (expandedRef.current) setExpanded(false)\n\t\t\treturn\n\t\t}\n\t\tif (wrap.clientWidth === 0) return\n\t\tconst doc = editor.state.doc\n\t\tconst multiBlock =\n\t\t\tdoc.childCount > 1 || (doc.firstChild !== null && doc.firstChild.type.name !== 'paragraph')\n\t\tif (multiBlock) {\n\t\t\tif (!expandedRef.current) setExpanded(true)\n\t\t\treturn\n\t\t}\n\t\tconst send = wrap.parentElement?.querySelector<HTMLElement>('.tlui-cmt-send')\n\t\tconst input = wrap.querySelector('.tlui-cmt-input')\n\t\tif (!send || !input) return\n\t\tmirror.innerHTML = input.innerHTML\n\t\tconst textWidth = mirror.offsetWidth\n\t\t// Measured against the wrap's *content* box: text wraps at clientWidth minus padding, so\n\t\t// without subtracting it the input grows a line before expansion fires and then snaps.\n\t\tconst wrapStyle = getComputedStyle(wrap)\n\t\tconst wrapPadX = parseFloat(wrapStyle.paddingLeft) + parseFloat(wrapStyle.paddingRight)\n\t\tconst collapsedAvailable =\n\t\t\twrap.clientWidth - wrapPadX - (expandedRef.current ? send.offsetWidth + 6 : 0)\n\t\tif (!expandedRef.current && textWidth > collapsedAvailable - 8) {\n\t\t\tsetExpanded(true)\n\t\t} else if (expandedRef.current && textWidth < collapsedAvailable - 24) {\n\t\t\tsetExpanded(false)\n\t\t}\n\t}\n\tconst remeasureRef = useRef(remeasure)\n\tremeasureRef.current = remeasure\n\n\t// Reachable from `handleKeyDown`, which is created before `useEditor` returns.\n\tconst editorRef = useRef<ReturnType<typeof useEditor>>(null)\n\n\t// `commands.enter()` re-dispatches through `handleKeyDown`; without this guard our own handler\n\t// would catch the synthetic Enter and submit.\n\tconst replayingEnter = useRef(false)\n\n\t// The suggestion plugin is built once and runs outside React, so it reads the mention callbacks\n\t// through refs \u2014 otherwise it queries the roster present at mount forever, never seeing a member\n\t// who joins later. Whether mentions are wired at all is fixed at mount; the callbacks are live.\n\tconst mentionsEnabled = !!getMentionSuggestions\n\tconst hasCustomRow = !!renderMentionSuggestion\n\tconst extensions = useMemo(() => {\n\t\tconst list = [...commentTipTapExtensions]\n\t\t// The mention node is always registered, or ProseMirror strips existing mentions when an\n\t\t// edited body loads. Only the `@` trigger is gated on the host providing a resolver.\n\t\tif (mentionsEnabled) {\n\t\t\tconst resolveSuggestions = (query: string) => getMentionSuggestionsRef.current?.(query) ?? []\n\t\t\tconst renderRow = hasCustomRow\n\t\t\t\t? (member: MentionMember) => renderMentionSuggestionRef.current?.(member)\n\t\t\t\t: undefined\n\t\t\tlist.push(\n\t\t\t\tcreateMentionExtension({\n\t\t\t\t\tsuggestion: createMentionSuggestion(resolveSuggestions, {\n\t\t\t\t\t\trenderMember: renderRow,\n\t\t\t\t\t\teditor: tlEditor,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t)\n\t\t} else {\n\t\t\tlist.push(createMentionExtension({ suggestion: { char: '@', allow: () => false } }))\n\t\t}\n\t\treturn list\n\t}, [mentionsEnabled, hasCustomRow, tlEditor])\n\n\tconst editor = useEditor(\n\t\t{\n\t\t\textensions,\n\t\t\tcontent: (value ?? EMPTY_COMMENT) as JSONContent,\n\t\t\teditable: interactive,\n\t\t\t// tldraw ships its own TextDirection extension, so TipTap's core one would warn about a\n\t\t\t// duplicate. Mirrors RichTextArea's setup.\n\t\t\tenableCoreExtensions: { textDirection: false },\n\t\t\ttextDirection: 'auto',\n\t\t\teditorProps: {\n\t\t\t\tattributes: {\n\t\t\t\t\tclass: 'tlui-cmt-input',\n\t\t\t\t\t'aria-label': placeholder,\n\t\t\t\t\trole: 'textbox',\n\t\t\t\t\t'aria-multiline': 'true',\n\t\t\t\t},\n\t\t\t\t// Runs before every keymap plugin, so it can distinguish Shift+Enter from Enter \u2014 an\n\t\t\t\t// `Enter` keymap binding also fires on Shift+Enter and would otherwise swallow it.\n\t\t\t\thandleKeyDown: (_view, event) => {\n\t\t\t\t\t// Let the keymaps handle the synthetic Enter we replay for a Shift+Enter newline.\n\t\t\t\t\tif (replayingEnter.current) return false\n\t\t\t\t\t// Up in an empty composer hands off to the host. With content it stays cursor\n\t\t\t\t\t// movement, and with the picker open it navigates the roster.\n\t\t\t\t\tif (event.key === 'ArrowUp' && !event.isComposing) {\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tonArrowUpWhenEmptyRef.current &&\n\t\t\t\t\t\t\teditorRef.current?.isEmpty &&\n\t\t\t\t\t\t\t!isMentionPickerOpen()\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tonArrowUpWhenEmptyRef.current()\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\tif (event.key !== 'Enter' || event.isComposing) return false\n\t\t\t\t\t// While the @-mention picker is open, Enter selects the highlighted member \u2014 defer.\n\t\t\t\t\tif (isMentionPickerOpen()) return false\n\t\t\t\t\t// Shift+Enter inserts a new line by replaying a plain Enter through the keymaps, reusing\n\t\t\t\t\t// the editor's list-aware handling. tldraw doesn't do soft breaks.\n\t\t\t\t\tif (event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {\n\t\t\t\t\t\t// An empty field would open the comment with a stray leading blank line.\n\t\t\t\t\t\tif (editorRef.current?.isEmpty) return true\n\t\t\t\t\t\treplayingEnter.current = true\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\teditorRef.current?.commands.enter()\n\t\t\t\t\t\t} finally {\n\t\t\t\t\t\t\treplayingEnter.current = false\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\t// Enter, Cmd+Enter, and Ctrl+Enter submit.\n\t\t\t\t\tif (!disabledRef.current) onSubmitRef.current?.()\n\t\t\t\t\treturn true\n\t\t\t\t},\n\t\t\t},\n\t\t\tonUpdate: ({ editor }) => {\n\t\t\t\tsetIsEmpty(editor.isEmpty)\n\t\t\t\t// Same-value state sets don't re-render, and the DOM is already updated here.\n\t\t\t\tremeasureRef.current()\n\t\t\t\tonChangeRef.current?.(editor.getJSON() as TLRichText)\n\t\t\t},\n\t\t},\n\t\t[interactive, placeholder]\n\t)\n\teditorRef.current = editor\n\n\t// Sync controlled resets (e.g. the parent clearing to EMPTY_COMMENT after posting) into the\n\t// editor without echoing back the value the editor itself just emitted.\n\tuseEffect(() => {\n\t\tif (!editor || value === undefined) return\n\t\tif (isEqual(editor.getJSON(), value)) return\n\t\teditor.commands.setContent(value as JSONContent)\n\t\tsetIsEmpty(editor.isEmpty)\n\t}, [editor, value])\n\n\t// Measure once the editor's content is in the DOM, so a composer that mounts pre-filled (the\n\t// edit-in-place composer) starts in the right layout.\n\tuseLayoutEffect(() => {\n\t\tremeasureRef.current()\n\t}, [editor, value])\n\n\t// Focus on the next frame rather than via TipTap's autofocus: the composer often mounts from a\n\t// canvas pointer event whose default focus handling would otherwise steal it back.\n\tuseEffect(() => {\n\t\tif (!autoFocus || !editor) return\n\t\tconst raf = requestAnimationFrame(() => editor.commands.focus('end'))\n\t\treturn () => cancelAnimationFrame(raf)\n\t}, [autoFocus, editor])\n\n\t// The whole field behaves like the text input: clicking its empty area (the padding, or the\n\t// space beside/below a short line) focuses the editor rather than only the text glyphs being\n\t// clickable. The input (caret placement) and the send button keep their own click handling.\n\tconst focusEditorFromField = (e: ReactMouseEvent<HTMLDivElement>) => {\n\t\tif ((e.target as HTMLElement).closest('.tlui-cmt-input, .tlui-cmt-send')) return\n\t\te.preventDefault()\n\t\teditor?.commands.focus('end')\n\t}\n\n\treturn (\n\t\t<div className=\"tlui-cmt-composer\">\n\t\t\t{leading ?? <Avatar author={author} />}\n\t\t\t<div\n\t\t\t\tclassName={[\n\t\t\t\t\t'tlui-cmt-composer__field',\n\t\t\t\t\tinteractive && expanded && 'tlui-cmt-composer__field--expanded',\n\t\t\t\t]\n\t\t\t\t\t.filter(Boolean)\n\t\t\t\t\t.join(' ')}\n\t\t\t\tonMouseDown={interactive ? focusEditorFromField : undefined}\n\t\t\t>\n\t\t\t\t<div className=\"tlui-cmt-composer__input-wrap\" ref={inputWrapRef}>\n\t\t\t\t\t<EditorContent editor={editor} />\n\t\t\t\t\t{isEmpty && (\n\t\t\t\t\t\t<div className=\"tlui-cmt-input__placeholder\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t{placeholder}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"tlui-cmt-composer__mirror tlui-cmt-input\"\n\t\t\t\t\t\tref={mirrorRef}\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t{interactive && <SendButton label={sendLabel} onClick={onSubmit} disabled={disabled} />}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import { JSONContent } from '@tiptap/core'\nimport { EditorContent, useEditor } from '@tiptap/react'\nimport {\n\tAvatar,\n\ttype CommentAuthor,\n\tcreateMentionExtension,\n\tcreateMentionSuggestion,\n\tisMentionPickerOpen,\n\tMentionMember,\n} from '@tldraw/mentions'\nimport {\n\ttype MouseEvent as ReactMouseEvent,\n\tReactNode,\n\tuseEffect,\n\tuseLayoutEffect,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n} from 'react'\nimport { isEqual, TLRichText, useMaybeEditor } from 'tldraw'\nimport { commentTipTapExtensions, EMPTY_COMMENT, isCommentEmpty } from './comment-extensions'\nimport { focusOnGestureEnd } from './gesture-focus'\nimport { SendButton } from './send-button'\n\n/** @public */\nexport interface CommentComposerProps {\n\tauthor: CommentAuthor\n\tplaceholder: string\n\t/** Controlled rich-text value. Omit for the presentational (display-only) composer. */\n\tvalue?: TLRichText\n\tonChange?(value: TLRichText): void\n\t/** Called on Send click or Enter. When set, the composer is interactive. */\n\tonSubmit?(): void\n\tsendLabel?: string\n\t/** Called when Up is pressed in an empty composer \u2014 e.g. to start editing the comment above,\n\t * the way chat apps edit your last message. With content in the field, Up moves the cursor. */\n\tonArrowUpWhenEmpty?(): void\n\tdisabled?: boolean\n\tautoFocus?: boolean\n\t/** The leading element before the field. Defaults to the author's avatar. */\n\tleading?: ReactNode\n\t/** Resolve the members matching an `@`-query (sync or async). Provide to enable mentions. */\n\tgetMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>\n\t/** Override a picker row's content. Defaults to avatar + name (+ secondary). */\n\trenderMentionSuggestion?(member: MentionMember): ReactNode\n}\n\n/**\n * The input for writing a comment: a TipTap rich-text editor restricted to the comment extension\n * set (bold, italic, lists, links, code, highlight \u2014 no headings), with a Send button. Formatting\n * is applied through markdown and keyboard shortcuts (e.g. `**bold**`, `- `, Cmd+B); there's no\n * floating toolbar. Presentational by default; pass value/onChange/onSubmit to drive it as a form.\n * @public @react\n */\nexport function CommentComposer({\n\tauthor,\n\tplaceholder,\n\tvalue,\n\tonChange,\n\tonSubmit,\n\tsendLabel = 'Send',\n\tonArrowUpWhenEmpty,\n\tdisabled,\n\tautoFocus,\n\tleading,\n\tgetMentionSuggestions,\n\trenderMentionSuggestion,\n}: CommentComposerProps) {\n\tconst interactive = !!onChange || !!onSubmit\n\t// Lets the mention popup track the camera. Null outside a tldraw editor.\n\tconst tlEditor = useMaybeEditor()\n\n\t// Read through refs so the editor isn't recreated when callback identity changes.\n\tconst onChangeRef = useRef(onChange)\n\tonChangeRef.current = onChange\n\tconst onSubmitRef = useRef(onSubmit)\n\tonSubmitRef.current = onSubmit\n\tconst onArrowUpWhenEmptyRef = useRef(onArrowUpWhenEmpty)\n\tonArrowUpWhenEmptyRef.current = onArrowUpWhenEmpty\n\tconst disabledRef = useRef(disabled)\n\tdisabledRef.current = disabled\n\tconst getMentionSuggestionsRef = useRef(getMentionSuggestions)\n\tgetMentionSuggestionsRef.current = getMentionSuggestions\n\tconst renderMentionSuggestionRef = useRef(renderMentionSuggestion)\n\trenderMentionSuggestionRef.current = renderMentionSuggestion\n\n\tconst [isEmpty, setIsEmpty] = useState(() => !value || isCommentEmpty(value))\n\t// Whether the field has grown to two rows: input across the full width, send button below.\n\t// Flips when the (single-line) content width reaches the space left beside the send button.\n\tconst [expanded, setExpanded] = useState(false)\n\tconst expandedRef = useRef(expanded)\n\texpandedRef.current = expanded\n\tconst inputWrapRef = useRef<HTMLDivElement>(null)\n\tconst mirrorRef = useRef<HTMLDivElement>(null)\n\n\t// Measure whether the content still fits on one line beside the send button, against a hidden\n\t// nowrap clone so marks and mention chips measure at their true width. The gap between the\n\t// expand and collapse thresholds is a dead zone, so the layout can't flap at the boundary.\n\tconst remeasure = () => {\n\t\tconst wrap = inputWrapRef.current\n\t\tconst mirror = mirrorRef.current\n\t\tconst editor = editorRef.current\n\t\tif (!wrap || !mirror || !editor) return\n\t\t// Empty always fits \u2014 and measuring before TipTap has laid out reads zero widths, flashing\n\t\t// the expanded layout for a frame on mount.\n\t\tif (editor.isEmpty) {\n\t\t\tif (expandedRef.current) setExpanded(false)\n\t\t\treturn\n\t\t}\n\t\tif (wrap.clientWidth === 0) return\n\t\tconst doc = editor.state.doc\n\t\tconst multiBlock =\n\t\t\tdoc.childCount > 1 || (doc.firstChild !== null && doc.firstChild.type.name !== 'paragraph')\n\t\tif (multiBlock) {\n\t\t\tif (!expandedRef.current) setExpanded(true)\n\t\t\treturn\n\t\t}\n\t\tconst send = wrap.parentElement?.querySelector<HTMLElement>('.tlui-cmt-send')\n\t\tconst input = wrap.querySelector('.tlui-cmt-input')\n\t\tif (!send || !input) return\n\t\tmirror.innerHTML = input.innerHTML\n\t\tconst textWidth = mirror.offsetWidth\n\t\t// Measured against the wrap's *content* box: text wraps at clientWidth minus padding, so\n\t\t// without subtracting it the input grows a line before expansion fires and then snaps.\n\t\tconst wrapStyle = getComputedStyle(wrap)\n\t\tconst wrapPadX = parseFloat(wrapStyle.paddingLeft) + parseFloat(wrapStyle.paddingRight)\n\t\tconst collapsedAvailable =\n\t\t\twrap.clientWidth - wrapPadX - (expandedRef.current ? send.offsetWidth + 6 : 0)\n\t\tif (!expandedRef.current && textWidth > collapsedAvailable - 8) {\n\t\t\tsetExpanded(true)\n\t\t} else if (expandedRef.current && textWidth < collapsedAvailable - 24) {\n\t\t\tsetExpanded(false)\n\t\t}\n\t}\n\tconst remeasureRef = useRef(remeasure)\n\tremeasureRef.current = remeasure\n\n\t// Reachable from `handleKeyDown`, which is created before `useEditor` returns.\n\tconst editorRef = useRef<ReturnType<typeof useEditor>>(null)\n\n\t// `commands.enter()` re-dispatches through `handleKeyDown`; without this guard our own handler\n\t// would catch the synthetic Enter and submit.\n\tconst replayingEnter = useRef(false)\n\n\t// The suggestion plugin is built once and runs outside React, so it reads the mention callbacks\n\t// through refs \u2014 otherwise it queries the roster present at mount forever, never seeing a member\n\t// who joins later. Whether mentions are wired at all is fixed at mount; the callbacks are live.\n\tconst mentionsEnabled = !!getMentionSuggestions\n\tconst hasCustomRow = !!renderMentionSuggestion\n\tconst extensions = useMemo(() => {\n\t\tconst list = [...commentTipTapExtensions]\n\t\t// The mention node is always registered, or ProseMirror strips existing mentions when an\n\t\t// edited body loads. Only the `@` trigger is gated on the host providing a resolver.\n\t\tif (mentionsEnabled) {\n\t\t\tconst resolveSuggestions = (query: string) => getMentionSuggestionsRef.current?.(query) ?? []\n\t\t\tconst renderRow = hasCustomRow\n\t\t\t\t? (member: MentionMember) => renderMentionSuggestionRef.current?.(member)\n\t\t\t\t: undefined\n\t\t\tlist.push(\n\t\t\t\tcreateMentionExtension({\n\t\t\t\t\tsuggestion: createMentionSuggestion(resolveSuggestions, {\n\t\t\t\t\t\trenderMember: renderRow,\n\t\t\t\t\t\teditor: tlEditor,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t)\n\t\t} else {\n\t\t\tlist.push(createMentionExtension({ suggestion: { char: '@', allow: () => false } }))\n\t\t}\n\t\treturn list\n\t}, [mentionsEnabled, hasCustomRow, tlEditor])\n\n\tconst editor = useEditor(\n\t\t{\n\t\t\textensions,\n\t\t\tcontent: (value ?? EMPTY_COMMENT) as JSONContent,\n\t\t\teditable: interactive,\n\t\t\t// tldraw ships its own TextDirection extension, so TipTap's core one would warn about a\n\t\t\t// duplicate. Mirrors RichTextArea's setup.\n\t\t\tenableCoreExtensions: { textDirection: false },\n\t\t\ttextDirection: 'auto',\n\t\t\teditorProps: {\n\t\t\t\tattributes: {\n\t\t\t\t\tclass: 'tlui-cmt-input',\n\t\t\t\t\t'aria-label': placeholder,\n\t\t\t\t\trole: 'textbox',\n\t\t\t\t\t'aria-multiline': 'true',\n\t\t\t\t},\n\t\t\t\t// Runs before every keymap plugin, so it can distinguish Shift+Enter from Enter \u2014 an\n\t\t\t\t// `Enter` keymap binding also fires on Shift+Enter and would otherwise swallow it.\n\t\t\t\thandleKeyDown: (_view, event) => {\n\t\t\t\t\t// Let the keymaps handle the synthetic Enter we replay for a Shift+Enter newline.\n\t\t\t\t\tif (replayingEnter.current) return false\n\t\t\t\t\t// Up in an empty composer hands off to the host. With content it stays cursor\n\t\t\t\t\t// movement, and with the picker open it navigates the roster.\n\t\t\t\t\tif (event.key === 'ArrowUp' && !event.isComposing) {\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tonArrowUpWhenEmptyRef.current &&\n\t\t\t\t\t\t\teditorRef.current?.isEmpty &&\n\t\t\t\t\t\t\t!isMentionPickerOpen()\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tonArrowUpWhenEmptyRef.current()\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\tif (event.key !== 'Enter' || event.isComposing) return false\n\t\t\t\t\t// While the @-mention picker is open, Enter selects the highlighted member \u2014 defer.\n\t\t\t\t\tif (isMentionPickerOpen()) return false\n\t\t\t\t\t// Shift+Enter inserts a new line by replaying a plain Enter through the keymaps, reusing\n\t\t\t\t\t// the editor's list-aware handling. tldraw doesn't do soft breaks.\n\t\t\t\t\tif (event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {\n\t\t\t\t\t\t// An empty field would open the comment with a stray leading blank line.\n\t\t\t\t\t\tif (editorRef.current?.isEmpty) return true\n\t\t\t\t\t\treplayingEnter.current = true\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\teditorRef.current?.commands.enter()\n\t\t\t\t\t\t} finally {\n\t\t\t\t\t\t\treplayingEnter.current = false\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\t// Enter, Cmd+Enter, and Ctrl+Enter submit.\n\t\t\t\t\tif (!disabledRef.current) onSubmitRef.current?.()\n\t\t\t\t\treturn true\n\t\t\t\t},\n\t\t\t},\n\t\t\tonUpdate: ({ editor }) => {\n\t\t\t\tsetIsEmpty(editor.isEmpty)\n\t\t\t\t// Same-value state sets don't re-render, and the DOM is already updated here.\n\t\t\t\tremeasureRef.current()\n\t\t\t\tonChangeRef.current?.(editor.getJSON() as TLRichText)\n\t\t\t},\n\t\t},\n\t\t[interactive, placeholder]\n\t)\n\teditorRef.current = editor\n\n\t// Sync controlled resets (e.g. the parent clearing to EMPTY_COMMENT after posting) into the\n\t// editor without echoing back the value the editor itself just emitted.\n\tuseEffect(() => {\n\t\tif (!editor || value === undefined) return\n\t\tif (isEqual(editor.getJSON(), value)) return\n\t\teditor.commands.setContent(value as JSONContent)\n\t\tsetIsEmpty(editor.isEmpty)\n\t}, [editor, value])\n\n\t// Measure once the editor's content is in the DOM, so a composer that mounts pre-filled (the\n\t// edit-in-place composer) starts in the right layout.\n\tuseLayoutEffect(() => {\n\t\tremeasureRef.current()\n\t}, [editor, value])\n\n\t// Focus on the next frame rather than via TipTap's autofocus: the composer often mounts from a\n\t// canvas pointer event whose default focus handling would otherwise steal it back.\n\tuseEffect(() => {\n\t\tif (!autoFocus || !editor) return\n\t\tconst raf = requestAnimationFrame(() => editor.commands.focus('end'))\n\t\treturn () => cancelAnimationFrame(raf)\n\t}, [autoFocus, editor])\n\n\t// The frame-later focus above places the caret but can't raise iOS's software keyboard, leaving\n\t// the composer looking ready while it swallows every keystroke. Focus once more from the release\n\t// that ends the placing gesture, which can.\n\tuseEffect(() => {\n\t\tif (!autoFocus || !editor) return\n\t\treturn focusOnGestureEnd(editor.view.dom.ownerDocument, () => editor.commands.focus('end'))\n\t}, [autoFocus, editor])\n\n\t// The whole field behaves like the text input: clicking its empty area (the padding, or the\n\t// space beside/below a short line) focuses the editor rather than only the text glyphs being\n\t// clickable. The input (caret placement) and the send button keep their own click handling.\n\tconst focusEditorFromField = (e: ReactMouseEvent<HTMLDivElement>) => {\n\t\tif ((e.target as HTMLElement).closest('.tlui-cmt-input, .tlui-cmt-send')) return\n\t\te.preventDefault()\n\t\teditor?.commands.focus('end')\n\t}\n\n\treturn (\n\t\t<div className=\"tlui-cmt-composer\">\n\t\t\t{leading ?? <Avatar author={author} />}\n\t\t\t<div\n\t\t\t\tclassName={[\n\t\t\t\t\t'tlui-cmt-composer__field',\n\t\t\t\t\tinteractive && expanded && 'tlui-cmt-composer__field--expanded',\n\t\t\t\t]\n\t\t\t\t\t.filter(Boolean)\n\t\t\t\t\t.join(' ')}\n\t\t\t\tonMouseDown={interactive ? focusEditorFromField : undefined}\n\t\t\t>\n\t\t\t\t<div className=\"tlui-cmt-composer__input-wrap\" ref={inputWrapRef}>\n\t\t\t\t\t<EditorContent editor={editor} />\n\t\t\t\t\t{isEmpty && (\n\t\t\t\t\t\t<div className=\"tlui-cmt-input__placeholder\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t{placeholder}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"tlui-cmt-composer__mirror tlui-cmt-input\"\n\t\t\t\t\t\tref={mirrorRef}\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t{interactive && <SendButton label={sendLabel} onClick={onSubmit} disabled={disabled} />}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["editor"]
|
|
7
7
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function focusOnGestureEnd(doc, focus) {
|
|
2
|
+
const stop = () => {
|
|
3
|
+
doc.removeEventListener("pointerup", onPointerUp, true);
|
|
4
|
+
doc.removeEventListener("pointerdown", stop, true);
|
|
5
|
+
};
|
|
6
|
+
function onPointerUp() {
|
|
7
|
+
stop();
|
|
8
|
+
focus();
|
|
9
|
+
}
|
|
10
|
+
doc.addEventListener("pointerup", onPointerUp, true);
|
|
11
|
+
doc.addEventListener("pointerdown", stop, true);
|
|
12
|
+
return stop;
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
focusOnGestureEnd
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=gesture-focus.mjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/ui/gesture-focus.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * iOS opens the software keyboard only for a focus that runs inside a user gesture's own task. A\n * focus scheduled for a later frame still moves the caret, so a field can look ready while\n * swallowing every keystroke until the reader taps it a second time.\n *\n * This covers the case where a field is mounted *during* a press \u2014 the comment composer opens on\n * pointer down and settles its anchor on pointer up \u2014 by running `focus` from the native pointerup\n * that ends the same gesture. That is late enough to survive the canvas taking focus for itself on\n * pointer down, and still inside the gesture, so iOS raises the keyboard.\n *\n * A pointerdown arriving first means the mounting gesture had already ended (a field opened from a\n * button, say), so the next release belongs to an unrelated gesture: give up rather than pull the\n * caret somewhere the reader didn't ask for.\n *\n * Returns a function that detaches the listeners; safe to call more than once.\n */\nexport function focusOnGestureEnd(doc: Document, focus: () => void): () => void {\n\tconst stop = () => {\n\t\tdoc.removeEventListener('pointerup', onPointerUp, true)\n\t\tdoc.removeEventListener('pointerdown', stop, true)\n\t}\n\tfunction onPointerUp() {\n\t\tstop()\n\t\tfocus()\n\t}\n\tdoc.addEventListener('pointerup', onPointerUp, true)\n\tdoc.addEventListener('pointerdown', stop, true)\n\treturn stop\n}\n"],
|
|
5
|
+
"mappings": "AAgBO,SAAS,kBAAkB,KAAe,OAA+B;AAC/E,QAAM,OAAO,MAAM;AAClB,QAAI,oBAAoB,aAAa,aAAa,IAAI;AACtD,QAAI,oBAAoB,eAAe,MAAM,IAAI;AAAA,EAClD;AACA,WAAS,cAAc;AACtB,SAAK;AACL,UAAM;AAAA,EACP;AACA,MAAI,iBAAiB,aAAa,aAAa,IAAI;AACnD,MAAI,iBAAiB,eAAe,MAAM,IAAI;AAC9C,SAAO;AACR;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useId } from "react";
|
|
3
3
|
import {
|
|
4
|
+
defineMessages,
|
|
4
5
|
TldrawUiButton,
|
|
5
6
|
TldrawUiDropdownMenuContent,
|
|
6
7
|
TldrawUiDropdownMenuRoot,
|
|
@@ -11,6 +12,9 @@ import {
|
|
|
11
12
|
} from "tldraw";
|
|
12
13
|
import { EmojiPicker } from "./emoji-picker.mjs";
|
|
13
14
|
import { SmileyIcon } from "./icons.mjs";
|
|
15
|
+
const messages = defineMessages({
|
|
16
|
+
commentsAddReaction: { id: "comments.add-reaction", defaultMessage: "Add reaction" }
|
|
17
|
+
});
|
|
14
18
|
function isMultiSelectPick(event) {
|
|
15
19
|
return !!event?.shiftKey;
|
|
16
20
|
}
|
|
@@ -41,8 +45,8 @@ function ReactionPicker({
|
|
|
41
45
|
TldrawUiButton,
|
|
42
46
|
{
|
|
43
47
|
type: "icon",
|
|
44
|
-
tooltip: msg(
|
|
45
|
-
title: msg(
|
|
48
|
+
tooltip: msg(messages.commentsAddReaction),
|
|
49
|
+
title: msg(messages.commentsAddReaction),
|
|
46
50
|
className,
|
|
47
51
|
children: /* @__PURE__ */ jsx(SmileyIcon, {})
|
|
48
52
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/ui/reaction-picker.tsx"],
|
|
4
|
-
"sourcesContent": ["import { useCallback, useId, type ComponentType, type KeyboardEvent, type MouseEvent } from 'react'\nimport {\n\tTldrawUiButton,\n\tTldrawUiDropdownMenuContent,\n\tTldrawUiDropdownMenuRoot,\n\tTldrawUiDropdownMenuTrigger,\n\ttlmenus,\n\tuseMaybeEditor,\n\tuseTranslation,\n} from 'tldraw'\nimport { EmojiPicker, type EmojiPickerProps } from './emoji-picker'\nimport { SmileyIcon } from './icons'\nimport { RenderReaction } from './reaction'\n\n/** @public */\nexport interface ReactionPickerProps {\n\t/** The emoji to offer. Defaults to `DEFAULT_REACTION_EMOJI`. */\n\temoji?: string[]\n\t/** Emoji the current user has already reacted with; shown as pressed in the grid. */\n\tselected?: string[]\n\t/** Called when an emoji is chosen. */\n\tonSelect?(emoji: string): void\n\t/** How to draw each emoji token. Defaults to the token string (OS emoji font). */\n\trenderReaction?: RenderReaction\n\t/**\n\t * What the button opens \u2014 the thing that produces a token. Defaults to `EmojiPicker`, the grid of\n\t * emoji. Swap it for any component taking the same props to offer something else entirely (the\n\t * drawn reactions example replaces it with a canvas you draw in); the props are passed straight\n\t * through either way.\n\t */\n\tpalette?: ComponentType<EmojiPickerProps>\n\t/**\n\t * Identifies the menu in tldraw's global menu registry, which keys open/closed state by id.\n\t * A thread renders one picker per comment, so this must differ per comment \u2014 sharing an id\n\t * makes every picker in the thread open at once. Defaults to a per-instance generated id;\n\t * pass one only for a stabler, more debuggable value.\n\t */\n\tmenuId?: string\n\t/** Class for the trigger button. Defaults to the card-action style, matching the \u22EF button. */\n\tclassName?: string\n}\n\n/**\n * Whether a palette pick asked to keep the palette open (shift held) for multi-select.\n * @internal\n */\nexport function isMultiSelectPick(event?: MouseEvent | KeyboardEvent): boolean {\n\treturn !!event?.shiftKey\n}\n\n/**\n * The add-reaction affordance: a smiley button that opens an emoji grid.\n *\n * Anchored to its own button rather than to the reactions row, so the menu keeps its position as\n * reactions are added and the row reflows.\n *\n * Picking an emoji dismisses the grid \u2014 adding and removing alike, since either way the picker has\n * done its job \u2014 and hands focus back to the trigger. Shift-picking keeps the grid open, for\n * choosing several reactions in a row.\n * @public @react\n */\nexport function ReactionPicker({\n\temoji,\n\tselected,\n\tonSelect,\n\trenderReaction,\n\tpalette: Palette = EmojiPicker,\n\tmenuId,\n\tclassName = 'tlui-cmt-thread__action',\n}: ReactionPickerProps) {\n\tconst msg = useTranslation()\n\tconst editor = useMaybeEditor()\n\t// Unique per mounted picker unless the host supplies something stabler \u2014 see `menuId`.\n\tconst generatedId = useId()\n\tconst id = menuId ?? `comment-reactions-${generatedId}`\n\n\t// The dropdown's open state lives in tldraw's global menu registry, so dropping the entry is\n\t// what closes it. The palette's tokens are plain buttons \u2014 a swappable component, not radix\n\t// menu items \u2014 so nothing dismisses the menu on its own.\n\tconst handleSelect = useCallback(\n\t\t(value: string, event?: MouseEvent | KeyboardEvent) => {\n\t\t\tonSelect?.(value)\n\t\t\tif (!isMultiSelectPick(event)) {\n\t\t\t\ttlmenus.deleteOpenMenu(id, editor?.contextId)\n\t\t\t}\n\t\t},\n\t\t[onSelect, id, editor]\n\t)\n\n\treturn (\n\t\t<TldrawUiDropdownMenuRoot id={id}>\n\t\t\t<TldrawUiDropdownMenuTrigger>\n\t\t\t\t<TldrawUiButton\n\t\t\t\t\ttype=\"icon\"\n\t\t\t\t\ttooltip={msg(
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import { useCallback, useId, type ComponentType, type KeyboardEvent, type MouseEvent } from 'react'\nimport {\n\tdefineMessages,\n\tTldrawUiButton,\n\tTldrawUiDropdownMenuContent,\n\tTldrawUiDropdownMenuRoot,\n\tTldrawUiDropdownMenuTrigger,\n\ttlmenus,\n\tuseMaybeEditor,\n\tuseTranslation,\n} from 'tldraw'\nimport { EmojiPicker, type EmojiPickerProps } from './emoji-picker'\nimport { SmileyIcon } from './icons'\nimport { RenderReaction } from './reaction'\n\nconst messages = defineMessages({\n\tcommentsAddReaction: { id: 'comments.add-reaction', defaultMessage: 'Add reaction' },\n})\n\n/** @public */\nexport interface ReactionPickerProps {\n\t/** The emoji to offer. Defaults to `DEFAULT_REACTION_EMOJI`. */\n\temoji?: string[]\n\t/** Emoji the current user has already reacted with; shown as pressed in the grid. */\n\tselected?: string[]\n\t/** Called when an emoji is chosen. */\n\tonSelect?(emoji: string): void\n\t/** How to draw each emoji token. Defaults to the token string (OS emoji font). */\n\trenderReaction?: RenderReaction\n\t/**\n\t * What the button opens \u2014 the thing that produces a token. Defaults to `EmojiPicker`, the grid of\n\t * emoji. Swap it for any component taking the same props to offer something else entirely (the\n\t * drawn reactions example replaces it with a canvas you draw in); the props are passed straight\n\t * through either way.\n\t */\n\tpalette?: ComponentType<EmojiPickerProps>\n\t/**\n\t * Identifies the menu in tldraw's global menu registry, which keys open/closed state by id.\n\t * A thread renders one picker per comment, so this must differ per comment \u2014 sharing an id\n\t * makes every picker in the thread open at once. Defaults to a per-instance generated id;\n\t * pass one only for a stabler, more debuggable value.\n\t */\n\tmenuId?: string\n\t/** Class for the trigger button. Defaults to the card-action style, matching the \u22EF button. */\n\tclassName?: string\n}\n\n/**\n * Whether a palette pick asked to keep the palette open (shift held) for multi-select.\n * @internal\n */\nexport function isMultiSelectPick(event?: MouseEvent | KeyboardEvent): boolean {\n\treturn !!event?.shiftKey\n}\n\n/**\n * The add-reaction affordance: a smiley button that opens an emoji grid.\n *\n * Anchored to its own button rather than to the reactions row, so the menu keeps its position as\n * reactions are added and the row reflows.\n *\n * Picking an emoji dismisses the grid \u2014 adding and removing alike, since either way the picker has\n * done its job \u2014 and hands focus back to the trigger. Shift-picking keeps the grid open, for\n * choosing several reactions in a row.\n * @public @react\n */\nexport function ReactionPicker({\n\temoji,\n\tselected,\n\tonSelect,\n\trenderReaction,\n\tpalette: Palette = EmojiPicker,\n\tmenuId,\n\tclassName = 'tlui-cmt-thread__action',\n}: ReactionPickerProps) {\n\tconst msg = useTranslation()\n\tconst editor = useMaybeEditor()\n\t// Unique per mounted picker unless the host supplies something stabler \u2014 see `menuId`.\n\tconst generatedId = useId()\n\tconst id = menuId ?? `comment-reactions-${generatedId}`\n\n\t// The dropdown's open state lives in tldraw's global menu registry, so dropping the entry is\n\t// what closes it. The palette's tokens are plain buttons \u2014 a swappable component, not radix\n\t// menu items \u2014 so nothing dismisses the menu on its own.\n\tconst handleSelect = useCallback(\n\t\t(value: string, event?: MouseEvent | KeyboardEvent) => {\n\t\t\tonSelect?.(value)\n\t\t\tif (!isMultiSelectPick(event)) {\n\t\t\t\ttlmenus.deleteOpenMenu(id, editor?.contextId)\n\t\t\t}\n\t\t},\n\t\t[onSelect, id, editor]\n\t)\n\n\treturn (\n\t\t<TldrawUiDropdownMenuRoot id={id}>\n\t\t\t<TldrawUiDropdownMenuTrigger>\n\t\t\t\t<TldrawUiButton\n\t\t\t\t\ttype=\"icon\"\n\t\t\t\t\ttooltip={msg(messages.commentsAddReaction)}\n\t\t\t\t\ttitle={msg(messages.commentsAddReaction)}\n\t\t\t\t\tclassName={className}\n\t\t\t\t>\n\t\t\t\t\t<SmileyIcon />\n\t\t\t\t</TldrawUiButton>\n\t\t\t</TldrawUiDropdownMenuTrigger>\n\t\t\t{/* left-aligned under the trigger, so the grid opens rightward off the card's edge */}\n\t\t\t<TldrawUiDropdownMenuContent side=\"bottom\" align=\"start\" alignOffset={0} sideOffset={4}>\n\t\t\t\t<Palette\n\t\t\t\t\temoji={emoji}\n\t\t\t\t\tselected={selected}\n\t\t\t\t\tonSelect={handleSelect}\n\t\t\t\t\trenderReaction={renderReaction}\n\t\t\t\t/>\n\t\t\t</TldrawUiDropdownMenuContent>\n\t\t</TldrawUiDropdownMenuRoot>\n\t)\n}\n"],
|
|
5
|
+
"mappings": "AA+FE,SAQG,KARH;AA/FF,SAAS,aAAa,aAAsE;AAC5F;AAAA,EACC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACM;AACP,SAAS,mBAA0C;AACnD,SAAS,kBAAkB;AAG3B,MAAM,WAAW,eAAe;AAAA,EAC/B,qBAAqB,EAAE,IAAI,yBAAyB,gBAAgB,eAAe;AACpF,CAAC;AAkCM,SAAS,kBAAkB,OAA6C;AAC9E,SAAO,CAAC,CAAC,OAAO;AACjB;AAaO,SAAS,eAAe;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,SAAS,UAAU;AAAA,EACnB;AAAA,EACA,YAAY;AACb,GAAwB;AACvB,QAAM,MAAM,eAAe;AAC3B,QAAM,SAAS,eAAe;AAE9B,QAAM,cAAc,MAAM;AAC1B,QAAM,KAAK,UAAU,qBAAqB,WAAW;AAKrD,QAAM,eAAe;AAAA,IACpB,CAAC,OAAe,UAAuC;AACtD,iBAAW,KAAK;AAChB,UAAI,CAAC,kBAAkB,KAAK,GAAG;AAC9B,gBAAQ,eAAe,IAAI,QAAQ,SAAS;AAAA,MAC7C;AAAA,IACD;AAAA,IACA,CAAC,UAAU,IAAI,MAAM;AAAA,EACtB;AAEA,SACC,qBAAC,4BAAyB,IACzB;AAAA,wBAAC,+BACA;AAAA,MAAC;AAAA;AAAA,QACA,MAAK;AAAA,QACL,SAAS,IAAI,SAAS,mBAAmB;AAAA,QACzC,OAAO,IAAI,SAAS,mBAAmB;AAAA,QACvC;AAAA,QAEA,8BAAC,cAAW;AAAA;AAAA,IACb,GACD;AAAA,IAEA,oBAAC,+BAA4B,MAAK,UAAS,OAAM,SAAQ,aAAa,GAAG,YAAY,GACpF;AAAA,MAAC;AAAA;AAAA,QACA;AAAA,QACA;AAAA,QACA,UAAU;AAAA,QACV;AAAA;AAAA,IACD,GACD;AAAA,KACD;AAEF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist-esm/ui/reaction.mjs
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { TldrawUiTooltip, useTranslation } from "tldraw";
|
|
2
|
+
import { defineMessages, TldrawUiTooltip, useTranslation } from "tldraw";
|
|
3
|
+
const messages = defineMessages({
|
|
4
|
+
commentsMentionYou: { id: "comments.mention-you", defaultMessage: "You" },
|
|
5
|
+
commentsReacted: {
|
|
6
|
+
id: "comments.reacted",
|
|
7
|
+
defaultMessage: "{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}}"
|
|
8
|
+
}
|
|
9
|
+
});
|
|
3
10
|
function defaultRenderReaction(token) {
|
|
4
11
|
return token;
|
|
5
12
|
}
|
|
@@ -35,16 +42,17 @@ function DefaultReactionTooltip({ reactors, children }) {
|
|
|
35
42
|
function DefaultReactionTooltipContent({ reactors }) {
|
|
36
43
|
const msg = useTranslation();
|
|
37
44
|
const names = reactors.map(
|
|
38
|
-
(reactor) => reactor.you ? msg(
|
|
45
|
+
(reactor) => reactor.you ? msg(messages.commentsMentionYou) : reactor.name
|
|
39
46
|
);
|
|
40
47
|
if (names.length === 0) return null;
|
|
41
48
|
const [a, b, c] = names;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
return /* @__PURE__ */ jsx(Fragment, { children: msg(messages.commentsReacted, {
|
|
50
|
+
count: names.length,
|
|
51
|
+
others: names.length - 3,
|
|
52
|
+
a,
|
|
53
|
+
b,
|
|
54
|
+
c
|
|
55
|
+
}) });
|
|
48
56
|
}
|
|
49
57
|
export {
|
|
50
58
|
DefaultReactionTooltip,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/ui/reaction.tsx"],
|
|
4
|
-
"sourcesContent": ["import { ComponentType, ReactNode } from 'react'\nimport { TldrawUiTooltip, useTranslation } from 'tldraw'\nimport { ReactionReactor } from './reactions'\n\n/** Render a reaction token to its visual \u2014 the emoji glyph by default. @public */\nexport type RenderReaction = (token: string) => ReactNode\n\n/** The default reaction renderer: emits the token string for the OS emoji font to draw. @public */\nexport function defaultRenderReaction(token: string): ReactNode {\n\treturn token\n}\n\n/** @public */\nexport interface ReactionProps {\n\temoji: string\n\tcount: number\n\tactive: boolean\n\t/** How to draw the emoji token. Defaults to the token string (OS emoji font). */\n\trenderReaction?: RenderReaction\n\t/** Who reacted with this emoji, in reaction order \u2014 shown when the pill is hovered. */\n\treactors: ReactionReactor[]\n\t/** Whether hovering shows the reactor list. Pass false to suppress it \u2014 e.g. while another\n\t * popup menu on the comment is open. Defaults to true. */\n\tenableHoverList?: boolean\n\t/** The hover affordance for the pill, naming who reacted. It receives the reactors and the pill\n\t * itself (as `children`) and returns the whole thing \u2014 so it owns the tooltip, its box, size,\n\t * shape, and position, not just the text inside. Defaults to `DefaultReactionTooltip`, which\n\t * hangs the built-in inline sentence below the pill. */\n\tReactionTooltip?: ComponentType<ReactionTooltipProps>\n\t/** Called when the pill is pressed \u2014 toggles the current user's reaction. */\n\tonClick?(): void\n}\n\n/**\n * A single emoji reaction pill with a count, highlighted when the user reacted. Hovering it shows a\n * tooltip naming who reacted (the current user included), when `enableHoverList` is set.\n * @public @react\n */\nexport function Reaction({\n\temoji,\n\tcount,\n\tactive,\n\treactors,\n\tenableHoverList = true,\n\trenderReaction = defaultRenderReaction,\n\tReactionTooltip = DefaultReactionTooltip,\n\tonClick,\n}: ReactionProps) {\n\t// An inert pill (no handler) is plain content, not a control \u2014 rendering it as a span keeps it\n\t// valid inside an anchor row (e.g. a notification list item) and out of the tab order.\n\tconst Tag = onClick ? 'button' : 'span'\n\tconst pill = (\n\t\t<Tag\n\t\t\tclassName={active ? 'tlui-cmt-reaction tlui-cmt-reaction--active' : 'tlui-cmt-reaction'}\n\t\t\t{...(onClick\n\t\t\t\t? { type: 'button' as const, 'aria-pressed': active, onClick }\n\t\t\t\t: { role: 'img' as const })}\n\t\t\t// The emoji alone announces as its unicode name (\"thumbs up\"); pairing it with the count\n\t\t\t// is what makes the pill's state legible to a screen reader. The inert span needs the\n\t\t\t// img role for the label to be announced at all.\n\t\t\taria-label={`${emoji} ${count}`}\n\t\t>\n\t\t\t<span className=\"tlui-cmt-reaction__emoji\">{renderReaction(emoji)}</span>\n\t\t\t<span className=\"tlui-cmt-reaction__count\">{count}</span>\n\t\t</Tag>\n\t)\n\n\t// A bare pill when hovering is suppressed, or when there's no one to name.\n\tif (!enableHoverList || reactors.length === 0) return pill\n\n\t// Hand the whole hover affordance to the tooltip component: it wraps the pill and owns how the\n\t// reactor list is presented.\n\treturn <ReactionTooltip reactors={reactors}>{pill}</ReactionTooltip>\n}\n\n/** @public */\nexport interface ReactionTooltipProps {\n\t/** Who reacted with this emoji, in reaction order. */\n\treactors: ReactionReactor[]\n\t/** The reaction pill to wrap. Anchor the hover affordance to this and render it. */\n\tchildren: ReactNode\n}\n\n/**\n * The default reactor tooltip: hangs the built-in inline sentence (`DefaultReactionTooltipContent`)\n * in a standard tooltip below the pill \u2014 the same tooltip the toolbar uses. Replace it via\n * `CommentingComponents.ReactionTooltip` to present the reactor list any other way (a different box,\n * avatars, a banner, anywhere on screen); it receives the pill as `children`.\n * @public @react\n */\nexport function DefaultReactionTooltip({ reactors, children }: ReactionTooltipProps) {\n\treturn (\n\t\t<TldrawUiTooltip side=\"bottom\" content={<DefaultReactionTooltipContent reactors={reactors} />}>\n\t\t\t{children}\n\t\t</TldrawUiTooltip>\n\t)\n}\n\n/**\n * The default reactor sentence naming who reacted \u2014 up to three names spelled out, then \"and N\n * others\" (e.g. \"You reacted\", \"You and Bo reacted\", \"You, Bo and Ada reacted\", \"You, Bo, Ada and 2\n * others reacted\"). The wording lives in the `comments.reacted
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import { ComponentType, ReactNode } from 'react'\nimport { defineMessages, TldrawUiTooltip, useTranslation } from 'tldraw'\nimport { ReactionReactor } from './reactions'\n\nconst messages = defineMessages({\n\tcommentsMentionYou: { id: 'comments.mention-you', defaultMessage: 'You' },\n\tcommentsReacted: {\n\t\tid: 'comments.reacted',\n\t\tdefaultMessage:\n\t\t\t'{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}}',\n\t},\n})\n\n/** Render a reaction token to its visual \u2014 the emoji glyph by default. @public */\nexport type RenderReaction = (token: string) => ReactNode\n\n/** The default reaction renderer: emits the token string for the OS emoji font to draw. @public */\nexport function defaultRenderReaction(token: string): ReactNode {\n\treturn token\n}\n\n/** @public */\nexport interface ReactionProps {\n\temoji: string\n\tcount: number\n\tactive: boolean\n\t/** How to draw the emoji token. Defaults to the token string (OS emoji font). */\n\trenderReaction?: RenderReaction\n\t/** Who reacted with this emoji, in reaction order \u2014 shown when the pill is hovered. */\n\treactors: ReactionReactor[]\n\t/** Whether hovering shows the reactor list. Pass false to suppress it \u2014 e.g. while another\n\t * popup menu on the comment is open. Defaults to true. */\n\tenableHoverList?: boolean\n\t/** The hover affordance for the pill, naming who reacted. It receives the reactors and the pill\n\t * itself (as `children`) and returns the whole thing \u2014 so it owns the tooltip, its box, size,\n\t * shape, and position, not just the text inside. Defaults to `DefaultReactionTooltip`, which\n\t * hangs the built-in inline sentence below the pill. */\n\tReactionTooltip?: ComponentType<ReactionTooltipProps>\n\t/** Called when the pill is pressed \u2014 toggles the current user's reaction. */\n\tonClick?(): void\n}\n\n/**\n * A single emoji reaction pill with a count, highlighted when the user reacted. Hovering it shows a\n * tooltip naming who reacted (the current user included), when `enableHoverList` is set.\n * @public @react\n */\nexport function Reaction({\n\temoji,\n\tcount,\n\tactive,\n\treactors,\n\tenableHoverList = true,\n\trenderReaction = defaultRenderReaction,\n\tReactionTooltip = DefaultReactionTooltip,\n\tonClick,\n}: ReactionProps) {\n\t// An inert pill (no handler) is plain content, not a control \u2014 rendering it as a span keeps it\n\t// valid inside an anchor row (e.g. a notification list item) and out of the tab order.\n\tconst Tag = onClick ? 'button' : 'span'\n\tconst pill = (\n\t\t<Tag\n\t\t\tclassName={active ? 'tlui-cmt-reaction tlui-cmt-reaction--active' : 'tlui-cmt-reaction'}\n\t\t\t{...(onClick\n\t\t\t\t? { type: 'button' as const, 'aria-pressed': active, onClick }\n\t\t\t\t: { role: 'img' as const })}\n\t\t\t// The emoji alone announces as its unicode name (\"thumbs up\"); pairing it with the count\n\t\t\t// is what makes the pill's state legible to a screen reader. The inert span needs the\n\t\t\t// img role for the label to be announced at all.\n\t\t\taria-label={`${emoji} ${count}`}\n\t\t>\n\t\t\t<span className=\"tlui-cmt-reaction__emoji\">{renderReaction(emoji)}</span>\n\t\t\t<span className=\"tlui-cmt-reaction__count\">{count}</span>\n\t\t</Tag>\n\t)\n\n\t// A bare pill when hovering is suppressed, or when there's no one to name.\n\tif (!enableHoverList || reactors.length === 0) return pill\n\n\t// Hand the whole hover affordance to the tooltip component: it wraps the pill and owns how the\n\t// reactor list is presented.\n\treturn <ReactionTooltip reactors={reactors}>{pill}</ReactionTooltip>\n}\n\n/** @public */\nexport interface ReactionTooltipProps {\n\t/** Who reacted with this emoji, in reaction order. */\n\treactors: ReactionReactor[]\n\t/** The reaction pill to wrap. Anchor the hover affordance to this and render it. */\n\tchildren: ReactNode\n}\n\n/**\n * The default reactor tooltip: hangs the built-in inline sentence (`DefaultReactionTooltipContent`)\n * in a standard tooltip below the pill \u2014 the same tooltip the toolbar uses. Replace it via\n * `CommentingComponents.ReactionTooltip` to present the reactor list any other way (a different box,\n * avatars, a banner, anywhere on screen); it receives the pill as `children`.\n * @public @react\n */\nexport function DefaultReactionTooltip({ reactors, children }: ReactionTooltipProps) {\n\treturn (\n\t\t<TldrawUiTooltip side=\"bottom\" content={<DefaultReactionTooltipContent reactors={reactors} />}>\n\t\t\t{children}\n\t\t</TldrawUiTooltip>\n\t)\n}\n\n/**\n * The default reactor sentence naming who reacted \u2014 up to three names spelled out, then \"and N\n * others\" (e.g. \"You reacted\", \"You and Bo reacted\", \"You, Bo and Ada reacted\", \"You, Bo, Ada and 2\n * others reacted\"). The wording lives in the `comments.reacted` translation string, whose ICU\n * plural picks the phrasing, so each locale controls both the grammar and how it counts. Exported\n * so a custom `ReactionTooltip` can reuse it inside its own box.\n * @public @react\n */\nexport function DefaultReactionTooltipContent({ reactors }: { reactors: ReactionReactor[] }) {\n\tconst msg = useTranslation()\n\tconst names = reactors.map((reactor) =>\n\t\treactor.you ? msg(messages.commentsMentionYou) : reactor.name\n\t)\n\tif (names.length === 0) return null\n\tconst [a, b, c] = names\n\treturn (\n\t\t<>\n\t\t\t{msg(messages.commentsReacted, {\n\t\t\t\tcount: names.length,\n\t\t\t\tothers: names.length - 3,\n\t\t\t\ta,\n\t\t\t\tb,\n\t\t\t\tc,\n\t\t\t})}\n\t\t</>\n\t)\n}\n"],
|
|
5
|
+
"mappings": "AA6DE,SA8DA,UApDC,KAVD;AA5DF,SAAS,gBAAgB,iBAAiB,sBAAsB;AAGhE,MAAM,WAAW,eAAe;AAAA,EAC/B,oBAAoB,EAAE,IAAI,wBAAwB,gBAAgB,MAAM;AAAA,EACxE,iBAAiB;AAAA,IAChB,IAAI;AAAA,IACJ,gBACC;AAAA,EACF;AACD,CAAC;AAMM,SAAS,sBAAsB,OAA0B;AAC/D,SAAO;AACR;AA4BO,SAAS,SAAS;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB,iBAAiB;AAAA,EACjB,kBAAkB;AAAA,EAClB;AACD,GAAkB;AAGjB,QAAM,MAAM,UAAU,WAAW;AACjC,QAAM,OACL;AAAA,IAAC;AAAA;AAAA,MACA,WAAW,SAAS,gDAAgD;AAAA,MACnE,GAAI,UACF,EAAE,MAAM,UAAmB,gBAAgB,QAAQ,QAAQ,IAC3D,EAAE,MAAM,MAAe;AAAA,MAI1B,cAAY,GAAG,KAAK,IAAI,KAAK;AAAA,MAE7B;AAAA,4BAAC,UAAK,WAAU,4BAA4B,yBAAe,KAAK,GAAE;AAAA,QAClE,oBAAC,UAAK,WAAU,4BAA4B,iBAAM;AAAA;AAAA;AAAA,EACnD;AAID,MAAI,CAAC,mBAAmB,SAAS,WAAW,EAAG,QAAO;AAItD,SAAO,oBAAC,mBAAgB,UAAqB,gBAAK;AACnD;AAiBO,SAAS,uBAAuB,EAAE,UAAU,SAAS,GAAyB;AACpF,SACC,oBAAC,mBAAgB,MAAK,UAAS,SAAS,oBAAC,iCAA8B,UAAoB,GACzF,UACF;AAEF;AAUO,SAAS,8BAA8B,EAAE,SAAS,GAAoC;AAC5F,QAAM,MAAM,eAAe;AAC3B,QAAM,QAAQ,SAAS;AAAA,IAAI,CAAC,YAC3B,QAAQ,MAAM,IAAI,SAAS,kBAAkB,IAAI,QAAQ;AAAA,EAC1D;AACA,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,CAAC,GAAG,GAAG,CAAC,IAAI;AAClB,SACC,gCACE,cAAI,SAAS,iBAAiB;AAAA,IAC9B,OAAO,MAAM;AAAA,IACb,QAAQ,MAAM,SAAS;AAAA,IACvB;AAAA,IACA;AAAA,IACA;AAAA,EACD,CAAC,GACF;AAEF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { defineMessages } from "tldraw";
|
|
2
|
+
const messages = defineMessages({
|
|
3
|
+
commentsReplies: {
|
|
4
|
+
id: "comments.replies",
|
|
5
|
+
defaultMessage: "{count, plural, one {# reply} other {# replies}}"
|
|
6
|
+
}
|
|
7
|
+
});
|
|
1
8
|
function replyCountLabel(msg, replyCount) {
|
|
2
9
|
if (replyCount <= 0) return null;
|
|
3
|
-
|
|
4
|
-
return msg(key).replace("{count}", String(replyCount));
|
|
10
|
+
return msg(messages.commentsReplies, { count: replyCount });
|
|
5
11
|
}
|
|
6
12
|
export {
|
|
7
13
|
replyCountLabel
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/ui/reply-count.ts"],
|
|
4
|
-
"sourcesContent": ["import { useTranslation } from 'tldraw'\n\n/**\n * The localized \"N replies\" label (e.g. \"1 reply\", \"3 replies\") for a thread with `replyCount`\n * replies \u2014 the comments after the opening one. Returns null when there are none, so callers render\n * nothing rather than \"0 replies\". Shared by the sidebar row and the hover preview so both read the\n * same, with each site owning where it puts the text.\n */\nexport function replyCountLabel(\n\tmsg: ReturnType<typeof useTranslation>,\n\treplyCount: number\n): string | null {\n\tif (replyCount <= 0) return null\n\
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import { defineMessages, useTranslation } from 'tldraw'\n\nconst messages = defineMessages({\n\tcommentsReplies: {\n\t\tid: 'comments.replies',\n\t\tdefaultMessage: '{count, plural, one {# reply} other {# replies}}',\n\t},\n})\n\n/**\n * The localized \"N replies\" label (e.g. \"1 reply\", \"3 replies\") for a thread with `replyCount`\n * replies \u2014 the comments after the opening one. Returns null when there are none, so callers render\n * nothing rather than \"0 replies\". Shared by the sidebar row and the hover preview so both read the\n * same, with each site owning where it puts the text.\n */\nexport function replyCountLabel(\n\tmsg: ReturnType<typeof useTranslation>,\n\treplyCount: number\n): string | null {\n\tif (replyCount <= 0) return null\n\treturn msg(messages.commentsReplies, { count: replyCount })\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,sBAAsC;AAE/C,MAAM,WAAW,eAAe;AAAA,EAC/B,iBAAiB;AAAA,IAChB,IAAI;AAAA,IACJ,gBAAgB;AAAA,EACjB;AACD,CAAC;AAQM,SAAS,gBACf,KACA,YACgB;AAChB,MAAI,cAAc,EAAG,QAAO;AAC5B,SAAO,IAAI,SAAS,iBAAiB,EAAE,OAAO,WAAW,CAAC;AAC3D;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/commenting",
|
|
3
3
|
"description": "tldraw commenting UI components.",
|
|
4
|
-
"version": "5.6.0-next.
|
|
4
|
+
"version": "5.6.0-next.fb83f87b7388",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw Inc.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"src"
|
|
33
33
|
],
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@tldraw/driver": "5.6.0-next.
|
|
35
|
+
"@tldraw/driver": "5.6.0-next.fb83f87b7388",
|
|
36
36
|
"@types/react": "^19.2.7",
|
|
37
37
|
"@types/react-dom": "^19.2.3",
|
|
38
38
|
"react": "^19.2.1",
|
|
@@ -50,9 +50,9 @@
|
|
|
50
50
|
"@tiptap/core": "^3.12.1",
|
|
51
51
|
"@tiptap/pm": "^3.12.1",
|
|
52
52
|
"@tiptap/react": "^3.12.1",
|
|
53
|
-
"@tldraw/mentions": "5.6.0-next.
|
|
54
|
-
"@tldraw/utils": "5.6.0-next.
|
|
55
|
-
"tldraw": "5.6.0-next.
|
|
53
|
+
"@tldraw/mentions": "5.6.0-next.fb83f87b7388",
|
|
54
|
+
"@tldraw/utils": "5.6.0-next.fb83f87b7388",
|
|
55
|
+
"tldraw": "5.6.0-next.fb83f87b7388"
|
|
56
56
|
},
|
|
57
57
|
"module": "dist-esm/index.mjs",
|
|
58
58
|
"source": "src/index.ts",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, useMemo, useRef } from 'react'
|
|
2
2
|
import {
|
|
3
|
+
defineMessages,
|
|
3
4
|
Editor,
|
|
4
5
|
TLCommentThread,
|
|
5
6
|
useContainer,
|
|
@@ -14,6 +15,13 @@ import { type CommentingContext } from './context'
|
|
|
14
15
|
import { sortThreadsForPreview, ThreadPreview, useMarkerPreview } from './thread-preview'
|
|
15
16
|
import { impreciseShapePinInset, isInInflatedViewport } from './thread-state'
|
|
16
17
|
|
|
18
|
+
const messages = defineMessages({
|
|
19
|
+
commentsClusterLabel: {
|
|
20
|
+
id: 'comments.cluster-label',
|
|
21
|
+
defaultMessage: '{count, plural, one {# comment here} other {# comments here}}',
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
17
25
|
/**
|
|
18
26
|
* The count badge standing in for several threads folded together at the current zoom. Hovering it
|
|
19
27
|
* previews its threads as a list; clicking zooms to the zoom level at which the cluster splits.
|
|
@@ -99,7 +107,7 @@ export const ClusterBadge = memo(function ClusterBadge({
|
|
|
99
107
|
type="button"
|
|
100
108
|
className="tlui-cmt-button tlui-cmt-canvas-cluster"
|
|
101
109
|
style={{ left: point.x, top: point.y }}
|
|
102
|
-
aria-label={msg(
|
|
110
|
+
aria-label={msg(messages.commentsClusterLabel, { count: node.count })}
|
|
103
111
|
onPointerDown={(e) => {
|
|
104
112
|
if (isCanvasPanGesture(editor, e)) {
|
|
105
113
|
forwardPointerEventToCanvas(container, e)
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
2
|
import {
|
|
3
|
+
createCommentReaction,
|
|
4
|
+
createCommentReactionId,
|
|
5
|
+
defineMessages,
|
|
3
6
|
Editor,
|
|
4
7
|
TLComment,
|
|
5
8
|
TLCommentReaction,
|
|
6
|
-
createCommentReaction,
|
|
7
|
-
createCommentReactionId,
|
|
8
9
|
useEditor,
|
|
10
|
+
useTranslation,
|
|
9
11
|
useValue,
|
|
10
12
|
} from 'tldraw'
|
|
11
13
|
import { RenderReaction } from '../ui/reaction'
|
|
@@ -16,6 +18,10 @@ import { UNKNOWN_AUTHOR } from './comment-render'
|
|
|
16
18
|
import { getCommentReactions } from './comment-store'
|
|
17
19
|
import { getCommentingOptions, useCommentingOptions } from './options'
|
|
18
20
|
|
|
21
|
+
const messages = defineMessages({
|
|
22
|
+
commentsUnknownAuthor: { id: 'comments.unknown-author', defaultMessage: 'Someone' },
|
|
23
|
+
})
|
|
24
|
+
|
|
19
25
|
/**
|
|
20
26
|
* One comment's reactions, oldest first, reactively.
|
|
21
27
|
*
|
|
@@ -52,15 +58,18 @@ export interface ReactionSummaryInput {
|
|
|
52
58
|
* Tally a comment's reactions into an entry per emoji, ordered by when that emoji was first used so
|
|
53
59
|
* the row stays stable as later reactions arrive. `active` marks the current user's emoji and
|
|
54
60
|
* `reactors` lists who reacted, in reaction order. `resolveName` names each reactor; an id it can't
|
|
55
|
-
* name falls back to
|
|
61
|
+
* name falls back to `unknownAuthorName`, never the raw user id. Pass a translated name for that:
|
|
62
|
+
* this is a plain function, so it can't reach the translations itself.
|
|
56
63
|
*
|
|
57
64
|
* @public
|
|
58
65
|
*/
|
|
59
66
|
export function summarizeReactions(
|
|
60
67
|
reactions: readonly ReactionSummaryInput[],
|
|
61
68
|
currentUserId?: string | null,
|
|
62
|
-
resolveName?: (userId: string) => string | undefined
|
|
69
|
+
resolveName?: (userId: string) => string | undefined,
|
|
70
|
+
opts?: { unknownAuthorName?: string }
|
|
63
71
|
): ReactionSummary[] {
|
|
72
|
+
const unknownAuthorName = opts?.unknownAuthorName ?? UNKNOWN_AUTHOR
|
|
64
73
|
const groups = new Map<
|
|
65
74
|
string,
|
|
66
75
|
{ count: number; active: boolean; firstAt: number; reactors: ReactionSummary['reactors'] }
|
|
@@ -68,7 +77,7 @@ export function summarizeReactions(
|
|
|
68
77
|
for (const reaction of reactions) {
|
|
69
78
|
const mine = currentUserId != null && reaction.userId === currentUserId
|
|
70
79
|
// Fall back to a generic name, never the raw user id, when the id can't be resolved.
|
|
71
|
-
const reactor = { name: resolveName?.(reaction.userId) ??
|
|
80
|
+
const reactor = { name: resolveName?.(reaction.userId) ?? unknownAuthorName, you: mine }
|
|
72
81
|
const group = groups.get(reaction.emoji)
|
|
73
82
|
if (group) {
|
|
74
83
|
group.count++
|
|
@@ -177,9 +186,11 @@ export function CommentReactions({ comment, currentUserId, resolveName }: Commen
|
|
|
177
186
|
const renderReaction = useReactionRenderer()
|
|
178
187
|
const { components } = useCommentingOptions()
|
|
179
188
|
const reactions = useCommentReactions(editor, comment.id)
|
|
189
|
+
const msg = useTranslation()
|
|
190
|
+
const unknownAuthorName = msg(messages.commentsUnknownAuthor)
|
|
180
191
|
const summaries = useMemo(
|
|
181
|
-
() => summarizeReactions(reactions, currentUserId, resolveName),
|
|
182
|
-
[reactions, currentUserId, resolveName]
|
|
192
|
+
() => summarizeReactions(reactions, currentUserId, resolveName, { unknownAuthorName }),
|
|
193
|
+
[reactions, currentUserId, resolveName, unknownAuthorName]
|
|
183
194
|
)
|
|
184
195
|
// Suppress the hover list while any menu is open (the reaction picker, an overflow menu…) so it
|
|
185
196
|
// doesn't compete with the menu the user is actually working in. Edit mode already hides the
|
|
@@ -4,10 +4,12 @@ import { renderHtmlFromRichTextWithExtensions, TLRichText } from 'tldraw'
|
|
|
4
4
|
import { commentTipTapExtensions, isCommentEmpty } from '../ui/comment-extensions'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The author name
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* The author name used when no source can name an id (e.g. a deleted account, or a member with no
|
|
8
|
+
* comment and no live presence). Hosts pre-empt it by resolving the id from `resolveAuthor`.
|
|
9
|
+
*
|
|
10
|
+
* The English fallback, for the places that can't translate: it's a module constant, so the UI
|
|
11
|
+
* reads `comments.unknown-author` instead and only lands here when there are no translations at
|
|
12
|
+
* all. Pass a translated name to {@link summarizeReactions} for the same reason.
|
|
11
13
|
*/
|
|
12
14
|
export const UNKNOWN_AUTHOR = 'Someone'
|
|
13
15
|
|
|
@@ -10,9 +10,11 @@ import {
|
|
|
10
10
|
Editor,
|
|
11
11
|
TLShapeId,
|
|
12
12
|
TLStateNodeConstructor,
|
|
13
|
+
TLUiOverrideHelpers,
|
|
14
|
+
TLUiToolsContextType,
|
|
13
15
|
} from 'tldraw'
|
|
14
16
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
15
|
-
import { CommentTool } from './comment-tool'
|
|
17
|
+
import { CommentTool, commentToolOverrides, startCommentAt } from './comment-tool'
|
|
16
18
|
import { commentsSidebarOpen, pendingComment, regionDraft } from './state'
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -117,17 +119,14 @@ describe('comment tool placement lifecycle', () => {
|
|
|
117
119
|
})
|
|
118
120
|
})
|
|
119
121
|
|
|
120
|
-
it('
|
|
122
|
+
it('cancels the placement and returns to select on a second click', () => {
|
|
121
123
|
editor.setCurrentTool('comment')
|
|
122
124
|
driver.pointerDown(400, 300)
|
|
123
125
|
driver.pointerUp(400, 300)
|
|
124
126
|
driver.pointerDown(200, 200)
|
|
125
127
|
driver.pointerUp(200, 200)
|
|
126
|
-
expect(editor.isIn('
|
|
127
|
-
expect(pendingComment.get(editor)).
|
|
128
|
-
anchor: { type: 'point', x: 200, y: 200 },
|
|
129
|
-
point: { x: 200, y: 200 },
|
|
130
|
-
})
|
|
128
|
+
expect(editor.isIn('select.idle')).toBe(true)
|
|
129
|
+
expect(pendingComment.get(editor)).toBeNull()
|
|
131
130
|
})
|
|
132
131
|
|
|
133
132
|
it('drops the pending composer when the tool exits', () => {
|
|
@@ -165,6 +164,72 @@ describe('comment tool placement lifecycle', () => {
|
|
|
165
164
|
})
|
|
166
165
|
})
|
|
167
166
|
|
|
167
|
+
describe('startCommentAt', () => {
|
|
168
|
+
let driver: Driver
|
|
169
|
+
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
driver = makeEditor()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('enters the tool and opens the composer at a bare point on empty canvas', () => {
|
|
175
|
+
commentsSidebarOpen.set(editor, true)
|
|
176
|
+
startCommentAt(editor, { x: 400, y: 300 })
|
|
177
|
+
expect(editor.isIn('comment.idle')).toBe(true)
|
|
178
|
+
expect(commentsSidebarOpen.get(editor)).toBe(false)
|
|
179
|
+
expect(pendingComment.get(editor)).toEqual({
|
|
180
|
+
anchor: { type: 'point', x: 400, y: 300 },
|
|
181
|
+
point: { x: 400, y: 300 },
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('anchors to the shape under the point', () => {
|
|
186
|
+
const id = makeShape()
|
|
187
|
+
startCommentAt(editor, { x: 150, y: 125 })
|
|
188
|
+
expect(pendingComment.get(editor)?.anchor).toMatchObject({ type: 'shape', shapeId: id })
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('hints the anchored shape, not the one under the live pointer', () => {
|
|
192
|
+
const anchored = makeShape()
|
|
193
|
+
const underPointer = makeShape(400, 100)
|
|
194
|
+
driver.pointerMove(450, 125)
|
|
195
|
+
editor.setCurrentTool('comment')
|
|
196
|
+
expect(editor.getHintingShapeIds()).toEqual([underPointer])
|
|
197
|
+
startCommentAt(editor, { x: 150, y: 125 })
|
|
198
|
+
expect(editor.getHintingShapeIds()).toEqual([anchored])
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('commentToolOverrides', () => {
|
|
203
|
+
let driver: Driver
|
|
204
|
+
|
|
205
|
+
function getCommentTool() {
|
|
206
|
+
const tools = commentToolOverrides.tools!(
|
|
207
|
+
editor,
|
|
208
|
+
{} as TLUiToolsContextType,
|
|
209
|
+
{} as TLUiOverrideHelpers
|
|
210
|
+
)
|
|
211
|
+
return tools.comment
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
driver = makeEditor()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('opens the composer where the context menu was opened, not at the live pointer', () => {
|
|
219
|
+
driver.pointerDown(400, 300).pointerUp(400, 300)
|
|
220
|
+
driver.pointerMove(600, 500)
|
|
221
|
+
getCommentTool().onSelect('context-menu')
|
|
222
|
+
expect(editor.isIn('comment.idle')).toBe(true)
|
|
223
|
+
expect(pendingComment.get(editor)?.point).toEqual({ x: 400, y: 300 })
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('only enters the tool from other sources', () => {
|
|
227
|
+
getCommentTool().onSelect('quick-actions')
|
|
228
|
+
expect(editor.isIn('comment.idle')).toBe(true)
|
|
229
|
+
expect(pendingComment.get(editor)).toBeNull()
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
168
233
|
describe('comment tool placement hints with regions enabled', () => {
|
|
169
234
|
it('clears the hint when a drag becomes a region', () => {
|
|
170
235
|
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
|
@@ -265,3 +330,14 @@ describe('comment tool anchoring to frames', () => {
|
|
|
265
330
|
expect(editor.getHintingShapeIds()).toEqual([])
|
|
266
331
|
})
|
|
267
332
|
})
|
|
333
|
+
|
|
334
|
+
describe('commentToolOverrides', () => {
|
|
335
|
+
// The label is data the toolbar puts through `msg()`, so free text here renders as itself in
|
|
336
|
+
// every locale and the extractor never sees it — which is how this one shipped as 'Comment'
|
|
337
|
+
// and stayed English everywhere. It has to be a declared id.
|
|
338
|
+
it('labels the tool with a translatable id', () => {
|
|
339
|
+
makeEditor()
|
|
340
|
+
const tools = commentToolOverrides.tools!(editor, {}, {} as never)
|
|
341
|
+
expect(tools.comment.label).toBe('tool.comment')
|
|
342
|
+
})
|
|
343
|
+
})
|