@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.
- package/dist/components/CreateCommentButton.d.ts +9 -0
- package/dist/components/CreateCommentButton.js +46 -0
- package/dist/components/FloatingComposer.d.ts +8 -0
- package/dist/components/FloatingComposer.js +147 -0
- package/dist/components/FloatingThread.d.ts +12 -0
- package/dist/components/FloatingThread.js +95 -0
- package/dist/components/ThreadBody.d.ts +8 -0
- package/dist/components/ThreadBody.js +190 -0
- package/dist/components/ThreadsSidebar.d.ts +13 -0
- package/dist/components/ThreadsSidebar.js +69 -0
- package/dist/components/floating-utils.d.ts +12 -0
- package/dist/components/floating-utils.js +12 -0
- package/dist/components/icons.d.ts +8 -0
- package/dist/components/icons.js +20 -0
- package/dist/context.d.ts +47 -0
- package/dist/context.js +173 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/style.css +371 -0
- package/dist/time.d.ts +2 -0
- package/dist/time.js +18 -0
- package/package.json +63 -0
|
@@ -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;
|