This commit is contained in:
Simon Knott 2025-01-17 11:39:51 +01:00
parent 5dcff8f60a
commit b75cf1fba6
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
4 changed files with 32 additions and 24 deletions

View file

@ -21,7 +21,7 @@ import { TreeItem } from './treeItem';
import { CopyToClipboard } from './copyToClipboard'; import { CopyToClipboard } from './copyToClipboard';
import './links.css'; import './links.css';
import { linkifyText } from '@web/renderUtils'; import { linkifyText } from '@web/renderUtils';
import { clsx } from '@web/uiUtils'; import { clsx, useFlash } from '@web/uiUtils';
export function navigate(href: string | URL) { export function navigate(href: string | URL) {
window.history.pushState({}, '', href); window.history.pushState({}, '', href);
@ -73,8 +73,8 @@ export const AttachmentLink: React.FunctionComponent<{
linkName?: string, linkName?: string,
openInNewTab?: boolean, openInNewTab?: boolean,
}> = ({ attachment, result, href, linkName, openInNewTab }) => { }> = ({ attachment, result, href, linkName, openInNewTab }) => {
const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment)); const [flash, triggerFlash] = useFlash();
const searchParams = React.useContext(SearchParamsContext); useAnchor('attachment-' + result.attachments.indexOf(attachment), triggerFlash);
return <TreeItem title={<span> return <TreeItem title={<span>
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>} {attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
@ -85,7 +85,7 @@ export const AttachmentLink: React.FunctionComponent<{
)} )}
</span>} loadChildren={attachment.body ? () => { </span>} loadChildren={attachment.body ? () => {
return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>]; return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>];
} : undefined} depth={0} style={{ lineHeight: '32px' }} flash={isAnchored ? searchParams : undefined}></TreeItem>; } : undefined} depth={0} style={{ lineHeight: '32px' }} flash={flash}></TreeItem>;
}; };
export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1))); export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
@ -119,12 +119,12 @@ const kMissingContentType = 'x-playwright/missing';
export type AnchorID = string | string[] | ((id: string) => boolean) | undefined; export type AnchorID = string | string[] | ((id: string) => boolean) | undefined;
export function useAnchor(id: AnchorID, onReveal: () => void) { export function useAnchor(id: AnchorID, onReveal: React.EffectCallback) {
const searchParams = React.useContext(SearchParamsContext); const searchParams = React.useContext(SearchParamsContext);
const isAnchored = useIsAnchored(id); const isAnchored = useIsAnchored(id);
React.useEffect(() => { React.useEffect(() => {
if (isAnchored) if (isAnchored)
onReveal(); return onReveal();
}, [isAnchored, onReveal, searchParams]); }, [isAnchored, onReveal, searchParams]);
} }

View file

@ -17,7 +17,7 @@
import * as React from 'react'; import * as React from 'react';
import './treeItem.css'; import './treeItem.css';
import * as icons from './icons'; import * as icons from './icons';
import { clsx, useFlash } from '@web/uiUtils'; import { clsx } from '@web/uiUtils';
export const TreeItem: React.FunctionComponent<{ export const TreeItem: React.FunctionComponent<{
title: JSX.Element, title: JSX.Element,
@ -26,11 +26,10 @@ export const TreeItem: React.FunctionComponent<{
expandByDefault?: boolean, expandByDefault?: boolean,
depth: number, depth: number,
style?: React.CSSProperties, style?: React.CSSProperties,
flash?: any flash?: boolean
}> = ({ title, loadChildren, onClick, expandByDefault, depth, style, flash }) => { }> = ({ title, loadChildren, onClick, expandByDefault, depth, style, flash }) => {
const addFlashClass = useFlash(flash);
const [expanded, setExpanded] = React.useState(expandByDefault || false); const [expanded, setExpanded] = React.useState(expandByDefault || false);
return <div className={clsx('tree-item', addFlashClass && 'yellow-flash')} style={style}> return <div className={clsx('tree-item', flash && 'yellow-flash')} style={style}>
<span className='tree-item-title' style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} > <span className='tree-item-title' style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
{loadChildren && !!expanded && icons.downArrow()} {loadChildren && !!expanded && icons.downArrow()}
{loadChildren && !expanded && icons.rightArrow()} {loadChildren && !expanded && icons.rightArrow()}

View file

@ -37,16 +37,18 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const [attachmentText, setAttachmentText] = React.useState<string | null>(null); const [attachmentText, setAttachmentText] = React.useState<string | null>(null);
const [placeholder, setPlaceholder] = React.useState<string | null>(null); const [placeholder, setPlaceholder] = React.useState<string | null>(null);
const [flash, triggerFlash] = useFlash();
const ref = React.useRef<HTMLSpanElement>(null); const ref = React.useRef<HTMLSpanElement>(null);
const isTextAttachment = isTextualMimeType(attachment.contentType); const isTextAttachment = isTextualMimeType(attachment.contentType);
const hasContent = !!attachment.sha1 || !!attachment.path; const hasContent = !!attachment.sha1 || !!attachment.path;
React.useEffect(() => { React.useEffect(() => {
if (reveal) if (reveal) {
ref.current?.scrollIntoView({ behavior: 'smooth' }); ref.current?.scrollIntoView({ behavior: 'smooth' });
}, [reveal]); return triggerFlash();
const flash = useFlash(reveal); }
}, [reveal, triggerFlash]);
React.useEffect(() => { React.useEffect(() => {
if (expanded && attachmentText === null && placeholder === null) { if (expanded && attachmentText === null && placeholder === null) {

View file

@ -14,6 +14,7 @@
limitations under the License. limitations under the License.
*/ */
import type { EffectCallback } from 'react';
import React from 'react'; import React from 'react';
// Recalculates the value when dependencies change. // Recalculates the value when dependencies change.
@ -225,15 +226,21 @@ export function scrollIntoViewIfNeeded(element: Element | undefined) {
const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f'; const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f';
export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug'); export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');
// flash is retriggered whenever the value changes export function useFlash(): [boolean, EffectCallback] {
export function useFlash(flash: any | undefined) { const [flash, setFlash] = React.useState(false);
const [flashState, setFlashState] = React.useState(false); const trigger = React.useCallback<React.EffectCallback>(() => {
React.useEffect(() => { let timeout: number | undefined;
if (flash) { setFlash(currentlyFlashing => {
setFlashState(true); if (!currentlyFlashing) {
const timeout = setTimeout(() => setFlashState(false), 1000); timeout = setTimeout(() => setFlash(false), 1000) as any;
return () => clearTimeout(timeout); return true;
} }
}, [flash]);
return flashState; // It's already flashing, so we remove the class and re-add it after 50ms to trigger the animation again.
timeout = setTimeout(() => setFlash(true), 50) as any;
return false;
});
return () => clearTimeout(timeout);
}, [setFlash]);
return [flash, trigger];
} }