mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
463f6ed57c
* fix(ui): fall back to legacy copy in insecure browser contexts navigator.clipboard only exists in secure contexts. Remote mode serves plain HTTP on a non-localhost host, so every bare navigator.clipboard.writeText call threw TypeError and copy buttons silently broke. Add copyTextToClipboard(text): Promise<boolean> to packages/ui/utils/clipboard.ts: it tries the async Clipboard API (guarded against synchronous throws), falls back to the existing copy-event plus execCommand path, reports success as a boolean, and never throws. copyTextWithFallback now returns whether the copy happened and accepts an optional focusOwner; copyTextPreservingFocus keeps its exported signature and behavior unchanged. Route all bare call sites through the helper, preserving each site's UX: Copied states only flip on success, error toasts and console errors remain for the failure case, fire-and-forget sites stay fire-and-forget. GoalSetupSurface gains the fallback and keeps its error surface for the all-strategies-failed case. Add DOM-gated unit tests for the helper and register them in CI. Closes #1173 * fix(ui): address clipboard fallback review findings Review follow-ups for the insecure-context clipboard fallback: 1. Tag the fallback textarea with data-clipboard-fallback and whitelist it in PopoutDialog's ANNOTATION_SELECTORS so the transient focus shift during a fallback copy no longer closes popout dialogs (TablePopout copy buttons, CodeFilePopout copy contents). 2. Widen AnnotationPanel's onQuickCopy prop to Promise<void | boolean>. A false resolution now suppresses the Copied flash; void resolution stays success so existing hosts keep today's behavior. The editor quick-copy site returns the helper's boolean. 3. In copyTextWithFallback, only flag the copy-event path as success when clipboardData was present and setData actually ran, and only when execCommand also reported success. A null clipboardData no longer calls preventDefault, so the textarea retry still runs. 4. Add tests pinning that the fallback runs synchronously when navigator.clipboard is absent (execCommand fires before the call returns, keeping it inside the user-gesture window), that a copy event without clipboardData is not treated as success, and that the fallback textarea carries the PopoutDialog focus-out marker and is removed after the copy resolves. 5. GoalSetupSurface surfaces the real writeText rejection message when the Clipboard API exists but fails and the fallback also fails; the generic unavailable message is reserved for the API-absent case.
111 lines
4.0 KiB
TypeScript
111 lines
4.0 KiB
TypeScript
import React, { useCallback } from 'react';
|
|
import { Dialog } from '@base-ui/react/dialog';
|
|
|
|
export const ANNOTATION_SELECTORS = [
|
|
'.annotation-toolbar',
|
|
'[data-comment-popover="true"]',
|
|
'[data-floating-picker="true"]',
|
|
// Transient hidden textarea created by the legacy clipboard fallback
|
|
// (packages/ui/utils/clipboard.ts). It briefly steals focus during copy;
|
|
// without this guard the focus-out close reason would dismiss the popout
|
|
// whenever a copy button inside it falls back to execCommand.
|
|
'[data-clipboard-fallback="true"]',
|
|
];
|
|
|
|
interface PopoutDialogProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
title: string;
|
|
container?: HTMLElement | null;
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
/** Extra data attributes to spread onto the dialog popup */
|
|
dataAttributes?: Record<string, string>;
|
|
}
|
|
|
|
export const PopoutDialog: React.FC<PopoutDialogProps> = ({
|
|
open,
|
|
onClose,
|
|
title,
|
|
container,
|
|
className,
|
|
children,
|
|
dataAttributes,
|
|
}) => {
|
|
const handleBackdropClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
const target = e.target as Element;
|
|
if (ANNOTATION_SELECTORS.some((sel) => target.closest(sel))) return;
|
|
onClose();
|
|
},
|
|
[onClose],
|
|
);
|
|
|
|
const handleOpenChange = useCallback(
|
|
(next: boolean, eventDetails: Dialog.Root.ChangeEventDetails) => {
|
|
if (next) return;
|
|
// Interactions with annotation UI (toolbars/popovers portal outside the
|
|
// dialog) must not dismiss the popout — the Radix version guarded this
|
|
// via onInteractOutside preventDefault; Base UI expresses it as
|
|
// cancelling the outside-press/focus-out close reason.
|
|
if (eventDetails.reason === 'outside-press' || eventDetails.reason === 'focus-out') {
|
|
// For focus events the annotation UI may sit on either side of the
|
|
// event (target = element losing focus, relatedTarget = gaining), so
|
|
// guard both.
|
|
const event = eventDetails.event as Event | undefined;
|
|
const candidates = [
|
|
event?.target as Element | null,
|
|
(event as FocusEvent | undefined)?.relatedTarget as Element | null,
|
|
];
|
|
if (
|
|
candidates.some(
|
|
(el) => el instanceof Element && ANNOTATION_SELECTORS.some((sel) => el.closest(sel)),
|
|
)
|
|
) {
|
|
eventDetails.cancel();
|
|
return;
|
|
}
|
|
}
|
|
onClose();
|
|
},
|
|
[onClose],
|
|
);
|
|
|
|
return (
|
|
<Dialog.Root open={open} onOpenChange={handleOpenChange} modal={false}>
|
|
<Dialog.Portal container={container ?? undefined}>
|
|
{/* Non-modal dialogs render no library backdrop, so we use a plain
|
|
div for the dark scrim + blur while keeping annotation toolbars
|
|
(which portal outside the dialog) interactive. */}
|
|
<div
|
|
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-[2px]"
|
|
onClick={handleBackdropClick}
|
|
aria-hidden="true"
|
|
/>
|
|
<Dialog.Popup
|
|
className={`fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden ${className ?? 'w-[calc(100vw-4rem)] max-w-[min(calc(100vw-4rem),1500px)] max-h-[calc(100vh-4rem)]'}`}
|
|
data-popout="true"
|
|
initialFocus={false}
|
|
{...dataAttributes}
|
|
>
|
|
<Dialog.Title className="sr-only">{title}</Dialog.Title>
|
|
<Dialog.Close
|
|
render={
|
|
<button
|
|
className="absolute top-3 right-3 z-20 p-1.5 rounded-md text-muted-foreground/70 hover:bg-muted hover:text-foreground transition-colors"
|
|
aria-label="Close"
|
|
/>
|
|
}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</Dialog.Close>
|
|
|
|
{children}
|
|
</Dialog.Popup>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
);
|
|
};
|