@plannotator/ui 0.53.0 → 0.54.0
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/HANDOFF.md +284 -27
- package/README.md +84 -14
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +177 -34
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/AnnotationToolstrip.tsx +45 -40
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +140 -5
- package/components/CommentPopover.tsx +46 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +152 -24
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MentionPicker.tsx +51 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/SkillReferenceMenu.tsx +39 -12
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +316 -47
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +522 -62
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +69 -6
- package/components/html-viewer/bridge-script.asset.js +504 -12
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +545 -12
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +23 -22
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +14 -6
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mentions.ts +34 -5
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import React, { useState, useRef, useEffect, useId } from 'react';
|
|
1
|
+
import React, { createContext, useContext, useMemo, useState, useRef, useEffect, useId } from 'react';
|
|
2
2
|
import { AnnotationType, type Annotation, type Block, type CodeAnnotation, type EditorAnnotation } from '../types';
|
|
3
3
|
import type { MentionSource } from '../utils/mentions';
|
|
4
4
|
import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
|
|
5
5
|
import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete';
|
|
6
|
+
import { buildSectionRefIndex, parseSectionRefs } from '../utils/sectionRefs';
|
|
6
7
|
import { isCurrentUser } from '../utils/identity';
|
|
7
8
|
import { ImageThumbnail } from './ImageThumbnail';
|
|
8
9
|
import { EditorAnnotationCard } from './EditorAnnotationCard';
|
|
@@ -16,6 +17,15 @@ import type { AnnotationScope, AnnotationDocumentGroup } from '../utils/annotati
|
|
|
16
17
|
import type { QuestionPanelRow } from '../utils/questionAnswers';
|
|
17
18
|
import { QuestionsPanelSection } from './QuestionsPanelSection';
|
|
18
19
|
import { HostTitle } from './HostTitle';
|
|
20
|
+
import { isCommentSubmitKey } from '../utils/commentSubmitKey';
|
|
21
|
+
import { useConfigValue } from '../config/useConfig';
|
|
22
|
+
import { imageRegionNumbers, RegionCardInfo, RegionNumber, regionOf } from './ImageRegions';
|
|
23
|
+
|
|
24
|
+
/** What a region card (#2100) needs from its panel: each region's number on
|
|
25
|
+
* its image, and how to load the image for the crop thumbnail. */
|
|
26
|
+
const RegionCardContext = createContext<{ numbers: ReadonlyMap<string, number>; thumbSrc?: (src: string) => string | null }>({
|
|
27
|
+
numbers: new Map(),
|
|
28
|
+
});
|
|
19
29
|
|
|
20
30
|
// Card type-word colors. Deletion uses `destructive` (reliably red on every
|
|
21
31
|
// theme, matching the in-document .deletion highlight). Comment uses the
|
|
@@ -120,6 +130,8 @@ interface PanelProps {
|
|
|
120
130
|
editorAnnotations?: EditorAnnotation[];
|
|
121
131
|
onDeleteEditorAnnotation?: (id: string) => void;
|
|
122
132
|
onClose?: () => void;
|
|
133
|
+
/** Navigate the document to a heading anchor, for `#Heading` references in comment text. */
|
|
134
|
+
onNavigateAnchor?: (hash: string) => void;
|
|
123
135
|
/** Copy the full feedback payload. May resolve a success boolean; resolving
|
|
124
136
|
* `false` suppresses the "Copied" flash. A void resolution (existing hosts)
|
|
125
137
|
* is treated as success, preserving the original behavior. */
|
|
@@ -213,13 +225,30 @@ interface PanelProps {
|
|
|
213
225
|
* the frame in `renderCardHeader`) turns them off per card. Absent: both
|
|
214
226
|
* drawn, exactly as before.
|
|
215
227
|
*/
|
|
216
|
-
cardParts?: (annotation: Annotation) => { quote?: boolean; images?: boolean };
|
|
228
|
+
cardParts?: (annotation: Annotation) => { quote?: boolean; images?: boolean; text?: boolean };
|
|
229
|
+
/**
|
|
230
|
+
* Optional: the host's own body for a plan-annotation card, drawn where the
|
|
231
|
+
* comment text goes, inside the card (a click on it selects the card). The
|
|
232
|
+
* Inbox draws a drawing that has no words as its picture, with `cardParts`
|
|
233
|
+
* `text: false` (its stored text is a stand-in for the agent, not the
|
|
234
|
+
* person's words: not drawn, and the edit box starts empty). Absent:
|
|
235
|
+
* nothing extra, exactly as before.
|
|
236
|
+
*/
|
|
237
|
+
renderCardBody?: (annotation: Annotation) => React.ReactNode;
|
|
238
|
+
/**
|
|
239
|
+
* Comments on part of an image (`imageRegion`, #2100) draw a crop of their
|
|
240
|
+
* box in place of the quote; this turns an image's src as written into a
|
|
241
|
+
* URL the browser can load (null: no crop, the file and size still show).
|
|
242
|
+
* Absent: no crop.
|
|
243
|
+
*/
|
|
244
|
+
imageRegionSrc?: (src: string) => string | null;
|
|
217
245
|
}
|
|
218
246
|
|
|
219
247
|
export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
220
248
|
emptyHint = 'Select text to annotate',
|
|
221
249
|
isOpen,
|
|
222
250
|
annotations,
|
|
251
|
+
blocks,
|
|
223
252
|
onSelect,
|
|
224
253
|
onDelete,
|
|
225
254
|
onEdit,
|
|
@@ -256,7 +285,19 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
256
285
|
title = 'Annotations',
|
|
257
286
|
titleNote,
|
|
258
287
|
cardParts,
|
|
288
|
+
renderCardBody,
|
|
289
|
+
imageRegionSrc,
|
|
290
|
+
onNavigateAnchor,
|
|
259
291
|
}) => {
|
|
292
|
+
// `#Heading` links in comment text; nothing is parsed unless the host can navigate.
|
|
293
|
+
const sectionRefs = useMemo(
|
|
294
|
+
() => (onNavigateAnchor ? buildSectionRefIndex(blocks) : undefined),
|
|
295
|
+
[blocks, onNavigateAnchor],
|
|
296
|
+
);
|
|
297
|
+
const regionCards = useMemo(
|
|
298
|
+
() => ({ numbers: imageRegionNumbers(annotations), thumbSrc: imageRegionSrc }),
|
|
299
|
+
[annotations, imageRegionSrc],
|
|
300
|
+
);
|
|
260
301
|
const isMobile = useIsMobile();
|
|
261
302
|
const embedded = presentation === 'embedded';
|
|
262
303
|
const mobilePanel = isMobile && !embedded;
|
|
@@ -517,8 +558,11 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
517
558
|
footer={group.isCurrent ? renderCardFooter?.(annotation) : undefined}
|
|
518
559
|
header={group.isCurrent ? renderCardHeader?.(annotation) : undefined}
|
|
519
560
|
parts={group.isCurrent ? cardParts?.(annotation) : undefined}
|
|
561
|
+
body={group.isCurrent ? renderCardBody?.(annotation) : undefined}
|
|
520
562
|
mentionSource={mentionSource}
|
|
521
563
|
unanchored={group.isCurrent ? (unanchoredIds?.has(annotation.id) ?? false) : false}
|
|
564
|
+
sectionRefs={group.isCurrent ? sectionRefs : undefined}
|
|
565
|
+
onNavigateAnchor={onNavigateAnchor}
|
|
522
566
|
/>
|
|
523
567
|
);
|
|
524
568
|
return isReply ? (
|
|
@@ -623,6 +667,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
623
667
|
>
|
|
624
668
|
<AnnotationCard
|
|
625
669
|
annotation={entry.annotation}
|
|
670
|
+
sectionRefs={sectionRefs}
|
|
671
|
+
onNavigateAnchor={onNavigateAnchor}
|
|
626
672
|
isSelected={selectedId === entry.annotation.id}
|
|
627
673
|
isMe={isCurrentUser(entry.annotation.author)}
|
|
628
674
|
onSelect={() => onSelect(entry.annotation.id)}
|
|
@@ -632,6 +678,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
632
678
|
footer={renderCardFooter?.(entry.annotation)}
|
|
633
679
|
header={renderCardHeader?.(entry.annotation)}
|
|
634
680
|
parts={cardParts?.(entry.annotation)}
|
|
681
|
+
body={renderCardBody?.(entry.annotation)}
|
|
635
682
|
mentionSource={mentionSource}
|
|
636
683
|
unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
|
|
637
684
|
/>
|
|
@@ -640,6 +687,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
640
687
|
<AnnotationCard
|
|
641
688
|
key={entry.annotation.id}
|
|
642
689
|
annotation={entry.annotation}
|
|
690
|
+
sectionRefs={sectionRefs}
|
|
691
|
+
onNavigateAnchor={onNavigateAnchor}
|
|
643
692
|
isSelected={selectedId === entry.annotation.id}
|
|
644
693
|
isMe={isCurrentUser(entry.annotation.author)}
|
|
645
694
|
onSelect={() => onSelect(entry.annotation.id)}
|
|
@@ -649,6 +698,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
649
698
|
footer={renderCardFooter?.(entry.annotation)}
|
|
650
699
|
header={renderCardHeader?.(entry.annotation)}
|
|
651
700
|
parts={cardParts?.(entry.annotation)}
|
|
701
|
+
body={renderCardBody?.(entry.annotation)}
|
|
652
702
|
mentionSource={mentionSource}
|
|
653
703
|
unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
|
|
654
704
|
/>
|
|
@@ -691,7 +741,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
691
741
|
</OverlayScrollArea>
|
|
692
742
|
|
|
693
743
|
{/* Quick Actions Footer */}
|
|
694
|
-
{headerCount > 0 && (
|
|
744
|
+
{headerCount > 0 && (onQuickCopy || (sharingEnabled && onShare)) && (
|
|
695
745
|
<div className="border-t border-border/50 px-3 py-2 flex gap-1.5">
|
|
696
746
|
{onQuickCopy && (
|
|
697
747
|
<button
|
|
@@ -745,12 +795,12 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
745
795
|
className="fixed inset-0 z-[59] bg-background/60 backdrop-blur-sm"
|
|
746
796
|
onClick={onClose}
|
|
747
797
|
/>
|
|
748
|
-
{panel}
|
|
798
|
+
<RegionCardContext.Provider value={regionCards}>{panel}</RegionCardContext.Provider>
|
|
749
799
|
</>
|
|
750
800
|
);
|
|
751
801
|
}
|
|
752
802
|
|
|
753
|
-
return panel
|
|
803
|
+
return <RegionCardContext.Provider value={regionCards}>{panel}</RegionCardContext.Provider>;
|
|
754
804
|
};
|
|
755
805
|
|
|
756
806
|
function formatTimestamp(ts: number): string {
|
|
@@ -766,9 +816,38 @@ function formatTimestamp(ts: number): string {
|
|
|
766
816
|
if (hours < 24) return `${hours}h`;
|
|
767
817
|
if (days < 7) return `${days}d`;
|
|
768
818
|
|
|
769
|
-
|
|
819
|
+
// An older date names its year: "Oct 20" alone reads as this October.
|
|
820
|
+
const date = new Date(ts);
|
|
821
|
+
const thisYear = date.getFullYear() === new Date(now).getFullYear();
|
|
822
|
+
return date.toLocaleDateString(
|
|
823
|
+
undefined,
|
|
824
|
+
thisYear ? { month: 'short', day: 'numeric' } : { month: 'short', day: 'numeric', year: 'numeric' },
|
|
825
|
+
);
|
|
770
826
|
}
|
|
771
827
|
|
|
828
|
+
/**
|
|
829
|
+
* A card's "author · time" line. The author truncates (its full name in the
|
|
830
|
+
* title) while the time never shrinks, so a long name cannot push the time
|
|
831
|
+
* out; `<bdi>` keeps a right-to-left name from reordering the time around it.
|
|
832
|
+
* The text reads exactly as before: "Alice (me) · 5m".
|
|
833
|
+
*/
|
|
834
|
+
const CardByline: React.FC<{ author?: string; isMe: boolean; timestamp: number }> = ({ author, isMe, timestamp }) => (
|
|
835
|
+
<span data-annotation-byline="true" className="flex min-w-0 items-center text-[10px] text-muted-foreground/50">
|
|
836
|
+
{author ? (
|
|
837
|
+
<>
|
|
838
|
+
<HostTitle title={author} cut>
|
|
839
|
+
<span data-annotation-author="true" className="min-w-0 truncate">
|
|
840
|
+
<bdi>{author}</bdi>
|
|
841
|
+
</span>
|
|
842
|
+
</HostTitle>
|
|
843
|
+
{isMe ? <span data-annotation-me="true" className="shrink-0 whitespace-pre"> (me)</span> : null}
|
|
844
|
+
<span className="shrink-0 whitespace-pre"> · </span>
|
|
845
|
+
</>
|
|
846
|
+
) : null}
|
|
847
|
+
<span data-annotation-time="true" className="shrink-0">{formatTimestamp(timestamp)}</span>
|
|
848
|
+
</span>
|
|
849
|
+
);
|
|
850
|
+
|
|
772
851
|
/** Pinned card for committed direct edits: +N/−M summary, expandable unified
|
|
773
852
|
* diff, and a two-step discard. Not part of the annotation timeline — edits
|
|
774
853
|
* are document state, not a selection-anchored note. */
|
|
@@ -870,6 +949,44 @@ const DirectEditsCard: React.FC<{
|
|
|
870
949
|
);
|
|
871
950
|
};
|
|
872
951
|
|
|
952
|
+
/** Comment text, with each `#Heading text` as a link that scrolls to that heading. */
|
|
953
|
+
const AnnotationText: React.FC<{
|
|
954
|
+
text: string;
|
|
955
|
+
className: string;
|
|
956
|
+
sectionRefs?: Map<string, string>;
|
|
957
|
+
onNavigateAnchor?: (hash: string) => void;
|
|
958
|
+
}> = ({ text, className, sectionRefs, onNavigateAnchor }) => {
|
|
959
|
+
const parts = React.useMemo(
|
|
960
|
+
() => (sectionRefs && onNavigateAnchor ? parseSectionRefs(text, sectionRefs) : [text]),
|
|
961
|
+
[text, sectionRefs, onNavigateAnchor],
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
return (
|
|
965
|
+
<p className={className}>
|
|
966
|
+
{parts.map((part, i) =>
|
|
967
|
+
typeof part === 'string' ? (
|
|
968
|
+
part
|
|
969
|
+
) : (
|
|
970
|
+
<a
|
|
971
|
+
key={i}
|
|
972
|
+
href={`#${part.anchor}`}
|
|
973
|
+
className="rounded-sm font-medium text-primary underline-offset-2 hover:underline focus-visible:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
974
|
+
onClick={(e) => {
|
|
975
|
+
// The host scrolls; the card's own onSelect would scroll to the
|
|
976
|
+
// annotation instead, and the URL hash stays as it was.
|
|
977
|
+
e.preventDefault();
|
|
978
|
+
e.stopPropagation();
|
|
979
|
+
onNavigateAnchor!(`#${part.anchor}`);
|
|
980
|
+
}}
|
|
981
|
+
>
|
|
982
|
+
{part.label}
|
|
983
|
+
</a>
|
|
984
|
+
),
|
|
985
|
+
)}
|
|
986
|
+
</p>
|
|
987
|
+
);
|
|
988
|
+
};
|
|
989
|
+
|
|
873
990
|
/**
|
|
874
991
|
* The plan-annotation card's edit box.
|
|
875
992
|
*
|
|
@@ -900,6 +1017,7 @@ const AnnotationEditComposer: React.FC<{
|
|
|
900
1017
|
const mention = useMentionAutocomplete({ text: value, setText: onChange, textareaRef, source: mentionSource });
|
|
901
1018
|
const listboxId = `annotation-card-mentions-${useId().replace(/:/g, '')}`;
|
|
902
1019
|
const menuOpen = mention.menu !== null;
|
|
1020
|
+
const enterSavesComment = useConfigValue('enterSavesComment');
|
|
903
1021
|
|
|
904
1022
|
// Focus-on-open, unchanged: this component mounts exactly when editing starts.
|
|
905
1023
|
useEffect(() => {
|
|
@@ -914,7 +1032,7 @@ const AnnotationEditComposer: React.FC<{
|
|
|
914
1032
|
// (and only a second Escape cancels the edit), and an arrowed-to row
|
|
915
1033
|
// takes Enter. Mod+Enter is never consumed by the menu.
|
|
916
1034
|
if (mention.onKeyDown(e)) return;
|
|
917
|
-
if (
|
|
1035
|
+
if (isCommentSubmitKey(e, enterSavesComment)) {
|
|
918
1036
|
e.preventDefault();
|
|
919
1037
|
onSave(mention.mentionIds);
|
|
920
1038
|
} else if (e.key === 'Escape') {
|
|
@@ -967,21 +1085,32 @@ const AnnotationCard: React.FC<{
|
|
|
967
1085
|
/** The annotation has no live location in the document (host-reported). */
|
|
968
1086
|
unanchored?: boolean;
|
|
969
1087
|
/** Built-in parts to draw (see `cardParts`). */
|
|
970
|
-
parts?: { quote?: boolean; images?: boolean };
|
|
971
|
-
|
|
1088
|
+
parts?: { quote?: boolean; images?: boolean; text?: boolean };
|
|
1089
|
+
/** Host body, drawn where the comment text goes (see `renderCardBody`). */
|
|
1090
|
+
body?: React.ReactNode;
|
|
1091
|
+
sectionRefs?: Map<string, string>;
|
|
1092
|
+
onNavigateAnchor?: (hash: string) => void;
|
|
1093
|
+
}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false, parts, body, sectionRefs, onNavigateAnchor }) => {
|
|
1094
|
+
// `text: false`: the stored text is the host's stand-in, never the person's words; the edit box starts empty.
|
|
1095
|
+
const shownText = parts?.text === false ? '' : annotation.text || '';
|
|
972
1096
|
const [isEditing, setIsEditing] = useState(false);
|
|
973
|
-
const [editText, setEditText] = useState(
|
|
1097
|
+
const [editText, setEditText] = useState(shownText);
|
|
1098
|
+
// A comment on part of an image (#2100): numbered like its box, with the
|
|
1099
|
+
// crop, file, size and line in place of the quote.
|
|
1100
|
+
const regionCards = useContext(RegionCardContext);
|
|
1101
|
+
const region = regionOf(annotation);
|
|
1102
|
+
const regionNumber = region ? regionCards.numbers.get(annotation.id) : undefined;
|
|
974
1103
|
|
|
975
1104
|
// Update editText when annotation.text changes
|
|
976
1105
|
useEffect(() => {
|
|
977
1106
|
if (!isEditing) {
|
|
978
|
-
setEditText(
|
|
1107
|
+
setEditText(shownText);
|
|
979
1108
|
}
|
|
980
|
-
}, [
|
|
1109
|
+
}, [shownText, isEditing]);
|
|
981
1110
|
|
|
982
1111
|
const handleStartEdit = (e: React.MouseEvent) => {
|
|
983
1112
|
e.stopPropagation();
|
|
984
|
-
setEditText(
|
|
1113
|
+
setEditText(shownText);
|
|
985
1114
|
setIsEditing(true);
|
|
986
1115
|
};
|
|
987
1116
|
|
|
@@ -998,7 +1127,7 @@ const AnnotationCard: React.FC<{
|
|
|
998
1127
|
};
|
|
999
1128
|
|
|
1000
1129
|
const handleCancelEdit = () => {
|
|
1001
|
-
setEditText(
|
|
1130
|
+
setEditText(shownText);
|
|
1002
1131
|
setIsEditing(false);
|
|
1003
1132
|
};
|
|
1004
1133
|
|
|
@@ -1037,6 +1166,12 @@ const AnnotationCard: React.FC<{
|
|
|
1037
1166
|
{/* Header: type word + author · time + actions */}
|
|
1038
1167
|
<div className="mb-1.5 flex items-center gap-1.5">
|
|
1039
1168
|
<span className={cn('text-[11px] font-medium', typeColor)}>{typeLabel}</span>
|
|
1169
|
+
{region && regionNumber !== undefined && <RegionNumber n={regionNumber} selected={isSelected} />}
|
|
1170
|
+
{region && (
|
|
1171
|
+
<span data-annotation-region="true" className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">
|
|
1172
|
+
Region
|
|
1173
|
+
</span>
|
|
1174
|
+
)}
|
|
1040
1175
|
{annotation.diffContext && (
|
|
1041
1176
|
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">
|
|
1042
1177
|
diff
|
|
@@ -1056,9 +1191,7 @@ const AnnotationCard: React.FC<{
|
|
|
1056
1191
|
</span>
|
|
1057
1192
|
</HostTitle>
|
|
1058
1193
|
)}
|
|
1059
|
-
<
|
|
1060
|
-
{annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
|
|
1061
|
-
</span>
|
|
1194
|
+
<CardByline author={annotation.author} isMe={isMe} timestamp={annotation.createdA} />
|
|
1062
1195
|
{/* Host header slot (a status stamp etc.) — interactions inside it
|
|
1063
1196
|
must not toggle card selection, exactly like the footer slot.
|
|
1064
1197
|
Rendered under readOnly too: its contents are host-owned. */}
|
|
@@ -1073,7 +1206,7 @@ const AnnotationCard: React.FC<{
|
|
|
1073
1206
|
</div>
|
|
1074
1207
|
)}
|
|
1075
1208
|
{!readOnly && (
|
|
1076
|
-
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
|
|
1209
|
+
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
|
|
1077
1210
|
{onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
|
|
1078
1211
|
<HostTitle title="Edit annotation" name>
|
|
1079
1212
|
<button
|
|
@@ -1103,15 +1236,22 @@ const AnnotationCard: React.FC<{
|
|
|
1103
1236
|
isEditing ? (
|
|
1104
1237
|
editComposer
|
|
1105
1238
|
) : (
|
|
1106
|
-
<
|
|
1107
|
-
{annotation.text}
|
|
1108
|
-
|
|
1239
|
+
<AnnotationText
|
|
1240
|
+
text={annotation.text || ''}
|
|
1241
|
+
className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90"
|
|
1242
|
+
sectionRefs={sectionRefs}
|
|
1243
|
+
onNavigateAnchor={onNavigateAnchor}
|
|
1244
|
+
/>
|
|
1109
1245
|
)
|
|
1110
1246
|
) : (
|
|
1111
1247
|
<>
|
|
1248
|
+
{/* A region shows its crop, file, size and line instead of a quote. */}
|
|
1249
|
+
{region && parts?.quote !== false && (
|
|
1250
|
+
<RegionCardInfo region={region} thumbSrc={regionCards.thumbSrc?.(region.src) ?? null} />
|
|
1251
|
+
)}
|
|
1112
1252
|
{/* Quote — the annotated text */}
|
|
1113
|
-
{parts?.quote !== false && (
|
|
1114
|
-
<p className="mb-1.5 line-clamp-2 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-muted-foreground/80">
|
|
1253
|
+
{!region && parts?.quote !== false && (
|
|
1254
|
+
<p className="mb-1.5 line-clamp-2 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-[11px] leading-relaxed text-muted-foreground/80">
|
|
1115
1255
|
"{annotation.originalText}"
|
|
1116
1256
|
</p>
|
|
1117
1257
|
)}
|
|
@@ -1121,13 +1261,17 @@ const AnnotationCard: React.FC<{
|
|
|
1121
1261
|
isEditing ? (
|
|
1122
1262
|
editComposer
|
|
1123
1263
|
) : (
|
|
1124
|
-
|
|
1125
|
-
<
|
|
1126
|
-
{
|
|
1127
|
-
|
|
1264
|
+
shownText && (
|
|
1265
|
+
<AnnotationText
|
|
1266
|
+
text={shownText}
|
|
1267
|
+
className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90"
|
|
1268
|
+
sectionRefs={sectionRefs}
|
|
1269
|
+
onNavigateAnchor={onNavigateAnchor}
|
|
1270
|
+
/>
|
|
1128
1271
|
)
|
|
1129
1272
|
)
|
|
1130
1273
|
)}
|
|
1274
|
+
{body != null && body !== false && body}
|
|
1131
1275
|
</>
|
|
1132
1276
|
)}
|
|
1133
1277
|
|
|
@@ -1176,6 +1320,7 @@ const CodeAnnotationCard: React.FC<{
|
|
|
1176
1320
|
}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false }) => {
|
|
1177
1321
|
const [isEditing, setIsEditing] = useState(false);
|
|
1178
1322
|
const [editText, setEditText] = useState(annotation.text || '');
|
|
1323
|
+
const enterSavesComment = useConfigValue('enterSavesComment');
|
|
1179
1324
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
1180
1325
|
|
|
1181
1326
|
useEffect(() => {
|
|
@@ -1224,11 +1369,9 @@ const CodeAnnotationCard: React.FC<{
|
|
|
1224
1369
|
{/* Header: type word + author · time + actions */}
|
|
1225
1370
|
<div className="mb-1.5 flex items-center gap-1.5">
|
|
1226
1371
|
<span className="text-[11px] font-medium text-primary">Code</span>
|
|
1227
|
-
<
|
|
1228
|
-
{annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdAt)}
|
|
1229
|
-
</span>
|
|
1372
|
+
<CardByline author={annotation.author} isMe={isMe} timestamp={annotation.createdAt} />
|
|
1230
1373
|
{!readOnly && (
|
|
1231
|
-
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
|
|
1374
|
+
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
|
|
1232
1375
|
{onEdit && !isEditing && (
|
|
1233
1376
|
<HostTitle title="Edit annotation" name>
|
|
1234
1377
|
<button
|
|
@@ -1261,7 +1404,7 @@ const CodeAnnotationCard: React.FC<{
|
|
|
1261
1404
|
</HostTitle>
|
|
1262
1405
|
|
|
1263
1406
|
{annotation.originalCode && (
|
|
1264
|
-
<p className="mt-1.5 line-clamp-2 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-muted-foreground/80">
|
|
1407
|
+
<p className="mt-1.5 line-clamp-2 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-[11px] leading-relaxed text-muted-foreground/80">
|
|
1265
1408
|
{annotation.originalCode}
|
|
1266
1409
|
</p>
|
|
1267
1410
|
)}
|
|
@@ -1274,7 +1417,7 @@ const CodeAnnotationCard: React.FC<{
|
|
|
1274
1417
|
value={editText}
|
|
1275
1418
|
onChange={(e) => setEditText(e.target.value)}
|
|
1276
1419
|
onKeyDown={(e) => {
|
|
1277
|
-
if (
|
|
1420
|
+
if (isCommentSubmitKey(e, enterSavesComment)) {
|
|
1278
1421
|
e.preventDefault();
|
|
1279
1422
|
handleSaveEdit();
|
|
1280
1423
|
} else if (e.key === 'Escape') {
|
|
@@ -1294,7 +1437,7 @@ const CodeAnnotationCard: React.FC<{
|
|
|
1294
1437
|
</div>
|
|
1295
1438
|
) : (
|
|
1296
1439
|
annotation.text && (
|
|
1297
|
-
<p className="mt-1.5 whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/90">
|
|
1440
|
+
<p className="mt-1.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
|
|
1298
1441
|
{annotation.text}
|
|
1299
1442
|
</p>
|
|
1300
1443
|
)
|
|
@@ -61,7 +61,7 @@ export const AnnotationSidebar: React.FC<SidebarProps> = ({
|
|
|
61
61
|
</span>
|
|
62
62
|
<button
|
|
63
63
|
onClick={(e) => { e.stopPropagation(); onDelete(ann.id); }}
|
|
64
|
-
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
64
|
+
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
65
65
|
title="Remove Annotation"
|
|
66
66
|
>
|
|
67
67
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
|
|
|
4
4
|
import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
|
|
5
5
|
import { type QuickLabel, getQuickLabels, quickLabelIndexForDigit, THUMBS_UP_LABEL } from "../utils/quickLabels";
|
|
6
6
|
import { copyTextToClipboard } from "../utils/clipboard";
|
|
7
|
+
import { copyRichSelection, richCopyOutcomeLabel, type RichCopyOutcome, type RichCopySource } from "../utils/richClipboard";
|
|
7
8
|
import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
|
|
8
9
|
import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
|
|
9
10
|
import { FloatingSelectionActionsPicker } from "./SelectionActionsDropdown";
|
|
@@ -30,6 +31,12 @@ interface AnnotationToolbarProps {
|
|
|
30
31
|
onQuickLabel?: (label: QuickLabel) => void;
|
|
31
32
|
/** Text to copy when the button is clicked */
|
|
32
33
|
copyText?: string;
|
|
34
|
+
/** Builds the captured text selection's sanitized HTML and plain text when
|
|
35
|
+
* Copy is pressed (never before). When present, Copy writes text/html plus
|
|
36
|
+
* text/plain in one clipboard write (a plain field still pastes plain
|
|
37
|
+
* text), and the Copy button shows even where hideCopyButton hides it.
|
|
38
|
+
* Absent: Copy copies plain text, exactly as before. No shortcut. */
|
|
39
|
+
getRichCopy?: () => RichCopySource | Promise<RichCopySource>;
|
|
33
40
|
/** Comment-only surfaces (HTML / live-app viewer): hide the Delete action,
|
|
34
41
|
* the quick-label picker, and the Alt+digit label shortcuts. A provided
|
|
35
42
|
* onQuickLabel then renders ONLY the hardcoded 👍 "Looks good" button —
|
|
@@ -57,7 +64,7 @@ interface AnnotationToolbarProps {
|
|
|
57
64
|
* this toolbar. The one-click 👍 is a separate affordance and is unaffected.
|
|
58
65
|
*/
|
|
59
66
|
quickLabels?: boolean;
|
|
60
|
-
/** Hide the copy button (set when a keyboard copy handler exists) */
|
|
67
|
+
/** Hide the plain copy button (set when a keyboard copy handler exists) */
|
|
61
68
|
hideCopyButton?: boolean;
|
|
62
69
|
/** Close toolbar when element scrolls out of viewport */
|
|
63
70
|
closeOnScrollOut?: boolean;
|
|
@@ -79,6 +86,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
79
86
|
selectionActionsIcon,
|
|
80
87
|
quickLabels: quickLabelsEnabled = true,
|
|
81
88
|
copyText,
|
|
89
|
+
getRichCopy,
|
|
82
90
|
commentOnly = false,
|
|
83
91
|
hideCopyButton = false,
|
|
84
92
|
closeOnScrollOut = false,
|
|
@@ -88,6 +96,10 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
88
96
|
}) => {
|
|
89
97
|
const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
|
|
90
98
|
const [copied, setCopied] = useState(false);
|
|
99
|
+
const [richCopied, setRichCopied] = useState<RichCopyOutcome | false>(false);
|
|
100
|
+
const richCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
101
|
+
const richCopySelectionRef = useRef({ element, getRichCopy });
|
|
102
|
+
richCopySelectionRef.current = { element, getRichCopy };
|
|
91
103
|
const [showQuickLabels, setShowQuickLabels] = useState(false);
|
|
92
104
|
const [showSelectionActions, setShowSelectionActions] = useState(false);
|
|
93
105
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
@@ -107,7 +119,18 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
107
119
|
const selectionActionsOpen = showSelectionActions && hasSelectionActions;
|
|
108
120
|
const quickLabelsOpen = showQuickLabels && !commentOnly && quickLabelsEnabled;
|
|
109
121
|
|
|
110
|
-
useEffect(() => {
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
setCopied(false);
|
|
124
|
+
setRichCopied(false);
|
|
125
|
+
}, [element]);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
setRichCopied(false);
|
|
128
|
+
if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
|
|
129
|
+
richCopyTimeoutRef.current = null;
|
|
130
|
+
}, [element, getRichCopy]);
|
|
131
|
+
useEffect(() => () => {
|
|
132
|
+
if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
|
|
133
|
+
}, []);
|
|
111
134
|
|
|
112
135
|
const handleCopy = async () => {
|
|
113
136
|
let textToCopy = copyText;
|
|
@@ -121,6 +144,24 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
121
144
|
}
|
|
122
145
|
};
|
|
123
146
|
|
|
147
|
+
const handleRichCopy = async () => {
|
|
148
|
+
setRichCopied(false);
|
|
149
|
+
const build = getRichCopy;
|
|
150
|
+
if (!build) return;
|
|
151
|
+
const fallback = copyText || element.querySelector('code')?.textContent || element.textContent || '';
|
|
152
|
+
const result = await copyRichSelection(build, fallback);
|
|
153
|
+
const current = richCopySelectionRef.current;
|
|
154
|
+
if (current.element !== element || current.getRichCopy !== build) return;
|
|
155
|
+
if (result) {
|
|
156
|
+
setRichCopied(result);
|
|
157
|
+
if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
|
|
158
|
+
richCopyTimeoutRef.current = setTimeout(() => {
|
|
159
|
+
setRichCopied(false);
|
|
160
|
+
richCopyTimeoutRef.current = null;
|
|
161
|
+
}, 1500);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
124
165
|
// Update position on scroll/resize
|
|
125
166
|
useEffect(() => {
|
|
126
167
|
const updatePosition = () => {
|
|
@@ -252,14 +293,28 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
252
293
|
}
|
|
253
294
|
`}</style>
|
|
254
295
|
<div data-pn-annotation-toolbar-row="true" className="flex items-center p-1 gap-0.5">
|
|
255
|
-
{!hideCopyButton && (
|
|
296
|
+
{(!hideCopyButton || getRichCopy) && (
|
|
256
297
|
<>
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
298
|
+
{getRichCopy ? (
|
|
299
|
+
// One Copy button: rich text (text/html plus text/plain) where
|
|
300
|
+
// the host turned rich copy on. "Copied!" for a rich copy; a
|
|
301
|
+
// plain-text fallback says what went on the clipboard.
|
|
302
|
+
<ToolbarButton
|
|
303
|
+
onClick={handleRichCopy}
|
|
304
|
+
icon={richCopied ? <CheckIcon /> : <CopyIcon />}
|
|
305
|
+
label={!richCopied ? "Copy"
|
|
306
|
+
: richCopied.copied === 'rich' ? "Copied!"
|
|
307
|
+
: richCopyOutcomeLabel(richCopied)}
|
|
308
|
+
className={richCopied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
|
|
309
|
+
/>
|
|
310
|
+
) : (
|
|
311
|
+
<ToolbarButton
|
|
312
|
+
onClick={handleCopy}
|
|
313
|
+
icon={copied ? <CheckIcon /> : <CopyIcon />}
|
|
314
|
+
label={copied ? "Copied!" : "Copy"}
|
|
315
|
+
className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
|
|
316
|
+
/>
|
|
317
|
+
)}
|
|
263
318
|
<div className="w-px h-5 bg-border mx-0.5" />
|
|
264
319
|
</>
|
|
265
320
|
)}
|