@defensestation/y-comments-blocknote 1.0.0-dev.84f89ec

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.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Formatting-toolbar item (same export name as v1): opens the floating
3
+ * composer for the current selection. Disabled when the selection is empty
4
+ * or the store's auth denies thread creation.
5
+ *
6
+ * Render inside BlockNote's FormattingToolbar (it uses the components
7
+ * context) and inside a CommentsProvider.
8
+ */
9
+ export declare function CreateCommentButton(): import("react").JSX.Element | null;
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useComponentsContext } from "@blocknote/react";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { useComments } from "../context.js";
5
+ import { CommentIcon } from "./icons.js";
6
+ /**
7
+ * Formatting-toolbar item (same export name as v1): opens the floating
8
+ * composer for the current selection. Disabled when the selection is empty
9
+ * or the store's auth denies thread creation.
10
+ *
11
+ * Render inside BlockNote's FormattingToolbar (it uses the components
12
+ * context) and inside a CommentsProvider.
13
+ */
14
+ export function CreateCommentButton() {
15
+ const Components = useComponentsContext();
16
+ const { editor, store, openComposer } = useComments();
17
+ const [hasSelection, setHasSelection] = useState(false);
18
+ useEffect(() => {
19
+ const update = () => {
20
+ try {
21
+ setHasSelection(editor.getSelectedText().trim().length > 0);
22
+ }
23
+ catch {
24
+ setHasSelection(false);
25
+ }
26
+ };
27
+ update();
28
+ return editor.onSelectionChange(update);
29
+ }, [editor]);
30
+ const onClick = useCallback(() => {
31
+ openComposer();
32
+ // close the formatting toolbar so the composer (which auto-focuses its
33
+ // textarea) is the only thing in front of the selection
34
+ try {
35
+ const toolbar = editor.getExtension("formattingToolbar");
36
+ toolbar?.store?.setState(false);
37
+ }
38
+ catch {
39
+ // no formatting toolbar extension — nothing to close
40
+ }
41
+ }, [openComposer, editor]);
42
+ if (!Components) {
43
+ return null;
44
+ }
45
+ return (_jsx(Components.FormattingToolbar.Button, { className: "bn-button", label: "Add comment", mainTooltip: "Add comment", icon: _jsx(CommentIcon, {}), isDisabled: !hasSelection || !store.auth.canCreateThread(), onClick: onClick }));
46
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Floating comment composer, positioned at the selection captured when
3
+ * `openComposer()` was called (normally by CreateCommentButton).
4
+ *
5
+ * On submit: build a relative-position anchor from the selection, create the
6
+ * thread in the store, select it. The document is never touched.
7
+ */
8
+ export declare function FloatingComposer(): import("react").JSX.Element | null;
@@ -0,0 +1,147 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { anchorFromRange } from "@defensestation/y-comments-prosemirror";
3
+ import { autoUpdate, flip, offset, shift, useFloating, } from "@floating-ui/react";
4
+ import { useCallback, useEffect, useRef, useState, } from "react";
5
+ import { useComments } from "../context.js";
6
+ import { OFFSCREEN_RECT } from "./floating-utils.js";
7
+ /**
8
+ * Floating comment composer, positioned at the selection captured when
9
+ * `openComposer()` was called (normally by CreateCommentButton).
10
+ *
11
+ * On submit: build a relative-position anchor from the selection, create the
12
+ * thread in the store, select it. The document is never touched.
13
+ */
14
+ export function FloatingComposer() {
15
+ const { editor, store, docId, composer, closeComposer, selectThread } = useComments();
16
+ const [text, setText] = useState("");
17
+ const [error, setError] = useState(null);
18
+ const [busy, setBusy] = useState(false);
19
+ const containerRef = useRef(null);
20
+ const textareaRef = useRef(null);
21
+ const { refs, floatingStyles, isPositioned } = useFloating({
22
+ open: composer !== null,
23
+ placement: "bottom",
24
+ strategy: "fixed",
25
+ middleware: [offset(8), flip(), shift({ padding: 8 })],
26
+ whileElementsMounted: autoUpdate,
27
+ });
28
+ // Anchor the popover to the selection's bounding box, recomputed live so
29
+ // scrolling or reflow doesn't strand it (coordsAtPos is viewport-relative).
30
+ useEffect(() => {
31
+ if (composer === null) {
32
+ return;
33
+ }
34
+ let view;
35
+ try {
36
+ view = editor.prosemirrorView;
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ refs.setPositionReference({
42
+ getBoundingClientRect: () => {
43
+ try {
44
+ const max = view.state.doc.content.size;
45
+ const a = view.coordsAtPos(Math.min(composer.from, max));
46
+ const b = view.coordsAtPos(Math.min(composer.to, max));
47
+ const left = Math.min(a.left, b.left);
48
+ const right = Math.max(a.right, b.right);
49
+ const top = Math.min(a.top, b.top);
50
+ const bottom = Math.max(a.bottom, b.bottom);
51
+ return { x: left, y: top, left, right, top, bottom, width: right - left, height: bottom - top };
52
+ }
53
+ catch {
54
+ return OFFSCREEN_RECT;
55
+ }
56
+ },
57
+ contextElement: view.dom,
58
+ });
59
+ }, [composer, editor, refs]);
60
+ useEffect(() => {
61
+ setText("");
62
+ setError(null);
63
+ }, [composer]);
64
+ // The popover mounts with visibility:hidden until floating-ui positions
65
+ // it, and hidden elements refuse focus — so autoFocus never fires. Focus
66
+ // once positioned; the rAF also lets BlockNote's post-click editor
67
+ // refocus settle first.
68
+ useEffect(() => {
69
+ if (composer === null || !isPositioned) {
70
+ return;
71
+ }
72
+ const raf = requestAnimationFrame(() => textareaRef.current?.focus());
73
+ return () => cancelAnimationFrame(raf);
74
+ }, [composer, isPositioned]);
75
+ // Dismiss on outside pointer-down or Escape.
76
+ useEffect(() => {
77
+ if (composer === null) {
78
+ return;
79
+ }
80
+ const onPointerDown = (e) => {
81
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
82
+ closeComposer();
83
+ }
84
+ };
85
+ const onKeyDown = (e) => {
86
+ if (e.key === "Escape") {
87
+ closeComposer();
88
+ }
89
+ };
90
+ document.addEventListener("mousedown", onPointerDown);
91
+ document.addEventListener("keydown", onKeyDown);
92
+ return () => {
93
+ document.removeEventListener("mousedown", onPointerDown);
94
+ document.removeEventListener("keydown", onKeyDown);
95
+ };
96
+ }, [composer, closeComposer]);
97
+ const submit = useCallback(() => {
98
+ if (composer === null) {
99
+ return;
100
+ }
101
+ const body = text.trim();
102
+ if (body.length === 0) {
103
+ return;
104
+ }
105
+ let anchor;
106
+ try {
107
+ const view = editor.prosemirrorView;
108
+ anchor = anchorFromRange(view.state, composer.from, composer.to);
109
+ }
110
+ catch (err) {
111
+ setError(err instanceof Error ? err.message : String(err));
112
+ return;
113
+ }
114
+ setBusy(true);
115
+ store
116
+ .createThread({ docId, anchor, body: { text: body } })
117
+ .then((thread) => {
118
+ setBusy(false);
119
+ closeComposer();
120
+ selectThread(thread.id);
121
+ })
122
+ .catch((err) => {
123
+ setBusy(false);
124
+ setError(err instanceof Error
125
+ ? `Couldn't create the comment: ${err.message}`
126
+ : "Couldn't create the comment — check the connection and try again.");
127
+ });
128
+ }, [composer, text, editor, store, docId, closeComposer, selectThread]);
129
+ const onSubmit = useCallback((e) => {
130
+ e.preventDefault();
131
+ submit();
132
+ }, [submit]);
133
+ // Enter posts the comment, Shift+Enter inserts a newline.
134
+ const onTextareaKeyDown = useCallback((e) => {
135
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
136
+ e.preventDefault();
137
+ submit();
138
+ }
139
+ }, [submit]);
140
+ if (composer === null) {
141
+ return null;
142
+ }
143
+ return (_jsx("div", { ref: (node) => {
144
+ containerRef.current = node;
145
+ refs.setFloating(node);
146
+ }, style: { ...floatingStyles, visibility: isPositioned ? undefined : "hidden" }, className: "y-comments-floating y-comments-floating-composer", "data-y-comments-composer": true, children: _jsxs("form", { className: "y-comments-composer", onSubmit: onSubmit, children: [_jsx("textarea", { ref: textareaRef, className: "y-comments-input", placeholder: "Add a comment\u2026", value: text, onChange: (e) => setText(e.target.value), onKeyDown: onTextareaKeyDown, rows: 2 }), error && (_jsx("div", { className: "y-comments-error", role: "alert", children: error })), _jsxs("div", { className: "y-comments-composer-actions", children: [_jsx("button", { type: "button", className: "y-comments-button", onClick: closeComposer, children: "Cancel" }), _jsx("button", { type: "submit", className: "y-comments-button y-comments-button-primary", disabled: text.trim().length === 0 || busy, children: busy ? "Saving…" : "Comment" })] })] }) }));
147
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Popover showing the full selected thread, anchored to its highlight span
3
+ * in the editor. Renders only while a thread with a live highlight is
4
+ * selected (sidebar-only threads — orphaned/container — expand in the
5
+ * sidebar instead).
6
+ *
7
+ * The reference is virtual and re-queries the decoration span on every
8
+ * position read: ProseMirror replaces the spans whenever decorations
9
+ * rebuild, so holding a DOM node would leave the popover anchored to a
10
+ * detached element whose rect is 0,0 — the "popover in the corner" bug.
11
+ */
12
+ export declare function FloatingThread(): import("react").JSX.Element | null;
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { autoUpdate, flip, offset, shift, useFloating, } from "@floating-ui/react";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { useComments } from "../context.js";
5
+ import { OFFSCREEN_RECT } from "./floating-utils.js";
6
+ import { ThreadBody } from "./ThreadBody.js";
7
+ /**
8
+ * Popover showing the full selected thread, anchored to its highlight span
9
+ * in the editor. Renders only while a thread with a live highlight is
10
+ * selected (sidebar-only threads — orphaned/container — expand in the
11
+ * sidebar instead).
12
+ *
13
+ * The reference is virtual and re-queries the decoration span on every
14
+ * position read: ProseMirror replaces the spans whenever decorations
15
+ * rebuild, so holding a DOM node would leave the popover anchored to a
16
+ * detached element whose rect is 0,0 — the "popover in the corner" bug.
17
+ */
18
+ export function FloatingThread() {
19
+ const { editor, threads, resolutions, selectedThreadId, selectThread } = useComments();
20
+ const containerRef = useRef(null);
21
+ const [spanExists, setSpanExists] = useState(false);
22
+ const thread = useMemo(() => threads.find((t) => t.id === selectedThreadId) ?? null, [threads, selectedThreadId]);
23
+ const resolution = thread ? resolutions.get(thread.id) : undefined;
24
+ const hasHighlight = thread !== null &&
25
+ !thread.resolved &&
26
+ (resolution?.kind === "exact" || resolution?.kind === "reanchored");
27
+ const { refs, floatingStyles, isPositioned } = useFloating({
28
+ open: hasHighlight && spanExists,
29
+ placement: "bottom-start",
30
+ strategy: "fixed",
31
+ middleware: [offset(6), flip(), shift({ padding: 8 })],
32
+ whileElementsMounted: autoUpdate,
33
+ });
34
+ // Virtual reference resolving the *current* span on every read; re-check
35
+ // span existence whenever threads/resolutions change (decorations rebuild
36
+ // replaces the spans).
37
+ useEffect(() => {
38
+ if (!hasHighlight || thread === null) {
39
+ setSpanExists(false);
40
+ return;
41
+ }
42
+ let viewDom;
43
+ try {
44
+ viewDom = editor.prosemirrorView.dom;
45
+ }
46
+ catch {
47
+ setSpanExists(false);
48
+ return;
49
+ }
50
+ const selector = `[data-thread-id="${CSS.escape(thread.id)}"]`;
51
+ setSpanExists(viewDom.querySelector(selector) !== null);
52
+ refs.setPositionReference({
53
+ getBoundingClientRect: () => {
54
+ const span = viewDom.querySelector(selector);
55
+ return span ? span.getBoundingClientRect() : OFFSCREEN_RECT;
56
+ },
57
+ contextElement: viewDom,
58
+ });
59
+ }, [hasHighlight, thread, resolutions, editor, refs]);
60
+ // Dismiss on outside pointer-down (clicks on the highlight or inside the
61
+ // popover keep it open) or Escape.
62
+ useEffect(() => {
63
+ if (!hasHighlight) {
64
+ return;
65
+ }
66
+ const onPointerDown = (e) => {
67
+ const target = e.target;
68
+ if (containerRef.current?.contains(target)) {
69
+ return;
70
+ }
71
+ if (target instanceof Element && target.closest("[data-thread-id]")) {
72
+ return; // highlight clicks are handled by the plugin
73
+ }
74
+ selectThread(null);
75
+ };
76
+ const onKeyDown = (e) => {
77
+ if (e.key === "Escape") {
78
+ selectThread(null);
79
+ }
80
+ };
81
+ document.addEventListener("mousedown", onPointerDown);
82
+ document.addEventListener("keydown", onKeyDown);
83
+ return () => {
84
+ document.removeEventListener("mousedown", onPointerDown);
85
+ document.removeEventListener("keydown", onKeyDown);
86
+ };
87
+ }, [hasHighlight, selectThread]);
88
+ if (!hasHighlight || thread === null || !spanExists) {
89
+ return null;
90
+ }
91
+ return (_jsx("div", { ref: (node) => {
92
+ containerRef.current = node;
93
+ refs.setFloating(node);
94
+ }, style: { ...floatingStyles, visibility: isPositioned ? undefined : "hidden" }, className: "y-comments-floating y-comments-floating-thread", "data-y-comments-floating-thread": true, children: _jsx(ThreadBody, { thread: thread }) }));
95
+ }
@@ -0,0 +1,8 @@
1
+ import type { Thread } from "@defensestation/y-comments-core";
2
+ export interface ThreadBodyProps {
3
+ thread: Thread;
4
+ /** Render the quoted text above the comments (used by the sidebar). */
5
+ showQuote?: boolean;
6
+ }
7
+ /** Full thread: header actions, comments, reply composer. */
8
+ export declare function ThreadBody({ thread, showQuote }: ThreadBodyProps): import("react").JSX.Element;
@@ -0,0 +1,190 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
3
+ import { useComments } from "../context.js";
4
+ import { relativeTime } from "../time.js";
5
+ import { CheckIcon, EditIcon, EmojiIcon, ReopenIcon, TrashIcon } from "./icons.js";
6
+ const REACTION_PALETTE = ["👍", "❤️", "✅", "👀", "😄", "🎉"];
7
+ /**
8
+ * Run a store action with visual failure feedback: on rejection an error
9
+ * line appears where the action happened, and the triggering state (draft
10
+ * text, edit mode) is preserved so the user can retry.
11
+ */
12
+ function useStoreAction() {
13
+ const [error, setError] = useState(null);
14
+ const [busy, setBusy] = useState(false);
15
+ const run = useCallback((label, action, onSuccess) => {
16
+ setBusy(true);
17
+ action().then(() => {
18
+ setBusy(false);
19
+ setError(null);
20
+ onSuccess?.();
21
+ }, () => {
22
+ setBusy(false);
23
+ setError(`${label} failed — check the connection and try again.`);
24
+ });
25
+ }, []);
26
+ return { error, busy, run };
27
+ }
28
+ /** Enter submits, Shift+Enter inserts a newline (IME composition ignored). */
29
+ function submitOnEnter(e, submit) {
30
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
31
+ e.preventDefault();
32
+ submit();
33
+ }
34
+ }
35
+ function ErrorLine({ error }) {
36
+ if (!error) {
37
+ return null;
38
+ }
39
+ return (_jsx("div", { className: "y-comments-error", role: "alert", children: error }));
40
+ }
41
+ function Avatar({ userId }) {
42
+ const { users } = useComments();
43
+ const user = users.get(userId);
44
+ if (user?.avatarUrl) {
45
+ return _jsx("img", { className: "y-comments-avatar", src: user.avatarUrl, alt: user.username });
46
+ }
47
+ const name = user?.username ?? userId;
48
+ return (_jsx("span", { className: "y-comments-avatar y-comments-avatar-fallback", title: name, children: name.slice(0, 1).toUpperCase() }));
49
+ }
50
+ function Username({ userId }) {
51
+ const { users } = useComments();
52
+ return _jsx("span", { className: "y-comments-username", children: users.get(userId)?.username ?? userId });
53
+ }
54
+ function Reactions({ thread, comment }) {
55
+ const { store } = useComments();
56
+ const [pickerOpen, setPickerOpen] = useState(false);
57
+ const pickerWrapRef = useRef(null);
58
+ const { error, run } = useStoreAction();
59
+ // Dismiss the palette on outside pointer-down, like any popover.
60
+ useEffect(() => {
61
+ if (!pickerOpen) {
62
+ return;
63
+ }
64
+ const onPointerDown = (e) => {
65
+ if (pickerWrapRef.current && !pickerWrapRef.current.contains(e.target)) {
66
+ setPickerOpen(false);
67
+ }
68
+ };
69
+ document.addEventListener("mousedown", onPointerDown);
70
+ return () => document.removeEventListener("mousedown", onPointerDown);
71
+ }, [pickerOpen]);
72
+ const toggle = (emoji, mine) => {
73
+ const input = { threadId: thread.id, commentId: comment.id, emoji };
74
+ run("Reaction", () => (mine ? store.deleteReaction(input) : store.addReaction(input)));
75
+ setPickerOpen(false);
76
+ };
77
+ const canReact = store.auth.canAddReaction(comment);
78
+ return (_jsxs("div", { className: "y-comments-reactions", children: [comment.reactions.map((reaction) => {
79
+ const mine = store.auth.canDeleteReaction(comment, reaction.emoji);
80
+ return (_jsxs("button", { type: "button", className: `y-comments-reaction${mine ? " y-comments-reaction-mine" : ""}`, title: reaction.userIds.join(", "), onClick: () => (mine || canReact ? toggle(reaction.emoji, mine) : undefined), children: [reaction.emoji, " ", reaction.userIds.length] }, reaction.emoji));
81
+ }), canReact && (_jsxs("span", { className: "y-comments-reaction-picker-wrap", ref: pickerWrapRef, children: [_jsx("button", { type: "button", className: "y-comments-icon-button", "aria-label": "Add reaction", onClick: () => setPickerOpen((open) => !open), children: _jsx(EmojiIcon, {}) }), pickerOpen && (_jsx("span", { className: "y-comments-reaction-picker", role: "menu", children: REACTION_PALETTE.map((emoji) => (_jsx("button", { type: "button", className: "y-comments-reaction", onClick: () => toggle(emoji, comment.reactions.some((r) => r.emoji === emoji && store.auth.canDeleteReaction(comment, emoji))), children: emoji }, emoji))) }))] })), _jsx(ErrorLine, { error: error })] }));
82
+ }
83
+ function CommentView({ thread, comment }) {
84
+ const { store, requestUsers } = useComments();
85
+ const [editing, setEditing] = useState(false);
86
+ const [draft, setDraft] = useState(comment.body.text);
87
+ const { error, busy, run } = useStoreAction();
88
+ useEffect(() => requestUsers([comment.userId]), [comment.userId, requestUsers]);
89
+ const submitEdit = () => {
90
+ const text = draft.trim();
91
+ if (text.length === 0) {
92
+ return;
93
+ }
94
+ // stay in edit mode (draft intact) unless the save succeeds
95
+ run("Saving the edit", () => store.updateComment({ threadId: thread.id, commentId: comment.id, body: { text } }), () => setEditing(false));
96
+ };
97
+ const onEditKeyDown = (e) => {
98
+ if (e.key === "Escape") {
99
+ // cancel just the edit — don't let the floating thread's document
100
+ // Escape handler close the whole popover
101
+ e.stopPropagation();
102
+ setEditing(false);
103
+ return;
104
+ }
105
+ submitOnEnter(e, submitEdit);
106
+ };
107
+ return (_jsxs("div", { className: "y-comments-comment", "data-comment-id": comment.id, children: [_jsxs("div", { className: "y-comments-comment-header", children: [_jsx(Avatar, { userId: comment.userId }), _jsx(Username, { userId: comment.userId }), _jsxs("span", { className: "y-comments-timestamp", title: comment.createdAt, children: [relativeTime(comment.createdAt), comment.updatedAt ? " (edited)" : ""] }), _jsxs("span", { className: "y-comments-comment-actions", children: [store.auth.canUpdateComment(comment) && (_jsx("button", { type: "button", className: "y-comments-icon-button", "aria-label": "Edit comment", onClick: () => {
108
+ setDraft(comment.body.text);
109
+ setEditing((v) => !v);
110
+ }, children: _jsx(EditIcon, {}) })), store.auth.canDeleteComment(comment) && (_jsx("button", { type: "button", className: "y-comments-icon-button", "aria-label": "Delete comment", onClick: () => run("Deleting the comment", () => store.deleteComment({ threadId: thread.id, commentId: comment.id })), children: _jsx(TrashIcon, {}) }))] })] }), editing ? (_jsxs("form", { className: "y-comments-composer", onSubmit: (e) => {
111
+ e.preventDefault();
112
+ submitEdit();
113
+ }, children: [_jsx("textarea", { className: "y-comments-input", value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: onEditKeyDown, rows: 2, autoFocus: true }), _jsx(ErrorLine, { error: error }), _jsxs("div", { className: "y-comments-composer-actions", children: [_jsx("button", { type: "button", className: "y-comments-button", onClick: () => setEditing(false), children: "Cancel" }), _jsx("button", { type: "submit", className: "y-comments-button y-comments-button-primary", disabled: busy, children: "Save" })] })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "y-comments-comment-body", children: comment.body.text }), _jsx(ErrorLine, { error: error })] })), _jsx(Reactions, { thread: thread, comment: comment })] }));
114
+ }
115
+ /** Full thread: header actions, comments, reply composer. */
116
+ export function ThreadBody({ thread, showQuote }) {
117
+ const { store, requestUsers } = useComments();
118
+ const [reply, setReply] = useState("");
119
+ const headerAction = useStoreAction();
120
+ const replyAction = useStoreAction();
121
+ const commentsRef = useRef(null);
122
+ // Follow the newest comment unless the user scrolled up to read history;
123
+ // scrolling back near the bottom re-pins.
124
+ const pinnedToBottom = useRef(true);
125
+ // Smooth scrolling emits scroll events from positions the user never
126
+ // chose; don't let those unpin (only a wheel/touch takeover does).
127
+ const autoScrolling = useRef(false);
128
+ const commentCount = useRef(thread.comments.length);
129
+ const scrollToLatest = useCallback((behavior) => {
130
+ const el = commentsRef.current;
131
+ if (!el) {
132
+ return;
133
+ }
134
+ if (typeof el.scrollTo === "function") {
135
+ autoScrolling.current = behavior === "smooth";
136
+ el.scrollTo({ top: el.scrollHeight, behavior });
137
+ }
138
+ else {
139
+ el.scrollTop = el.scrollHeight; // jsdom
140
+ }
141
+ }, []);
142
+ // Open showing the latest comment.
143
+ useEffect(() => {
144
+ scrollToLatest("auto");
145
+ }, [scrollToLatest]);
146
+ useEffect(() => {
147
+ const grew = thread.comments.length > commentCount.current;
148
+ commentCount.current = thread.comments.length;
149
+ if (grew && pinnedToBottom.current) {
150
+ scrollToLatest("smooth");
151
+ }
152
+ }, [thread.comments.length, scrollToLatest]);
153
+ const onCommentsScroll = () => {
154
+ const el = commentsRef.current;
155
+ if (!el) {
156
+ return;
157
+ }
158
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
159
+ if (autoScrolling.current) {
160
+ if (nearBottom) {
161
+ autoScrolling.current = false; // arrived
162
+ pinnedToBottom.current = true;
163
+ }
164
+ return;
165
+ }
166
+ pinnedToBottom.current = nearBottom;
167
+ };
168
+ // A wheel or touch means the user took over, even mid-animation.
169
+ const onUserScrollIntent = () => {
170
+ autoScrolling.current = false;
171
+ };
172
+ const userIds = useMemo(() => Array.from(new Set(thread.comments.map((c) => c.userId))), [thread.comments]);
173
+ useEffect(() => requestUsers(userIds), [userIds, requestUsers]);
174
+ const submitReply = () => {
175
+ const text = reply.trim();
176
+ if (text.length === 0) {
177
+ return;
178
+ }
179
+ // the draft is only cleared once the reply actually saved
180
+ replyAction.run("Sending the reply", () => store.addComment({ threadId: thread.id, body: { text } }), () => {
181
+ setReply("");
182
+ // your own reply always comes into view, even if you were scrolled up
183
+ pinnedToBottom.current = true;
184
+ });
185
+ };
186
+ return (_jsxs("div", { className: "y-comments-thread", "data-thread-body": thread.id, children: [_jsxs("div", { className: "y-comments-thread-header", children: [thread.outdated && _jsx("span", { className: "y-comments-badge", children: "Outdated" }), thread.resolved && _jsx("span", { className: "y-comments-badge y-comments-badge-resolved", children: "Resolved" }), _jsx("span", { className: "y-comments-thread-header-spacer" }), !thread.resolved && store.auth.canResolveThread(thread) && (_jsx("button", { type: "button", className: "y-comments-icon-button", "aria-label": "Resolve thread", title: "Resolve", onClick: () => headerAction.run("Resolving", () => store.resolveThread({ threadId: thread.id })), children: _jsx(CheckIcon, {}) })), thread.resolved && store.auth.canUnresolveThread(thread) && (_jsx("button", { type: "button", className: "y-comments-icon-button", "aria-label": "Reopen thread", title: "Reopen", onClick: () => headerAction.run("Reopening", () => store.unresolveThread({ threadId: thread.id })), children: _jsx(ReopenIcon, {}) })), store.auth.canDeleteThread(thread) && (_jsx("button", { type: "button", className: "y-comments-icon-button", "aria-label": "Delete thread", title: "Delete thread", onClick: () => headerAction.run("Deleting the thread", () => store.deleteThread({ threadId: thread.id })), children: _jsx(TrashIcon, {}) }))] }), _jsx(ErrorLine, { error: headerAction.error }), showQuote && thread.anchor.quotedText && (_jsx("blockquote", { className: "y-comments-quote", children: thread.anchor.quotedText })), _jsx("div", { className: "y-comments-comments", ref: commentsRef, onScroll: onCommentsScroll, onWheel: onUserScrollIntent, onTouchMove: onUserScrollIntent, children: thread.comments.map((comment) => (_jsx(CommentView, { thread: thread, comment: comment }, comment.id))) }), store.auth.canAddComment(thread) && (_jsxs("form", { className: "y-comments-composer", onSubmit: (e) => {
187
+ e.preventDefault();
188
+ submitReply();
189
+ }, children: [_jsx("textarea", { className: "y-comments-input", placeholder: "Reply\u2026", value: reply, onChange: (e) => setReply(e.target.value), onKeyDown: (e) => submitOnEnter(e, submitReply), rows: 1 }), _jsx(ErrorLine, { error: replyAction.error }), _jsx("div", { className: "y-comments-composer-actions", children: _jsx("button", { type: "submit", className: "y-comments-button y-comments-button-primary", disabled: reply.trim().length === 0 || replyAction.busy, children: "Reply" }) })] }))] }));
190
+ }
@@ -0,0 +1,13 @@
1
+ export type ThreadsFilter = "open" | "resolved" | "all";
2
+ export interface ThreadsSidebarProps {
3
+ /** Initial filter. The user can change it via the built-in control. */
4
+ defaultFilter?: ThreadsFilter;
5
+ className?: string;
6
+ }
7
+ /**
8
+ * All threads for the document, ordered by their resolved position in the
9
+ * doc. Threads whose anchor no longer resolves to text (container/orphaned)
10
+ * or that the store marked `outdated` are grouped last under "Outdated",
11
+ * showing their stored quote — the GitHub outdated-review-comment pattern.
12
+ */
13
+ export declare function ThreadsSidebar({ defaultFilter, className }: ThreadsSidebarProps): import("react").JSX.Element;
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
3
+ import { useComments } from "../context.js";
4
+ import { ThreadBody } from "./ThreadBody.js";
5
+ const FILTERS = [
6
+ { value: "open", label: "Open" },
7
+ { value: "resolved", label: "Resolved" },
8
+ { value: "all", label: "All" },
9
+ ];
10
+ /**
11
+ * All threads for the document, ordered by their resolved position in the
12
+ * doc. Threads whose anchor no longer resolves to text (container/orphaned)
13
+ * or that the store marked `outdated` are grouped last under "Outdated",
14
+ * showing their stored quote — the GitHub outdated-review-comment pattern.
15
+ */
16
+ export function ThreadsSidebar({ defaultFilter = "open", className }) {
17
+ const { editor, threads, resolutions, selectedThreadId, selectThread } = useComments();
18
+ const [filter, setFilter] = useState(defaultFilter);
19
+ const { positioned, outdated } = useMemo(() => {
20
+ const visible = threads.filter((t) => filter === "all" ? true : filter === "resolved" ? t.resolved : !t.resolved);
21
+ const positioned = [];
22
+ const outdated = [];
23
+ for (const thread of visible) {
24
+ const resolution = resolutions.get(thread.id);
25
+ if (!thread.outdated &&
26
+ (resolution?.kind === "exact" || resolution?.kind === "reanchored")) {
27
+ positioned.push({ thread, at: resolution.from.index });
28
+ }
29
+ else {
30
+ outdated.push(thread);
31
+ }
32
+ }
33
+ positioned.sort((a, b) => a.at - b.at);
34
+ outdated.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
35
+ return { positioned, outdated };
36
+ }, [threads, resolutions, filter]);
37
+ const focusThread = (thread, scroll) => {
38
+ selectThread(thread.id);
39
+ if (!scroll) {
40
+ return;
41
+ }
42
+ try {
43
+ const span = editor.prosemirrorView.dom.querySelector(`[data-thread-id="${CSS.escape(thread.id)}"]`);
44
+ if (span) {
45
+ span.scrollIntoView({ behavior: "smooth", block: "center" });
46
+ span.classList.add("y-comments-flash");
47
+ setTimeout(() => span.classList.remove("y-comments-flash"), 1200);
48
+ }
49
+ }
50
+ catch {
51
+ // editor unmounted
52
+ }
53
+ };
54
+ // Cards contain their own controls (reply box, resolve/delete buttons);
55
+ // interacting with those must not re-select the thread or yank the editor
56
+ // scroll position.
57
+ const isInteractive = (target) => target instanceof Element &&
58
+ target.closest("button, textarea, input, a, [contenteditable]") !== null;
59
+ const renderCard = (thread, scrollOnClick) => (_jsx("div", { role: "button", tabIndex: 0, className: `y-comments-card${thread.id === selectedThreadId ? " y-comments-card-selected" : ""}`, "data-thread-card": thread.id, onClick: (e) => {
60
+ if (!isInteractive(e.target)) {
61
+ focusThread(thread, scrollOnClick);
62
+ }
63
+ }, onKeyDown: (e) => {
64
+ if (e.key === "Enter" && e.target === e.currentTarget) {
65
+ focusThread(thread, scrollOnClick);
66
+ }
67
+ }, children: _jsx(ThreadBody, { thread: thread, showQuote: true }) }, thread.id));
68
+ return (_jsxs("aside", { className: `y-comments-sidebar${className ? ` ${className}` : ""}`, children: [_jsxs("div", { className: "y-comments-sidebar-header", children: [_jsx("span", { className: "y-comments-sidebar-title", children: "Comments" }), _jsx("span", { className: "y-comments-filter", role: "tablist", children: FILTERS.map((f) => (_jsx("button", { type: "button", role: "tab", "aria-selected": filter === f.value, className: `y-comments-filter-button${filter === f.value ? " y-comments-filter-button-active" : ""}`, onClick: () => setFilter(f.value), children: f.label }, f.value))) })] }), positioned.length === 0 && outdated.length === 0 && (_jsx("div", { className: "y-comments-empty", children: "No comments" })), positioned.map(({ thread }) => renderCard(thread, true)), outdated.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: "y-comments-group-label", children: "Outdated" }), outdated.map((thread) => renderCard(thread, false))] }))] }));
69
+ }
@@ -0,0 +1,12 @@
1
+ /** Rect returned while a floating element's anchor is missing — parks the
2
+ * popover far off-screen instead of the viewport's top-left corner. */
3
+ export declare const OFFSCREEN_RECT: {
4
+ x: number;
5
+ y: number;
6
+ top: number;
7
+ left: number;
8
+ right: number;
9
+ bottom: number;
10
+ width: number;
11
+ height: number;
12
+ };
@@ -0,0 +1,12 @@
1
+ /** Rect returned while a floating element's anchor is missing — parks the
2
+ * popover far off-screen instead of the viewport's top-left corner. */
3
+ export const OFFSCREEN_RECT = {
4
+ x: -9999,
5
+ y: -9999,
6
+ top: -9999,
7
+ left: -9999,
8
+ right: -9999,
9
+ bottom: -9999,
10
+ width: 0,
11
+ height: 0,
12
+ };
@@ -0,0 +1,8 @@
1
+ import type { SVGProps } from "react";
2
+ export declare const CommentIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
3
+ export declare const CheckIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
4
+ export declare const ReopenIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
5
+ export declare const TrashIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
6
+ export declare const EditIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
7
+ export declare const EmojiIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
8
+ export declare const CloseIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;