diff --git a/src/content/panel/components/ComponentDetails.tsx b/src/content/panel/components/ComponentDetails.tsx index ee0d5b2..9c96b0a 100644 --- a/src/content/panel/components/ComponentDetails.tsx +++ b/src/content/panel/components/ComponentDetails.tsx @@ -4,12 +4,13 @@ import { buildUrl, copyAsCssSelector, copyAsFetchSnippet, + ensureProtocol, unpublishedUri, } from '@/lib/clay-uri'; -import { copyToClipboard } from '@/lib/clipboard'; import { captureElementToClipboard } from '@/lib/screenshot'; import type { RuntimeMessage } from '@/lib/types'; import { getPanelHost } from '../../shadow-host'; +import { useCopyAction } from '../hooks/useCopyAction'; import { useEnvHost, useStore } from '../store'; import { CopyableUri } from './CopyableUri'; import { Icon } from './Icon'; @@ -21,6 +22,10 @@ export function ComponentDetails() { const selected = useStore((s) => s.selected); const pushToast = useStore((s) => s.pushToast); const envHost = useEnvHost(); + // Each "Copy as…" button needs its own inline-feedback signal so a click + // on cURL doesn't make every button flash "Copied". The hook tracks the + // most-recently-used key; siblings compare against it. + const { copy, copiedKey } = useCopyAction(); if (!selected) { return ( @@ -34,11 +39,6 @@ export function ComponentDetails() { chrome.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage); }; - const copy = async (text: string, label: string) => { - const ok = await copyToClipboard(text); - pushToast(ok ? `${label} copied` : 'Copy failed', ok ? 'success' : 'error'); - }; - const screenshot = async () => { try { const ok = await captureElementToClipboard(selected.element, getPanelHost()); @@ -103,26 +103,52 @@ export function ComponentDetails() {
Copy as…
-
diff --git a/src/content/panel/components/CopyableUri.tsx b/src/content/panel/components/CopyableUri.tsx index e8c7ff2..b5615d1 100644 --- a/src/content/panel/components/CopyableUri.tsx +++ b/src/content/panel/components/CopyableUri.tsx @@ -1,14 +1,19 @@ -import { copyToClipboard } from '@/lib/clipboard'; -import { useStore } from '../store'; +import { ensureProtocol } from '@/lib/clay-uri'; +import { useCopyAction } from '../hooks/useCopyAction'; import { Icon } from './Icon'; interface CopyableUriProps { - /** The string actually written to the clipboard. */ + /** + * The Clay URI being represented. The actual string written to the + * clipboard is `ensureProtocol(uri)` so the user always pastes a + * fully-qualified URL — bare URIs aren't useful in a browser bar, + * curl, or chat tool. + */ readonly uri: string; /** - * Optional shorter text to display in the row. Defaults to `uri`. Useful - * for component rows where the visible label is the instance ID but the - * value worth copying is the full URI. + * Optional shorter text to display in the row. Defaults to `uri`. + * Useful for component rows where the visible label is the instance ID + * but the value worth copying is the full URI. */ readonly displayText?: string; /** Used in the toast + accessible label, e.g. "Page URI" or "Component URI". */ @@ -19,21 +24,28 @@ interface CopyableUriProps { * A URI rendered in muted monospace with a copy-icon button on the right * of the same row. Hover (and keyboard focus) brightens the button so the * affordance is discoverable but not visually noisy at rest. + * + * After a successful copy the icon swaps to a checkmark and the button + * picks up the `cs-uri-row-copy-copied` modifier class for ~1.5s, giving + * the user co-located confirmation without forcing them to hunt for the + * toast at the bottom of the panel. */ export function CopyableUri({ uri, displayText, label }: CopyableUriProps) { - const pushToast = useStore((s) => s.pushToast); + const { copy, copiedKey } = useCopyAction(); + const copied = copiedKey === 'default'; - const onCopy = async () => { - const ok = await copyToClipboard(uri); - pushToast(ok ? `${label} copied` : 'Copy failed', ok ? 'success' : 'error'); + const onCopy = () => { + void copy(ensureProtocol(uri), label); }; - // When the displayed text differs from the value being copied (component - // rows show the instance id, copy yields the full URI), surface the full - // URI in the tooltip so it's still discoverable. - const tooltip = - displayText && displayText !== uri - ? `Copy ${label} to clipboard\n${uri}` + // The display text in the row stays as-is (short, clean, no scheme). The + // tooltip surfaces the *exact* string that will be copied so power users + // can verify the URL form before pasting. + const copiedText = ensureProtocol(uri); + const tooltip = copied + ? 'Copied!' + : displayText && displayText !== copiedText + ? `Copy ${label} to clipboard\n${copiedText}` : `Copy ${label} to clipboard`; return ( @@ -41,12 +53,12 @@ export function CopyableUri({ uri, displayText, label }: CopyableUriProps) {

{displayText ?? uri}

); diff --git a/src/content/panel/components/ExportMenu.tsx b/src/content/panel/components/ExportMenu.tsx index 00c02b1..c6bbd38 100644 --- a/src/content/panel/components/ExportMenu.tsx +++ b/src/content/panel/components/ExportMenu.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState } from 'react'; import { buildManifest, formatManifest } from '@/lib/exporter'; -import { copyToClipboard } from '@/lib/clipboard'; import type { ExportFormat } from '@/lib/types'; +import { useCopyAction } from '../hooks/useCopyAction'; import { useStore } from '../store'; +import { Icon } from './Icon'; const OPTIONS: Array<{ format: ExportFormat; label: string; help: string }> = [ { format: 'json', label: 'JSON', help: 'Full structured data' }, @@ -26,6 +27,10 @@ export function ExportMenu() { const page = useStore((s) => s.page); const components = useStore((s) => s.components); const pushToast = useStore((s) => s.pushToast); + // Each format gets its own copy key so a click on JSON doesn't briefly + // tag CSV as "Copied". The menu auto-closes a beat *after* the copy + // succeeds so the user sees the inline confirmation before it goes away. + const { copy, copiedKey } = useCopyAction(); useEffect(() => { if (!open) return; @@ -72,16 +77,19 @@ export function ExportMenu() { }; const exportAs = async (format: ExportFormat) => { - setOpen(false); try { const text = formatManifest(buildManifest(page, components), format); - const ok = await copyToClipboard(text); - pushToast( - ok ? `${format.toUpperCase()} manifest copied to clipboard` : 'Copy failed', - ok ? 'success' : 'error' - ); + const ok = await copy(text, `${format.toUpperCase()} manifest`, format); + if (ok) { + // Hold the menu open briefly so the user can see the inline "Copied" + // affordance light up before we collapse it. + setTimeout(() => setOpen(false), 900); + } else { + setOpen(false); + } } catch (err) { pushToast(err instanceof Error ? err.message : 'Export failed', 'error'); + setOpen(false); } }; @@ -102,17 +110,28 @@ export function ExportMenu() { role="menu" style={{ top: coords.top, right: coords.right }} > - {OPTIONS.map((opt) => ( - - ))} + {OPTIONS.map((opt) => { + const copied = copiedKey === opt.format; + return ( + + ); + })} )} diff --git a/src/content/panel/components/Icon.tsx b/src/content/panel/components/Icon.tsx index 8352009..280c452 100644 --- a/src/content/panel/components/Icon.tsx +++ b/src/content/panel/components/Icon.tsx @@ -159,6 +159,16 @@ const ICONS = { ), + check: ( + + ), } as const; export type IconName = keyof typeof ICONS; diff --git a/src/content/panel/components/JsonPreview.tsx b/src/content/panel/components/JsonPreview.tsx index d31d394..be91ab0 100644 --- a/src/content/panel/components/JsonPreview.tsx +++ b/src/content/panel/components/JsonPreview.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { buildUrl } from '@/lib/clay-uri'; -import { copyToClipboard } from '@/lib/clipboard'; import { highlightJson } from '@/lib/json-highlight'; +import { useCopyAction } from '../hooks/useCopyAction'; import { useEnvHost, useStore } from '../store'; import { Icon } from './Icon'; @@ -25,8 +25,9 @@ function initialStateFor(uri: string | null, host: string): FetchState { export function JsonPreview() { const selected = useStore((s) => s.selected); const page = useStore((s) => s.page); - const pushToast = useStore((s) => s.pushToast); const envHost = useEnvHost(); + const { copy, copiedKey } = useCopyAction(); + const copied = copiedKey === 'default'; const targetUri = selected?.uri ?? page?.pageUri ?? null; const fetchUrl = targetUri ? buildUrl(targetUri, '.json', envHost) : null; @@ -95,17 +96,21 @@ export function JsonPreview() { return null; } - const onCopy = async () => { - const ok = await copyToClipboard(JSON.stringify(state.data, null, 2)); - pushToast(ok ? 'JSON copied' : 'Copy failed', ok ? 'success' : 'error'); + const onCopy = () => { + void copy(JSON.stringify(state.data, null, 2), 'JSON'); }; return (

JSON

-
diff --git a/src/content/panel/components/SeoTab.tsx b/src/content/panel/components/SeoTab.tsx
index a234be2..531dab0 100644
--- a/src/content/panel/components/SeoTab.tsx
+++ b/src/content/panel/components/SeoTab.tsx
@@ -1,5 +1,4 @@
 import { useEffect, useMemo, useState } from 'react';
-import { copyToClipboard } from '@/lib/clipboard';
 import { highlightJson } from '@/lib/json-highlight';
 import {
   extractSeoMeta,
@@ -10,7 +9,7 @@ import {
   type SeoIssue,
   type SeoMeta,
 } from '@/lib/seo';
-import { useStore } from '../store';
+import { useCopyAction } from '../hooks/useCopyAction';
 import { Icon } from './Icon';
 
 const TONE: Record = {
@@ -76,22 +75,22 @@ function JsonLdBlockCard({
   index: number;
   issues: readonly JsonLdIssue[];
 }) {
-  const pushToast = useStore((s) => s.pushToast);
   // Open by default if the block has any errors so the user immediately
   // sees what's wrong without an extra click.
   const hasError = issues.some((i) => i.severity === 'error');
   const [open, setOpen] = useState(hasError);
   const summary = summarizeJsonLd(block);
   const headerSeverity = worstSeverity(issues);
+  const { copy, copiedKey } = useCopyAction();
+  const copied = copiedKey === 'default';
 
-  const onCopy = async (e: React.MouseEvent) => {
+  const onCopy = (e: React.MouseEvent) => {
     // Prevent the click from toggling the 
open state. e.preventDefault(); e.stopPropagation(); const text = summary.invalid && isInvalidBlock(block) ? (block.raw ?? '') : JSON.stringify(block, null, 2); - const ok = await copyToClipboard(text); - pushToast(ok ? `Copied JSON-LD block #${index + 1}` : 'Copy failed', ok ? 'success' : 'error'); + void copy(text, `JSON-LD block #${index + 1}`); }; const cardClasses = ['cs-jsonld-card']; @@ -135,12 +134,14 @@ function JsonLdBlockCard({ )} {/* Body is only mounted once expanded — saves the syntax-highlight diff --git a/src/content/panel/components/ShareMenu.tsx b/src/content/panel/components/ShareMenu.tsx index d18d1b8..ced3938 100644 --- a/src/content/panel/components/ShareMenu.tsx +++ b/src/content/panel/components/ShareMenu.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buildShareLink } from '@/lib/clay-uri'; -import { copyToClipboard } from '@/lib/clipboard'; import { availableEnvsFor, findMappingForHost, rewriteUrlToEnv } from '@/lib/site-host'; import { SITE_ENV_LABELS, SITE_ENV_ORDER, type SiteEnv } from '@/lib/types'; +import { useCopyAction } from '../hooks/useCopyAction'; import { useStore } from '../store'; import { Icon } from './Icon'; @@ -39,6 +39,10 @@ export function ShareMenu({ uri }: Props) { const siteHosts = useStore((s) => s.preferences.siteHosts); const pushToast = useStore((s) => s.pushToast); + // Two visible buttons can each be the trigger ("Share" main + per-env + // menu items), so we use the keyed form of the hook. + const { copy, copiedKey } = useCopyAction(); + const mainCopied = copiedKey === 'main'; // Cross-env targets are derived from configuration only — the URL itself // is computed at click time so SPA navigation can never produce a stale @@ -85,32 +89,33 @@ export function ShareMenu({ uri }: Props) { }; }, [open]); - const copyShare = useCallback( - async (targetUrl: string, label: string) => { - const ok = await copyToClipboard(targetUrl); - pushToast(ok ? `Share link copied (${label})` : 'Copy failed', ok ? 'success' : 'error'); - }, - [pushToast] - ); - const onShareClick = useCallback(() => { - setOpen(false); const currentMatch = findMappingForHost(location.hostname, siteHosts); - const label = currentMatch ? `Current — ${SITE_ENV_LABELS[currentMatch.env]}` : 'Current page'; - void copyShare(buildShareLink(location.href, uri), label); - }, [copyShare, siteHosts, uri]); + const label = currentMatch ? `Share link (${SITE_ENV_LABELS[currentMatch.env]})` : 'Share link'; + void copy(buildShareLink(location.href, uri), label, 'main'); + }, [copy, siteHosts, uri]); const onMenuClick = useCallback( - (target: MenuTarget) => { - setOpen(false); + async (target: MenuTarget) => { const rewritten = rewriteUrlToEnv(location.href, target.env, siteHosts); if (!rewritten) { pushToast(`No ${target.label} host configured for this site`, 'error'); + setOpen(false); return; } - void copyShare(buildShareLink(rewritten, uri), target.label); + const ok = await copy( + buildShareLink(rewritten, uri), + `Share link (${target.label})`, + target.key + ); + if (ok) { + // Hold the menu open briefly so the inline "Copied" affordance is visible. + setTimeout(() => setOpen(false), 900); + } else { + setOpen(false); + } }, - [copyShare, pushToast, siteHosts, uri] + [copy, pushToast, siteHosts, uri] ); const toggle = useCallback(() => { @@ -134,11 +139,11 @@ export function ShareMenu({ uri }: Props) { return ( ); } @@ -147,11 +152,11 @@ export function ShareMenu({ uri }: Props) {
- ))} + {menuTargets.map((t) => { + const copied = copiedKey === t.key; + return ( + + ); + })}
)} diff --git a/src/content/panel/hooks/useCopyAction.ts b/src/content/panel/hooks/useCopyAction.ts new file mode 100644 index 0000000..9055060 --- /dev/null +++ b/src/content/panel/hooks/useCopyAction.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { copyToClipboard } from '@/lib/clipboard'; +import { useStore } from '../store'; + +/** + * Default key used by single-button consumers that don't need to + * disambiguate between siblings. Picked as a string instead of `null` + * so callers can always do `copiedKey === 'btnA'` without null-checks. + */ +const DEFAULT_KEY = 'default'; +const DEFAULT_DURATION_MS = 1500; + +interface UseCopyActionResult { + /** + * Copy `text` to the clipboard, push a feedback toast, and (on success) + * mark `key` as the most recently copied so the calling component can + * render an inline "Copied" affordance for `durationMs`. + * + * - `text` : what to write to the clipboard verbatim. Caller is + * responsible for any normalization (e.g. `ensureProtocol`). + * - `toastLabel` : short noun used in the toast — e.g. passing `"URI"` + * yields the toast text `"URI copied"`. + * - `key` : optional disambiguator when one component renders + * multiple copy buttons. Defaults to `'default'`. + */ + readonly copy: (text: string, toastLabel: string, key?: string) => Promise; + /** + * Key of the most recently copied button, or `null` once the inline + * feedback window has elapsed. `=== DEFAULT_KEY` for single-button uses. + */ + readonly copiedKey: string | null; +} + +/** + * Bundles the three things every copy button in the panel needs: + * + * 1. The actual `navigator.clipboard.writeText` call (delegated to + * `copyToClipboard`). + * 2. A toast push so the user gets *some* feedback even if their eyes + * are away from the button (e.g. they triggered copy via shortcut + * or the button is in a now-collapsed menu). + * 3. A short-lived `copiedKey` flag so the button itself can render an + * inline "Copied" affordance (icon swap, label change, tinted border + * — caller's choice). + * + * Why both a toast *and* an inline indicator? They cover different + * failure modes of feedback: a toast is global but easy to miss if the + * user is focused on the button; the inline indicator is co-located but + * disappears if the menu collapses. Together they guarantee one of them + * is always visible right after a click. + */ +export function useCopyAction(durationMs = DEFAULT_DURATION_MS): UseCopyActionResult { + const [copiedKey, setCopiedKey] = useState(null); + const timerRef = useRef | null>(null); + const pushToast = useStore((s) => s.pushToast); + + // If the consumer unmounts during the post-copy window (e.g. user + // collapses the panel right after copying), make sure the timeout + // doesn't stamp on a defunct setState. + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current); + }, + [] + ); + + const copy = useCallback( + async (text: string, toastLabel: string, key: string = DEFAULT_KEY) => { + const ok = await copyToClipboard(text); + if (ok) { + setCopiedKey(key); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopiedKey(null), durationMs); + pushToast(`${toastLabel} copied`, 'success'); + } else { + pushToast('Copy failed', 'error'); + } + return ok; + }, + [durationMs, pushToast] + ); + + return { copy, copiedKey }; +} diff --git a/src/content/panel/hooks/useKeyboardShortcuts.ts b/src/content/panel/hooks/useKeyboardShortcuts.ts index 8e16906..e0174d0 100644 --- a/src/content/panel/hooks/useKeyboardShortcuts.ts +++ b/src/content/panel/hooks/useKeyboardShortcuts.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { copyToClipboard } from '@/lib/clipboard'; -import { buildUrl } from '@/lib/clay-uri'; +import { buildUrl, ensureProtocol } from '@/lib/clay-uri'; import type { RuntimeMessage } from '@/lib/types'; import { useStore } from '../store'; @@ -80,10 +80,12 @@ export function useKeyboardShortcuts(): void { clearPending(); if (combo === 'yp' && page) { - const ok = await copyToClipboard(page.pageUri); + // Always copy a fully-qualified URL so the keyboard shortcut + // matches what the in-panel copy button writes. + const ok = await copyToClipboard(ensureProtocol(page.pageUri)); pushToast(ok ? 'Page URI copied' : 'Copy failed', ok ? 'success' : 'error'); } else if (combo === 'yc' && selected) { - const ok = await copyToClipboard(selected.uri); + const ok = await copyToClipboard(ensureProtocol(selected.uri)); pushToast(ok ? 'Component URI copied' : 'Copy failed', ok ? 'success' : 'error'); } else if (combo === 'op' && page) { chrome.runtime.sendMessage({ diff --git a/src/content/panel/styles.css b/src/content/panel/styles.css index 2201cf4..8b2a639 100644 --- a/src/content/panel/styles.css +++ b/src/content/panel/styles.css @@ -369,6 +369,33 @@ filter: brightness(1.05); } +/* ── "Copied" inline confirmation state ──────────────────────────────── + Applied to copy-action buttons for ~1.5s after a successful copy. + Picks up the success color so the affordance is unambiguous; pointer + stays as default-cursor since clicking again just re-copies. */ +.cs-link.cs-link-copied { + background: var(--cs-success); + border-color: var(--cs-success); + color: #fff !important; + cursor: default; +} + +.cs-link.cs-link-copied:hover { + background: var(--cs-success); + border-color: var(--cs-success); + color: #fff !important; + filter: brightness(1.05); +} + +/* Smaller variant for the icon-only buttons (CopyableUri row + JSON tab + header + JSON-LD card header). The icon is already swapped to a check + mark in JSX; the green tint provides the secondary signal. */ +.cs-icon-btn.cs-icon-btn-copied, +.cs-uri-row-copy.cs-uri-row-copy-copied { + color: var(--cs-success); + opacity: 1; +} + .cs-empty { text-align: center; padding: 32px 16px; @@ -1028,6 +1055,19 @@ background: var(--cs-bg-hover); } +.cs-export-item.cs-export-item-copied, +.cs-export-item.cs-export-item-copied:hover { + background: rgba(22, 163, 74, 0.12); + cursor: default; +} + +.cs-export-item.cs-export-item-copied .cs-export-label { + color: var(--cs-success); + display: inline-flex; + align-items: center; + gap: 4px; +} + .cs-export-label { font-weight: 600; font-size: 12px; diff --git a/src/lib/clay-uri.ts b/src/lib/clay-uri.ts index e37167a..8f25f43 100644 --- a/src/lib/clay-uri.ts +++ b/src/lib/clay-uri.ts @@ -78,6 +78,24 @@ export function splitHostAndPath(uri: string): { host: string; path: string } { return { host: '', path: '/' + cleaned }; } +/** + * Prepend `https://` to a Clay URI when no protocol is present, so the + * resulting string is paste-able into a browser, curl, or any other URL + * consumer. URIs already carrying `http://` or `https://` are returned + * unchanged so we never silently rewrite an explicitly-http reference. + * + * Empty / nullish input returns an empty string. Any leading `//` is + * collapsed to keep the resulting URL well-formed in case a caller passes + * a protocol-relative URI. + */ +export function ensureProtocol(uri: string | null | undefined): string { + if (!uri) return ''; + const trimmed = uri.trim(); + if (!trimmed) return ''; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed.replace(/^\/+/, '')}`; +} + /** * Normalizes a user-provided host string into a `protocol://hostname` form * with no trailing slash. Returns an empty string when no host is set. diff --git a/tests/lib/clay-uri.test.ts b/tests/lib/clay-uri.test.ts index 675c4b8..50c19e5 100644 --- a/tests/lib/clay-uri.test.ts +++ b/tests/lib/clay-uri.test.ts @@ -7,6 +7,7 @@ import { buildUrl, copyAsCssSelector, copyAsFetchSnippet, + ensureProtocol, getComponentName, getDisplayName, getInstance, @@ -288,6 +289,40 @@ describe('buildShareLink + parseShareTarget', () => { }); }); +describe('ensureProtocol', () => { + it('prepends https:// to a bare Clay URI', () => { + expect(ensureProtocol('example.com/_components/x/instances/y')).toBe( + 'https://example.com/_components/x/instances/y' + ); + }); + + it('preserves an existing https:// scheme', () => { + expect(ensureProtocol('https://example.com/_pages/foo')).toBe('https://example.com/_pages/foo'); + }); + + it('preserves an existing http:// scheme (does not silently upgrade)', () => { + // Caller may be working against a local http://localhost:3001 dev server; + // we must not rewrite to https:// or curl/fetch will fail TLS handshakes. + expect(ensureProtocol('http://localhost:3001/_pages/x')).toBe('http://localhost:3001/_pages/x'); + }); + + it('handles protocol-relative URIs by collapsing the leading //', () => { + expect(ensureProtocol('//example.com/_pages/foo')).toBe('https://example.com/_pages/foo'); + }); + + it('returns empty string for nullish or whitespace-only input', () => { + expect(ensureProtocol(undefined)).toBe(''); + expect(ensureProtocol(null)).toBe(''); + expect(ensureProtocol('')).toBe(''); + expect(ensureProtocol(' ')).toBe(''); + }); + + it('trims surrounding whitespace before deciding whether to prepend', () => { + expect(ensureProtocol(' example.com/_pages/x ')).toBe('https://example.com/_pages/x'); + expect(ensureProtocol(' https://example.com ')).toBe('https://example.com'); + }); +}); + describe('copy helpers', () => { it('builds a fetch snippet against the env host', () => { const snip = copyAsFetchSnippet(