diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index ca75dd06..85136388 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; +import { CopyIcon } from '@plannotator/ui/components/icons/copyIcons'; import { type Origin, getAgentName } from '@plannotator/shared/agents'; import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter } from '@plannotator/ui/utils/parser'; import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer'; @@ -1279,9 +1280,7 @@ const App: React.FC = () => { title="Copy plan content" > Copy - - - + - ); - } - - return ( - - ); -}; +// Re-exported from @plannotator/ui for backwards compatibility. +// The canonical implementation now lives in packages/ui/components/CopyButton.tsx. +export { CopyButton } from '@plannotator/ui/components/CopyButton'; diff --git a/packages/review-editor/components/FileHeader.tsx b/packages/review-editor/components/FileHeader.tsx index 30759cf0..e026f7d9 100644 --- a/packages/review-editor/components/FileHeader.tsx +++ b/packages/review-editor/components/FileHeader.tsx @@ -1,4 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; +import { CopyIcon, CheckIcon } from '@plannotator/ui/components/icons/copyIcons'; +import { useCopyToClipboard } from '@plannotator/ui/hooks/useCopyToClipboard'; interface FileHeaderProps { filePath: string; @@ -43,7 +45,7 @@ export const FileHeader: React.FC = ({ stageError, onFileComment, }) => { - const [copied, setCopied] = useState(false); + const { copied, copy } = useCopyToClipboard(); const [headerWidth, setHeaderWidth] = useState(0); const headerRef = useRef(null); const fileCommentRef = useRef(null); @@ -168,30 +170,18 @@ export const FileHeader: React.FC = ({ )} diff --git a/packages/review-editor/components/ReviewSidebar.tsx b/packages/review-editor/components/ReviewSidebar.tsx index 19362c9c..c247dceb 100644 --- a/packages/review-editor/components/ReviewSidebar.tsx +++ b/packages/review-editor/components/ReviewSidebar.tsx @@ -1,4 +1,6 @@ import React, { useState, useEffect } from 'react'; +import { CopyIcon, CheckIcon } from '@plannotator/ui/components/icons/copyIcons'; +import { useCopyToClipboard } from '@plannotator/ui/hooks/useCopyToClipboard'; import { CodeAnnotation, type EditorAnnotation } from '@plannotator/ui/types'; import { isCurrentUser } from '@plannotator/ui/utils/identity'; import { EditorAnnotationCard } from '@plannotator/ui/components/EditorAnnotationCard'; @@ -148,7 +150,7 @@ export const ReviewSidebar: React.FC = /* React.memo */({ onOpenPRPanel, }) => { const totalCount = annotations.length + (editorAnnotations?.length ?? 0); - const [copied, setCopied] = useState(false); + const { copied, copy } = useCopyToClipboard(); const [activeTab, setActiveTab] = useState('annotations'); const hasAgents = REVIEW_AGENTS_ENABLED && !!agentCapabilities?.available; const runningAgentCount = (agentJobs ?? []).filter(j => j.status === 'running' || j.status === 'starting').length; @@ -176,13 +178,7 @@ export const ReviewSidebar: React.FC = /* React.memo */({ const handleQuickCopy = async () => { if (!feedbackMarkdown) return; - try { - await navigator.clipboard.writeText(feedbackMarkdown); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (e) { - console.error('Failed to copy:', e); - } + await copy(feedbackMarkdown); }; // Group annotations by file @@ -451,16 +447,12 @@ export const ReviewSidebar: React.FC = /* React.memo */({ > {copied ? ( <> - - - + Copied ) : ( <> - - - + Copy Feedback )} diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index b76c4fa0..0d48f815 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -5,6 +5,7 @@ import { ImageThumbnail } from './ImageThumbnail'; import { EditorAnnotationCard } from './EditorAnnotationCard'; import { useIsMobile } from '../hooks/useIsMobile'; import { OverlayScrollArea } from './OverlayScrollArea'; +import { CopyIcon, CheckIcon } from './icons/copyIcons'; interface PanelProps { isOpen: boolean; @@ -159,22 +160,18 @@ export const AnnotationPanel: React.FC = ({ onClick={async () => { await onQuickCopy(); setCopiedText(true); - setTimeout(() => setCopiedText(false), 2000); + setTimeout(() => setCopiedText(false), 1500); }} className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-all text-muted-foreground hover:text-foreground hover:bg-muted/50" > {copiedText ? ( <> - - - + Copied ) : ( <> - - - + Copy )} diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index f6d10616..d01f8507 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -4,6 +4,8 @@ import { createPortal } from "react-dom"; import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape"; import { type QuickLabel, getQuickLabels } from "../utils/quickLabels"; import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker"; +import { CopyIcon, CheckIcon } from "./icons/copyIcons"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; type PositionMode = 'center-above' | 'top-right'; @@ -48,7 +50,7 @@ export const AnnotationToolbar: React.FC = ({ onMouseLeave, }) => { const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null); - const [copied, setCopied] = useState(false); + const { copied, copy, reset: resetCopied } = useCopyToClipboard(); const [showQuickLabels, setShowQuickLabels] = useState(false); const toolbarRef = useRef(null); const zapButtonRef = useRef(null); @@ -60,15 +62,13 @@ export const AnnotationToolbar: React.FC = ({ const codeEl = element.querySelector('code'); textToCopy = codeEl?.textContent || element.textContent || ''; } - await navigator.clipboard.writeText(textToCopy); - setCopied(true); - setTimeout(() => setCopied(false), 1500); + await copy(textToCopy); }; // Reset copied state when element changes useEffect(() => { - setCopied(false); - }, [element]); + resetCopied(); + }, [element, resetCopied]); // Update position on scroll/resize useEffect(() => { @@ -191,7 +191,7 @@ export const AnnotationToolbar: React.FC = ({
: } + icon={copied ? : } label={copied ? "Copied!" : "Copy"} className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"} /> @@ -243,18 +243,6 @@ export const AnnotationToolbar: React.FC = ({ }; // Icons -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - const TrashIcon = () => ( diff --git a/packages/ui/components/CopyButton.tsx b/packages/ui/components/CopyButton.tsx new file mode 100644 index 00000000..fe955a72 --- /dev/null +++ b/packages/ui/components/CopyButton.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { CopyIcon, CheckIcon } from './icons/copyIcons'; +import { useCopyToClipboard } from '../hooks/useCopyToClipboard'; + +interface CopyButtonProps { + text: string; + className?: string; + /** "overlay" (default): absolute-positioned, hover-reveal (parent needs group relative). + * "inline": normal flow, always visible, compact. */ + variant?: 'overlay' | 'inline'; + /** Optional label shown next to the icon (inline variant only). */ + label?: string; +} + +/** Copy-to-clipboard button with "Copied" flash. */ +export const CopyButton: React.FC = ({ text, className = '', variant = 'overlay', label }) => { + const { copied, copy } = useCopyToClipboard(); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + await copy(text); + }; + + if (variant === 'inline') { + return ( + + {copied ? 'Copied' : label} + + )} + + ); + } + + return ( + + ); +}; diff --git a/packages/ui/components/ExportModal.tsx b/packages/ui/components/ExportModal.tsx index 99ca856b..6d85c502 100644 --- a/packages/ui/components/ExportModal.tsx +++ b/packages/ui/components/ExportModal.tsx @@ -12,6 +12,7 @@ import { getBearSettings } from '../utils/bear'; import { getOctarineSettings } from '../utils/octarine'; import { wrapFeedbackForAgent } from '../utils/parser'; import { OverlayScrollArea } from './OverlayScrollArea'; +import { CopyIcon, CheckIcon } from './icons/copyIcons'; interface ExportModalProps { isOpen: boolean; @@ -271,16 +272,12 @@ export const ExportModal: React.FC = ({ > {copied === 'short' ? ( <> - - - + Copied ) : ( <> - - - + Copy )} @@ -332,16 +329,12 @@ export const ExportModal: React.FC = ({ > {copied === 'full' ? ( <> - - - + Copied ) : ( <> - - - + Copy )} diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 147e2001..dee6d035 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -7,6 +7,8 @@ import { Frontmatter, computeListIndices } from '../utils/parser'; import { ListMarker } from './ListMarker'; import { AnnotationToolbar } from './AnnotationToolbar'; import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker'; +import { CopyIcon, CheckIcon } from './icons/copyIcons'; +import { useCopyToClipboard } from '../hooks/useCopyToClipboard'; // Debug error boundary to catch silent toolbar crashes class ToolbarErrorBoundary extends React.Component< @@ -152,18 +154,12 @@ export const Viewer = forwardRef(({ onToggleCheckbox, checkboxOverrides, }, ref) => { - const [copied, setCopied] = useState(false); + const { copied, copy: copyToClipboard } = useCopyToClipboard(); const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); const globalCommentButtonRef = useRef(null); const handleCopyPlan = async () => { - try { - await navigator.clipboard.writeText(markdown); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (e) { - console.error('Failed to copy:', e); - } + await copyToClipboard(markdown); }; const containerRef = useRef(null); const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null); @@ -509,16 +505,12 @@ export const Viewer = forwardRef(({ > {copied ? ( <> - - - + Copied! ) : ( <> - - - + {actionsLabelMode === 'full' && {copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}} {actionsLabelMode === 'short' && Copy} @@ -1143,7 +1135,7 @@ interface CodeBlockProps { } const CodeBlock: React.FC = ({ block, onHover, onLeave, isHovered }) => { - const [copied, setCopied] = useState(false); + const { copied, copy } = useCopyToClipboard(); const containerRef = useRef(null); const codeRef = useRef(null); @@ -1158,14 +1150,8 @@ const CodeBlock: React.FC = ({ block, onHover, onLeave, isHovere }, [block.content, block.language]); const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(block.content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }, [block.content]); + await copy(block.content); + }, [block.content, copy]); const handleMouseEnter = () => { if (containerRef.current) { @@ -1190,13 +1176,9 @@ const CodeBlock: React.FC = ({ block, onHover, onLeave, isHovere title={copied ? 'Copied!' : 'Copy code'} > {copied ? ( - - - + ) : ( - - - + )}
diff --git a/packages/ui/components/icons/copyIcons.tsx b/packages/ui/components/icons/copyIcons.tsx
new file mode 100644
index 00000000..fdbe7fd1
--- /dev/null
+++ b/packages/ui/components/icons/copyIcons.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+
+/**
+ * Shared copy/clipboard icons (Copy / Check).
+ *
+ * These SVGs were previously duplicated across 10+ files. Centralizing them
+ * here keeps the iconography consistent and makes future glyph tweaks trivial.
+ *
+ * Each icon takes an optional `className` so callers control sizing:
+ * - inline actions (e.g. inside annotation toolbar): w-3 h-3
+ * - standalone buttons (e.g. file header "Copy Diff"): w-4 h-4
+ */
+
+interface IconProps {
+  className?: string;
+}
+
+export const CopyIcon: React.FC = ({ className = 'w-3 h-3' }) => (
+  
+);
+
+export const CheckIcon: React.FC = ({ className = 'w-3 h-3' }) => (
+  
+);
diff --git a/packages/ui/hooks/useCopyToClipboard.ts b/packages/ui/hooks/useCopyToClipboard.ts
new file mode 100644
index 00000000..b9bb663e
--- /dev/null
+++ b/packages/ui/hooks/useCopyToClipboard.ts
@@ -0,0 +1,30 @@
+import { useState, useCallback } from 'react';
+
+/**
+ * Centralizes the clipboard + copied state + timeout pattern that was
+ * previously duplicated across 15+ files with inconsistent timeout values
+ * (1500ms vs 2000ms). Default timeout is 1500ms.
+ *
+ * Returns `reset` for callers that need to clear the copied state imperatively
+ * (e.g. when the annotation target element changes).
+ */
+export function useCopyToClipboard(timeoutMs = 1500) {
+  const [copied, setCopied] = useState(false);
+
+  const copy = useCallback(
+    async (text: string) => {
+      try {
+        await navigator.clipboard.writeText(text);
+        setCopied(true);
+        setTimeout(() => setCopied(false), timeoutMs);
+      } catch {
+        // Clipboard API may not be available in some environments
+      }
+    },
+    [timeoutMs],
+  );
+
+  const reset = useCallback(() => setCopied(false), []);
+
+  return { copied, copy, reset };
+}