@beyondwork/docx-react-component 1.0.17 → 1.0.19
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/README.md +8 -2
- package/package.json +32 -34
- package/src/api/README.md +5 -1
- package/src/api/public-types.ts +374 -4
- package/src/api/session-state.ts +58 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/image-commands.ts +147 -0
- package/src/core/commands/index.ts +5 -1
- package/src/core/commands/list-commands.ts +231 -36
- package/src/core/commands/paragraph-layout-commands.ts +339 -0
- package/src/core/commands/section-layout-commands.ts +680 -0
- package/src/core/commands/style-commands.ts +262 -0
- package/src/core/search/search-text.ts +329 -0
- package/src/core/selection/mapping.ts +41 -0
- package/src/core/state/editor-state.ts +1 -1
- package/src/index.ts +30 -0
- package/src/io/docx-session.ts +260 -39
- package/src/io/export/serialize-main-document.ts +202 -5
- package/src/io/export/serialize-numbering.ts +28 -7
- package/src/io/normalize/normalize-text.ts +63 -25
- package/src/io/ooxml/numbering-sentinels.ts +44 -0
- package/src/io/ooxml/parse-footnotes.ts +212 -20
- package/src/io/ooxml/parse-headers-footers.ts +229 -25
- package/src/io/ooxml/parse-inline-media.ts +16 -0
- package/src/io/ooxml/parse-main-document.ts +411 -6
- package/src/io/ooxml/parse-numbering.ts +7 -0
- package/src/io/ooxml/parse-settings.ts +184 -0
- package/src/io/ooxml/parse-shapes.ts +25 -0
- package/src/io/ooxml/parse-styles.ts +463 -0
- package/src/io/ooxml/parse-theme.ts +32 -0
- package/src/model/canonical-document.ts +133 -3
- package/src/model/cds-1.0.0.ts +13 -0
- package/src/model/snapshot.ts +2 -1
- package/src/runtime/document-layout.ts +332 -0
- package/src/runtime/document-navigation.ts +564 -0
- package/src/runtime/document-runtime.ts +265 -35
- package/src/runtime/document-search.ts +145 -0
- package/src/runtime/numbering-prefix.ts +47 -26
- package/src/runtime/page-layout-estimation.ts +212 -0
- package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
- package/src/runtime/session-capabilities.ts +2 -0
- package/src/runtime/story-context.ts +164 -0
- package/src/runtime/story-targeting.ts +162 -0
- package/src/runtime/surface-projection.ts +239 -12
- package/src/runtime/table-schema.ts +87 -5
- package/src/runtime/view-state.ts +459 -0
- package/src/ui/WordReviewEditor.tsx +1902 -312
- package/src/ui/browser-export.ts +52 -0
- package/src/ui/headless/preserve-editor-selection.ts +5 -0
- package/src/ui/headless/selection-helpers.ts +20 -0
- package/src/ui/headless/selection-toolbar-model.ts +22 -0
- package/src/ui/headless/use-editor-keyboard.ts +6 -1
- package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
- package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
- package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
- package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
- package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
- package/src/ui-tailwind/index.ts +2 -1
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
- package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
- package/src/ui-tailwind/theme/editor-theme.css +123 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
- package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
- package/src/validation/compatibility-engine.ts +92 -20
- package/src/validation/diagnostics.ts +1 -0
- package/src/validation/docx-comment-proof.ts +487 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React, { useRef, useState } from "react";
|
|
2
|
-
import { Check,
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Check, CornerDownRight, RotateCcw } from "lucide-react";
|
|
3
3
|
|
|
4
4
|
import type { CommentSidebarSnapshot, CommentSidebarThreadSnapshot } from "../../api/public-types";
|
|
5
5
|
|
|
@@ -22,130 +22,218 @@ export function TwCommentSidebar(props: TwCommentSidebarProps) {
|
|
|
22
22
|
|
|
23
23
|
return (
|
|
24
24
|
<div className="outline-none">
|
|
25
|
-
<
|
|
26
|
-
{comments.openCommentIds.length} open
|
|
27
|
-
|
|
25
|
+
<div className="mb-2 flex items-center gap-2 text-[10px] text-tertiary">
|
|
26
|
+
<span>{comments.openCommentIds.length} open</span>
|
|
27
|
+
<span className="text-border">·</span>
|
|
28
|
+
<span>{comments.resolvedCommentIds.length} resolved</span>
|
|
29
|
+
{comments.detachedCommentIds.length > 0 && (
|
|
30
|
+
<>
|
|
31
|
+
<span className="text-border">·</span>
|
|
32
|
+
<span>{comments.detachedCommentIds.length} detached</span>
|
|
33
|
+
</>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
28
36
|
{comments.threads.length > 0 ? (
|
|
29
|
-
<div className="space-y-1">
|
|
30
|
-
{comments.threads.map((thread) =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<StatusBadge status={thread.status} />
|
|
53
|
-
</div>
|
|
54
|
-
<p className="text-xs text-tertiary mb-1">{thread.createdAt}</p>
|
|
55
|
-
<p className="text-xs font-medium text-comment bg-comment-soft rounded px-1 py-0.5 inline-block mb-1.5">
|
|
56
|
-
{thread.excerpt}
|
|
57
|
-
</p>
|
|
37
|
+
<div className="space-y-1.5">
|
|
38
|
+
{comments.threads.map((thread) => (
|
|
39
|
+
<CommentThreadCard
|
|
40
|
+
key={thread.commentId}
|
|
41
|
+
thread={thread}
|
|
42
|
+
isActive={activeCommentId === thread.commentId}
|
|
43
|
+
currentUserId={currentUserId}
|
|
44
|
+
onOpenComment={props.onOpenComment}
|
|
45
|
+
onResolveComment={props.onResolveComment}
|
|
46
|
+
onReopenComment={props.onReopenComment}
|
|
47
|
+
onAddReply={props.onAddReply}
|
|
48
|
+
onEditBody={props.onEditBody}
|
|
49
|
+
/>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
) : (
|
|
53
|
+
<div className="rounded-xl border border-dashed border-border bg-surface/60 px-3 py-4 text-[11px] leading-5 text-tertiary">
|
|
54
|
+
No comment threads yet. Select text and add one from the toolbar.
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
61
|
+
function CommentThreadCard(props: {
|
|
62
|
+
thread: CommentSidebarThreadSnapshot;
|
|
63
|
+
isActive: boolean;
|
|
64
|
+
currentUserId?: string;
|
|
65
|
+
onOpenComment?: (thread: CommentSidebarThreadSnapshot) => void;
|
|
66
|
+
onResolveComment?: (commentId: string) => void;
|
|
67
|
+
onReopenComment?: (commentId: string) => void;
|
|
68
|
+
onAddReply?: (commentId: string, body: string) => void;
|
|
69
|
+
onEditBody?: (commentId: string, body: string) => void;
|
|
70
|
+
}) {
|
|
71
|
+
const { thread, isActive } = props;
|
|
72
|
+
const leadEntry = thread.entries[0];
|
|
73
|
+
const isDraftThread = thread.status === "open" && thread.entryCount === 1 && isEmptyCommentBody(leadEntry?.body);
|
|
74
|
+
const isOwnComment = props.currentUserId != null && leadEntry?.authorId === props.currentUserId;
|
|
75
|
+
const canEdit = isOwnComment && thread.status === "open" && props.onEditBody != null;
|
|
76
|
+
const hasNoBody = isEmptyCommentBody(leadEntry?.body);
|
|
77
|
+
const showExcerpt = Boolean(thread.excerpt) && !isDraftThread && thread.excerpt !== "Empty thread";
|
|
71
78
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
const scrollRef = useCallback(
|
|
80
|
+
(node: HTMLDivElement | null) => {
|
|
81
|
+
if (node && isActive && typeof node.scrollIntoView === "function") {
|
|
82
|
+
node.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
[isActive],
|
|
86
|
+
);
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
ref={scrollRef}
|
|
91
|
+
data-comment-thread-id={thread.commentId}
|
|
92
|
+
data-comment-thread-status={thread.status}
|
|
93
|
+
role="button"
|
|
94
|
+
tabIndex={0}
|
|
95
|
+
className={[
|
|
96
|
+
"cursor-pointer rounded-xl border px-2.5 py-2 transition-colors",
|
|
97
|
+
focusRingClass,
|
|
98
|
+
isActive
|
|
99
|
+
? "border-accent/25 bg-accent-soft/35"
|
|
100
|
+
: "border-border bg-canvas hover:border-border-strong hover:bg-surface/70",
|
|
101
|
+
thread.status === "detached" ? "opacity-70" : "",
|
|
102
|
+
].join(" ")}
|
|
103
|
+
onClick={() => props.onOpenComment?.(thread)}
|
|
104
|
+
onKeyDown={(event) => {
|
|
105
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
props.onOpenComment?.(thread);
|
|
108
|
+
}
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{/* Header row: avatar + author + date + status */}
|
|
112
|
+
<div className="mb-1 flex items-center gap-1.5">
|
|
113
|
+
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-accent/10 text-[8px] font-semibold text-accent">
|
|
114
|
+
{thread.createdBy.charAt(0).toUpperCase()}
|
|
115
|
+
</span>
|
|
116
|
+
<span className="truncate text-[10px] font-medium text-primary">{thread.createdBy}</span>
|
|
117
|
+
<span data-comment-thread-created-at="true" className="text-[9px] text-tertiary">
|
|
118
|
+
{formatCommentDate(thread.createdAt)}
|
|
119
|
+
</span>
|
|
120
|
+
<span className="flex-1" />
|
|
121
|
+
{isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
|
|
122
|
+
{thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
|
|
123
|
+
{thread.status === "detached" ? <StatusBadge label="detached" tone="detached" /> : null}
|
|
124
|
+
</div>
|
|
85
125
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
126
|
+
{/* Excerpt — anchored text from document */}
|
|
127
|
+
{showExcerpt ? (
|
|
128
|
+
<p className="mb-1 rounded-md border-l-2 border-comment/25 bg-comment-soft/30 px-2 py-1 text-[9px] leading-4 text-comment/80 italic line-clamp-2">
|
|
129
|
+
{thread.excerpt}
|
|
130
|
+
</p>
|
|
131
|
+
) : null}
|
|
91
132
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
</div>
|
|
133
|
+
{/* Comment body */}
|
|
134
|
+
{canEdit && (isActive || hasNoBody) ? (
|
|
135
|
+
<InlineEditableBody
|
|
136
|
+
body={leadEntry?.body ?? ""}
|
|
137
|
+
autoFocus={isActive && hasNoBody}
|
|
138
|
+
onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
|
|
139
|
+
label={isDraftThread ? "New comment" : undefined}
|
|
140
|
+
/>
|
|
141
|
+
) : leadEntry?.body ? (
|
|
142
|
+
<p
|
|
143
|
+
className="text-[10px] leading-[1.15rem] text-secondary line-clamp-3"
|
|
144
|
+
data-comment-thread-body="true"
|
|
145
|
+
>
|
|
146
|
+
{leadEntry.body}
|
|
147
|
+
</p>
|
|
148
|
+
) : canEdit ? (
|
|
149
|
+
<p
|
|
150
|
+
className="cursor-text text-[10px] italic text-tertiary"
|
|
151
|
+
onClick={(e) => {
|
|
152
|
+
e.stopPropagation();
|
|
153
|
+
props.onOpenComment?.(thread);
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
New comment
|
|
157
|
+
</p>
|
|
158
|
+
) : null}
|
|
119
159
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
)
|
|
126
|
-
|
|
160
|
+
{/* Reply entries (compact) */}
|
|
161
|
+
{thread.entries.slice(1).map((entry) => (
|
|
162
|
+
<div key={entry.entryId} className="mt-1 border-t border-border/40 pt-1">
|
|
163
|
+
<div className="mb-0.5 flex items-center gap-1">
|
|
164
|
+
<span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
|
|
165
|
+
<span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
|
|
166
|
+
</div>
|
|
167
|
+
<p
|
|
168
|
+
className="text-[10px] leading-4 text-secondary line-clamp-2"
|
|
169
|
+
data-comment-reply-body="true"
|
|
170
|
+
>
|
|
171
|
+
{entry.body}
|
|
172
|
+
</p>
|
|
127
173
|
</div>
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
|
|
174
|
+
))}
|
|
175
|
+
|
|
176
|
+
{thread.entryCount > thread.entries.length ? (
|
|
177
|
+
<p className="mt-1 text-[9px] text-tertiary">
|
|
178
|
+
+{thread.entryCount - thread.entries.length} more
|
|
131
179
|
</p>
|
|
132
|
-
)}
|
|
180
|
+
) : null}
|
|
181
|
+
|
|
182
|
+
{/* Inline actions — compact, horizontal */}
|
|
183
|
+
<div className="mt-1.5 flex items-center gap-0.5">
|
|
184
|
+
{thread.status === "open" && (
|
|
185
|
+
<>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium text-insert hover:bg-insert-soft transition-colors"
|
|
189
|
+
onClick={(e) => { e.stopPropagation(); props.onResolveComment?.(thread.commentId); }}
|
|
190
|
+
>
|
|
191
|
+
<Check className="h-2 w-2" /> Resolve
|
|
192
|
+
</button>
|
|
193
|
+
{props.onAddReply && (
|
|
194
|
+
<ReplyInput commentId={thread.commentId} onAddReply={props.onAddReply} />
|
|
195
|
+
)}
|
|
196
|
+
</>
|
|
197
|
+
)}
|
|
198
|
+
{thread.status === "resolved" && (
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
className="inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-medium text-secondary hover:bg-surface transition-colors"
|
|
202
|
+
data-comment-thread-action="reopen"
|
|
203
|
+
onClick={(e) => { e.stopPropagation(); props.onReopenComment?.(thread.commentId); }}
|
|
204
|
+
>
|
|
205
|
+
<RotateCcw className="h-2 w-2" /> Reopen
|
|
206
|
+
</button>
|
|
207
|
+
)}
|
|
208
|
+
{thread.status === "detached" && (
|
|
209
|
+
<span className="text-[9px] text-comment">Detached</span>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
133
212
|
</div>
|
|
134
213
|
);
|
|
135
214
|
}
|
|
136
215
|
|
|
137
216
|
function InlineEditableBody(props: {
|
|
138
|
-
commentId: string;
|
|
139
217
|
body: string;
|
|
218
|
+
autoFocus?: boolean;
|
|
219
|
+
label?: string;
|
|
140
220
|
onSave: (newBody: string) => void;
|
|
141
221
|
}) {
|
|
142
|
-
const [isEditing, setIsEditing] = useState(
|
|
222
|
+
const [isEditing, setIsEditing] = useState(props.autoFocus || props.body === "");
|
|
143
223
|
const [draft, setDraft] = useState(props.body);
|
|
224
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (isEditing && textareaRef.current) {
|
|
228
|
+
textareaRef.current.focus();
|
|
229
|
+
textareaRef.current.setSelectionRange(draft.length, draft.length);
|
|
230
|
+
}
|
|
231
|
+
}, [isEditing]);
|
|
144
232
|
|
|
145
233
|
if (!isEditing) {
|
|
146
234
|
return (
|
|
147
235
|
<p
|
|
148
|
-
className=
|
|
236
|
+
className={`cursor-text rounded px-1 text-[10px] leading-[1.15rem] -mx-1 transition-colors hover:bg-surface ${props.body ? "text-secondary" : "text-tertiary italic"}`}
|
|
149
237
|
onClick={(e) => {
|
|
150
238
|
e.stopPropagation();
|
|
151
239
|
setDraft(props.body);
|
|
@@ -153,68 +241,84 @@ function InlineEditableBody(props: {
|
|
|
153
241
|
}}
|
|
154
242
|
title="Click to edit"
|
|
155
243
|
>
|
|
156
|
-
{props.body}
|
|
244
|
+
{props.body || "Click to add comment\u2026"}
|
|
157
245
|
</p>
|
|
158
246
|
);
|
|
159
247
|
}
|
|
160
248
|
|
|
161
249
|
return (
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
e.preventDefault();
|
|
250
|
+
<div className="space-y-1">
|
|
251
|
+
{props.label ? (
|
|
252
|
+
<span className="block text-[10px] font-medium uppercase tracking-[0.08em] text-tertiary">
|
|
253
|
+
{props.label}
|
|
254
|
+
</span>
|
|
255
|
+
) : null}
|
|
256
|
+
<textarea
|
|
257
|
+
ref={textareaRef}
|
|
258
|
+
className="w-full resize-none rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] leading-4 text-primary placeholder:text-tertiary focus:outline-none focus:ring-1 focus:ring-accent"
|
|
259
|
+
rows={2}
|
|
260
|
+
value={draft}
|
|
261
|
+
placeholder="Type your comment..."
|
|
262
|
+
onClick={(e) => e.stopPropagation()}
|
|
263
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
264
|
+
onBlur={() => {
|
|
178
265
|
if (draft.trim() && draft.trim() !== props.body) {
|
|
179
266
|
props.onSave(draft.trim());
|
|
180
267
|
}
|
|
181
268
|
setIsEditing(false);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
269
|
+
}}
|
|
270
|
+
onKeyDown={(e) => {
|
|
271
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
if (draft.trim() && draft.trim() !== props.body) {
|
|
274
|
+
props.onSave(draft.trim());
|
|
275
|
+
}
|
|
276
|
+
setIsEditing(false);
|
|
277
|
+
}
|
|
278
|
+
if (e.key === "Escape") {
|
|
279
|
+
setDraft(props.body);
|
|
280
|
+
setIsEditing(false);
|
|
281
|
+
}
|
|
282
|
+
e.stopPropagation();
|
|
283
|
+
}}
|
|
284
|
+
/>
|
|
285
|
+
</div>
|
|
190
286
|
);
|
|
191
287
|
}
|
|
192
288
|
|
|
193
289
|
function ReplyInput(props: { commentId: string; onAddReply: (commentId: string, body: string) => void }) {
|
|
194
290
|
const [body, setBody] = useState("");
|
|
195
291
|
const [isOpen, setIsOpen] = useState(false);
|
|
292
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
293
|
+
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (isOpen && inputRef.current) {
|
|
296
|
+
inputRef.current.focus();
|
|
297
|
+
}
|
|
298
|
+
}, [isOpen]);
|
|
196
299
|
|
|
197
300
|
if (!isOpen) {
|
|
198
301
|
return (
|
|
199
302
|
<button
|
|
200
303
|
type="button"
|
|
201
|
-
className="inline-flex items-center gap-1 text-
|
|
304
|
+
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-tertiary hover:text-secondary hover:bg-surface transition-colors"
|
|
202
305
|
onClick={(e) => {
|
|
203
306
|
e.stopPropagation();
|
|
204
307
|
setIsOpen(true);
|
|
205
308
|
}}
|
|
206
309
|
>
|
|
207
|
-
<
|
|
310
|
+
<CornerDownRight className="h-2.5 w-2.5" /> Reply
|
|
208
311
|
</button>
|
|
209
312
|
);
|
|
210
313
|
}
|
|
211
314
|
|
|
212
315
|
return (
|
|
213
|
-
<div className="mt-
|
|
316
|
+
<div className="w-full mt-1.5" onClick={(e) => e.stopPropagation()}>
|
|
214
317
|
<textarea
|
|
215
|
-
|
|
318
|
+
ref={inputRef}
|
|
319
|
+
className="w-full rounded border border-border bg-surface px-2 py-1 text-[11px] text-primary placeholder:text-tertiary resize-none focus:outline-none focus:ring-1 focus:ring-accent"
|
|
216
320
|
rows={2}
|
|
217
|
-
placeholder="
|
|
321
|
+
placeholder="Reply..."
|
|
218
322
|
value={body}
|
|
219
323
|
onChange={(e) => setBody(e.target.value)}
|
|
220
324
|
onKeyDown={(e) => {
|
|
@@ -230,13 +334,12 @@ function ReplyInput(props: { commentId: string; onAddReply: (commentId: string,
|
|
|
230
334
|
}
|
|
231
335
|
e.stopPropagation();
|
|
232
336
|
}}
|
|
233
|
-
autoFocus
|
|
234
337
|
/>
|
|
235
|
-
<div className="flex gap-1
|
|
338
|
+
<div className="flex gap-1 mt-0.5">
|
|
236
339
|
<button
|
|
237
340
|
type="button"
|
|
238
341
|
disabled={!body.trim()}
|
|
239
|
-
className="
|
|
342
|
+
className="rounded px-1.5 py-0.5 text-[10px] font-medium text-accent hover:bg-accent-soft transition-colors disabled:opacity-40"
|
|
240
343
|
onClick={() => {
|
|
241
344
|
if (body.trim()) {
|
|
242
345
|
props.onAddReply(props.commentId, body.trim());
|
|
@@ -245,15 +348,12 @@ function ReplyInput(props: { commentId: string; onAddReply: (commentId: string,
|
|
|
245
348
|
}
|
|
246
349
|
}}
|
|
247
350
|
>
|
|
248
|
-
|
|
351
|
+
Send
|
|
249
352
|
</button>
|
|
250
353
|
<button
|
|
251
354
|
type="button"
|
|
252
|
-
className="
|
|
253
|
-
onClick={() => {
|
|
254
|
-
setBody("");
|
|
255
|
-
setIsOpen(false);
|
|
256
|
-
}}
|
|
355
|
+
className="rounded px-1.5 py-0.5 text-[10px] text-tertiary hover:bg-surface transition-colors"
|
|
356
|
+
onClick={() => { setBody(""); setIsOpen(false); }}
|
|
257
357
|
>
|
|
258
358
|
Cancel
|
|
259
359
|
</button>
|
|
@@ -262,15 +362,45 @@ function ReplyInput(props: { commentId: string; onAddReply: (commentId: string,
|
|
|
262
362
|
);
|
|
263
363
|
}
|
|
264
364
|
|
|
265
|
-
function
|
|
365
|
+
function formatCommentDate(raw: string): string {
|
|
366
|
+
try {
|
|
367
|
+
const date = new Date(raw);
|
|
368
|
+
if (Number.isNaN(date.getTime())) return raw;
|
|
369
|
+
const now = new Date();
|
|
370
|
+
const diffMs = now.getTime() - date.getTime();
|
|
371
|
+
const diffMin = Math.floor(diffMs / 60000);
|
|
372
|
+
if (diffMin < 1) return "just now";
|
|
373
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
374
|
+
const diffHours = Math.floor(diffMin / 60);
|
|
375
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
376
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
377
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
378
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
379
|
+
month: "short",
|
|
380
|
+
day: "numeric",
|
|
381
|
+
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
|
382
|
+
}).format(date);
|
|
383
|
+
} catch {
|
|
384
|
+
return raw;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function StatusBadge(props: { label: string; tone: "resolved" | "detached" | "draft" }) {
|
|
266
389
|
const styles: Record<string, string> = {
|
|
267
|
-
open: "text-accent bg-accent-soft",
|
|
268
390
|
resolved: "text-insert bg-insert-soft",
|
|
269
391
|
detached: "text-comment bg-warning-soft",
|
|
392
|
+
draft: "text-secondary bg-subtle",
|
|
270
393
|
};
|
|
271
394
|
return (
|
|
272
|
-
<span
|
|
273
|
-
{props.
|
|
395
|
+
<span
|
|
396
|
+
className={`shrink-0 rounded px-1 py-px text-[8px] font-medium uppercase tracking-[0.08em] ${styles[props.tone] ?? "text-secondary bg-subtle"}`}
|
|
397
|
+
data-comment-thread-badge={props.tone}
|
|
398
|
+
>
|
|
399
|
+
{props.label}
|
|
274
400
|
</span>
|
|
275
401
|
);
|
|
276
402
|
}
|
|
403
|
+
|
|
404
|
+
function isEmptyCommentBody(body: string | undefined): boolean {
|
|
405
|
+
return !body || body.trim() === "";
|
|
406
|
+
}
|
|
@@ -52,27 +52,27 @@ export function TwReviewRail(props: TwReviewRailProps) {
|
|
|
52
52
|
return (
|
|
53
53
|
<aside
|
|
54
54
|
aria-label="Review rail"
|
|
55
|
-
className="flex w-[
|
|
55
|
+
className="flex w-[336px] shrink-0 flex-col border-l border-border bg-canvas"
|
|
56
56
|
>
|
|
57
57
|
<Tabs.Root
|
|
58
58
|
value={props.activeTab}
|
|
59
59
|
onValueChange={(v: string) => props.onActiveTabChange(v as ReviewRailTab)}
|
|
60
60
|
className="flex flex-1 flex-col min-h-0"
|
|
61
61
|
>
|
|
62
|
-
<Tabs.List className="flex shrink-0 border-b border-border">
|
|
62
|
+
<Tabs.List className="flex shrink-0 border-b border-border px-2">
|
|
63
63
|
<Tabs.Trigger
|
|
64
64
|
value="comments"
|
|
65
|
-
className={`flex-1 py-2 text-xs text-tertiary transition-colors data-[state=active]:text-primary data-[state=active]:shadow-[inset_0_-2px_0_var(--color-accent)] outline-none ${focusRingClass}`}
|
|
65
|
+
className={`flex-1 py-2 text-xs text-tertiary font-medium transition-colors data-[state=active]:text-primary data-[state=active]:font-semibold data-[state=active]:shadow-[inset_0_-2px_0_var(--color-accent)] outline-none ${focusRingClass}`}
|
|
66
66
|
>
|
|
67
67
|
Comments{" "}
|
|
68
|
-
<span className="text-tertiary">{props.comments.totalCount}</span>
|
|
68
|
+
<span className="ml-1 inline-flex min-w-[14px] items-center justify-center rounded-full bg-subtle px-1.5 py-px text-[10px] font-medium text-tertiary">{props.comments.totalCount}</span>
|
|
69
69
|
</Tabs.Trigger>
|
|
70
70
|
<Tabs.Trigger
|
|
71
71
|
value="changes"
|
|
72
|
-
className={`flex-1 py-2 text-xs text-tertiary transition-colors data-[state=active]:text-primary data-[state=active]:shadow-[inset_0_-2px_0_var(--color-accent)] outline-none ${focusRingClass}`}
|
|
72
|
+
className={`flex-1 py-2 text-xs text-tertiary font-medium transition-colors data-[state=active]:text-primary data-[state=active]:font-semibold data-[state=active]:shadow-[inset_0_-2px_0_var(--color-accent)] outline-none ${focusRingClass}`}
|
|
73
73
|
>
|
|
74
74
|
Changes{" "}
|
|
75
|
-
<span className="text-tertiary">{props.trackedChanges.totalCount}</span>
|
|
75
|
+
<span className="ml-1 inline-flex min-w-[14px] items-center justify-center rounded-full bg-subtle px-1.5 py-px text-[10px] font-medium text-tertiary">{props.trackedChanges.totalCount}</span>
|
|
76
76
|
</Tabs.Trigger>
|
|
77
77
|
{/* Health moved to toolbar popover */}
|
|
78
78
|
</Tabs.List>
|